From 1f24f1f2f2b85ee352a4dae5f0745ac4effe2f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 9 Jun 2026 17:03:01 +0200 Subject: [PATCH 01/46] Sync shows/disappears --- static/css/markdown.css | 76 +++++++++++++++++++++-------------- static/css/notes2.css | 12 +++++- static/js/marked_position.mjs | 2 +- static/js/sync.mjs | 21 +++++----- 4 files changed, 67 insertions(+), 44 deletions(-) diff --git a/static/css/markdown.css b/static/css/markdown.css index bd68c7b..85cde2c 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -4,53 +4,69 @@ .heading-container { display: grid; grid-template-columns: min-content 1fr; - grid-gap: 16px; + grid-gap: 12px; white-space: nowrap; align-items: center; - margin-top: 32px; - margin-bottom: 8px; + margin-bottom: 16px; &:first-child { - margin-top: 32px; + margin-top: 32px !important; + .line { + display: none !important; + } } .line { border-bottom: 1px solid var(--line-color); } - } - h1 { - border-bottom: 1px solid #ccc; + &[data-heading="1"] { + margin-top: 64px; + } - display: inline-block; - font-size: 1.25em; + &[data-heading="2"], + &[data-heading="3"] { + margin-top: 16px; - border-radius: 8px; - color: #fff; - background-color: var(--color1); - padding: 4px 12px; + .line { + display: none; + } + } - margin-top: 0px; - margin-bottom: 0px; + h1 { + border-bottom: 1px solid #ccc; - } + display: inline-block; + font-size: 1.25em; - h2 { - font-size: 1.25em; - margin-top: 0px; - margin-bottom: 0px; - color: var(--color1); - } + clip-path: polygon(0 0, 100% 0, calc(100% - 16px) 100%, 0 100%); - h2+.line, - h3+.line { - border-bottom: none !important; - } + color: #fff; + background-color: var(--color1); + padding: 4px 24px 4px 16px; + + margin-top: 0px; + margin-bottom: 0px; + + } + + h2 { + font-size: 1.25em; + margin-top: 16px; + margin-bottom: 0px; + color: var(--color1); + } + + h3 { + margin: 0; + + &:before { + font-size: 1.0em; + content: "> "; + color: var(--color1); + } + } - h3:before { - font-size: 1.0em; - content: "> "; - color: var(--color1); } a { diff --git a/static/css/notes2.css b/static/css/notes2.css index f55e1a1..76c660b 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -307,7 +307,8 @@ button { } n2-syncprogress { - position: absolute; + display: grid; + position: fixed; top: 8px; right: 8px; padding: 8px 16px; @@ -316,8 +317,15 @@ n2-syncprogress { font-weight: bold; background-color: var(--color1); color: #fff; + box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px; + + opacity: 0; + transition: opacity 250ms; + + &.show { + opacity: 1; + } - display: grid; grid-template-columns: min-content repeat(3, min-content); grid-gap: 8px 8px; white-space: nowrap; diff --git a/static/js/marked_position.mjs b/static/js/marked_position.mjs index 175f490..311e806 100644 --- a/static/js/marked_position.mjs +++ b/static/js/marked_position.mjs @@ -115,7 +115,7 @@ export class MarkedPosition { heading(token) { const content = this.parser.parseInline(token.tokens) return ` -
+
${content}\n
\n
diff --git a/static/js/sync.mjs b/static/js/sync.mjs index 4db4473..f0c9296 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -19,7 +19,6 @@ export class Sync { let nodeCountDownload = await this.getNodeCount(oldMax) let nodeCountUpload = await nodeStore.sendQueue.count() - console.log(nodeCountUpload) _mbus.dispatch('SYNC_DOWNLOAD_COUNT', { count: nodeCountDownload }) _mbus.dispatch('SYNC_UPLOAD_COUNT', { count: nodeCountUpload }) @@ -81,11 +80,11 @@ export class Sync { handled++ if (handled % 100 === 0) - _mbus.dispatch('SYNC_HANDLED', { handled }) + _mbus.dispatch('SYNC_DOWNLOADED', { handled }) } } while (res.Continue) - _mbus.dispatch('SYNC_HANDLED', { handled }) + _mbus.dispatch('SYNC_DOWNLOADED', { handled }) nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { @@ -178,11 +177,12 @@ export class N2SyncProgress extends CustomHTMLElement { super() this.reset() + _mbus.subscribe('SYNC_DOWNLOAD_COUNT', event => this.progressHandler(event)) _mbus.subscribe('SYNC_UPLOAD_COUNT', event => this.progressHandler(event)) - _mbus.subscribe('SYNC_HANDLED', event => this.progressHandler(event)) - _mbus.subscribe('SYNC_DONE', event => this.progressHandler(event)) + _mbus.subscribe('SYNC_DOWNLOADED', event => this.progressHandler(event)) _mbus.subscribe('SYNC_UPLOADED', event => this.progressHandler(event)) + _mbus.subscribe('SYNC_DONE', event => this.progressHandler(event)) }//}}} reset() {//{{{ this.state = { @@ -205,11 +205,14 @@ export class N2SyncProgress extends CustomHTMLElement { this.setSyncState(true) break - case 'SYNC_HANDLED': - console.log('SYNC_HANDLED', eventData.handled) + case 'SYNC_DOWNLOADED': this.state.nodesSynced = eventData.handled break + case 'SYNC_UPLOADED': + this.state.nodesUploaded += eventData.count + break + case 'SYNC_DONE': // Hides the progress bar. this.setSyncState(false) @@ -221,10 +224,6 @@ export class N2SyncProgress extends CustomHTMLElement { // Reload the tree nodes to reflect the new/updated nodes. window._app.tree.reset() break - - case 'SYNC_UPLOADED': - this.state.nodesUploaded += eventData.count - break } this.render() }//}}} From be7f5dbf30263173fb5f38ac5d6c522b39821f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 9 Jun 2026 17:23:44 +0200 Subject: [PATCH 02/46] Sync better --- node.go | 4 ---- static/css/markdown.css | 14 ++++++-------- static/css/notes2.css | 4 ++++ static/js/sidebar.mjs | 4 +--- static/js/sync.mjs | 20 +++++++++++++------- 5 files changed, 24 insertions(+), 22 deletions(-) diff --git a/node.go b/node.go index 0beb1b1..c8afe94 100644 --- a/node.go +++ b/node.go @@ -54,10 +54,6 @@ type Node struct { Content string ContentEncrypted string `db:"content_encrypted" json:"-"` Markdown bool - - // CryptoKeyID int `db:"crypto_key_id"` - //Files []File - //ChecklistGroups []ChecklistGroup } func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ diff --git a/static/css/markdown.css b/static/css/markdown.css index 85cde2c..1ecbc94 100644 --- a/static/css/markdown.css +++ b/static/css/markdown.css @@ -22,6 +22,7 @@ &[data-heading="1"] { margin-top: 64px; + margin-bottom: 32px; } &[data-heading="2"], @@ -33,6 +34,10 @@ } } + h1, h2, h3 { + margin: 0; + } + h1 { border-bottom: 1px solid #ccc; @@ -45,21 +50,14 @@ background-color: var(--color1); padding: 4px 24px 4px 16px; - margin-top: 0px; - margin-bottom: 0px; - } h2 { font-size: 1.25em; - margin-top: 16px; - margin-bottom: 0px; color: var(--color1); } h3 { - margin: 0; - &:before { font-size: 1.0em; content: "> "; @@ -85,7 +83,7 @@ table { border: 1px solid #ccc; border-collapse: collapse; - margin-top: 14px; + margin-top: 16px; th { text-align: left; diff --git a/static/css/notes2.css b/static/css/notes2.css index 76c660b..44618cf 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -326,6 +326,10 @@ n2-syncprogress { opacity: 1; } + &.ok { + background-color: #5aa02c; + } + grid-template-columns: min-content repeat(3, min-content); grid-gap: 8px 8px; white-space: nowrap; diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index da05750..23c78c0 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -117,7 +117,6 @@ export class N2Sidebar extends CustomHTMLElement { this.tabIndex = 0 this.treeNodeComponents = {} - this.treeTrunk = [] this.expandedNodes = {} // keyed on UUID this.selectedNode = null this.rendered = false @@ -170,10 +169,9 @@ export class N2Sidebar extends CustomHTMLElement { }// }}} reset() {// {{{ this.treeNodeComponents = {} - this.treeTrunk = [] this.rendered = false this.elTreenodes.replaceChildren() - this.populateFirstLevel() + this.render() }// }}} getNodeExpanded(UUID) {//{{{ if (this.expandedNodes[UUID] === undefined) diff --git a/static/js/sync.mjs b/static/js/sync.mjs index f0c9296..35485eb 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -20,6 +20,7 @@ export class Sync { let nodeCountDownload = await this.getNodeCount(oldMax) let nodeCountUpload = await nodeStore.sendQueue.count() + _mbus.dispatch('SYNC_START') _mbus.dispatch('SYNC_DOWNLOAD_COUNT', { count: nodeCountDownload }) _mbus.dispatch('SYNC_UPLOAD_COUNT', { count: nodeCountUpload }) @@ -175,9 +176,8 @@ export class N2SyncProgress extends CustomHTMLElement { }// }}} constructor() {//{{{ super() - this.reset() - + _mbus.subscribe('SYNC_START', () => this.reset()) _mbus.subscribe('SYNC_DOWNLOAD_COUNT', event => this.progressHandler(event)) _mbus.subscribe('SYNC_UPLOAD_COUNT', event => this.progressHandler(event)) _mbus.subscribe('SYNC_DOWNLOADED', event => this.progressHandler(event)) @@ -185,12 +185,14 @@ export class N2SyncProgress extends CustomHTMLElement { _mbus.subscribe('SYNC_DONE', event => this.progressHandler(event)) }//}}} reset() {//{{{ + this.classList.remove('ok') this.state = { nodesToDownload: 0, nodesToUpload: 0, - nodesSynced: 0, + nodesDowloaded: 0, nodesUploaded: 0, } + this.render() }//}}} progressHandler(event) {//{{{ const eventData = event.detail.data @@ -206,7 +208,7 @@ export class N2SyncProgress extends CustomHTMLElement { break case 'SYNC_DOWNLOADED': - this.state.nodesSynced = eventData.handled + this.state.nodesDowloaded = eventData.handled break case 'SYNC_UPLOADED': @@ -214,23 +216,26 @@ export class N2SyncProgress extends CustomHTMLElement { break case 'SYNC_DONE': + this.classList.add('ok') + // Hides the progress bar. this.setSyncState(false) // Don't update anything if nothing was synced. - if (this.state.nodesSynced === 0) + if (this.state.nodesDowloaded === 0) break // Reload the tree nodes to reflect the new/updated nodes. - window._app.tree.reset() + window._app.sidebar.reset() break } this.render() }//}}} render() {//{{{ - this.elDownloadTransferred.innerText = this.state.nodesSynced + this.elDownloadTransferred.innerText = this.state.nodesDowloaded this.elDownloadTotal.innerText = this.state.nodesToDownload + console.log('setting elUploadTransferred', this.state.nodesUploaded) this.elUploadTransferred.innerText = this.state.nodesUploaded this.elUploadTotal.innerText = this.state.nodesToUpload }//}}} @@ -238,6 +243,7 @@ export class N2SyncProgress extends CustomHTMLElement { if (state) this.classList.add('show') else + // Give the user a chance to see what it ended on. setTimeout(() => this.classList.remove('show'), 1500) }// }}} } From 3e8d5b6d9a58f3e197d8c8b9c5d31cf5ccfbf026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 10 Jun 2026 08:03:33 +0200 Subject: [PATCH 03/46] Fixes for HistoryUUID --- main.go | 8 +-- node.go | 6 +- sql/00001.sql | 12 ++-- sql/00003.sql | 1 + sql/00004.sql | 135 +++++++++++++++++++++++++++++++++++++++ static/js/node_store.mjs | 31 ++++++++- static/js/page_node.mjs | 1 + 7 files changed, 176 insertions(+), 18 deletions(-) create mode 100644 sql/00003.sql create mode 100644 sql/00004.sql diff --git a/main.go b/main.go index 875fd00..664a50e 100644 --- a/main.go +++ b/main.go @@ -277,12 +277,6 @@ func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{ return } - /* - Log.Debug("/sync/from_server", "num_nodes", len(nodes), "maxSeq", maxSeq) - foo, _ := json.Marshal(nodes) - os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644) - */ - j, _ := json.Marshal(struct { OK bool Nodes []Node @@ -383,7 +377,7 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ return } - _, err = db.Exec(`CALL add_nodes($1, $2, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData) + _, err = db.Exec(`CALL add_nodes($1, $2::uuid, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData) if err != nil { Log.Error("sync", "error", err) httpError(w, err) diff --git a/node.go b/node.go index c8afe94..6b79769 100644 --- a/node.go +++ b/node.go @@ -44,6 +44,7 @@ type Node struct { UUID string UserID int `db:"user_id"` ParentUUID string `db:"parent_uuid"` + HistoryUUID string `db:"history_uuid"` Name string Created time.Time Updated time.Time @@ -122,7 +123,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, rows, err = db.Queryx(` SELECT uuid, - COALESCE(parent_uuid, '') AS parent_uuid, + COALESCE(parent_uuid, '00000000-0000-0000-0000-000000000000'::uuid) AS parent_uuid, name, created, updated, @@ -137,7 +138,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, public.node WHERE user_id = $1 AND - client != $5 AND + client != $5::uuid AND NOT history AND ( created_seq > $4 OR updated_seq > $4 OR @@ -251,6 +252,7 @@ func RetrieveNodeHistory(userID int, nodeUUID string, offset int) (nodes []Node, rows, err = db.Queryx(` SELECT uuid, + history_uuid, user_id, name, created, diff --git a/sql/00001.sql b/sql/00001.sql index 7eb8273..4aecc91 100644 --- a/sql/00001.sql +++ b/sql/00001.sql @@ -257,7 +257,7 @@ $$; CREATE TABLE public.client ( id integer NOT NULL, user_id integer NOT NULL, - client_uuid character(36) DEFAULT ''::bpchar NOT NULL, + client_uuid uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, created timestamp with time zone DEFAULT now() NOT NULL, description character varying DEFAULT ''::character varying NOT NULL ); @@ -302,8 +302,8 @@ CREATE SEQUENCE public.node_updates CREATE TABLE public.node ( id integer NOT NULL, user_id integer NOT NULL, - uuid character(36) DEFAULT gen_random_uuid() NOT NULL, - parent_uuid character(36), + "uuid" uuid DEFAULT gen_random_uuid() NOT NULL, + parent_uuid uuid, created timestamp with time zone DEFAULT now() NOT NULL, updated timestamp with time zone DEFAULT now() NOT NULL, deleted timestamp with time zone, @@ -315,7 +315,7 @@ CREATE TABLE public.node ( content_encrypted text DEFAULT ''::text NOT NULL, markdown boolean DEFAULT false NOT NULL, history boolean DEFAULT false NOT NULL, - client character(36) DEFAULT ''::bpchar NOT NULL, + client uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, client_sequence integer, CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0)) ); @@ -328,7 +328,7 @@ CREATE TABLE public.node ( CREATE TABLE public.node_history ( id integer NOT NULL, user_id integer NOT NULL, - uuid character(36) NOT NULL, + "uuid" uuid NOT NULL, parents character varying[], created timestamp with time zone NOT NULL, updated timestamp with time zone NOT NULL, @@ -336,7 +336,7 @@ CREATE TABLE public.node_history ( content text NOT NULL, content_encrypted text NOT NULL, markdown boolean DEFAULT false NOT NULL, - client character(36) DEFAULT ''::bpchar NOT NULL, + client uuid DEFAULT '00000000-0000-0000-0000-000000000000'::uuid NOT NULL, client_sequence integer ); diff --git a/sql/00003.sql b/sql/00003.sql new file mode 100644 index 0000000..a0cd4b1 --- /dev/null +++ b/sql/00003.sql @@ -0,0 +1 @@ +ALTER TABLE public.node_history ADD history_uuid uuid NULL; diff --git a/sql/00004.sql b/sql/00004.sql new file mode 100644 index 0000000..eafbad2 --- /dev/null +++ b/sql/00004.sql @@ -0,0 +1,135 @@ +CREATE UNIQUE INDEX node_history_user_id_idx ON public.node_history (user_id,"uuid",history_uuid); + + +ALTER TABLE public.node ALTER COLUMN "uuid" TYPE uuid USING "uuid"::uuid::uuid; +ALTER TABLE public.node ALTER COLUMN parent_uuid TYPE uuid USING parent_uuid::uuid::uuid; +ALTER TABLE public.node ALTER COLUMN client TYPE uuid USING client::uuid::uuid; + + + +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_client_seq int; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + RAISE NOTICE '--------------------------'; + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + (node_data->>'Created')::timestamptz, + (node_data->>'Updated')::timestamptz, + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + (node_data->>'Markdown')::bool, + '', /* content_encrypted */ + p_client_uuid, + (node_data->>'ClientSequence')::int + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client, client_sequence + INTO + db_uuid, db_updated, db_client, db_client_seq + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + + + + -- Is the node not in database? It needs to be created. + IF db_uuid IS NULL THEN + RAISE NOTICE '01 New node %', node_uuid; + + INSERT INTO public."node" ( + user_id, "uuid", parent_uuid, created, updated, + "name", "content", markdown, "content_encrypted", + client, client_sequence + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + (node_data->>'Created')::timestamptz, + (node_data->>'Updated')::timestamptz, + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + (node_data->>'Markdown')::bool, + '', /* content_encrypted */ + p_client_uuid, + (node_data->>'ClientSequence')::int + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + markdown = (node_data->>'Markdown')::bool, + client = p_client_uuid, + client_sequence = (node_data->>'ClientSequence')::int + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index 3bd2701..9920e06 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -57,7 +57,7 @@ export class NodeStore { break case 6: - nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'Updated'] }) + nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'HistoryUUID'] }) break case 7: @@ -471,13 +471,38 @@ class NodeHistoryStore extends SimpleNodeStore { } }) }// }}} + test() { + const uuid = '019ead99-984c-72b6-98f0-814991473ad6' + const lowerBound = [uuid, ''] + const upperBound = [uuid, 'z'] + const range = IDBKeyRange.bound(lowerBound, upperBound) + + const cursor = this.db + .transaction(['nodes', this.storeName], 'readonly') + .objectStore(this.storeName) + .openCursor(range, 'prev') + + cursor.onsuccess = (event) => { + const cursor = event.target.result + if (!cursor) + return + + console.log(cursor.value) + cursor.continue() + } + } + retrievePage(uuid, perPage, page) {// {{{ return new Promise((resolve, _reject) => { + + const lowerBound = [uuid, '00000000-0000-0000-0000-000000000000'] + const upperBound = [uuid, 'ffffffff-ffff-ffff-ffff-ffffffffffff'] + const range = IDBKeyRange.bound(lowerBound, upperBound) + const cursor = this.db .transaction(['nodes', this.storeName], 'readonly') .objectStore(this.storeName) - .index('byUUID') - .openCursor(uuid, 'prev') + .openCursor(range, 'prev') let retrieved = 0 let first = true diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index cca6cf0..2005816 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -422,6 +422,7 @@ export class Node { async save() {//{{{ this.data.Content = this._content this.data.Updated = new Date().toISOString() + this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node. this._modified = false _mbus.dispatch('NODE_UNMODIFIED') From 95a26e67d5409eca7b0de9e6c046b5f67fbac30f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 10 Jun 2026 16:37:33 +0200 Subject: [PATCH 04/46] Better node saving/history --- static/js/app.mjs | 4 --- static/js/page_node.mjs | 56 +++++++++++++++++++++++------------------ static/js/sync.mjs | 3 +-- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/static/js/app.mjs b/static/js/app.mjs index 38aebd4..4a1faec 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -162,10 +162,6 @@ export class App { const nn = Node.create(name, this.currentNode.UUID) nn.save() - - nodeStore.sendQueue.add(nn) - nodeStore.add([nn]) - }//}}} async goToNode(nodeUUID, dontPush, dontExpand) {//{{{ if (nodeUUID === null || nodeUUID === undefined) diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 2005816..8f4feb1 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -115,31 +115,9 @@ export class N2PageNodeUI extends CustomHTMLElement { if (!this.node.isModified()) return - /* The node history is a local store for node history. - * This could be provisioned from the server or cleared if - * deemed unnecessary. - * - * The send queue is what will be sent back to the server - * to have a recorded history of the notes. - * - * A setting to be implemented in the future could be to - * not save the history locally at all. */ - - // The node is still in its old state and will present - // the unmodified content to the node store. - const history = nodeStore.nodesHistory.add(this.node) - - // Prepares the node object for saving. - // Sets Updated value to current date and time. + // node.save takes care of both "nodes" and "nodes_history" stores, also adds it to send queue. + // Sets "Updated" value to current date and time and generates a new history UUID. await this.node.save() - - // Updated node is added to the send queue to be stored on server. - const sendQueue = nodeStore.sendQueue.add(this.node) - - // Updated node is saved to the primary node store. - const nodeStoreAdding = nodeStore.add([this.node]) - - await Promise.all([history, sendQueue, nodeStoreAdding]) }// }}} contentChanged(event) {//{{{ @@ -306,7 +284,7 @@ export class Node { return 0 }//}}} static create(name, parentUUID) {// {{{ - return new Node({ + const node = new Node({ UUID: uuidv7(), Created: (new Date()).toISOString(), Content: '', @@ -315,6 +293,12 @@ export class Node { Markdown: false, History: false, }) + + // Newly created node (not constructed from existing data) is considered modified + // since node.save returns early if it isn't modified. + node._modified = true + + return node }// }}} constructor(nodeData, level) {//{{{ @@ -431,6 +415,28 @@ export class Node { // the ancestry path could be interesting. const ancestors = await nodeStore.getNodeAncestry(this) this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse() + + /* The node history is a local store for node history. + * This could be provisioned from the server or cleared if + * deemed unnecessary. + * + * The send queue is what will be sent back to the server + * to have a recorded history of the notes. + * + * A setting to be implemented in the future could be to + * not save the history locally at all. */ + + // Current node is added to history. It will be duplicated with the "nodes" store + // for simplicity, to hopefully avoid bugs. + const history = nodeStore.nodesHistory.add(this) + + // Updated node is added to the send queue to be stored on server. + const sendQueue = nodeStore.sendQueue.add(this) + + // Updated node is saved to the primary node store. + const nodeStoreAdding = nodeStore.add([this]) + + return Promise.all([history, sendQueue, nodeStoreAdding]) }//}}} } diff --git a/static/js/sync.mjs b/static/js/sync.mjs index 35485eb..b6328aa 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -89,7 +89,7 @@ export class Sync { nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { - console.log('sync node tree', e) + console.error('sync node tree', e) } finally { syncEnd = Date.now() const duration = (syncEnd - syncStart) / 1000 @@ -235,7 +235,6 @@ export class N2SyncProgress extends CustomHTMLElement { this.elDownloadTransferred.innerText = this.state.nodesDowloaded this.elDownloadTotal.innerText = this.state.nodesToDownload - console.log('setting elUploadTransferred', this.state.nodesUploaded) this.elUploadTransferred.innerText = this.state.nodesUploaded this.elUploadTotal.innerText = this.state.nodesToUpload }//}}} From c5831382704b0d2c6f3a42d6b04860cef93325bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 10 Jun 2026 17:08:30 +0200 Subject: [PATCH 05/46] Show newly created node --- static/js/app.mjs | 7 ++++++- static/js/page_node.mjs | 1 - static/js/sidebar.mjs | 7 +++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/static/js/app.mjs b/static/js/app.mjs index 4a1faec..89c0011 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -161,7 +161,12 @@ export class App { return const nn = Node.create(name, this.currentNode.UUID) - nn.save() + await nn.save() + + // Treenode is forcefully rerendered and children refetched to both show the new node + // and to get it resorted. + const treenode = this.sidebar.getTreeNode(this.currentNode.UUID) + treenode.render(true, true) }//}}} async goToNode(nodeUUID, dontPush, dontExpand) {//{{{ if (nodeUUID === null || nodeUUID === undefined) diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 8f4feb1..40997e2 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -291,7 +291,6 @@ export class Node { Name: name, ParentUUID: parentUUID, Markdown: false, - History: false, }) // Newly created node (not constructed from existing data) is considered modified diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 23c78c0..8d5bcbd 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -212,6 +212,9 @@ export class N2Sidebar extends CustomHTMLElement { isSelected(node) {//{{{ return this.selectedNode?.UUID === node.UUID }//}}} + getTreeNode(uuid) {// {{{ + return this.treeNodeComponents[uuid] + }// }}} async keyHandler(event) {//{{{ let handled = true @@ -514,8 +517,8 @@ export class N2TreeNode extends CustomHTMLElement { if (this.rendered && force_update !== true) return this - if (this.sidebar.getNodeExpanded(this.node.UUID)) - await this.fetchChildren() + if (this.sidebar.getNodeExpanded(this.node.UUID) || force_refetch_children) + await this.fetchChildren(force_refetch_children) // Update the name and selected status. this.elName.querySelector('span').innerText = this.node.get('Name') From 9ebda044282b9c041e154c9c805d7f23c7b433d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 10 Jun 2026 17:21:29 +0200 Subject: [PATCH 06/46] Node renaming --- static/js/app.mjs | 5 +++++ static/js/page_node.mjs | 39 ++++++++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/static/js/app.mjs b/static/js/app.mjs index 89c0011..0ce2cb0 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -74,6 +74,11 @@ export class App { keyHandler(event) {//{{{ let handled = true + if (event.key == 'F2') { + this.nodeUI.renameNode() + return + } + // All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. // Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving. // Thus, the exception is acceptable to consequent use of alt+shift. diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 40997e2..93f7c7a 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -63,18 +63,7 @@ export class N2PageNodeUI extends CustomHTMLElement { _mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown())) _mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data)) - this.elName.addEventListener('click', () => { - const name = prompt('Change title', this.node.data.Name) - if (name === null) - return - - try { - this.node.setName(name) - } catch (err) { - console.error(err) - alert(err) - } - }) + this.elName.addEventListener('click', async () => this.renameNode()) this.elNodeContent.addEventListener('input', event => this.contentChanged(event)) this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event)) this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown())) @@ -93,7 +82,7 @@ export class N2PageNodeUI extends CustomHTMLElement { this.node.setContent(this.elNodeContent.value) }) this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' })) - this.elIconSave.addEventListener('click', ()=>this.saveNode()) + this.elIconSave.addEventListener('click', () => this.saveNode()) this.showMarkdown(true) }// }}} @@ -111,6 +100,30 @@ export class N2PageNodeUI extends CustomHTMLElement { } else this.elNodeContent.focus({ preventScroll: true }) }// }}} + async renameNode() { + const name = prompt('Change title', this.node.data.Name) + if (name === null) + return + + try { + // Document isn't only renamed, but also saved at once. + // Not really correct, but good enough to not have to implement + // a separate way to only rename the document. Since history is + // preserved it shouldn't be that horrible. + this.node.setName(name) + await this.node.save() + + // Re-render the parent treenode forcefully to sort it again. + const parentUUID = this.node.ParentUUID + if (!parentUUID) + return + const parentTreeNode = _app.sidebar.getTreeNode(parentUUID) + parentTreeNode?.render(true, true) + } catch (err) { + console.error(err) + alert(err) + } + } async saveNode() {// {{{ if (!this.node.isModified()) return From 8a22cf569fcd409e421ce0e6a9eaa246e7678dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 10 Jun 2026 17:24:13 +0200 Subject: [PATCH 07/46] Cleanup --- static/js/app.mjs | 30 ++---------------------------- static/js/page_node.mjs | 4 ++-- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/static/js/app.mjs b/static/js/app.mjs index 0ce2cb0..112827e 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -87,25 +87,15 @@ export class App { switch (event.key.toUpperCase()) { case 'T': - if (document.activeElement.id === 'tree-nodes') { + if (document.activeElement.id === 'tree-nodes') this.nodeUI.takeFocus() - } else { + else this.sidebar.focus() - } break case 'F': _mbus.dispatch('op-search') break - /* - case 'C': - this.showPage('node') - break - - case 'E': - this.showPage('keys') - break - */ case 'M': globalThis._mbus.dispatch('MARKDOWN_TOGGLE') @@ -115,25 +105,9 @@ export class App { this.createNode() break - /* - case 'P': - this.showPage('node-properties') - break - - */ case 'S': this.nodeUI.saveNode() break - /* - - case 'U': - this.showPage('upload') - break - - case 'F': - this.showPage('search') - break - */ default: handled = false diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 93f7c7a..963a72b 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -100,7 +100,7 @@ export class N2PageNodeUI extends CustomHTMLElement { } else this.elNodeContent.focus({ preventScroll: true }) }// }}} - async renameNode() { + async renameNode() {// {{{ const name = prompt('Change title', this.node.data.Name) if (name === null) return @@ -123,7 +123,7 @@ export class N2PageNodeUI extends CustomHTMLElement { console.error(err) alert(err) } - } + }// }}} async saveNode() {// {{{ if (!this.node.isModified()) return From 31eee4ede57c10124cadfcd59afd8048c44fc78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 10 Jun 2026 18:21:30 +0200 Subject: [PATCH 08/46] Removed old fields --- node.go | 10 ++-- sql/00005.sql | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 sql/00005.sql diff --git a/node.go b/node.go index 6b79769..e3a61d5 100644 --- a/node.go +++ b/node.go @@ -54,7 +54,6 @@ type Node struct { DeletedSeq sql.NullInt64 `db:"deleted_seq"` Content string ContentEncrypted string `db:"content_encrypted" json:"-"` - Markdown bool } func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ @@ -75,7 +74,7 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 public.node WHERE user_id = $1 AND - NOT history AND ( + ( created_seq > $4 OR updated_seq > $4 OR deleted_seq > $4 @@ -132,14 +131,13 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, updated_seq, deleted_seq, content, - content_encrypted, - markdown + content_encrypted FROM public.node WHERE user_id = $1 AND client != $5::uuid AND - NOT history AND ( + ( created_seq > $4 OR updated_seq > $4 OR deleted_seq > $4 @@ -192,7 +190,7 @@ func NodesCount(userID int, synced uint64, clientUUID string) (count int, err er WHERE user_id = $1 AND client != $3 AND - NOT history AND ( + ( created_seq > $2 OR updated_seq > $2 OR deleted_seq > $2 diff --git a/sql/00005.sql b/sql/00005.sql new file mode 100644 index 0000000..b272085 --- /dev/null +++ b/sql/00005.sql @@ -0,0 +1,129 @@ +-- Some cleanup of old columns not used anymore. +DROP INDEX public.node_history_client_idx; +ALTER TABLE public.node_history DROP COLUMN client_sequence; + +ALTER TABLE public.node DROP COLUMN markdown; +DROP INDEX public.node_history_idx; +ALTER TABLE public.node DROP COLUMN history; +ALTER TABLE public.node DROP COLUMN client_sequence; + + + +CREATE OR REPLACE PROCEDURE public.add_nodes(IN p_user_id integer, IN p_client_uuid uuid, IN p_nodes jsonb) + LANGUAGE plpgsql +AS $procedure$ + +DECLARE + node_data jsonb; + node_updated timestamptz; + db_updated timestamptz; + db_uuid uuid; + db_client uuid; + db_history_uuid uuid; + node_uuid uuid; + node_parent_uuid uuid; + node_history_uuid uuid; + +BEGIN + FOR node_data IN SELECT * FROM jsonb_array_elements(p_nodes) + LOOP + node_uuid = (node_data->>'UUID')::uuid; + node_history_uuid = (node_data->>'HistoryUUID')::uuid; + node_updated = (node_data->>'Updated')::timestamptz; + + + + -- Frontend is using an all-zero UUID to define the root node. + -- Database is using NULL. + IF node_data->>'ParentUUID' = '00000000-0000-0000-0000-000000000000' OR node_data->>'ParentUUID' = '' THEN + node_parent_uuid = NULL; + ELSE + node_parent_uuid = (node_data->>'ParentUUID')::uuid; + END IF; + + + + -- Every jode has a new history UUID to keep the history entry uniquely identifiable + -- across clients. A history entry could potentially be sent again, but should be + -- safe to ignore as every change to a node should have a new history UUID. + -- + -- The current node is also stored as history. + INSERT INTO node_history( + user_id, "uuid", "history_uuid", parents, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, -- combined key + node_uuid, -- combined key + node_history_uuid, -- combined key + (jsonb_populate_record(null::json_ancestor_array, node_data))."Ancestors", + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ) + ON CONFLICT ("user_id", "uuid", "history_uuid") + DO NOTHING; + + + + -- Retrieve the current modified timestamp for this node from the database. + SELECT + uuid, updated, client + INTO + db_uuid, db_updated, db_client + FROM public."node" + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + + + + -- Is the node not in database? It needs to be created. + IF db_uuid IS NULL THEN + RAISE NOTICE '01 New node %', node_uuid; + + INSERT INTO public."node" ( + user_id, "uuid", parent_uuid, created, updated, + "name", "content", "content_encrypted", + client + ) + VALUES( + p_user_id, + node_uuid, + node_parent_uuid, + COALESCE((node_data->>'Created')::timestamptz, NOW()), + COALESCE((node_data->>'Updated')::timestamptz, NOW()), + (node_data->>'Name')::varchar, + (node_data->>'Content')::text, + '', /* content_encrypted */ + p_client_uuid + ); + + CONTINUE; + + END IF; + + + + -- Update the public node as well if it was older than incoming node. + IF node_updated > db_updated THEN + UPDATE public."node" + SET + updated = (node_data->>'Updated')::timestamptz, + updated_seq = nextval('node_updates'), + name = (node_data->>'Name')::varchar, + content = (node_data->>'Content')::text, + client = p_client_uuid + WHERE + user_id = p_user_id AND + uuid::uuid = node_uuid::uuid; + END IF; + + END LOOP; +END +$procedure$ +; From b3ca0d29d0ea43c93aa8bcafcba60ee19fe0179c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 10 Jun 2026 20:03:31 +0200 Subject: [PATCH 09/46] Root page override --- static/css/notes2.css | 40 +++++++++++++++++++++++++++++++++++++++ static/js/app.mjs | 19 ++++++++++++++----- static/js/node_store.mjs | 21 -------------------- static/js/page_node.mjs | 5 +++++ static/js/sidebar.mjs | 4 ++-- views/pages/notes2.gotmpl | 10 +++++++++- 6 files changed, 70 insertions(+), 29 deletions(-) diff --git a/static/css/notes2.css b/static/css/notes2.css index 44618cf..b57704d 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -230,6 +230,20 @@ button { } &.node { + &.root-node-override { + #page-root { + display: contents; + } + + #page-node { + display: none; + } + } + + #page-root { + display: none; + } + #page-node { display: contents; } @@ -343,6 +357,32 @@ n2-syncprogress { } } +#page-root { + & > div { + grid-area: content; + align-self: start; + margin-top: 64px; + + display: grid; + justify-items: center; + + /* logo */ + img { + margin-bottom: 16px; + height: 32px; + } + + .create { + border: 2px solid #529b00; + padding: 16px 32px; + margin-top: 64px; + background-color: #d9ffc9; + cursor: pointer; + + } + } +} + /* ============================================================= */ n2-nodeui { diff --git a/static/js/app.mjs b/static/js/app.mjs index 112827e..7601a90 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -16,26 +16,33 @@ export class App { document.getElementById('tree-nodes')?.focus() }) + const mainPage = document.getElementById('main-page') + const determineNodePage = uuid => { + if (uuid == ROOT_NODE) + mainPage.classList.add('root-node-override') + else + mainPage.classList.remove('root-node-override') + } + _mbus.subscribe('TREE_RENDERED', async () => { // Subscribing to the start node existing after the tree trunk is // fetched since the NODE_COMPONENT_EXIST message isn't sent for the // root node itself, and the root node should be selected in the tree // after it is rendered when the site is shown without UUID in the URL. const startNode = await this.getStartNode() - - if (startNode.UUID == ROOT_NODE) - this.goToNode(startNode.UUID, false, false) - else - this.goToNode(startNode.UUID, false, false) + determineNodePage(startNode.UUID) + this.goToNode(startNode.UUID, false, false) }) _mbus.subscribe('TREE_NODE_SELECTED', event => { const node = event.detail.data + determineNodePage(node.UUID) this.goToNode(node.UUID, false, false) }) _mbus.subscribe('GO_TO_NODE', event => { const node = event.detail.data + determineNodePage(node.nodeUUID) this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand) }) @@ -61,6 +68,8 @@ export class App { document.getElementById('node-content')?.focus() }) + document.querySelector('#page-root .create').addEventListener('click', () => this.createNode()) + _mbus.dispatch('SHOW_PAGE', { page: 'node' }) window._sync = new Sync() diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index 9920e06..f31a4b6 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -471,27 +471,6 @@ class NodeHistoryStore extends SimpleNodeStore { } }) }// }}} - test() { - const uuid = '019ead99-984c-72b6-98f0-814991473ad6' - const lowerBound = [uuid, ''] - const upperBound = [uuid, 'z'] - const range = IDBKeyRange.bound(lowerBound, upperBound) - - const cursor = this.db - .transaction(['nodes', this.storeName], 'readonly') - .objectStore(this.storeName) - .openCursor(range, 'prev') - - cursor.onsuccess = (event) => { - const cursor = event.target.result - if (!cursor) - return - - console.log(cursor.value) - cursor.continue() - } - } - retrievePage(uuid, perPage, page) {// {{{ return new Promise((resolve, _reject) => { diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 963a72b..28388f8 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -416,6 +416,11 @@ export class Node { _mbus.dispatch('NODE_MODIFIED', { node: this }) }// }}} async save() {//{{{ + // Just safeguarding not using the root node, + // which sort of exist but isn't supposed to communicate to server. + if (this.UUID == ROOT_NODE) + return + this.data.Content = this._content this.data.Updated = new Date().toISOString() this.data.HistoryUUID = uuidv7() // every time the node is saved a new history UUID identifies the changed node. diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 8d5bcbd..7d73d6a 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -281,7 +281,7 @@ export class N2Sidebar extends CustomHTMLElement { } }//}}} async navigateLeft(n) {//{{{ - if (n === null || n === undefined) + if (n === null || n === undefined || n.UUID == ROOT_NODE) return const expanded = this.getNodeExpanded(n.UUID) @@ -331,7 +331,7 @@ export class N2Sidebar extends CustomHTMLElement { _mbus.dispatch("GO_TO_NODE", { nodeUUID: n.getSiblingAfter()?.UUID, dontPush: false, dontExpand: true }) }//}}} async navigateUp(n) {//{{{ - if (n === null || n === undefined) + if (n === null || n === undefined || n.UUID == ROOT_NODE) return let parent = null diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index 422b672..abec2b0 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -10,10 +10,18 @@
+
+
+ +
{{ .VERSION }}
+ +
Create note
+
+
+
-
From cc2415a06d9265ef9de9e3187167d0f388a70f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 11 Jun 2026 09:08:30 +0200 Subject: [PATCH 10/46] Bumped to v20 --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 664a50e..9dabb27 100644 --- a/main.go +++ b/main.go @@ -23,7 +23,7 @@ import ( "text/template" ) -const VERSION = "v19" +const VERSION = "v20" const CONTEXT_USER = 1 const SYNC_PAGINATION = 200 From 5dac84efdcff92e04c7be3787f21b6174136c9a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Fri, 12 Jun 2026 07:12:38 +0200 Subject: [PATCH 11/46] Cleaner CSS page management --- static/css/notes2.css | 50 ++++++++++++++++++++++++++----------------- static/js/app.mjs | 19 ++++++++-------- 2 files changed, 39 insertions(+), 30 deletions(-) diff --git a/static/css/notes2.css b/static/css/notes2.css index b57704d..017124a 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -218,27 +218,16 @@ button { } } + +/* =============== * + * PAGE MANAGEMENT * + * =============== */ [id^="page-"] { display: none; } -#main-page { - display: contents; - - &:focus-within { - background-color: #faf; - } - - &.node { - &.root-node-override { - #page-root { - display: contents; - } - - #page-node { - display: none; - } - } +#notes2 { + &.page-node { #page-root { display: none; @@ -249,7 +238,7 @@ button { } } - &.storage { + &.page-storage { #page-storage { display: contents; @@ -259,7 +248,7 @@ button { } } - &.history { + &.page-history { #page-history { display: grid; grid-area: n2-pagehistory; @@ -267,6 +256,27 @@ button { n2-pagehistory {} } } + + &.root-node-override { + [id^="page-"] { + display: none !important; + } + + #page-root { + display: contents !important; + } + + } + +} + +#main-page { + display: contents; + + &:focus-within { + background-color: #faf; + } + } #crumbs { @@ -358,7 +368,7 @@ n2-syncprogress { } #page-root { - & > div { + &>div { grid-area: content; align-self: start; margin-top: 64px; diff --git a/static/js/app.mjs b/static/js/app.mjs index 7601a90..a54be2e 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -4,6 +4,8 @@ import { N2Sidebar } from 'sidebar' import { Node } from 'node' export class App { + static PAGES = ['node', 'history', 'storage'] + constructor() {// {{{ this.currentNode = null this.sidebar = new N2Sidebar() @@ -16,12 +18,15 @@ export class App { document.getElementById('tree-nodes')?.focus() }) - const mainPage = document.getElementById('main-page') + // Start node shows a system-wide page instead of node editing + // since the start node is kind of magic and doesn't fit into + // the syncing system. const determineNodePage = uuid => { + const el = document.getElementById('notes2') if (uuid == ROOT_NODE) - mainPage.classList.add('root-node-override') + el.classList.add('root-node-override') else - mainPage.classList.remove('root-node-override') + el.classList.remove('root-node-override') } _mbus.subscribe('TREE_RENDERED', async () => { @@ -47,13 +52,7 @@ export class App { }) _mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => { - let classList = document.querySelector('#main-page').classList - classList.forEach(e => - classList.remove(e) - ) - classList.add(page) - - classList = document.querySelector('#notes2').classList + const classList = document.getElementById('notes2').classList classList.forEach(e => { if (e.startsWith('page-')) classList.remove(e) From 73d87d61c48cf5dad66ca3ce49d56eee1ea5af3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Fri, 12 Jun 2026 07:13:25 +0200 Subject: [PATCH 12/46] Fixed syncing alert not showing the proper error --- static/js/sync.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/sync.mjs b/static/js/sync.mjs index b6328aa..fe72c3f 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -158,7 +158,7 @@ export class Sync { } catch (e) { console.trace(e) - alert(e) + alert(e.error) return } } From 2d036f847a9dd909c1123d531818c58acb27791b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Fri, 12 Jun 2026 07:25:02 +0200 Subject: [PATCH 13/46] Better debuggability for node sync problems --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 9dabb27..a85ca72 100644 --- a/main.go +++ b/main.go @@ -379,7 +379,7 @@ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ _, err = db.Exec(`CALL add_nodes($1, $2::uuid, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData) if err != nil { - Log.Error("sync", "error", err) + Log.Error("sync", "error", err, "user_id", user.UserID, "client_uuid", user.ClientUUID, "node_data", request.NodeData) httpError(w, err) return } From ffb7f4ac53f7ac933d16dc1a4f6017ecfd1a5c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Fri, 12 Jun 2026 08:27:06 +0200 Subject: [PATCH 14/46] Go to newly created node. --- static/js/app.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/js/app.mjs b/static/js/app.mjs index a54be2e..4127b91 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -152,8 +152,9 @@ export class App { // Treenode is forcefully rerendered and children refetched to both show the new node // and to get it resorted. - const treenode = this.sidebar.getTreeNode(this.currentNode.UUID) - treenode.render(true, true) + const parentTreenode = this.sidebar.getTreeNode(this.currentNode.UUID) + await parentTreenode.render(true, true) + _mbus.dispatch('GO_TO_NODE', { nodeUUID: nn.UUID }) }//}}} async goToNode(nodeUUID, dontPush, dontExpand) {//{{{ if (nodeUUID === null || nodeUUID === undefined) From 9af733be641a0cd5f5945633347fd4ebbb9edb05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Fri, 12 Jun 2026 08:47:24 +0200 Subject: [PATCH 15/46] Icons and keybindings for creating sub-documents and sibling documents --- static/css/notes2.css | 2 +- static/images/icon_new_document.svg | 49 +++++++++++++++++++++++++++++ static/js/app.mjs | 35 +++++++++++++-------- static/js/page_node.mjs | 10 +++++- 4 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 static/images/icon_new_document.svg diff --git a/static/css/notes2.css b/static/css/notes2.css index 017124a..04c9f68 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -9,7 +9,7 @@ --line-color: #ccc; --tree-expander: 0px; - --functions-width: 150px; + --functions-width: 180px; } html { diff --git a/static/images/icon_new_document.svg b/static/images/icon_new_document.svg new file mode 100644 index 0000000..a105e05 --- /dev/null +++ b/static/images/icon_new_document.svg @@ -0,0 +1,49 @@ + + + + + + + + file-document-plus-outline + + + diff --git a/static/js/app.mjs b/static/js/app.mjs index 4127b91..876d11d 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -82,19 +82,18 @@ export class App { keyHandler(event) {//{{{ let handled = true - if (event.key == 'F2') { - this.nodeUI.renameNode() - return - } - - // All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. + // Most keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. // Ctrl+S is the exception to using Alt+Shift, since it is overridable and in such widespread use for saving. // Thus, the exception is acceptable to consequent use of alt+shift. - if (!(event.shiftKey && event.altKey) && !(event.key.toUpperCase() === 'S' && event.ctrlKey)) - return + const SHIFT_ALT = event.shiftKey && !event.ctrlKey && event.altKey + const SHIFT_CTRL_ALT = event.shiftKey && event.ctrlKey && event.altKey switch (event.key.toUpperCase()) { + case 'F2': + this.nodeUI.renameNode() + break case 'T': + if (!SHIFT_ALT) break if (document.activeElement.id === 'tree-nodes') this.nodeUI.takeFocus() else @@ -102,18 +101,25 @@ export class App { break case 'F': + if (!SHIFT_ALT) break _mbus.dispatch('op-search') break case 'M': + if (!SHIFT_ALT) break globalThis._mbus.dispatch('MARKDOWN_TOGGLE') break case 'N': - this.createNode() + if (SHIFT_ALT) + this.createNode() + else if (SHIFT_CTRL_ALT) { + this.createNode(this.currentNode?.ParentUUID) + } break case 'S': + if (!SHIFT_ALT) break this.nodeUI.saveNode() break @@ -142,17 +148,20 @@ export class App { async saveNode() {//{{{ }//}}} - async createNode() {//{{{ - let name = prompt("Name") + async createNode(createUnderUUID) {//{{{ + const parentUUID = createUnderUUID ? createUnderUUID : this.currentNode.UUID + const p = createUnderUUID ? 'Name for sibling document' : 'Name for sub-document' + + let name = prompt(p) if (!name) return - const nn = Node.create(name, this.currentNode.UUID) + const nn = Node.create(name, parentUUID) await nn.save() // Treenode is forcefully rerendered and children refetched to both show the new node // and to get it resorted. - const parentTreenode = this.sidebar.getTreeNode(this.currentNode.UUID) + const parentTreenode = this.sidebar.getTreeNode(parentUUID) await parentTreenode.render(true, true) _mbus.dispatch('GO_TO_NODE', { nodeUUID: nn.UUID }) }//}}} diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 28388f8..b86b172 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -9,10 +9,11 @@ export class N2PageNodeUI extends CustomHTMLElement { + + ` + }// }}} + constructor() {// {{{ + super(true) + + document.addEventListener('dragover', e => { + this.style.left = `${e.clientX + 8}px` + this.style.top = `${e.clientY}px` + }) + + this.dragTarget = null + }// }}} + start() {// {{{ + this.style.display = 'block' + }// }}} + end() {// {{{ + this.style.display = 'none' + }// }}} + icon(name) {// {{{ + if (name != '') + name = '_' + name + this.elIcon.setAttribute('src', `/images/${_VERSION}/icon_drag${name}.svg`) + }// }}} + setTarget(t) {// {{{ + this.dragTarget = t + }// }}} + getTarget() {// {{{ + return this.dragTarget + }// }}} +} + +customElements.define('n2-crumbs', N2Crumbs) +customElements.define('n2-crumb', N2Crumb) +customElements.define('n2-dragicon', N2DragIcon) + // vim: foldmethod=marker diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index f31a4b6..324f004 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -247,6 +247,7 @@ export class NodeStore { nodeStore = t.objectStore('nodes') t.oncomplete = (_event) => { + console.log('complete') resolve() } @@ -358,6 +359,7 @@ class SimpleNodeStore { // Node to be moved is first stored in the new queue. const req = store.put(node.data) req.onsuccess = () => { + console.log('here') resolve() } req.onerror = (event) => { diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 834e22f..f65afb9 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -422,6 +422,11 @@ export class Node { getParent() {//{{{ return this._parent }//}}} + moveToParent(newParentUUID) {// {{{ + this.ParentUUID = newParentUUID + this.data.ParentUUID = newParentUUID + this._modified = true + }// }}} isLastSibling() {//{{{ return this._sibling_after === null }//}}} @@ -463,9 +468,10 @@ export class Node { // When stored into database and ancestry was changed, // the ancestry path could be interesting. + /* const ancestors = await nodeStore.getNodeAncestry(this) this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse() - + */ /* The node history is a local store for node history. * This could be provisioned from the server or cleared if * deemed unnecessary. @@ -481,12 +487,17 @@ export class Node { const history = nodeStore.nodesHistory.add(this) // Updated node is added to the send queue to be stored on server. + const sendQueue = nodeStore.sendQueue.add(this) // Updated node is saved to the primary node store. const nodeStoreAdding = nodeStore.add([this]) - return Promise.all([history, sendQueue, nodeStoreAdding]) + console.log('waiting') + await Promise.all([history, sendQueue, nodeStoreAdding]) + console.log('waiting done') + + return }//}}} } diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 7d73d6a..561de8d 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -449,15 +449,20 @@ export class N2Sidebar extends CustomHTMLElement { treenode?.scrollIntoView({ block: 'nearest' }) }// }}} } -customElements.define('n2-sidebar', N2Sidebar) export class N2TreeNode extends CustomHTMLElement { + static DRAG_ICON = new Image() + static DRAG_ICON_OK = new Image() + static {// {{{ + N2TreeNode.DRAG_ICON.src = `/images/${_VERSION}/leaf.svg` + N2TreeNode.DRAG_ICON_OK.src = `/images/${_VERSION}/expanded.svg` + this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
- +
@@ -490,6 +545,7 @@ export class N2TreeNode extends CustomHTMLElement { constructor(sidebar, node, parent) {//{{{ super() + this.setAttribute('draggable', 'true') this.classList.add('node') this.sidebar = sidebar @@ -498,6 +554,7 @@ export class N2TreeNode extends CustomHTMLElement { this.children_populated = false this.rendered = false + this.dragNode = null this.elExpandToggle.addEventListener('click', () => this.sidebar.setNodeExpanded(this.node, !this.sidebar.getNodeExpanded(this.node.UUID))) this.elName.addEventListener('click', () => _mbus.dispatch('TREE_NODE_SELECTED', this.node)) @@ -505,6 +562,70 @@ export class N2TreeNode extends CustomHTMLElement { _mbus.subscribe(`NODE_EXPAND_${node.UUID}`, _state => { this.render(true) }) + + // Drag-and-dropping of nodes + this.addEventListener('dragstart', event => this.dragStart(event)) + this.addEventListener('dragend', event => this.dragEnd(event)) + this.addEventListener('dragover', event => this.dragOver(event)) + this.addEventListener('drop', event => this.dragDrop(event)) + this.elName.addEventListener('dragenter', event => this.dragEnter(event)) + this.elName.addEventListener('dragleave', event => this.dragLeave(event)) + }// }}} + dragStart(e) {// {{{ + if (this.node.isModified()) { + alert('Save note before moving it.') + e.stopPropagation() + e.preventDefault() + return + } + + this.classList.add('drag-source') + const blankPixel = new Image() + blankPixel.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + e.dataTransfer.setDragImage(blankPixel, 0, 0) + e.dataTransfer.allowedEffects = 'none' + e.stopPropagation() + _app.dragIcon.start() + }// }}} + dragEnd(e) {// {{{ + this.classList.remove('drag-source') + _app.dragIcon.end() + e.stopPropagation() + }// }}} + dragOver(e) {// {{{ + e.dataTransfer.dropEffect = 'move' + e.preventDefault() + }// }}} + async dragDrop(e) {// {{{ + e.stopPropagation() + const moveToNode = _app.dragIcon.getTarget() + await _app.moveNode(this.node, moveToNode.node.UUID) + return + + _app.sidebar.setNodeExpanded(moveToNode, true) + await this.render(true, true) + await moveToNode.render(true, true) + + this.dragLeave(e) + }// }}} + dragEnter(e) {// {{{ + const targetNode = e.target.closest('n2-treenode') + if (targetNode.classList.contains('drag-source')) + return + e.stopPropagation() + _app.dragIcon.icon('ok') + this.classList.add('drag-target') + + _app.dragIcon.setTarget(this) + }// }}} + dragLeave(e) {// {{{ + e.stopPropagation() + e.dataTransfer.dropEffect = 'none' + e.dataTransfer.setDragImage(N2TreeNode.DRAG_ICON, -16, 8) + _app.dragIcon.icon('') + this.classList.remove('drag-target') + + _app.dragIcon.setTarget(null) }// }}} async fetchChildren(force_fetch) {//{{{ if (this.children_populated && !force_fetch) @@ -575,6 +696,8 @@ export class N2TreeNode extends CustomHTMLElement { img.setAttribute('src', newSrc) }// }}} } + +customElements.define('n2-sidebar', N2Sidebar) customElements.define('n2-treenode', N2TreeNode) // vim: foldmethod=marker diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index abec2b0..381d672 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,4 +1,7 @@ {{ define "page" }} + + +
>
From 53d8d16086d8941b35b348ab4008a08513535171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Sun, 14 Jun 2026 14:36:52 +0200 Subject: [PATCH 22/46] Initial work on node menu --- static/css/notes2.css | 2 +- static/js/page_node.mjs | 17 ++++++++++++++++- views/pages/notes2.gotmpl | 2 ++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/static/css/notes2.css b/static/css/notes2.css index 079e3c1..dfe4156 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -9,7 +9,7 @@ --line-color: #ccc; --tree-expander: 0px; - --functions-width: 180px; + --functions-width: 216px; } html { diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index f65afb9..f6aa681 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -9,7 +9,7 @@ export class N2PageNodeUI extends CustomHTMLElement { +
+ + + + +
+ ` + }// }}} + constructor() {// {{{ + super() + }// }}} +} +customElements.define('n2-nodemenu', N2NodeMenu) + export class N2PageNodeUI extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
@@ -28,11 +92,13 @@ export class N2PageNodeUI extends CustomHTMLElement {
- - - + +
+ ` }// }}} @@ -41,7 +107,6 @@ export class N2PageNodeUI extends CustomHTMLElement { this.node = null this.style.display = 'contents' - this.classList.add('show-markdown') // TODO Should probably be moved to settings. this.marked = new MarkedPosition() _mbus.subscribe('NODE_UI_OPEN', event => { @@ -70,11 +135,27 @@ export class N2PageNodeUI extends CustomHTMLElement { _mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data)) _mbus.subscribe('MARKDOWN_CHANGE_CHECKBOX', ({ detail }) => this.checkboxUpdated(detail.data)) + // Binding the node rename handler. this.elName.addEventListener('click', async () => this.renameNode()) + + // Bind handlers for content keyboard input and paste. this.elNodeContent.addEventListener('input', event => this.contentChanged(event)) this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event)) + + // Bind node icon handlers. + this.elIconSave.addEventListener('click', () => this.saveNode()) this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown())) - this.elIconTableFormat.addEventListener('click', event => { + this.elIconNewDocument.addEventListener('click', event => { + if (event.shiftKey) + _app.createNode(this.node.ParentUUID) + else + _app.createNode() + }) + + // Bind node menu items to handlers. + this.elNodeMenu.elFormatTables.addEventListener('click', event => { + this.elNodeMenu.hidePopover() + if (!event.shiftKey) this.elNodeContent.value = this.formatAllTables(this.elNodeContent.value) else { @@ -88,15 +169,12 @@ export class N2PageNodeUI extends CustomHTMLElement { this.node.setContent(this.elNodeContent.value) }) - this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' })) - this.elIconSave.addEventListener('click', () => this.saveNode()) - this.elIconNewDocument.addEventListener('click', event => { - if (event.shiftKey) - _app.createNode(this.node.ParentUUID) - else - _app.createNode() + this.elNodeMenu.elHistory.addEventListener('click', () => { + _mbus.dispatch('SHOW_PAGE', { page: 'history' }) }) + // Default is to always show markdown. + this.classList.add('show-markdown') // TODO Should probably be moved to settings. this.showMarkdown(true) }// }}} renderName() {// {{{ @@ -309,7 +387,7 @@ export class N2PageNodeUI extends CustomHTMLElement { // Node is modified with the new value. User has to save manually, otherwise other changes could be saved // when a save wasn't expected. - const newValue =`[${checkbox.checked ? 'x' : ' '}] ` + const newValue = `[${checkbox.checked ? 'x' : ' '}] ` const modifiedContent = this.node.content().slice(0, pos.start) + newValue + this.node.content().slice(pos.end) this.node.setContent(modifiedContent) @@ -506,22 +584,10 @@ export class Node { console.log('waiting') await Promise.all([history, sendQueue, nodeStoreAdding]) console.log('waiting done') - + return }//}}} } -class N2Menu extends CustomHTMLElement { - static {// {{{ - this.tmpl = document.createElement('template') - this.tmpl.innerHTML = ` -
Popover content
- ` - }// }}} - constructor() {// {{{ - super() - }// }}} -} -customElements.define('n2-menu', N2Menu) // vim: foldmethod=marker diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index a726451..381d672 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -6,8 +6,6 @@
>
- -
Popover content
From dbd3872f0fd90c9f672fcc64d572a56f080c8316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 16 Jun 2026 08:35:58 +0200 Subject: [PATCH 32/46] Fixed flashing history page --- views/pages/notes2.gotmpl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index 381d672..d54c71d 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,9 +1,12 @@ {{ define "page" }} + + + -
+
>
@@ -37,9 +40,6 @@
- - -