From ac8b334eeed678a7ee8fbc30a4af83852a5361a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 3 Dec 2024 06:53:31 +0100 Subject: [PATCH] Rewrite --- main.go | 21 +- node.go | 510 +++----------------------------------- sql/00001.sql | 80 ++++-- sql/00002.sql | 30 +-- sql/00003.sql | 11 +- sql/00004.sql | 1 - sql/00005.sql | 2 - sql/00006.sql | 5 - sql/00007.sql | 10 - sql/00008.sql | 2 - sql/00009.sql | 1 - sql/00010.sql | 1 - sql/00011.sql | 5 - sql/00012.sql | 2 - sql/00013.sql | 1 - sql/00014.sql | 18 -- sql/00015.sql | 14 -- sql/00016.sql | 1 - sql/00017.sql | 11 - sql/00018.sql | 2 - sql/00019.sql | 1 - sql/00020.sql | 2 - sql/00021.sql | 1 - sql/00022.sql | 21 -- static/css/main.css | 9 +- static/css/notes2.css | 101 +++++++- static/js/app.mjs | 17 +- static/js/node.mjs | 74 +++--- static/js/node_store.mjs | 95 ++++++- static/js/sync.mjs | 19 ++ static/less/main.less | 10 +- static/less/notes2.less | 121 ++++++++- views/layouts/main.gotmpl | 1 + views/pages/notes2.gotmpl | 3 +- views/pages/sync.gotmpl | 13 + 35 files changed, 541 insertions(+), 675 deletions(-) delete mode 100644 sql/00004.sql delete mode 100644 sql/00005.sql delete mode 100644 sql/00006.sql delete mode 100644 sql/00007.sql delete mode 100644 sql/00008.sql delete mode 100644 sql/00009.sql delete mode 100644 sql/00010.sql delete mode 100644 sql/00011.sql delete mode 100644 sql/00012.sql delete mode 100644 sql/00013.sql delete mode 100644 sql/00014.sql delete mode 100644 sql/00015.sql delete mode 100644 sql/00016.sql delete mode 100644 sql/00017.sql delete mode 100644 sql/00018.sql delete mode 100644 sql/00019.sql delete mode 100644 sql/00020.sql delete mode 100644 sql/00021.sql delete mode 100644 sql/00022.sql create mode 100644 static/js/sync.mjs create mode 100644 views/pages/sync.gotmpl diff --git a/main.go b/main.go index e5063d8..24d4d81 100644 --- a/main.go +++ b/main.go @@ -106,10 +106,11 @@ func main() { // {{{ http.HandleFunc("/", rootHandler) http.HandleFunc("/notes2", pageNotes2) http.HandleFunc("/login", pageLogin) + http.HandleFunc("/sync", pageSync) http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) - http.HandleFunc("/node/tree", authenticated(actionNodeTree)) + http.HandleFunc("/node/tree/{timestamp}", authenticated(actionNodeTree)) http.HandleFunc("/node/retrieve/{id}", authenticated(actionNodeRetrieve)) http.HandleFunc("/service_worker.js", pageServiceWorker) @@ -212,20 +213,32 @@ func pageNotes2(w http.ResponseWriter, r *http.Request) { // {{{ return } } // }}} +func pageSync(w http.ResponseWriter, r *http.Request) { // {{{ + page := NewPage("sync") + + err := Webengine.Render(page, w, r) + if err != nil { + w.Write([]byte(err.Error())) + return + } +} // }}} func actionNodeTree(w http.ResponseWriter, r *http.Request) { // {{{ user := getUser(r) + changedFrom, _ := strconv.Atoi(r.PathValue("timestamp")) - nodes, err := NodeTree(user.ID, 0) + nodes, maxSeq, err := NodeTree(user.ID, uint64(changedFrom)) if err != nil { + Log.Error("/node/tree", "error", err) httpError(w, err) return } j, _ := json.Marshal(struct { OK bool - Nodes []Node - }{true, nodes}) + Nodes []TreeNode + MaxSeq uint64 + }{true, nodes, maxSeq}) Log.Debug("tree", "nodes", nodes) w.Write(j) } // }}} diff --git a/node.go b/node.go index 5808971..d936fd5 100644 --- a/node.go +++ b/node.go @@ -5,6 +5,7 @@ import ( "github.com/jmoiron/sqlx" // Standard + "database/sql" "time" ) @@ -24,6 +25,18 @@ type ChecklistGroup struct { Items []ChecklistItem } +type TreeNode struct { + UUID string + ParentUUID string `db:"parent_uuid"` + Name string + Created time.Time + Updated time.Time + Deleted bool + CreatedSeq uint64 `db:"created_seq"` + UpdatedSeq uint64 `db:"updated_seq"` + DeletedSeq sql.NullInt64 `db:"deleted_seq"` +} + type Node struct { ID int UserID int `db:"user_id"` @@ -44,45 +57,32 @@ type Node struct { Markdown bool } -func NodeTree(userID, startNodeID int) (nodes []Node, err error) { // {{{ +func NodeTree(userID int, synced uint64) (nodes []TreeNode, maxSeq uint64, err error) { // {{{ var rows *sqlx.Rows rows, err = db.Queryx(` - WITH RECURSIVE nodetree AS ( - SELECT - *, - array[name::text] AS path, - 0 AS level - FROM node - WHERE - user_id = $1 AND - CASE $2::int - WHEN 0 THEN parent_id IS NULL - ELSE parent_id = $2 - END - - UNION ALL - - SELECT - n.*, - path||n.name::text AS path, - nt.level + 1 AS level - FROM node n - INNER JOIN nodetree nt ON n.parent_id = nt.id - ) - SELECT - id, - user_id, - COALESCE(parent_id, 0) AS parent_id, + uuid, + COALESCE(parent_uuid, '') AS parent_uuid, name, + created, updated, - level - FROM nodetree + deleted IS NOT NULL AS deleted, + created_seq, + updated_seq, + deleted_seq + FROM + public.node + WHERE + user_id = $1 AND ( + created_seq > $2 OR + updated_seq > $2 OR + deleted_seq > $2 + ) ORDER BY - path ASC + created ASC `, userID, - startNodeID, + synced, ) if err != nil { return @@ -94,17 +94,14 @@ func NodeTree(userID, startNodeID int) (nodes []Node, err error) { // {{{ Level int } - nodes = []Node{} + nodes = []TreeNode{} for rows.Next() { - node := Node{} - node.Complete = false - node.Crumbs = []Node{} - node.Children = []Node{} - node.Files = []File{} + node := TreeNode{} if err = rows.StructScan(&node); err != nil { return } nodes = append(nodes, node) + maxSeq = max(maxSeq, node.CreatedSeq, node.UpdatedSeq, uint64(node.DeletedSeq.Int64)) } return @@ -362,442 +359,3 @@ func NodeCrumbs(nodeID int) (nodes []Node, err error) { // {{{ } return } // }}} -/* -func CreateNode(userID, parentID int, name string) (node Node, err error) { // {{{ - var rows *sqlx.Rows - - rows, err = service.Db.Conn.Queryx(` - INSERT INTO node(user_id, parent_id, name) - VALUES($1, NULLIF($2, 0)::integer, $3) - RETURNING - id, - user_id, - COALESCE(parent_id, 0) AS parent_id, - name, - content - `, - userID, - parentID, - name, - ) - if err != nil { - return - } - defer rows.Close() - - for rows.Next() { - node = Node{} - if err = rows.StructScan(&node); err != nil { - return - } - node.Children = []Node{} - node.Files = []File{} - node.Complete = true - } - - node.Crumbs, err = NodeCrumbs(node.ID) - return -} // }}} -func UpdateNode(userID, nodeID, timeOffset int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{ - if nodeID == 0 { - return - } - - var timezone string - row := service.Db.Conn.QueryRow(`SELECT timezone FROM _webservice.user WHERE id=$1`, userID) - err = row.Scan(&timezone) - if err != nil { - err = werr.Wrap(err).WithCode("002-000F") - return - } - - var scannedSchedules, dbSchedules, add, remove []Schedule - scannedSchedules = ScanForSchedules(timezone, content) - for i := range scannedSchedules { - scannedSchedules[i].Node.ID = nodeID - scannedSchedules[i].UserID = userID - } - - var tsx *sql.Tx - tsx, err = service.Db.Conn.Begin() - if err != nil { - return - } - - dbSchedules, err = RetrieveSchedules(userID, nodeID) - if err != nil { - tsx.Rollback() - return - } - - for _, scanned := range scannedSchedules { - found := false - for _, db := range dbSchedules { - if scanned.IsEqual(db) { - found = true - break - } - } - if !found { - add = append(add, scanned) - } - } - - for _, db := range dbSchedules { - found := false - for _, scanned := range scannedSchedules { - if db.IsEqual(scanned) { - found = true - break - } - } - if !found { - remove = append(remove, db) - } - } - - for _, event := range remove { - err = event.Delete(tsx) - if err != nil { - tsx.Rollback() - return - } - } - - for _, event := range add { - err = event.Insert(tsx) - if err != nil { - tsx.Rollback() - return - } - } - - if cryptoKeyID > 0 { - _, err = tsx.Exec(` - UPDATE node - SET - content = '', - content_encrypted = $1, - markdown = $5, - crypto_key_id = CASE $2::int - WHEN 0 THEN NULL - ELSE $2 - END - WHERE - id = $3 AND - user_id = $4 - `, - content, - cryptoKeyID, - nodeID, - userID, - markdown, - ) - } else { - _, err = tsx.Exec(` - UPDATE node - SET - content = $1, - content_encrypted = '', - markdown = $5, - crypto_key_id = CASE $2::int - WHEN 0 THEN NULL - ELSE $2 - END - WHERE - id = $3 AND - user_id = $4 - `, - content, - cryptoKeyID, - nodeID, - userID, - markdown, - ) - } - if err != nil { - tsx.Rollback() - return - } - - err = tsx.Commit() - - return -} // }}} -func RenameNode(userID, nodeID int, name string) (err error) { // {{{ - _, err = service.Db.Conn.Exec(` - UPDATE node SET name = $1 WHERE user_id = $2 AND id = $3 - `, - name, - userID, - nodeID, - ) - return -} // }}} -func DeleteNode(userID, nodeID int) (err error) { // {{{ - _, err = service.Db.Conn.Exec(` - WITH RECURSIVE nodetree AS ( - SELECT - id, parent_id - FROM node - WHERE - user_id = $1 AND id = $2 - - UNION - - SELECT - n.id, n.parent_id - FROM node n - INNER JOIN nodetree nt ON n.parent_id = nt.id - ) - - DELETE FROM node WHERE id IN ( - SELECT id FROM nodetree - )`, - userID, - nodeID, - ) - return -} // }}} -func SearchNodes(userID int, search string) (nodes []Node, err error) { // {{{ - nodes = []Node{} - var rows *sqlx.Rows - rows, err = service.Db.Conn.Queryx(` - SELECT - id, - user_id, - COALESCE(parent_id, 0) AS parent_id, - name, - updated - FROM node - WHERE - user_id = $1 AND - crypto_key_id IS NULL AND - ( - content ~* $2 OR - name ~* $2 - ) - ORDER BY - updated DESC - `, userID, search) - if err != nil { - return - } - defer rows.Close() - - for rows.Next() { - node := Node{} - node.Complete = false - if err = rows.StructScan(&node); err != nil { - return - } - nodes = append(nodes, node) - } - - return -} // }}} - -func ChecklistGroupAdd(userID, nodeID int, label string) (item ChecklistGroup, err error) { // {{{ - var row *sqlx.Row - row = service.Db.Conn.QueryRowx( - ` - INSERT INTO checklist_group(node_id, "order", "label") - ( - SELECT - $1, - MAX("order")+1 AS "order", - $2 AS "label" - FROM checklist_group g - INNER JOIN node n ON g.node_id = n.id - WHERE - user_id = $3 AND - node_id = $1 - GROUP BY - node_id - ) UNION ( - SELECT - node.id AS node_id, - 0 AS "order", - $2 AS "label" - FROM node - WHERE - user_id = $3 AND - node.id = $1 - ) - ORDER BY "order" DESC - LIMIT 1 - RETURNING - * - `, - nodeID, - label, - userID, - ) - err = row.StructScan(&item) - return -} // }}} -func ChecklistGroupLabel(userID, checklistGroupID int, label string) (item ChecklistItem, err error) { // {{{ - _, err = service.Db.Conn.Exec( - ` - UPDATE checklist_group g - SET label = $3 - FROM node n - WHERE - g.node_id = n.id AND - n.user_id = $1 AND - g.id = $2; - `, - userID, - checklistGroupID, - label, - ) - return -} // }}} -func ChecklistGroupItemAdd(userID, checklistGroupID int, label string) (item ChecklistItem, err error) { // {{{ - var row *sqlx.Row - row = service.Db.Conn.QueryRowx( - ` - INSERT INTO checklist_item(checklist_group_id, "order", "label") - ( - SELECT - checklist_group_id, - MAX("order")+1 AS "order", - $1 AS "label" - FROM checklist_item - WHERE - checklist_group_id = $2 - GROUP BY - checklist_group_id - ) UNION ( - SELECT $2 AS checklist_group_id, 0 AS "order", $1 AS "label" - ) - ORDER BY "order" DESC - LIMIT 1 - RETURNING - * - `, - label, - checklistGroupID, - ) - err = row.StructScan(&item) - return -} // }}} -func ChecklistGroupDelete(userID, checklistGroupID int) (err error) { // {{{ - _, err = service.Db.Conn.Exec( - ` - DELETE - FROM checklist_group g - USING - node n - WHERE - g.id = $2 AND - g.node_id = n.id AND - n.user_id = $1 - `, - userID, - checklistGroupID, - ) - return -} // }}} - -func ChecklistItemState(userID, checklistItemID int, state bool) (err error) { // {{{ - _, err = service.Db.Conn.Exec( - ` - UPDATE checklist_item i - SET checked = $3 - FROM checklist_group g, node n - WHERE - i.checklist_group_id = g.id AND - g.node_id = n.id AND - n.user_id = $1 AND - i.id = $2; - `, - userID, - checklistItemID, - state, - ) - return -} // }}} -func ChecklistItemLabel(userID, checklistItemID int, label string) (err error) { // {{{ - _, err = service.Db.Conn.Exec( - ` - UPDATE checklist_item i - SET label = $3 - FROM checklist_group g, node n - WHERE - i.checklist_group_id = g.id AND - g.node_id = n.id AND - n.user_id = $1 AND - i.id = $2; - `, - userID, - checklistItemID, - label, - ) - return -} // }}} -func ChecklistItemDelete(userID, checklistItemID int) (err error) { // {{{ - _, err = service.Db.Conn.Exec( - ` - DELETE - FROM checklist_item i - USING - checklist_group g, - node n - WHERE - i.id = $2 AND - i.checklist_group_id = g.id AND - g.node_id = n.id AND - n.user_id = $1 - `, - userID, - checklistItemID, - ) - return -} // }}} -func ChecklistItemMove(userID, checklistItemID, afterItemID int) (err error) { // {{{ - _, err = service.Db.Conn.Exec( - ` - WITH - "to" AS ( - SELECT - i.checklist_group_id AS group_id, - i."order" - FROM checklist_item i - INNER JOIN checklist_group g ON i.checklist_group_id = g.id - INNER JOIN node n ON g.node_id = n.id - WHERE - n.user_id = $1 AND - i.id = $3 - ), - - update_order AS ( - UPDATE checklist_item - SET - "order" = - CASE - WHEN checklist_item."order" <= "to"."order" THEN checklist_item."order" - 1 - WHEN checklist_item."order" > "to"."order" THEN checklist_item."order" + 1 - END - FROM "to" - WHERE - checklist_item.id != $2 AND - checklist_item.checklist_group_id = "to".group_id - ) - - UPDATE checklist_item - SET - checklist_group_id = "to".group_id, - "order" = "to"."order" - FROM "to" - WHERE - checklist_item.id = $2 - `, - userID, - checklistItemID, - afterItemID, - ) - return -} // }}} - -*/ diff --git a/sql/00001.sql b/sql/00001.sql index c08f6b1..08d5266 100644 --- a/sql/00001.sql +++ b/sql/00001.sql @@ -1,29 +1,61 @@ -CREATE TABLE public."user" ( - id SERIAL NOT NULL, - username VARCHAR NOT NULL, - name VARCHAR NOT NULL, - "password" VARCHAR NOT NULL, - last_login TIMESTAMP NOT NULL DEFAULT now(), - CONSTRAINT newtable_pk PRIMARY KEY (id) -); +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS pgcrypto; -CREATE TABLE public."session" ( - uuid UUID NOT NULL, - user_id INT4 NULL, - created TIMESTAMP NOT NULL DEFAULT now(), - CONSTRAINT session_pk PRIMARY KEY (uuid), - CONSTRAINT user_session_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE +CREATE SEQUENCE node_updates; + +CREATE TABLE public."user" ( + id serial4 NOT NULL, + username varchar NOT NULL, + "name" varchar NOT NULL, + "password" varchar NOT NULL, + last_login timestamp DEFAULT now() NOT NULL, + timezone varchar DEFAULT 'UTC'::character varying NOT NULL, + CONSTRAINT user_pk PRIMARY KEY (id) ); CREATE TABLE public.node ( - id SERIAL NOT NULL, - user_id INT4 NOT NULL, - parent_id INT4 NULL, - "name" VARCHAR(256) NOT NULL DEFAULT '', - "content" TEXT NOT NULL DEFAULT '', - CONSTRAINT name_length CHECK (LENGTH(TRIM(name)) > 0), - CONSTRAINT node_pk PRIMARY KEY (id), - CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT, - CONSTRAINT node_fk FOREIGN KEY (parent_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT -); + id serial4 NOT NULL, + user_id int4 NOT NULL, + "uuid" bpchar(36) DEFAULT gen_random_uuid() NOT NULL, + parent_uuid bpchar(36) NULL, + created timestamptz DEFAULT NOW() NOT NULL, + updated timestamptz DEFAULT NOW() NOT NULL, + deleted timestamptz NULL, + + created_seq bigint NOT NULL DEFAULT nextval('node_updates'), + updated_seq bigint NOT NULL DEFAULT nextval('node_updates'), + deleted_seq bigint NULL, + + "name" varchar(256) DEFAULT ''::character varying NOT NULL, + "content" text DEFAULT ''::text NOT NULL, + content_encrypted text DEFAULT ''::text NOT NULL, + markdown bool DEFAULT false NOT NULL, + + CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0)), + CONSTRAINT node_pk PRIMARY KEY (id), + CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT +); +CREATE UNIQUE INDEX node_uuid_idx ON public.node USING btree (uuid); +CREATE INDEX node_search_index ON public.node USING gin (name gin_trgm_ops, content gin_trgm_ops); + +CREATE OR REPLACE FUNCTION node_update_timestamp() +RETURNS TRIGGER +LANGUAGE PLPGSQL +AS $$ +BEGIN + IF NEW.updated = OLD.updated THEN + UPDATE node + SET + updated = NOW(), + updated_seq = nextval('node_updates') + WHERE + id=NEW.id; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE TRIGGER node_update AFTER UPDATE ON node +FOR EACH ROW +EXECUTE PROCEDURE node_update_timestamp(); diff --git a/sql/00002.sql b/sql/00002.sql index 95907f6..9d7bd8a 100644 --- a/sql/00002.sql +++ b/sql/00002.sql @@ -1,17 +1,19 @@ -ALTER TABLE node ADD COLUMN updated TIMESTAMP NOT NULL DEFAULT NOW(); - -CREATE OR REPLACE FUNCTION node_update_timestamp() -RETURNS TRIGGER -LANGUAGE PLPGSQL -AS $$ +CREATE FUNCTION public.password_hash(salt_hex char(32), pass bytea) +RETURNS char(96) +LANGUAGE plpgsql +AS +$$ BEGIN - IF NEW.updated = OLD.updated THEN - UPDATE node SET updated = NOW() WHERE id=NEW.id; - END IF; - RETURN NEW; + RETURN ( + SELECT + salt_hex || + encode( + sha256( + decode(salt_hex, 'hex') || /* salt in binary */ + pass /* password */ + ), + 'hex' + ) + ); END; $$; - -CREATE OR REPLACE TRIGGER node_update AFTER UPDATE ON node -FOR EACH ROW -EXECUTE PROCEDURE node_update_timestamp() diff --git a/sql/00003.sql b/sql/00003.sql index f1bec37..0fb2a51 100644 --- a/sql/00003.sql +++ b/sql/00003.sql @@ -1,10 +1 @@ -CREATE TABLE public.file ( - id serial NOT NULL, - user_id int4 NOT NULL, - filename varchar(256) NOT NULL DEFAULT '', - "size" int4 NOT NULL DEFAULT 0, - mime varchar(256) NOT NULL DEFAULT '', - uploaded timestamp NOT NULL DEFAULT NOW(), - CONSTRAINT file_pk PRIMARY KEY (id), - CONSTRAINT file_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT -); +ALTER TABLE public.node ADD CONSTRAINT node_node_fk FOREIGN KEY (parent_uuid) REFERENCES public.node("uuid") ON DELETE SET NULL ON UPDATE SET NULL; diff --git a/sql/00004.sql b/sql/00004.sql deleted file mode 100644 index f5a5a5e..0000000 --- a/sql/00004.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE file ADD COLUMN md5 CHAR(32) DEFAULT '' diff --git a/sql/00005.sql b/sql/00005.sql deleted file mode 100644 index fe21e55..0000000 --- a/sql/00005.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE public.file ADD node_id int4 NOT NULL; -ALTER TABLE public.file ADD CONSTRAINT file_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT; diff --git a/sql/00006.sql b/sql/00006.sql deleted file mode 100644 index 8d886ad..0000000 --- a/sql/00006.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE public.file DROP CONSTRAINT file_node_fk; -ALTER TABLE public.file ADD CONSTRAINT file_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE public.file DROP CONSTRAINT file_fk; -ALTER TABLE public.file ADD CONSTRAINT file_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/sql/00007.sql b/sql/00007.sql deleted file mode 100644 index 73ac4c6..0000000 --- a/sql/00007.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE public.crypto_key ( - id serial NOT NULL, - user_id int4 NOT NULL, - description varchar(255) NOT NULL DEFAULT '', - "key" char(144) NOT NULL, - CONSTRAINT crypto_key_pk PRIMARY KEY (id), - CONSTRAINT crypto_key_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE -); - -COMMENT ON COLUMN public.crypto_key.key IS 'salt(16 bytes) + [key encrypted with pbkdf2(pass, salt)]'; diff --git a/sql/00008.sql b/sql/00008.sql deleted file mode 100644 index d85edc2..0000000 --- a/sql/00008.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE public.node ADD crypto_key_id int4 NULL; -ALTER TABLE public.node ADD CONSTRAINT crypto_key_fk FOREIGN KEY (crypto_key_id) REFERENCES public.crypto_key(id) ON DELETE RESTRICT ON UPDATE RESTRICT; diff --git a/sql/00009.sql b/sql/00009.sql deleted file mode 100644 index 7b172b5..0000000 --- a/sql/00009.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE SEQUENCE aes_ccm_counter AS int8 INCREMENT BY 1 NO CYCLE; diff --git a/sql/00010.sql b/sql/00010.sql deleted file mode 100644 index c461d9d..0000000 --- a/sql/00010.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE public.crypto_key ADD CONSTRAINT crypto_user_description_un UNIQUE (user_id, description); diff --git a/sql/00011.sql b/sql/00011.sql deleted file mode 100644 index f6107f1..0000000 --- a/sql/00011.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE node ADD COLUMN content_encrypted TEXT NOT NULL DEFAULT ''; -UPDATE node SET content_encrypted = content, content = '' WHERE crypto_key_id IS NOT NULL; - -CREATE EXTENSION pg_trgm; -CREATE INDEX node_content_index ON node USING gin (content gin_trgm_ops); diff --git a/sql/00012.sql b/sql/00012.sql deleted file mode 100644 index 80ed44f..0000000 --- a/sql/00012.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP INDEX node_content_index; -CREATE INDEX node_search_index ON node USING gin (name gin_trgm_ops, content gin_trgm_ops); diff --git a/sql/00013.sql b/sql/00013.sql deleted file mode 100644 index 5ba26b9..0000000 --- a/sql/00013.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE public.node ADD COLUMN markdown bool NOT NULL DEFAULT false; diff --git a/sql/00014.sql b/sql/00014.sql deleted file mode 100644 index 3a1123c..0000000 --- a/sql/00014.sql +++ /dev/null @@ -1,18 +0,0 @@ -CREATE TABLE checklist_group ( - id serial NOT NULL, - node_id int4 NOT NULL, - "order" int NOT NULL DEFAULT 0, - label varchar NOT NULL, - CONSTRAINT checklist_group_pk PRIMARY KEY (id), - CONSTRAINT checklist_group_node_fk FOREIGN KEY (node_id) REFERENCES public."node"(id) ON DELETE CASCADE ON UPDATE CASCADE -); - -CREATE TABLE checklist_item ( - id serial NOT NULL, - checklist_group_id int4 NOT NULL, - "order" int NOT NULL DEFAULT 0, - label varchar NOT NULL, - checked bool NOT NULL DEFAULT false, - CONSTRAINT checklist_item_pk PRIMARY KEY (id), - CONSTRAINT checklist_group_item_fk FOREIGN KEY (checklist_group_id) REFERENCES public."checklist_group"(id) ON DELETE CASCADE ON UPDATE CASCADE -) diff --git a/sql/00015.sql b/sql/00015.sql deleted file mode 100644 index 06d7ad0..0000000 --- a/sql/00015.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE public.schedule ( - id SERIAL NOT NULL, - user_id INT4 NOT NULL, - node_id INT4 NOT NULL, - schedule_uuid CHAR(36) DEFAULT GEN_RANDOM_UUID() NOT NULL, - "time" TIMESTAMP NOT NULL, - description VARCHAR DEFAULT '' NOT NULL, - acknowledged BOOL DEFAULT false NOT NULL, - - CONSTRAINT schedule_pk PRIMARY KEY (id), - CONSTRAINT schedule_uuid UNIQUE (schedule_uuid), - CONSTRAINT schedule_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT schedule_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE -); diff --git a/sql/00016.sql b/sql/00016.sql deleted file mode 100644 index 8a98bf1..0000000 --- a/sql/00016.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE public.schedule ADD CONSTRAINT schedule_event UNIQUE (user_id, node_id, "time", description); diff --git a/sql/00017.sql b/sql/00017.sql deleted file mode 100644 index 16181d1..0000000 --- a/sql/00017.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE public.notification ( - id SERIAl NOT NULL, - user_id INT4 NOT NULL, - service VARCHAR DEFAULT 'NTFY' NOT NULL, - "configuration" JSONB DEFAULT '{}' NOT NULL, - prio INT DEFAULT 0 NOT NULL, - - CONSTRAINT notification_pk PRIMARY KEY (id), - CONSTRAINT notification_unique UNIQUE (user_id,prio), - CONSTRAINT notification_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE -); diff --git a/sql/00018.sql b/sql/00018.sql deleted file mode 100644 index 261a5b8..0000000 --- a/sql/00018.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamptz USING "time"::timestamptz; - diff --git a/sql/00019.sql b/sql/00019.sql deleted file mode 100644 index 6b170a3..0000000 --- a/sql/00019.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE public.schedule ADD COLUMN remind_minutes int NOT NULL DEFAULT 0; diff --git a/sql/00020.sql b/sql/00020.sql deleted file mode 100644 index 21cd914..0000000 --- a/sql/00020.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE public."user" ADD timezone varchar DEFAULT 'UTC' NOT NULL; -ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamp USING "time"::timestamp; diff --git a/sql/00021.sql b/sql/00021.sql deleted file mode 100644 index 88d7364..0000000 --- a/sql/00021.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE public.node ALTER COLUMN updated TYPE timestamptz USING updated::timestamptz; diff --git a/sql/00022.sql b/sql/00022.sql deleted file mode 100644 index 5f678e1..0000000 --- a/sql/00022.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -CREATE FUNCTION public.password_hash(salt_hex char(32), pass bytea) -RETURNS char(96) -LANGUAGE plpgsql -AS -$$ -BEGIN - RETURN ( - SELECT - salt_hex || - encode( - sha256( - decode(salt_hex, 'hex') || /* salt in binary */ - pass /* password */ - ), - 'hex' - ) - ); -END; -$$; diff --git a/static/css/main.css b/static/css/main.css index 05539e5..75f1925 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,7 +1,14 @@ html { box-sizing: border-box; background: #efede8; - font-size: 14pt; + font-family: "Liberation Mono", monospace; + font-size: 14px; + margin: 0px; + padding: 0px; +} +body { + margin: 0px; + padding: 0px; } *, *:before, diff --git a/static/css/notes2.css b/static/css/notes2.css index 45bf1d3..c3e26ba 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -1,5 +1,12 @@ +html { + background-color: #fff; +} +#notes2 { + display: grid; + grid-template-columns: min-content 1fr; + min-height: 100vh; +} #tree { - grid-area: tree; padding: 16px; background-color: #333; color: #ddd; @@ -30,9 +37,99 @@ #tree .node .children { padding-left: 24px; margin-left: 8px; - border-left: 1px solid #555; + border-left: 1px solid #444; grid-column: 1 / -1; } #tree .node .children.collapsed { display: none; } +#crumbs { + margin: 16px; +} +.crumbs { + display: flex; + flex-wrap: wrap; + padding: 8px 16px; + background: #e4e4e4; + color: #333; + border-radius: 6px; +} +.crumbs .crumb { + margin-right: 8px; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; +} +.crumbs .crumb:after { + content: "•"; + margin-left: 8px; + color: #fe5f55; +} +.crumbs .crumb:last-child { + margin-right: 0; +} +.crumbs .crumb:last-child:after { + content: ''; + margin-left: 0px; +} +/* ============================================================= * + * Textarea replicates the height of an element expanding height * + * ============================================================= */ +.grow-wrap { + /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ + display: grid; + font-size: 1em; +} +.grow-wrap::after { + /* Note the weird space! Needed to preventy jumpy behavior */ + content: attr(data-replicated-value) " "; + /* This is how textarea text behaves */ + width: calc(100% - 32px); + max-width: 900px; + white-space: pre-wrap; + word-wrap: break-word; + color: #f0f; + background: rgba(0, 255, 255, 0.5); + justify-self: center; + /* Hidden from view, clicks, and screen readers */ + visibility: hidden; +} +.grow-wrap > textarea { + /* You could leave this, but after a user resizes, then it ruins the auto sizing */ + resize: none; + /* Firefox shows scrollbar on growth, you can hide like this. */ + overflow: hidden; +} +.grow-wrap > textarea, +.grow-wrap::after { + /* Identical styling required!! */ + padding: 0.5rem; + font: inherit; + /* Place on top of each other */ + grid-area: 1 / 1 / 2 / 2; +} +/* ============================================================= */ +.node-name { + background: #fff; + color: #000; + text-align: center; + font-weight: bold; + margin-top: 32px; + margin-bottom: 32px; + font-size: 1.5em; +} +.node-content { + justify-self: center; + word-wrap: break-word; + font-family: monospace; + color: #333; + width: calc(100% - 32px); + max-width: 900px; + resize: none; + border: none; + outline: none; +} +.node-content:invalid { + background: #f5f5f5; + padding-top: 16px; +} diff --git a/static/js/app.mjs b/static/js/app.mjs index 2511229..999e238 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -8,20 +8,28 @@ const html = htm.bind(h) export class Notes2 { constructor() {//{{{ this.startNode = null - this.tree = createRef() + this.tree = null this.nodeUI = createRef() + this.nodeModified = signal(false) this.setStartNode() }//}}} render() {//{{{ return html` - <${Tree} ref=${this.tree} app=${this} /> - <${NodeUI} app=${this} ref=${this.nodeUI} /> +
+ <${NodeUI} app=${this} ref=${this.nodeUI} /> +
` }//}}} setStartNode() {//{{{ + /* const urlParams = new URLSearchParams(window.location.search) const nodeID = urlParams.get('node') + */ + + const parts = document.URL.split('#') + const nodeID = parts[1] + this.startNode = new Node(this, nodeID ? Number.parseInt(nodeID) : 0) }//}}} @@ -43,6 +51,7 @@ class Tree extends Component { this.treeNodeComponents = {} this.treeTrunk = [] this.selectedTreeNode = null + this.props.app.tree = this this.retrieve() }//}}} @@ -100,6 +109,7 @@ class Tree extends Component { .catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) }) }//}}} setSelected(node) {//{{{ + return // TODO if (this.selectedTreeNode) this.selectedTreeNode.selected.value = false @@ -109,6 +119,7 @@ class Tree extends Component { this.expandToTrunk(node.ID) }//}}} crumbsUpdateNodes(node) {//{{{ + console.log('crumbs', this.props.app.startNode.Crumbs) for (const crumb in this.props.app.startNode.Crumbs) { // Start node is loaded before the tree. const node = this.treeNodes[crumb.ID] diff --git a/static/js/node.mjs b/static/js/node.mjs index a9425af..0095ca8 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -113,7 +113,7 @@ export class NodeUI extends Component { return html` <${menu} /> -
this.saveNode()}> + -
-
${crumbs} +
+
+
${crumbs} +
${page} @@ -134,7 +136,8 @@ export class NodeUI extends Component { async componentDidMount() {//{{{ // When rendered and fetching the node, keys could be needed in order to // decrypt the content. - await this.retrieveKeys() + /* TODO - implement keys. + await this.retrieveKeys() */ this.props.app.startNode.retrieve(node => { this.node.value = node @@ -209,15 +212,13 @@ export class NodeUI extends Component { }//}}} goToNode(nodeID, dontPush) {//{{{ - /* TODO - implement modified values if (this.props.app.nodeModified.value) { if (!confirm("Changes not saved. Do you want to discard changes?")) return } - */ if (!dontPush) - history.pushState({ nodeID }, '', `/?node=${nodeID}`) + history.pushState({ nodeID }, '', `/notes2#${nodeID}`) // New node is fetched in order to retrieve content and files. // Such data is unnecessary to transfer for tree/navigational purposes. @@ -229,7 +230,7 @@ export class NodeUI extends Component { // Tree needs to know another node is selected, in order to render any // previously selected node not selected. - this.props.app.tree.setSelected(node) + //this.props.app.tree.setSelected(node) // Hide tree toggle, as this would be the next natural action to do manually anyway. // At least in mobile mode. @@ -335,7 +336,7 @@ class NodeContent extends Component { ` } - var element + let element if (node.RenderMarkdown.value) element = html`<${MarkdownContent} key='markdown-content' content=${content} />` else @@ -350,6 +351,17 @@ class NodeContent extends Component { componentDidMount() {//{{{ this.resize() window.addEventListener('resize', () => this.resize()) + + const contentResizeObserver = new ResizeObserver(entries => { + for (const idx in entries) { + const w = entries[idx].contentRect.width + document.getElementById('crumbs').style.width = `${w}px` + } + }); + + const nodeContent = document.getElementById('node-content') + contentResizeObserver.observe(nodeContent); + }//}}} componentDidUpdate() {//{{{ this.resize() @@ -361,7 +373,7 @@ class NodeContent extends Component { this.resize() }//}}} resize() {//{{{ - let textarea = document.getElementById('node-content') + const textarea = document.getElementById('node-content') if (textarea) textarea.parentNode.dataset.replicatedValue = textarea.value }//}}} @@ -467,27 +479,29 @@ export class Node { // Used to expand the crumbs upon site loading. }//}}} retrieve(callback) {//{{{ + nodeStore.get(this.ID).then(node => { + this.ParentID = node.ParentID + this.UserID = node.UserID + this.CryptoKeyID = node.CryptoKeyID + this.Name = node.Name + this._content = node.Content + this.Children = node.Children + this.Crumbs = node.Crumbs + this.Files = node.Files + this.Markdown = node.Markdown + //this.RenderMarkdown.value = this.Markdown + this.initChecklist(node.ChecklistGroups) + callback(this) + }) + .catch(e => { console.log(e); alert(e) }) + + /* TODO - implement schedules this.app.request('/schedule/list', { NodeID: this.ID }) .then(res => { this.ScheduleEvents.value = res.ScheduleEvents }) + */ - this.app.request('/node/retrieve', { ID: this.ID }) - .then(res => { - this.ParentID = res.Node.ParentID - this.UserID = res.Node.UserID - this.CryptoKeyID = res.Node.CryptoKeyID - this.Name = res.Node.Name - this._content = res.Node.Content - this.Children = res.Node.Children - this.Crumbs = res.Node.Crumbs - this.Files = res.Node.Files - this.Markdown = res.Node.Markdown - this.RenderMarkdown.value = this.Markdown - this.initChecklist(res.Node.ChecklistGroups) - callback(this) - }) - .catch(this.app.responseError) }//}}} delete(callback) {//{{{ this.app.request('/node/delete', { @@ -1044,7 +1058,7 @@ class ScheduleEventListTab extends Component { if (evt.RemindMinutes > 0) return html`${evt.RemindMinutes} min` } - const nodeLink = () => html`${evt.Node.Name}` + const nodeLink = () => html`${evt.Node.Name}` return html` @@ -1108,12 +1122,12 @@ class ScheduleCalendarTab extends Component { return { title: sch.Description, start: sch.Time, - url: `/?node=${sch.Node.ID}`, + url: `/notes2#${sch.Node.ID}`, } }) successCallback(fullcalendarEvents) }) - .catch(err=>failureCallback(err)) + .catch(err => failureCallback(err)) } } diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index 5fa6269..a7cb684 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -10,13 +10,15 @@ export class NodeStore { }//}}} async initializeDB() {//{{{ return new Promise((resolve, reject) => { - const req = indexedDB.open('notes', 2) + const req = indexedDB.open('notes', 3) // Schema upgrades for IndexedDB. // These can start from different points depending on updates to Notes2 since a device was online. req.onupgradeneeded = (event) => { - let store + let treeNodes + let nodes + let appState const db = event.target.result const trx = event.target.transaction @@ -26,12 +28,19 @@ export class NodeStore { // The schema transformations. switch (i) { case 1: - store = db.createObjectStore('nodes', { keyPath: 'ID' }) - store.createIndex('nameIndex', 'Name', { unique: false }) + treeNodes = db.createObjectStore('treeNodes', { keyPath: 'UUID' }) + treeNodes.createIndex('nameIndex', 'Name', { unique: false }) + + nodes = db.createObjectStore('nodes', { keyPath: 'UUID' }) + nodes.createIndex('nameIndex', 'Name', { unique: false }) break + case 2: - trx.objectStore('nodes').createIndex('parentIndex', 'ParentID', { unique: false }) + trx.objectStore('treeNodes').createIndex('parentIndex', 'ParentUUID', { unique: false }) break + + case 3: + appState = db.createObjectStore('appState', { keyPath: 'key' }) } } } @@ -47,6 +56,78 @@ export class NodeStore { }) }//}}} + async getAppState(key) {//{{{ + return new Promise((resolve, reject) => { + const trx = this.db.transaction('appState', 'readonly') + const appState = trx.objectStore('appState') + const getRequest = appState.get(key) + getRequest.onsuccess = (event) => { + if (event.target.result !== undefined) { + resolve(event.target.result) + } else { + resolve(null) + } + } + getRequest.onerror = (event) => reject(event.target.error) + }) + }//}}} + async setAppState(key, value) {//{{{ + return new Promise((resolve, reject) => { + try { + const t = this.db.transaction('appState', 'readwrite') + const appState = t.objectStore('appState') + t.onerror = (event) => { + console.log('transaction error', event.target.error) + reject(event.target.error) + } + t.oncomplete = () => { + resolve() + } + + const record = { key, value } + const addReq = appState.put(record) + addReq.onerror = (event) => { + console.log('Error!', event.target.error, key, value) + } + } catch (e) { + reject(e) + } + }) + }//}}} + + async updateTreeRecords(records) {//{{{ + return new Promise((resolve, reject) => { + try { + let max = 0 + const t = this.db.transaction('treeNodes', 'readwrite') + const nodeStore = t.objectStore('treeNodes') + t.onerror = (event) => { + console.log('transaction error', event.target.error) + reject(event.target.error) + } + t.oncomplete = () => { + console.log(max) + resolve(max) + } + + // records is an object, not an array. + for (const i in records) { + const record = records[i] + const addReq = nodeStore.put(record) + addReq.onsuccess = () => { + max = Math.max(max, record.CreatedSeq, record.UpdatedSeq, record.DeletedSeq.Int64) + console.log('OK!', record.UUID, record.Name) + } + addReq.onerror = (event) => { + console.log('Error!', event.target.error, record.UUID) + } + } + + } catch (e) { + console.log(e) + } + }) + }//}}} async add(records) {//{{{ return new Promise((resolve, reject) => { try { @@ -60,7 +141,9 @@ export class NodeStore { resolve() } - for (const record in records) { + // records is an object, not an array. + for (const recordIdx in records) { + const record = records[recordIdx] const addReq = nodeStore.put(record) addReq.onsuccess = () => { console.log('OK!', record.ID, record.Name) diff --git a/static/js/sync.mjs b/static/js/sync.mjs new file mode 100644 index 0000000..12c080b --- /dev/null +++ b/static/js/sync.mjs @@ -0,0 +1,19 @@ +import { API } from 'api' + +export class Sync { + static async tree() { + let oldMax = 0 + nodeStore.getAppState('latest_sync') + .then(state => { + if (state !== null) { + oldMax = state.value + return state.value + } + return 0 + }) + .then(sequence => API.query('POST', `/node/tree/${sequence}`, {})) + .then(res => nodeStore.updateTreeRecords(res.Nodes)) + .then(newMax => nodeStore.setAppState('latest_sync', Math.max(oldMax, newMax))) + .catch(e => alert(e)) + } +} diff --git a/static/less/main.less b/static/less/main.less index d2b9e2c..ab5ba49 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -3,7 +3,15 @@ html { box-sizing: border-box; background: @color2; - font-size: 14pt; + font-family: "Liberation Mono", monospace; + font-size: 14px; + margin: 0px; + padding: 0px; +} + +body { + margin: 0px; + padding: 0px; } *, diff --git a/static/less/notes2.less b/static/less/notes2.less index 8b728ff..4ef3027 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -1,7 +1,17 @@ @import "theme.less"; +html { + background-color: #fff; +} + +#notes2 { + display: grid; + grid-template-columns: min-content 1fr; + min-height: 100vh; +} + #tree { - grid-area: tree; + //grid-area: tree; padding: 16px; background-color: #333; color: #ddd; @@ -41,7 +51,7 @@ .children { padding-left: 24px; margin-left: 8px; - border-left: 1px solid #555; + border-left: 1px solid #444; grid-column: 1 / -1; &.collapsed { @@ -50,3 +60,110 @@ } } } + +#crumbs { + //grid-area: crumbs; + margin: 16px; +} + +.crumbs { + display: flex; + flex-wrap: wrap; + padding: 8px 16px; + background: #e4e4e4; + color: #333; + border-radius: 6px; + + .crumb { + margin-right: 8px; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + } + + .crumb:after { + content: "•"; + margin-left: 8px; + color: @color1 + } + + .crumb:last-child { + margin-right: 0; + } + .crumb:last-child:after { + content: ''; + margin-left: 0px; + } + +} + +/* ============================================================= * + * Textarea replicates the height of an element expanding height * + * ============================================================= */ +.grow-wrap { + /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ + display: grid; + //grid-area: content; + font-size: 1.0em; +} +.grow-wrap::after { + /* Note the weird space! Needed to preventy jumpy behavior */ + content: attr(data-replicated-value) " "; + + /* This is how textarea text behaves */ + width: calc(100% - 32px); + max-width: 900px; + white-space: pre-wrap; + word-wrap: break-word; + color: #f0f; + background: rgba(0, 255, 255, 0.5); + justify-self: center; + + /* Hidden from view, clicks, and screen readers */ + visibility: hidden; +} +.grow-wrap > textarea { + /* You could leave this, but after a user resizes, then it ruins the auto sizing */ + resize: none; + + /* Firefox shows scrollbar on growth, you can hide like this. */ + overflow: hidden; +} +.grow-wrap > textarea, +.grow-wrap::after { + /* Identical styling required!! */ + padding: 0.5rem; + font: inherit; + + /* Place on top of each other */ + grid-area: 1 / 1 / 2 / 2; +} +/* ============================================================= */ +.node-name { + background: #fff; + color: #000; + text-align: center; + font-weight: bold; + margin-top: 32px; + margin-bottom: 32px; + font-size: 1.5em; +} + +.node-content { + //grid-area: content; + justify-self: center; + word-wrap: break-word; + font-family: monospace; + color: #333; + width: calc(100% - 32px); + max-width: 900px; + resize: none; + border: none; + outline: none; + + + &:invalid { + background: #f5f5f5; + padding-top: 16px; + } +} diff --git a/views/layouts/main.gotmpl b/views/layouts/main.gotmpl index 1931f13..62a4e62 100644 --- a/views/layouts/main.gotmpl +++ b/views/layouts/main.gotmpl @@ -24,6 +24,7 @@ "htm": "/js/{{ .VERSION }}/lib/htm/htm.mjs", "api": "/js/{{ .VERSION }}/api.mjs", + "sync": "/js/{{ .VERSION }}/sync.mjs", "key": "/js/{{ .VERSION }}/key.mjs", "checklist": "/js/{{ .VERSION }}/checklist.mjs", "crypto": "/js/{{ .VERSION }}/crypto.mjs", diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index 9894ce3..cd4c43b 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,4 +1,5 @@ {{ define "page" }} +
+
+{{ end }}