Compare commits
5 commits
3e8d5b6d9a
...
31eee4ede5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31eee4ede5 | ||
|
|
8a22cf569f | ||
|
|
9ebda04428 | ||
|
|
c583138270 | ||
|
|
95a26e67d5 |
6 changed files with 208 additions and 81 deletions
10
node.go
10
node.go
|
|
@ -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
129
sql/00005.sql
Normal 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$
|
||||||
|
;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()))
|
||||||
|
|
@ -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])
|
||||||
}//}}}
|
}//}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}//}}}
|
}//}}}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue