From 8988720c0e22c5c055698587656e8adf50ae8ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 10 Jul 2025 10:39:33 +0200 Subject: [PATCH] Handling of data types --- sql/0009.sql | 1 + static/css/main.css | 3 ++ static/js/app.mjs | 111 +++++++++++++++++++++++++++++++++----- static/less/main.less | 4 ++ type.go | 122 ++++++++++++++++++++++++++++++++++++++++-- webserver.go | 108 ++++++++++++++++++++++++++++++++++++- 6 files changed, 331 insertions(+), 18 deletions(-) create mode 100644 sql/0009.sql 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)