Handling of data types
This commit is contained in:
parent
9d50b97436
commit
8988720c0e
6 changed files with 331 additions and 18 deletions
1
sql/0009.sql
Normal file
1
sql/0009.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE public."type" ADD CONSTRAINT type_name_unique UNIQUE (name);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div class="img"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/file-document-check-outline.svg" /></div>
|
||||
<div class="img"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/${t.Schema.icon}.svg" /></div>
|
||||
<div class="title">${t.Schema.title || t.Name}</div>
|
||||
`
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<div style="float: left;" class="label">${this.type.Schema.title}</div>
|
||||
<div style="float: right;"><img src="/images/${_VERSION}/node_modules/@mdi/svg/svg/content-save.svg" /></div>
|
||||
<div style="float: right;"><img class="save" src="/images/${_VERSION}/node_modules/@mdi/svg/svg/content-save.svg" /></div>
|
||||
</div>
|
||||
<div style="clear: both;">
|
||||
<input type="text" class="name">
|
||||
|
|
@ -743,13 +783,60 @@ class TypeSchemaEditor {
|
|||
<textarea class="type-schema"></textarea>
|
||||
`
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -416,5 +416,9 @@ dialog#connection-data {
|
|||
|
||||
img {
|
||||
height: 32px;
|
||||
|
||||
&.saving {
|
||||
filter: invert(.7) sepia(.5) hue-rotate(0deg) saturate(600%) brightness(0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
122
type.go
122
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
|
||||
|
|
|
|||
108
webserver.go
108
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue