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$ +; diff --git a/static/js/app.mjs b/static/js/app.mjs index 38aebd4..112827e 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. @@ -82,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') @@ -110,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 @@ -161,11 +140,12 @@ export class App { return const nn = Node.create(name, this.currentNode.UUID) - nn.save() - - nodeStore.sendQueue.add(nn) - nodeStore.add([nn]) + 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 2005816..963a72b 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,35 +100,37 @@ 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 - /* 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,15 +297,20 @@ export class Node { return 0 }//}}} static create(name, parentUUID) {// {{{ - return new Node({ + const node = new Node({ UUID: uuidv7(), Created: (new Date()).toISOString(), Content: '', Name: name, ParentUUID: parentUUID, 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 +427,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/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') 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 }//}}}