From edd3d11b098b7573d8922ecb94a3344964b220a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Mon, 15 Jun 2026 16:39:56 +0200 Subject: [PATCH 01/20] Fix three layers of safeguards to ensure node doesn't become it's own parent --- sql/00008.sql | 123 ++++++++++++++++++++++++++++++++++++++++ static/js/page_node.mjs | 3 + static/js/sidebar.mjs | 24 +++++--- 3 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 sql/00008.sql diff --git a/sql/00008.sql b/sql/00008.sql new file mode 100644 index 0000000..2701ba5 --- /dev/null +++ b/sql/00008.sql @@ -0,0 +1,123 @@ +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; + + -- Safeguard against being your own parent. + IF node_uuid = node_parent_uuid THEN + RAISE EXCEPTION 'Node UUID is same as node parent UUID.' USING ERRCODE = 'XPRNT'; + 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'), + parent_uuid = node_parent_uuid, + 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$ +; diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index f6aa681..339f903 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -424,6 +424,9 @@ export class Node { return this._parent }//}}} moveToParent(newParentUUID) {// {{{ + if (this.UUID === newParentUUID) + throw new Error("New parent UUID is the same as node UUID. Can't be your own parent.") + this.ParentUUID = newParentUUID this.data.ParentUUID = newParentUUID this._modified = true diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 63c4ddc..becf44d 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -598,15 +598,25 @@ export class N2TreeNode extends CustomHTMLElement { e.preventDefault() }// }}} async dragDrop(e) {// {{{ - e.stopPropagation() - const sourceNode = _app.dragIcon.getSource() - await _app.moveNode(sourceNode.node, this.node.UUID) + try { + e.stopPropagation() + const sourceNode = _app.dragIcon.getSource() - _app.sidebar.setNodeExpanded(this, true) - await this.render(true, true) - await sourceNode.render(true, true) + // Abort if user drops the node back on itself. + if (sourceNode.node.UUID === this.node.UUID) + return - this.dragLeave(e) + await _app.moveNode(sourceNode.node, this.node.UUID) + + _app.sidebar.setNodeExpanded(this, true) + await this.render(true, true) + await sourceNode.render(true, true) + } catch (e) { + console.error(e) + alert(e) + } finally { + this.dragLeave(e) + } }// }}} dragEnter(e) {// {{{ const targetNode = e.target.closest('n2-treenode') From d9adfd3a91e38af6b853b0f1a486275bd7597cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Mon, 15 Jun 2026 17:39:01 +0200 Subject: [PATCH 02/20] Preparation for special nodes --- sql/00009.sql | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 sql/00009.sql diff --git a/sql/00009.sql b/sql/00009.sql new file mode 100644 index 0000000..50487f3 --- /dev/null +++ b/sql/00009.sql @@ -0,0 +1,35 @@ +-- Special node such as orphaned and deleted nodes. +ALTER TABLE public.node ADD special bool DEFAULT false NOT NULL; + + +-- Needs to be dropped in order to drop the index on UUID. +ALTER TABLE public.node DROP CONSTRAINT node_node_fk; + +-- Index was missing user ID. +DROP INDEX public.node_uuid_idx; +CREATE UNIQUE INDEX node_user_uuid_idx ON public.node (user_id,"uuid"); + +-- Restore the "foreign" key of parent UUID back to UUID. +ALTER TABLE public.node ADD CONSTRAINT node_node_fk FOREIGN KEY (user_id,parent_uuid) REFERENCES public.node(user_id,"uuid") ON DELETE RESTRICT ON UPDATE RESTRICT; + + +-- Auto-create the special nodes for each user. +CREATE OR REPLACE FUNCTION create_user_nodes() +RETURNS TRIGGER AS $$ +BEGIN + -- NEW holds the row being created. + -- No semi-colons omitted here, PL/pgSQL requires them. + INSERT INTO public.node (user_id, uuid, parent_uuid, special, name) + VALUES + (NEW.id, '00000000-0000-0000-0000-000000000000'::uuid, null, true, 'Start'), + (NEW.id, '00000000-0000-0000-0000-000000000001'::uuid, null, true, 'Orphaned nodes'), + (NEW.id, '00000000-0000-0000-0000-000000000002'::uuid, null, true, 'Deleted nodes'); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_after_user_insert +AFTER INSERT ON public.user +FOR EACH ROW +EXECUTE FUNCTION create_user_nodes(); From da7999fb24a1113d6ab5dccff0422e5ef44d2f64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Mon, 15 Jun 2026 19:13:27 +0200 Subject: [PATCH 03/20] Work on special pages --- node.go | 2 + static/css/notes2.css | 5 ++ static/images/leaf.svg | 6 +- static/images/leaf_deleted.svg | 68 ++++++++++++++ static/js/node_store.mjs | 158 +++++++++++++++------------------ static/js/page_node.mjs | 17 ++-- static/js/sidebar.mjs | 24 ++++- 7 files changed, 185 insertions(+), 95 deletions(-) create mode 100644 static/images/leaf_deleted.svg diff --git a/node.go b/node.go index e3a61d5..a25c771 100644 --- a/node.go +++ b/node.go @@ -54,6 +54,7 @@ type Node struct { DeletedSeq sql.NullInt64 `db:"deleted_seq"` Content string ContentEncrypted string `db:"content_encrypted" json:"-"` + Special bool } func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ @@ -135,6 +136,7 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, FROM public.node WHERE + NOT special AND user_id = $1 AND client != $5::uuid AND ( diff --git a/static/css/notes2.css b/static/css/notes2.css index dfe4156..f6af751 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -186,6 +186,11 @@ button { img { width: auto; height: 18px; + + &.deleted { + height: 24px; + transform: translateX(3px) translateY(3px); + } } } diff --git a/static/images/leaf.svg b/static/images/leaf.svg index 9d200c3..17f4fe2 100644 --- a/static/images/leaf.svg +++ b/static/images/leaf.svg @@ -24,12 +24,12 @@ inkscape:deskcolor="#d1d1d1" inkscape:document-units="px" inkscape:zoom="31.614857" - inkscape:cx="5.0609117" - inkscape:cy="9.5524708" + inkscape:cx="5.0450964" + inkscape:cy="9.5682862" inkscape:window-width="2190" inkscape:window-height="1401" inkscape:window-x="1463" - inkscape:window-y="0" + inkscape:window-y="18" inkscape:window-maximized="1" inkscape:current-layer="layer1" showgrid="false" /> + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outlinedelete-circle diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index 324f004..488294f 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -1,6 +1,8 @@ import { Node } from 'node' export const ROOT_NODE = '00000000-0000-0000-0000-000000000000' +export const ORPHANED_NODE = '00000000-0000-0000-0000-000000000001' +export const DELETED_NODE = '00000000-0000-0000-0000-000000000002' export class NodeStore { constructor() {//{{{ @@ -76,8 +78,7 @@ export class NodeStore { this.sendQueue = new SimpleNodeStore(this.db, 'send_queue') this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history') this.files = new SimpleNodeStore(this.db, 'files') - this.initializeRootNode() - .then(() => resolve()) + resolve() } req.onerror = (event) => { @@ -85,37 +86,6 @@ export class NodeStore { } }) }//}}} - initializeRootNode() {//{{{ - return new Promise((resolve, reject) => { - // The root node is a magical node which displays as the first node if none is specified. - // If not already existing, it will be created. - const trx = this.db.transaction('nodes', 'readwrite') - const nodes = trx.objectStore('nodes') - const getRequest = nodes.get(ROOT_NODE) - getRequest.onsuccess = (event) => { - // Root node exists - nice! - if (event.target.result !== undefined) { - resolve(event.target.result) - return - } - - const putRequest = nodes.put({ - UUID: ROOT_NODE, - Name: 'Notes2', - Content: 'Hello, World!', - Updated: new Date().toISOString(), - ParentUUID: '', - }) - putRequest.onsuccess = (event) => { - resolve(event.target.result) - } - putRequest.onerror = (event) => { - reject(event.target.error) - } - } - getRequest.onerror = (event) => reject(event.target.error) - }) - }//}}} purgeCache() {//{{{ this.nodes = {} }//}}} @@ -272,74 +242,92 @@ export class NodeStore { }//}}} get(uuid, suppliedNodestore) {//{{{ return new Promise((resolve, reject) => { + switch (uuid) { + case ROOT_NODE: + const rootNode = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1) + this.nodes[ROOT_NODE] = rootNode + resolve(rootNode) + return + case DELETED_NODE: + const deletedNode = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1) + this.nodes[DELETED_NODE] = deletedNode + resolve(deletedNode) + return + } + // A nodestore can be provided in order to // avoid creating new transactions. let trx let nodeStore = suppliedNodestore if (nodeStore === undefined) { - trx = this.db.transaction('nodes', 'readonly') - nodeStore = trx.objectStore('nodes') + trx = this.db.transaction('nodes', 'readonly') + nodeStore = trx.objectStore('nodes') + } + + const getRequest = nodeStore.get(uuid) + + getRequest.onsuccess = (event) => { + // Node not found in IndexedDB. + if (event.target.result === undefined) { + reject("No such node") + return } + const node = this.node(uuid, event.target.result, -1) + resolve(node) + } + }) +}//}}} +getNodeAncestry(node, accumulated) {//{{{ + return new Promise((resolve, reject) => { + if (accumulated === undefined) + accumulated = [] - const getRequest = nodeStore.get(uuid) + const nodeParentIndex = this.db + .transaction('nodes', 'readonly') + .objectStore('nodes') - getRequest.onsuccess = (event) => { - // Node not found in IndexedDB. - if (event.target.result === undefined) { - reject("No such node") - return - } - const node = this.node(uuid, event.target.result, -1) - resolve(node) - } - }) - }//}}} - getNodeAncestry(node, accumulated) {//{{{ - return new Promise((resolve, reject) => { - if (accumulated === undefined) - accumulated = [] + if (node.UUID === ROOT_NODE || node.ParentUUID === ROOT_NODE) { + resolve(accumulated) + return + } - const nodeParentIndex = this.db - .transaction('nodes', 'readonly') - .objectStore('nodes') + if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) { + resolve(accumulated) + return + } - if (node.UUID === ROOT_NODE || node.ParentUUID === ROOT_NODE) { - resolve(accumulated) + const getRequest = nodeParentIndex.get(node.ParentUUID) + getRequest.onsuccess = (event) => { + // Node not found in IndexedDB. + // Not expected to happen. + const parentNodeData = event.target.result + if (parentNodeData === undefined) { + reject("No such node") return } - const getRequest = nodeParentIndex.get(node.ParentUUID) - getRequest.onsuccess = (event) => { - // Node not found in IndexedDB. - // Not expected to happen. - const parentNodeData = event.target.result - if (parentNodeData === undefined) { - reject("No such node") - return - } + const parentNode = this.node(parentNodeData.UUID, parentNodeData, -1) + this.getNodeAncestry(parentNode, accumulated.concat(parentNode)) + .then(accumulated => resolve(accumulated)) + } + }) - const parentNode = this.node(parentNodeData.UUID, parentNodeData, -1) - this.getNodeAncestry(parentNode, accumulated.concat(parentNode)) - .then(accumulated => resolve(accumulated)) - } - }) +}//}}} +newTransaction(objectStore, mode) {// {{{ + return this.db.transaction(objectStore, mode) +}// }}} - }//}}} - newTransaction(objectStore, mode) {// {{{ - return this.db.transaction(objectStore, mode) - }// }}} - - nodeCount() {//{{{ - return new Promise((resolve, reject) => { - const t = this.db.transaction('nodes', 'readwrite') - const nodeStore = t.objectStore('nodes') - const countReq = nodeStore.count() - countReq.onsuccess = event => { - resolve(event.target.result) - } - }) - }//}}} +nodeCount() {//{{{ + return new Promise((resolve, reject) => { + const t = this.db.transaction('nodes', 'readwrite') + const nodeStore = t.objectStore('nodes') + const countReq = nodeStore.count() + countReq.onsuccess = event => { + resolve(event.target.result) + } + }) +}//}}} } class SimpleNodeStore { diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 339f903..403456b 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -46,7 +46,10 @@ export class N2PageNodeUI extends CustomHTMLElement { _mbus.subscribe('NODE_UI_OPEN', event => { this.node = event.detail.data - this.showMarkdown(true) + + + if (!this.node.isSpecial()) + this.showMarkdown(true) this.render() }) @@ -437,6 +440,9 @@ export class Node { isFirstSibling() {//{{{ return this._sibling_before === null }//}}} + isSpecial() {// {{{ + return this.data.Special + }// }}} content() {//{{{ /* TODO - implement crypto if (this.CryptoKeyID != 0 && !this._decrypted) @@ -506,16 +512,15 @@ export class Node { } class N2Menu extends CustomHTMLElement { - static { + static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
Popover content
` - } - - constructor() { + }// }}} + constructor() {// {{{ super() - } + }// }}} } customElements.define('n2-menu', N2Menu) diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index becf44d..4162208 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -1,4 +1,5 @@ -import { ROOT_NODE } from 'node_store' +import { ROOT_NODE, ORPHANED_NODE , DELETED_NODE} from 'node_store' +import { Node } from 'node' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { Color, Solver } from './lib/css_colorize.mjs' @@ -156,8 +157,15 @@ export class N2Sidebar extends CustomHTMLElement { this.expandedNodes[ROOT_NODE] = true const startnode = await nodeStore.get(ROOT_NODE) const starttreenode = new N2TreeNode(this, startnode, null) + + const deletednode = await nodeStore.get(DELETED_NODE) + const deletedtreenode = new SpecialNodeDeleted(this, deletednode, null) + this.treeNodeComponents[startnode.UUID] = starttreenode + this.treeNodeComponents[deletednode.UUID] = deletedtreenode + this.elTreenodes.appendChild(await starttreenode.render()) + this.elTreenodes.appendChild(await deletedtreenode.render()) // Notify the application that the initial tree is rendered (with children) // and that initial node selection can take place. App will check URL to @@ -668,6 +676,12 @@ export class N2TreeNode extends CustomHTMLElement { // The expand icon is only changed to not get a flickering when re-rendering. if (this.node.UUID === ROOT_NODE) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/icon_home.svg`) + + else if (this.node.UUID === '00000000-0000-0000-0000-000000000002') { + this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf_deleted.svg`) + this.elExpand.classList.add('deleted') + } + else if (!this.node.hasChildren()) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`) else if (this.sidebar.getNodeExpanded(this.node.UUID)) @@ -703,7 +717,15 @@ export class N2TreeNode extends CustomHTMLElement { }// }}} } +class SpecialNodeDeleted extends N2TreeNode { + constructor(sidebar, node, parent) {//{{{ + super(sidebar, node, parent) + this.removeAttribute('draggable') + }//}}} +} + customElements.define('n2-sidebar', N2Sidebar) customElements.define('n2-treenode', N2TreeNode) +customElements.define('n2-specialnodedeleted', SpecialNodeDeleted) // vim: foldmethod=marker From 15bd742ef70f6756449dc645a1f53ae7201c7e58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 16 Jun 2026 06:54:10 +0200 Subject: [PATCH 04/20] More work on special pages --- static/images/leaf_orphaned.svg | 61 ++++++++++++++ static/js/node_store.mjs | 138 ++++++++++++++++---------------- static/js/sidebar.mjs | 28 ++++++- 3 files changed, 158 insertions(+), 69 deletions(-) create mode 100644 static/images/leaf_orphaned.svg diff --git a/static/images/leaf_orphaned.svg b/static/images/leaf_orphaned.svg new file mode 100644 index 0000000..8b1cc37 --- /dev/null +++ b/static/images/leaf_orphaned.svg @@ -0,0 +1,61 @@ + + + +folder-openfolder-open-outlinenotebook-outlinetext-box-outlinedelete-circleghost diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index 488294f..6be8f82 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -15,6 +15,8 @@ export class NodeStore { this.sendQueue = null this.nodesHistory = null this.files = null + + this.initializeSpecialNodes() }//}}} initializeDB() {//{{{ return new Promise((resolve, reject) => { @@ -86,9 +88,11 @@ export class NodeStore { } }) }//}}} - purgeCache() {//{{{ - this.nodes = {} - }//}}} + initializeSpecialNodes() {// {{{ + this.nodes[ROOT_NODE] = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1) + this.nodes[DELETED_NODE] = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1) + this.nodes[ORPHANED_NODE] = new Node({ UUID: ORPHANED_NODE, Name: 'Orphaned nodes', Special: true }, -1) + }// }}} node(uuid, dataIfUndefined, newLevel) {//{{{ let n = this.nodes[uuid] @@ -244,14 +248,9 @@ export class NodeStore { return new Promise((resolve, reject) => { switch (uuid) { case ROOT_NODE: - const rootNode = new Node({ UUID: ROOT_NODE, Name: 'Start', Special: true }, -1) - this.nodes[ROOT_NODE] = rootNode - resolve(rootNode) - return case DELETED_NODE: - const deletedNode = new Node({ UUID: DELETED_NODE, Name: 'Deleted nodes', Special: true }, -1) - this.nodes[DELETED_NODE] = deletedNode - resolve(deletedNode) + case ORPHANED_NODE: + resolve(this.nodes[uuid]) return } @@ -261,73 +260,78 @@ export class NodeStore { let nodeStore = suppliedNodestore if (nodeStore === undefined) { - trx = this.db.transaction('nodes', 'readonly') - nodeStore = trx.objectStore('nodes') - } - - const getRequest = nodeStore.get(uuid) - - getRequest.onsuccess = (event) => { - // Node not found in IndexedDB. - if (event.target.result === undefined) { - reject("No such node") - return + trx = this.db.transaction('nodes', 'readonly') + nodeStore = trx.objectStore('nodes') } - const node = this.node(uuid, event.target.result, -1) - resolve(node) - } - }) -}//}}} -getNodeAncestry(node, accumulated) {//{{{ - return new Promise((resolve, reject) => { - if (accumulated === undefined) - accumulated = [] - const nodeParentIndex = this.db - .transaction('nodes', 'readonly') - .objectStore('nodes') + const getRequest = nodeStore.get(uuid) - if (node.UUID === ROOT_NODE || node.ParentUUID === ROOT_NODE) { - resolve(accumulated) - return - } + getRequest.onsuccess = (event) => { + // Node not found in IndexedDB. + if (event.target.result === undefined) { + reject("No such node") + return + } + const node = this.node(uuid, event.target.result, -1) + resolve(node) + } + }) + }//}}} + getNodeAncestry(node, accumulated) {//{{{ + return new Promise((resolve, reject) => { + if (accumulated === undefined) + accumulated = [] - if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) { - resolve(accumulated) - return - } + const nodeParentIndex = this.db + .transaction('nodes', 'readonly') + .objectStore('nodes') - const getRequest = nodeParentIndex.get(node.ParentUUID) - getRequest.onsuccess = (event) => { - // Node not found in IndexedDB. - // Not expected to happen. - const parentNodeData = event.target.result - if (parentNodeData === undefined) { - reject("No such node") + if (node.UUID === ROOT_NODE || node.ParentUUID === ROOT_NODE) { + resolve(accumulated) return } - const parentNode = this.node(parentNodeData.UUID, parentNodeData, -1) - this.getNodeAncestry(parentNode, accumulated.concat(parentNode)) - .then(accumulated => resolve(accumulated)) - } - }) + if (node.UUID === DELETED_NODE || node.ParentUUID === DELETED_NODE) { + resolve(accumulated) + return + } -}//}}} -newTransaction(objectStore, mode) {// {{{ - return this.db.transaction(objectStore, mode) -}// }}} + if (node.UUID === ORPHANED_NODE || node.ParentUUID === ORPHANED_NODE) { + resolve(accumulated) + return + } -nodeCount() {//{{{ - return new Promise((resolve, reject) => { - const t = this.db.transaction('nodes', 'readwrite') - const nodeStore = t.objectStore('nodes') - const countReq = nodeStore.count() - countReq.onsuccess = event => { - resolve(event.target.result) - } - }) -}//}}} + const getRequest = nodeParentIndex.get(node.ParentUUID) + getRequest.onsuccess = (event) => { + // Node not found in IndexedDB. + // Not expected to happen. + const parentNodeData = event.target.result + if (parentNodeData === undefined) { + reject("No such node") + return + } + + const parentNode = this.node(parentNodeData.UUID, parentNodeData, -1) + this.getNodeAncestry(parentNode, accumulated.concat(parentNode)) + .then(accumulated => resolve(accumulated)) + } + }) + + }//}}} + newTransaction(objectStore, mode) {// {{{ + return this.db.transaction(objectStore, mode) + }// }}} + + nodeCount() {//{{{ + return new Promise((resolve, reject) => { + const t = this.db.transaction('nodes', 'readwrite') + const nodeStore = t.objectStore('nodes') + const countReq = nodeStore.count() + countReq.onsuccess = event => { + resolve(event.target.result) + } + }) + }//}}} } class SimpleNodeStore { diff --git a/static/js/sidebar.mjs b/static/js/sidebar.mjs index 4162208..06b2943 100644 --- a/static/js/sidebar.mjs +++ b/static/js/sidebar.mjs @@ -1,4 +1,4 @@ -import { ROOT_NODE, ORPHANED_NODE , DELETED_NODE} from 'node_store' +import { ROOT_NODE, ORPHANED_NODE, DELETED_NODE } from 'node_store' import { Node } from 'node' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { Color, Solver } from './lib/css_colorize.mjs' @@ -161,11 +161,22 @@ export class N2Sidebar extends CustomHTMLElement { const deletednode = await nodeStore.get(DELETED_NODE) const deletedtreenode = new SpecialNodeDeleted(this, deletednode, null) + const orphanednode = await nodeStore.get(ORPHANED_NODE) + const orphanedtreenode = new SpecialNodeOrphaned(this, orphanednode, null) + + startnode._sibling_after = deletednode + deletednode._sibling_before = startnode + + deletednode._sibling_after = orphanednode + orphanednode._sibling_before = deletednode + this.treeNodeComponents[startnode.UUID] = starttreenode this.treeNodeComponents[deletednode.UUID] = deletedtreenode + this.treeNodeComponents[orphanednode.UUID] = orphanedtreenode this.elTreenodes.appendChild(await starttreenode.render()) this.elTreenodes.appendChild(await deletedtreenode.render()) + this.elTreenodes.appendChild(await orphanedtreenode.render()) // Notify the application that the initial tree is rendered (with children) // and that initial node selection can take place. App will check URL to @@ -677,11 +688,16 @@ export class N2TreeNode extends CustomHTMLElement { if (this.node.UUID === ROOT_NODE) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/icon_home.svg`) - else if (this.node.UUID === '00000000-0000-0000-0000-000000000002') { + else if (this.node.UUID === DELETED_NODE) { this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf_deleted.svg`) this.elExpand.classList.add('deleted') } + else if (this.node.UUID === ORPHANED_NODE) { + this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf_orphaned.svg`) + this.elExpand.classList.add('deleted') + } + else if (!this.node.hasChildren()) this.setImgSrc(this.elExpand, `/images/${window._VERSION}/leaf.svg`) else if (this.sidebar.getNodeExpanded(this.node.UUID)) @@ -724,8 +740,16 @@ class SpecialNodeDeleted extends N2TreeNode { }//}}} } +class SpecialNodeOrphaned extends N2TreeNode { + constructor(sidebar, node, parent) {//{{{ + super(sidebar, node, parent) + this.removeAttribute('draggable') + }//}}} +} + customElements.define('n2-sidebar', N2Sidebar) customElements.define('n2-treenode', N2TreeNode) customElements.define('n2-specialnodedeleted', SpecialNodeDeleted) +customElements.define('n2-specialnodeorphaned', SpecialNodeOrphaned) // vim: foldmethod=marker From e71516fd76ff9c947e4f7a99e9bcb7a76eadb885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 16 Jun 2026 08:27:01 +0200 Subject: [PATCH 05/20] Working node menu --- static/css/notes2.css | 5 +- static/js/page_node.mjs | 122 +++++++++++++++++++++++++++++--------- views/pages/notes2.gotmpl | 2 - 3 files changed, 98 insertions(+), 31 deletions(-) diff --git a/static/css/notes2.css b/static/css/notes2.css index f6af751..43dedb5 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -9,7 +9,10 @@ --line-color: #ccc; --tree-expander: 0px; - --functions-width: 216px; + --functions-width: 150px; + + --menu-color: #fff; + --menu-item-hover-color: #f4f4f4; } html { diff --git a/static/js/page_node.mjs b/static/js/page_node.mjs index 403456b..2106ada 100644 --- a/static/js/page_node.mjs +++ b/static/js/page_node.mjs @@ -2,14 +2,70 @@ import { ROOT_NODE, uuidv7 } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { MarkedPosition } from './marked_position.mjs' +class N2NodeMenu extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + +
+ + + + +
+ ` + }// }}} + 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 06/20] 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 @@
- - -