diff --git a/sql/0009.sql b/sql/0009.sql
new file mode 100644
index 0000000..d8cfa76
--- /dev/null
+++ b/sql/0009.sql
@@ -0,0 +1 @@
+ALTER TABLE public."type" ADD CONSTRAINT type_name_unique UNIQUE (name);
diff --git a/static/css/main.css b/static/css/main.css
index e56b823..b375bf9 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -316,3 +316,6 @@ dialog#connection-data div.button {
#editor-type-schema img {
height: 32px;
}
+#editor-type-schema img.saving {
+ filter: invert(0.7) sepia(0.5) hue-rotate(0deg) saturate(600%) brightness(0.75);
+}
diff --git a/static/js/app.mjs b/static/js/app.mjs
index cff0b86..a11a6ac 100644
--- a/static/js/app.mjs
+++ b/static/js/app.mjs
@@ -108,8 +108,19 @@ export class App {
break
case 'TYPE_EDIT':
- const editor = new TypeSchemaEditor(event.detail.Type)
- document.getElementById('editor-type-schema').replaceChildren(editor.render())
+ const typeID = event.detail
+ fetch(`/types/${typeID}`)
+ .then(data => data.json())
+ .then(json => {
+ if (!json.OK) {
+ showError(json.Error)
+ return
+ }
+
+ const editor = new TypeSchemaEditor(json.Type)
+ document.getElementById('editor-type-schema').replaceChildren(editor.render())
+ })
+ .catch(err => showError(err))
break
default:
@@ -695,6 +706,13 @@ export class TypesList {
render() {// {{{
const div = document.createElement('div')
+ const create = document.createElement('img')
+ create.setAttribute('src', `/images/${_VERSION}/node_modules/@mdi/svg/svg/plus-box.svg`)
+ create.style.height = '32px'
+ create.style.cursor = 'pointer';
+ create.addEventListener('click', () => this.createType())
+ div.appendChild(create)
+
this.types.sort(_app.typeSort)
let prevGroup = null
@@ -714,28 +732,50 @@ export class TypesList {
const tDiv = document.createElement('div')
tDiv.classList.add('type')
tDiv.innerHTML = `
-

+ 
${t.Schema.title || t.Name}
`
- tDiv.addEventListener('click', () => mbus.dispatch('TYPE_EDIT', { Type: t }))
+ tDiv.addEventListener('click', () => mbus.dispatch('TYPE_EDIT', t.ID))
div.appendChild(tDiv)
}
return div
}// }}}
+ createType() {// {{{
+ const name = prompt("Type name")
+ if (name === null)
+ return
+ fetch(`/types/create`, {
+ method: 'POST',
+ body: JSON.stringify({ name }),
+ })
+ .then(data => data.json())
+ .then(json => {
+ if (!json.OK) {
+ showError(json.Error)
+ return
+ }
+
+ _app.typesList.fetchTypes().then(() => {
+ mbus.dispatch('TYPES_LIST_FETCHED')
+ mbus.dispatch('TYPE_EDIT', json.Type.ID)
+ })
+ })
+ .catch(err => showError(err))
+ }// }}}
}
class TypeSchemaEditor {
- constructor(dataType) {
+ constructor(dataType) {// {{{
this.type = dataType
- }
- render() {
+ }// }}}
+ render() {// {{{
const tmpl = document.createElement('template')
tmpl.innerHTML = `
${this.type.Schema.title}
-
+
@@ -743,13 +783,60 @@ class TypeSchemaEditor {
`
- tmpl.content.querySelector('.name').value = this.type.Name
+ this.textarea = tmpl.content.querySelector('textarea')
+ this.textarea.value = JSON.stringify(this.type.Schema, null, 4)
+ this.textarea.addEventListener('keydown', event => {
+ if (!event.ctrlKey || event.key !== 's')
+ return
+ event.stopPropagation()
+ event.preventDefault()
+ this.save()
+ })
- const textarea = tmpl.content.querySelector('textarea')
- textarea.value = JSON.stringify(this.type.Schema, null, 4)
+ this.name = tmpl.content.querySelector('.name')
+ this.name.value = this.type.Name
+
+ this.img_save = tmpl.content.querySelector('img.save')
+ this.img_save.addEventListener('click', () => this.save())
return tmpl.content
- }
+ }// }}}
+ save() {// {{{
+ const req = {
+ Name: this.name.value,
+ Schema: this.textarea.value,
+ }
+
+ const start_update = Date.now()
+ this.img_save.classList.add('saving')
+
+ fetch(`/types/update/${this.type.ID}`, {
+ method: 'POST',
+ body: JSON.stringify(req),
+ })
+ .then(data => data.json())
+ .then(json => {
+ if (!json.OK) {
+ showError(json.Error)
+ return
+ }
+
+ const time_left = 100 - (Date.now() - start_update)
+ setTimeout(() => {
+ this.img_save.classList.remove('saving')
+ this.refreshTypeUI()
+ }, Math.max(time_left, 0))
+
+ })
+ .catch(err => showError(err))
+
+ }// }}}
+ async refreshTypeUI() {// {{{
+ _app.typesList.fetchTypes().then(() => {
+ mbus.dispatch('TYPES_LIST_FETCHED')
+ mbus.dispatch('TYPE_EDIT', this.type.ID)
+ })
+ }// }}}
}
class ConnectedNodes {
diff --git a/static/less/main.less b/static/less/main.less
index 4324e5d..90b1478 100644
--- a/static/less/main.less
+++ b/static/less/main.less
@@ -416,5 +416,9 @@ dialog#connection-data {
img {
height: 32px;
+
+ &.saving {
+ filter: invert(.7) sepia(.5) hue-rotate(0deg) saturate(600%) brightness(0.75);
+ }
}
}
diff --git a/type.go b/type.go
index b639a07..464f5f5 100644
--- a/type.go
+++ b/type.go
@@ -2,10 +2,15 @@ package main
import (
// External
+ werr "git.gibonuddevalla.se/go/wrappederror"
"github.com/jmoiron/sqlx"
// Standard
"encoding/json"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
"time"
)
@@ -17,7 +22,7 @@ type NodeType struct {
Updated time.Time
}
-func GetType(typeID int) (typ NodeType, err error) {
+func GetType(typeID int) (typ NodeType, err error) { // {{{
row := db.QueryRowx(`
SELECT
id,
@@ -36,9 +41,8 @@ func GetType(typeID int) (typ NodeType, err error) {
err = json.Unmarshal(typ.SchemaRaw, &typ.Schema)
return
-}
-
-func GetTypes() (types []NodeType, err error) {
+} // }}}
+func GetTypes() (types []NodeType, err error) { // {{{
types = []NodeType{}
var rows *sqlx.Rows
rows, err = db.Queryx(`
@@ -53,6 +57,7 @@ func GetTypes() (types []NodeType, err error) {
name ASC
`)
if err != nil {
+ err = werr.Wrap(err)
return
}
defer rows.Close()
@@ -61,11 +66,13 @@ func GetTypes() (types []NodeType, err error) {
var typ NodeType
err = rows.StructScan(&typ)
if err != nil {
+ err = werr.Wrap(err)
return
}
err = json.Unmarshal(typ.SchemaRaw, &typ.Schema)
if err != nil {
+ err = werr.Wrap(err)
return
}
@@ -73,4 +80,109 @@ func GetTypes() (types []NodeType, err error) {
}
return
-}
+} // }}}
+func CreateType(name, schema string) (nodeType NodeType, err error) {// {{{
+ row := db.QueryRow(`INSERT INTO public.type(name, schema) VALUES($1, $2) RETURNING id, updated`, name, schema)
+
+ err = row.Scan(&nodeType.ID, &nodeType.Updated)
+ if err != nil {
+ err = werr.Wrap(err)
+ return
+ }
+
+ nodeType.Name = name
+ err = json.Unmarshal([]byte(schema), &nodeType.Schema)
+ if err != nil {
+ err = werr.Wrap(err)
+ return
+ }
+
+ return
+}// }}}
+func UpdateType(typeID int, name, schema string) (err error) { // {{{
+ _, err = db.Exec(`UPDATE public.type SET name=$2, schema=$3 WHERE id=$1`, typeID, name, schema)
+ if err != nil {
+ err = werr.Wrap(err)
+ return
+ }
+ return
+} // }}}
+
+type TypeSchema map[string]any
+
+func (t *TypeSchema) GetString(key string) string { // {{{
+ if vAny, found := (*t)[key]; found {
+ if v, ok := vAny.(string); ok {
+ return v
+ } else {
+ return ""
+ }
+ }
+ return ""
+} // }}}
+func (t *TypeSchema) SetString(key, value string) { // {{{
+ (*t)[key] = value
+} // }}}
+func ValidateType(name, schemaData string) (fixedSchema string, err error) { // {{{
+ schema := make(TypeSchema)
+
+ acceptedChars := regexp.MustCompile(`^[_a-zA-Z0-9]+$`)
+ if !acceptedChars.MatchString(name) {
+ err = errors.New("Name can only consist of a-z, A-Z, 0-9 and underscore (_)")
+ return
+ }
+
+ err = json.Unmarshal([]byte(schemaData), &schema)
+ if err != nil {
+ err = werr.Wrap(err)
+ return
+ }
+
+ schema.SetString("$id", fmt.Sprintf("https://datagraph.ahall.se/schema/%s.json", name))
+ schema.SetString("$schema", "https://json-schema.org/draft/2020-12/schema")
+
+ icon := strings.TrimSpace(schema.GetString("icon"))
+ if icon == "" {
+ icon = "help"
+ }
+ schema.SetString("icon", icon)
+
+ title := strings.TrimSpace(schema.GetString("title"))
+ if title == "" {
+ title = name
+ }
+ schema.SetString("title", title)
+
+ group := strings.TrimSpace(schema.GetString("x-group"))
+ if group == "" {
+ group = "Uncategorized"
+ }
+ schema.SetString("x-group", group)
+
+ typ := strings.TrimSpace(schema.GetString("type"))
+ if typ != "object" {
+ typ = "object"
+ }
+ schema.SetString("type", typ)
+
+ if _, found := schema["properties"]; !found {
+ schema["properties"] = map[string]any{
+ "comment": map[string]any{
+ "type": "string",
+ "format": "textarea",
+ "propertyOrder": 16384,
+ "title": "Comment",
+ "options": map[string]any{
+ "input_height": "100px",
+ },
+ },
+ }
+ }
+
+ fixedSchemaBytes, _ := json.Marshal(schema)
+ fixedSchema = string(fixedSchemaBytes)
+
+ return
+} // }}}
+
+// vim: foldmethod=marker
diff --git a/webserver.go b/webserver.go
index 5cb2d85..007d4d7 100644
--- a/webserver.go
+++ b/webserver.go
@@ -47,6 +47,8 @@ func initWebserver() (err error) {
http.HandleFunc("/nodes/connect", actionNodeConnect)
http.HandleFunc("/types/{typeID}", actionType)
http.HandleFunc("/types/", actionTypesAll)
+ http.HandleFunc("/types/create", actionTypeCreate)
+ http.HandleFunc("/types/update/{typeID}", actionTypeUpdate)
http.HandleFunc("/connection/update/{connID}", actionConnectionUpdate)
http.HandleFunc("/connection/delete/{connID}", actionConnectionDelete)
@@ -364,6 +366,7 @@ func actionNodeConnect(w http.ResponseWriter, r *http.Request) { // {{{
w.Write(j)
} // }}}
+
func actionType(w http.ResponseWriter, r *http.Request) { // {{{
typeID := 0
typeIDStr := r.PathValue("typeID")
@@ -376,7 +379,15 @@ func actionType(w http.ResponseWriter, r *http.Request) { // {{{
return
}
- j, _ := json.Marshal(typ)
+ res := struct {
+ OK bool
+ Type NodeType
+ }{
+ true,
+ typ,
+ }
+
+ j, _ := json.Marshal(res)
w.Write(j)
} // }}}
func actionTypesAll(w http.ResponseWriter, r *http.Request) { // {{{
@@ -398,6 +409,101 @@ func actionTypesAll(w http.ResponseWriter, r *http.Request) { // {{{
j, _ := json.Marshal(out)
w.Write(j)
} // }}}
+func actionTypeCreate(w http.ResponseWriter, r *http.Request) { // {{{
+ var req struct {
+ Name string
+ }
+
+ body, _ := io.ReadAll(r.Body)
+ err := json.Unmarshal(body, &req)
+ if err != nil {
+ err = werr.Wrap(err)
+ httpError(w, err)
+ return
+ }
+
+ var newSchema string
+ if newSchema, err = ValidateType(req.Name, "{}"); err != nil {
+ err = werr.Wrap(err)
+ httpError(w, err)
+ return
+ }
+
+ nodeType, err := CreateType(req.Name, newSchema)
+ if err != nil {
+
+ if wrapped, ok := err.(*werr.Error); ok {
+ pqErr, ok := wrapped.Wrapped.(*pq.Error)
+ if ok && pqErr.Code == "23505" {
+ err = errors.New("This type already exist.")
+ } else {
+ err = werr.Wrap(err)
+ }
+ } else {
+ err = werr.Wrap(err)
+ }
+ httpError(w, err)
+ return
+ }
+
+ res := struct {
+ OK bool
+ Type NodeType
+ }{
+ true,
+ nodeType,
+ }
+
+ j, _ := json.Marshal(res)
+ w.Write(j)
+} // }}}
+func actionTypeUpdate(w http.ResponseWriter, r *http.Request) { // {{{
+ typeID := 0
+ typeIDStr := r.PathValue("typeID")
+ typeID, _ = strconv.Atoi(typeIDStr)
+
+ var req struct {
+ Name string
+ Schema string
+ }
+
+ body, _ := io.ReadAll(r.Body)
+ err := json.Unmarshal(body, &req)
+ if err != nil {
+ err = werr.Wrap(err)
+ httpError(w, err)
+ return
+ }
+
+ var fixedSchema string
+ if fixedSchema, err = ValidateType(req.Name, req.Schema); err != nil {
+ if wErr, ok := err.(*werr.Error); ok {
+ if jsonErr, ok := wErr.Wrapped.(*json.SyntaxError); ok {
+ err = jsonErr
+ } else {
+ err = werr.Wrap(err)
+ }
+ } else {
+ err = werr.Wrap(err)
+ }
+ httpError(w, err)
+ return
+ }
+
+ err = UpdateType(typeID, req.Name, fixedSchema)
+ if err != nil {
+ err = werr.Wrap(err)
+ httpError(w, err)
+ return
+ }
+
+ res := struct {
+ OK bool
+ }{true}
+ j, _ := json.Marshal(res)
+ w.Write(j)
+} // }}}
+
func actionConnectionUpdate(w http.ResponseWriter, r *http.Request) { // {{{
connIDStr := r.PathValue("connID")
connID, err := strconv.Atoi(connIDStr)