Compare commits

..

5 commits

Author SHA1 Message Date
Magnus Åhall
31eee4ede5 Removed old fields 2026-06-10 18:21:30 +02:00
Magnus Åhall
8a22cf569f Cleanup 2026-06-10 17:24:13 +02:00
Magnus Åhall
9ebda04428 Node renaming 2026-06-10 17:21:29 +02:00
Magnus Åhall
c583138270 Show newly created node 2026-06-10 17:08:30 +02:00
Magnus Åhall
95a26e67d5 Better node saving/history 2026-06-10 16:37:33 +02:00
6 changed files with 208 additions and 81 deletions

10
node.go
View file

@ -54,7 +54,6 @@ type Node struct {
DeletedSeq sql.NullInt64 `db:"deleted_seq"` DeletedSeq sql.NullInt64 `db:"deleted_seq"`
Content string Content string
ContentEncrypted string `db:"content_encrypted" json:"-"` ContentEncrypted string `db:"content_encrypted" json:"-"`
Markdown bool
} }
func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ 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 public.node
WHERE WHERE
user_id = $1 AND user_id = $1 AND
NOT history AND ( (
created_seq > $4 OR created_seq > $4 OR
updated_seq > $4 OR updated_seq > $4 OR
deleted_seq > $4 deleted_seq > $4
@ -132,14 +131,13 @@ func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node,
updated_seq, updated_seq,
deleted_seq, deleted_seq,
content, content,
content_encrypted, content_encrypted
markdown
FROM FROM
public.node public.node
WHERE WHERE
user_id = $1 AND user_id = $1 AND
client != $5::uuid AND client != $5::uuid AND
NOT history AND ( (
created_seq > $4 OR created_seq > $4 OR
updated_seq > $4 OR updated_seq > $4 OR
deleted_seq > $4 deleted_seq > $4
@ -192,7 +190,7 @@ func NodesCount(userID int, synced uint64, clientUUID string) (count int, err er
WHERE WHERE
user_id = $1 AND user_id = $1 AND
client != $3 AND client != $3 AND
NOT history AND ( (
created_seq > $2 OR created_seq > $2 OR
updated_seq > $2 OR updated_seq > $2 OR
deleted_seq > $2 deleted_seq > $2

129
sql/00005.sql Normal file
View file

@ -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$
;

View file

@ -74,6 +74,11 @@ export class App {
keyHandler(event) {//{{{ keyHandler(event) {//{{{
let handled = true 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. // 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. // 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. // Thus, the exception is acceptable to consequent use of alt+shift.
@ -82,25 +87,15 @@ export class App {
switch (event.key.toUpperCase()) { switch (event.key.toUpperCase()) {
case 'T': case 'T':
if (document.activeElement.id === 'tree-nodes') { if (document.activeElement.id === 'tree-nodes')
this.nodeUI.takeFocus() this.nodeUI.takeFocus()
} else { else
this.sidebar.focus() this.sidebar.focus()
}
break break
case 'F': case 'F':
_mbus.dispatch('op-search') _mbus.dispatch('op-search')
break break
/*
case 'C':
this.showPage('node')
break
case 'E':
this.showPage('keys')
break
*/
case 'M': case 'M':
globalThis._mbus.dispatch('MARKDOWN_TOGGLE') globalThis._mbus.dispatch('MARKDOWN_TOGGLE')
@ -110,25 +105,9 @@ export class App {
this.createNode() this.createNode()
break break
/*
case 'P':
this.showPage('node-properties')
break
*/
case 'S': case 'S':
this.nodeUI.saveNode() this.nodeUI.saveNode()
break break
/*
case 'U':
this.showPage('upload')
break
case 'F':
this.showPage('search')
break
*/
default: default:
handled = false handled = false
@ -161,11 +140,12 @@ export class App {
return return
const nn = Node.create(name, this.currentNode.UUID) const nn = Node.create(name, this.currentNode.UUID)
nn.save() await nn.save()
nodeStore.sendQueue.add(nn)
nodeStore.add([nn])
// 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) {//{{{ async goToNode(nodeUUID, dontPush, dontExpand) {//{{{
if (nodeUUID === null || nodeUUID === undefined) if (nodeUUID === null || nodeUUID === undefined)

View file

@ -63,18 +63,7 @@ export class N2PageNodeUI extends CustomHTMLElement {
_mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown())) _mbus.subscribe('MARKDOWN_TOGGLE', () => this.showMarkdown(!this.showMarkdown()))
_mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data)) _mbus.subscribe('MARKDOWN_EDIT', ({ detail }) => this.editMarkdown(detail.data))
this.elName.addEventListener('click', () => { this.elName.addEventListener('click', async () => this.renameNode())
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.elNodeContent.addEventListener('input', event => this.contentChanged(event)) this.elNodeContent.addEventListener('input', event => this.contentChanged(event))
this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event)) this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event))
this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown())) this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown()))
@ -93,7 +82,7 @@ export class N2PageNodeUI extends CustomHTMLElement {
this.node.setContent(this.elNodeContent.value) this.node.setContent(this.elNodeContent.value)
}) })
this.elIconHistory.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'history' })) 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) this.showMarkdown(true)
}// }}} }// }}}
@ -111,35 +100,37 @@ export class N2PageNodeUI extends CustomHTMLElement {
} else } else
this.elNodeContent.focus({ preventScroll: true }) 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() {// {{{ async saveNode() {// {{{
if (!this.node.isModified()) if (!this.node.isModified())
return return
/* The node history is a local store for node history. // node.save takes care of both "nodes" and "nodes_history" stores, also adds it to send queue.
* This could be provisioned from the server or cleared if // Sets "Updated" value to current date and time and generates a new history UUID.
* 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.
await this.node.save() 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) {//{{{ contentChanged(event) {//{{{
@ -306,15 +297,20 @@ export class Node {
return 0 return 0
}//}}} }//}}}
static create(name, parentUUID) {// {{{ static create(name, parentUUID) {// {{{
return new Node({ const node = new Node({
UUID: uuidv7(), UUID: uuidv7(),
Created: (new Date()).toISOString(), Created: (new Date()).toISOString(),
Content: '', Content: '',
Name: name, Name: name,
ParentUUID: parentUUID, ParentUUID: parentUUID,
Markdown: false, 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) {//{{{ constructor(nodeData, level) {//{{{
@ -431,6 +427,28 @@ export class Node {
// the ancestry path could be interesting. // the ancestry path could be interesting.
const ancestors = await nodeStore.getNodeAncestry(this) const ancestors = await nodeStore.getNodeAncestry(this)
this.data.Ancestors = ancestors.map(a => a.get('Name')).reverse() 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])
}//}}} }//}}}
} }

View file

@ -212,6 +212,9 @@ export class N2Sidebar extends CustomHTMLElement {
isSelected(node) {//{{{ isSelected(node) {//{{{
return this.selectedNode?.UUID === node.UUID return this.selectedNode?.UUID === node.UUID
}//}}} }//}}}
getTreeNode(uuid) {// {{{
return this.treeNodeComponents[uuid]
}// }}}
async keyHandler(event) {//{{{ async keyHandler(event) {//{{{
let handled = true let handled = true
@ -514,8 +517,8 @@ export class N2TreeNode extends CustomHTMLElement {
if (this.rendered && force_update !== true) if (this.rendered && force_update !== true)
return this return this
if (this.sidebar.getNodeExpanded(this.node.UUID)) if (this.sidebar.getNodeExpanded(this.node.UUID) || force_refetch_children)
await this.fetchChildren() await this.fetchChildren(force_refetch_children)
// Update the name and selected status. // Update the name and selected status.
this.elName.querySelector('span').innerText = this.node.get('Name') this.elName.querySelector('span').innerText = this.node.get('Name')

View file

@ -89,7 +89,7 @@ export class Sync {
nodeStore.setAppState('latest_sync_node', currMax) nodeStore.setAppState('latest_sync_node', currMax)
} catch (e) { } catch (e) {
console.log('sync node tree', e) console.error('sync node tree', e)
} finally { } finally {
syncEnd = Date.now() syncEnd = Date.now()
const duration = (syncEnd - syncStart) / 1000 const duration = (syncEnd - syncStart) / 1000
@ -235,7 +235,6 @@ export class N2SyncProgress extends CustomHTMLElement {
this.elDownloadTransferred.innerText = this.state.nodesDowloaded this.elDownloadTransferred.innerText = this.state.nodesDowloaded
this.elDownloadTotal.innerText = this.state.nodesToDownload this.elDownloadTotal.innerText = this.state.nodesToDownload
console.log('setting elUploadTransferred', this.state.nodesUploaded)
this.elUploadTransferred.innerText = this.state.nodesUploaded this.elUploadTransferred.innerText = this.state.nodesUploaded
this.elUploadTotal.innerText = this.state.nodesToUpload this.elUploadTotal.innerText = this.state.nodesToUpload
}//}}} }//}}}