diff --git a/main.go b/main.go index f86a8ab..34545e6 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "log/slog" "net/http" "path" @@ -24,6 +25,7 @@ import ( const VERSION = "v1" const CONTEXT_USER = 1 +const SYNC_PAGINATION = 250 var ( FlagGenerate bool @@ -122,7 +124,8 @@ func main() { // {{{ http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) - http.HandleFunc("/node/tree/{timestamp}/{offset}", authenticated(actionNodeTree)) + http.HandleFunc("/sync/node/{sequence}/{offset}", authenticated(actionSyncNode)) + http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve)) http.HandleFunc("/service_worker.js", pageServiceWorker) @@ -201,7 +204,10 @@ func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{ return } - err = tmpl.Execute(w, struct{ VERSION string; DevMode bool }{VERSION, FlagDev}) + err = tmpl.Execute(w, struct { + VERSION string + DevMode bool + }{VERSION, FlagDev}) if err != nil { w.Write([]byte(err.Error())) return @@ -235,22 +241,40 @@ func pageSync(w http.ResponseWriter, r *http.Request) { // {{{ } } // }}} -func actionNodeTree(w http.ResponseWriter, r *http.Request) { // {{{ - user := getUser(r) - changedFrom, _ := strconv.Atoi(r.PathValue("timestamp")) - offset, _ := strconv.Atoi(r.PathValue("offset")) - - nodes, maxSeq, moreRowsExist, err := NodeTree(user.ID, offset, uint64(changedFrom)) +func actionSyncNode(w http.ResponseWriter, r *http.Request) { // {{{ + // The purpose of the Client UUID is to avoid + // sending nodes back once again to a client that + // just created or modified it. + request := struct { + ClientUUID string + }{} + body, _ := io.ReadAll(r.Body) + err := json.Unmarshal(body, &request) if err != nil { Log.Error("/node/tree", "error", err) httpError(w, err) return } + user := getUser(r) + changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) + offset, _ := strconv.Atoi(r.PathValue("offset")) + + nodes, maxSeq, moreRowsExist, err := Nodes(user.ID, offset, uint64(changedFrom), request.ClientUUID) + if err != nil { + Log.Error("/node/tree", "error", err) + httpError(w, err) + return + } + + Log.Debug("/node/tree", "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 []TreeNode - MaxSeq uint64 + OK bool + Nodes []Node + MaxSeq uint64 Continue bool }{true, nodes, maxSeq, moreRowsExist}) w.Write(j) diff --git a/node.go b/node.go index 18f7c0a..83b1c5a 100644 --- a/node.go +++ b/node.go @@ -38,21 +38,23 @@ type TreeNode struct { } type Node struct { - UUID string - UserID int `db:"user_id"` - ParentUUID string `db:"parent_uuid"` - CryptoKeyID int `db:"crypto_key_id"` - Name string - Content string - Updated time.Time - Files []File - Complete bool - Level int - - ChecklistGroups []ChecklistGroup - + UUID string + UserID int `db:"user_id"` + ParentUUID string `db:"parent_uuid"` + Name string + Created time.Time + Updated time.Time + Deleted bool + CreatedSeq uint64 `db:"created_seq"` + UpdatedSeq uint64 `db:"updated_seq"` + DeletedSeq sql.NullInt64 `db:"deleted_seq"` + 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) { // {{{ @@ -72,7 +74,8 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 FROM public.node WHERE - user_id = $1 AND ( + user_id = $1 AND + NOT history AND ( created_seq > $4 OR updated_seq > $4 OR deleted_seq > $4 @@ -91,11 +94,6 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 } defer rows.Close() - type resultRow struct { - Node - Level int - } - nodes = []TreeNode{} numNodes := 0 for rows.Next() { @@ -120,6 +118,71 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 return } // }}} +func Nodes(userID, offset int, synced uint64, clientUUID string) (nodes []Node, maxSeq uint64, moreRowsExist bool, err error) { // {{{ + var rows *sqlx.Rows + rows, err = db.Queryx(` + SELECT + uuid, + COALESCE(parent_uuid, '') AS parent_uuid, + name, + created, + updated, + deleted IS NOT NULL AS deleted, + created_seq, + updated_seq, + deleted_seq, + content, + content_encrypted, + markdown + FROM + public.node + WHERE + user_id = $1 AND + client != $5 AND + NOT history AND ( + created_seq > $4 OR + updated_seq > $4 OR + deleted_seq > $4 + ) + ORDER BY + id ASC + LIMIT $2 OFFSET $3 + `, + userID, + SYNC_PAGINATION+1, + offset, + synced, + clientUUID, + ) + if err != nil { + return + } + defer rows.Close() + + nodes = []Node{} + numNodes := 0 + for rows.Next() { + // Query selects up to one more row than the decided limit. + // Saves one SQL query for row counting. + // Thus if numNodes is larger than the limit, more rows exist for the next call. + numNodes++ + if numNodes > SYNC_PAGINATION { + moreRowsExist = true + return + } + + node := Node{} + if err = rows.StructScan(&node); err != nil { + return + } + nodes = append(nodes, node) + + // DeletedSeq will be 0 if invalid, and thus not be a problem for the max function. + maxSeq = max(maxSeq, node.CreatedSeq, node.UpdatedSeq, uint64(node.DeletedSeq.Int64)) + } + + return +} // }}} func RetrieveNode(userID int, nodeUUID string) (node Node, err error) { // {{{ var rows *sqlx.Row rows = db.QueryRowx(` diff --git a/sql/00004.sql b/sql/00004.sql new file mode 100644 index 0000000..7ac464d --- /dev/null +++ b/sql/00004.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.node ADD COLUMN history bool NOT NULL DEFAULT false; +CREATE INDEX node_history_idx ON public.node (history); diff --git a/sql/00005.sql b/sql/00005.sql new file mode 100644 index 0000000..a366070 --- /dev/null +++ b/sql/00005.sql @@ -0,0 +1 @@ +ALTER TABLE public.node ADD COLUMN client bpchar(36) NOT NULL DEFAULT ''; diff --git a/static/css/notes2.css b/static/css/notes2.css index f31e3e9..30f207a 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -4,17 +4,13 @@ html { #notes2 { min-height: 100vh; display: grid; - grid-template-areas: "tree crumbs" "tree name" "tree content" "tree checklist" "tree schedule" "tree files" "tree blank"; + grid-template-areas: "tree crumbs" "tree name" "tree content" "tree blank"; grid-template-columns: min-content 1fr; - grid-template-rows: min-content /* crumbs */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr; - /* blank */ } @media only screen and (max-width: 600px) { #notes2 { - grid-template-areas: "crumbs" "name" "content" "checklist" "schedule" "files" "blank"; + grid-template-areas: "crumbs" "name" "content" "blank"; grid-template-columns: 1fr; - grid-template-rows: min-content /* crumbs */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr; - /* blank */ } #notes2 #tree { display: none; @@ -45,6 +41,9 @@ html { grid-template-rows: min-content 1fr; margin-top: 12px; } +#tree .node .expand-toggle { + user-select: none; +} #tree .node .expand-toggle img { width: 16px; height: 16px; @@ -84,6 +83,13 @@ html { color: #333; border-radius: 5px; } +.crumbs.node-modified { + background-color: #fe5f55; + color: #efede8; +} +.crumbs.node-modified .crumb:after { + color: #efede8; +} .crumbs .crumb { margin-right: 8px; cursor: pointer; @@ -163,3 +169,7 @@ html { background: #f5f5f5; padding-top: 16px; } +#blank { + grid-area: blank; + height: 32px; +} diff --git a/static/js/node.mjs b/static/js/node.mjs index 98eef87..d234789 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -14,11 +14,12 @@ export class NodeUI extends Component { this.nodeModified = signal(false) this.keys = signal([]) this.page = signal('node') + this.crumbs = [] window.addEventListener('popstate', evt => { if (evt.state?.hasOwnProperty('nodeUUID')) - this.goToNode(evt.state.nodeUUID, true) + _notes2.current.goToNode(evt.state.nodeUUID, true) else - this.goToNode(0, true) + _notes2.current.goToNode('00000000-0000-0000-0000-000000000000', true) }) window.addEventListener('keydown', evt => this.keyHandler(evt)) @@ -28,19 +29,32 @@ export class NodeUI extends Component { return const node = this.node.value - document.title = node.Name + document.title = node.get('Name') + + const nodeModified = this.nodeModified.value ? 'node-modified' : '' + + + const crumbDivs = [ + html`
_notes2.current.goToNode(ROOT_NODE)}>Start
` + ] + for (let i = this.crumbs.length-1; i >= 0; i--) { + const crumbNode = this.crumbs[i] + crumbDivs.push(html`
_notes2.current.goToNode(crumbNode.UUID)}>${crumbNode.get('Name')}
`) + } + if (node.UUID !== ROOT_NODE) + crumbDivs.push( + html`
_notes2.current.goToNode(node.UUID)}>${node.get('Name')}
` + ) return html` -
-
-
_notes2.current.goToNode(ROOT_NODE)}>Start
-
Minnie
-
Fluffy
-
Chili
+
this.saveNode()}> +
+ ${crumbDivs}
-
${node.Name}
+
${node.get('Name')}
<${NodeContent} key=${node.UUID} node=${node} ref=${this.nodeContent} /> +
` @@ -56,10 +70,6 @@ export class NodeUI extends Component { html`
this.goToNode(node.ID)}>${node.Name}
` ).reverse()) - let modified = '' - if (this.props.app.nodeModified.value) - modified = 'modified' - // Page to display let page = '' @@ -144,15 +154,30 @@ export class NodeUI extends Component { }//}}} async componentDidMount() {//{{{ _notes2.current.goToNode(this.props.startNode.UUID, true) + _notes2.current.tree.expandToTrunk(this.props.startNode) }//}}} setNode(node) {//{{{ this.nodeModified.value = false this.node.value = node }//}}} + setCrumbs(nodes) {//{{{ + this.crumbs = nodes + }//}}} + async saveNode() {//{{{ + if (!this.nodeModified.value) + return + + await nodeStore.copyToNodesHistory(this.node.value) + + // Prepares the node object for saving. + // Sets Updated value to current date and time. + const node = this.node.value + node.save() + await nodeStore.add([node]) + this.nodeModified.value = false + }//}}} keyHandler(evt) {//{{{ - return - let handled = true // All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. @@ -162,6 +187,7 @@ export class NodeUI extends Component { return switch (evt.key.toUpperCase()) { + /* case 'C': this.showPage('node') break @@ -182,12 +208,14 @@ export class NodeUI extends Component { this.showPage('node-properties') break + */ case 'S': - if (this.page.value == 'node') + if (this.page.value === 'node') this.saveNode() - else if (this.page.value == 'node-properties') + else if (this.page.value === 'node-properties') this.nodeProperties.current.save() break + /* case 'U': this.showPage('upload') @@ -196,6 +224,7 @@ export class NodeUI extends Component { case 'F': this.showPage('search') break + */ default: handled = false @@ -227,11 +256,13 @@ class NodeContent extends Component { ` } + /* let element if (node.RenderMarkdown.value) element = html`<${MarkdownContent} key='markdown-content' content=${content} />` else - element = html` + */ + const element = html`
@@ -259,8 +290,7 @@ class NodeContent extends Component { }//}}} contentChanged(evt) {//{{{ _notes2.current.nodeUI.current.nodeModified.value = true - const content = evt.target.value - this.props.node.setContent(content) + this.props.node.setContent(evt.target.value) this.resize() }//}}} resize() {//{{{ @@ -271,17 +301,25 @@ class NodeContent extends Component { } export class Node { + static sort(a, b) {//{{{ + if (a.data.Name < b.data.Name) return -1 + if (a.data.Name > b.data.Name) return 0 + return 0 + }//}}} constructor(nodeData, level) {//{{{ this.Level = level + this.data = nodeData + + this.UUID = nodeData.UUID + this.ParentUUID = nodeData.ParentUUID this._children_fetched = false this.Children = [] - this.UUID = nodeData.UUID - this.ParentUUID = nodeData.ParentUUID - this.UserID = nodeData.UserID - this.CryptoKeyID = nodeData.CryptoKeyID - this.Name = nodeData.Name + this._content = this.data.Content + this._modified = false + + /* this.RenderMarkdown = signal(nodeData.RenderMarkdown) this.Markdown = false this.ShowChecklist = signal(false) @@ -294,6 +332,15 @@ export class Node { this.ScheduleEvents = signal([]) // it doesn't control it afterwards. // Used to expand the crumbs upon site loading. + */ + }//}}} + + get(prop) {//{{{ + return this.data[prop] + }//}}} + updated() {//{{{ + // '2024-12-17T17:33:48.85939Z + return new Date(Date.parse(this.data.Updated)) }//}}} hasFetchedChildren() {//{{{ return this._children_fetched @@ -304,15 +351,16 @@ export class Node { this.Children = await nodeStore.getTreeNodes(this.UUID, this.Level + 1) this._children_fetched = true return this.Children - }//}}} content() {//{{{ /* TODO - implement crypto if (this.CryptoKeyID != 0 && !this._decrypted) this.#decrypt() */ + this.modified = true return this._content }//}}} + setContent(new_content) {//{{{ this._content = new_content /* TODO - implement crypto @@ -324,10 +372,10 @@ export class Node { this._decrypted = true */ }//}}} - static sort(a, b) {//{{{ - if (a.Name < b.Name) return -1 - if (a.Name > b.Name) return 0 - return 0 + save() {//{{{ + this.data.Content = this._content + this.data.Updated = new Date().toISOString() + this._modified = false }//}}} } diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index 57909b7..a1a8a1a 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -1,4 +1,3 @@ -import { API } from 'api' import { Node } from 'node' export const ROOT_NODE = '00000000-0000-0000-0000-000000000000' @@ -10,18 +9,19 @@ export class NodeStore { } this.db = null + this.nodes = {} }//}}} async initializeDB() {//{{{ return new Promise((resolve, reject) => { - const req = indexedDB.open('notes', 3) - + const req = indexedDB.open('notes', 7) // Schema upgrades for IndexedDB. // These can start from different points depending on updates to Notes2 since a device was online. req.onupgradeneeded = (event) => { - let treeNodes let nodes let appState + let sendQueue + let nodesHistory const db = event.target.result const trx = event.target.transaction @@ -31,28 +31,43 @@ export class NodeStore { // The schema transformations. switch (i) { case 1: - treeNodes = db.createObjectStore('treeNodes', { keyPath: 'UUID' }) - treeNodes.createIndex('nameIndex', 'Name', { unique: false }) - nodes = db.createObjectStore('nodes', { keyPath: 'UUID' }) - nodes.createIndex('nameIndex', 'Name', { unique: false }) + nodes.createIndex('byName', 'Name', { unique: false }) break case 2: - trx.objectStore('treeNodes').createIndex('parentIndex', 'ParentUUID', { unique: false }) + trx.objectStore('nodes').createIndex('byParent', 'ParentUUID', { unique: false }) break case 3: - appState = db.createObjectStore('appState', { keyPath: 'key' }) + appState = db.createObjectStore('app_state', { keyPath: 'key' }) + break + + case 4: + trx.objectStore('nodes').createIndex('byModified', 'modified', { unique: false }) + break + + case 5: + sendQueue = db.createObjectStore('send_queue', { keyPath: ['UUID', 'Updated'] }) + sendQueue.createIndex('updated', 'Updated', { unique: false }) + break + + case 6: + nodesHistory = db.createObjectStore('nodes_history', { keyPath: ['UUID', 'Updated'] }) + break + + case 7: + trx.objectStore('nodes_history').createIndex('byUUID', 'UUID', { unique: false }) + break } } } req.onsuccess = (event) => { this.db = event.target.result - this.initializeRootNode().then(() => - resolve() - ) + this.initializeRootNode() + .then(() => this.initializeClientUUID()) + .then(() => resolve()) } req.onerror = (event) => { @@ -60,7 +75,7 @@ export class NodeStore { } }) }//}}} - initializeRootNode() {//{{{ + async 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. @@ -78,6 +93,8 @@ export class NodeStore { UUID: ROOT_NODE, Name: 'Notes2', Content: 'Hello, World!', + Updated: new Date().toISOString(), + ParentUUID: '', }) putRequest.onsuccess = (event) => { resolve(event.target.result) @@ -89,11 +106,25 @@ export class NodeStore { getRequest.onerror = (event) => reject(event.target.error) }) }//}}} + async initializeClientUUID() {//{{{ + let clientUUID = await this.getAppState('client_uuid') + if (clientUUID !== null) + return + clientUUID = crypto.randomUUID() + return this.setAppState('client_uuid', clientUUID) + }//}}} + + node(uuid, dataIfUndefined, newLevel) {//{{{ + let n = this.nodes[uuid] + if (n === undefined && dataIfUndefined !== undefined) + n = this.nodes[uuid] = new Node(dataIfUndefined, newLevel) + return n + }//}}} async getAppState(key) {//{{{ return new Promise((resolve, reject) => { - const trx = this.db.transaction('appState', 'readonly') - const appState = trx.objectStore('appState') + const trx = this.db.transaction('app_state', 'readonly') + const appState = trx.objectStore('app_state') const getRequest = appState.get(key) getRequest.onsuccess = (event) => { if (event.target.result !== undefined) { @@ -108,8 +139,8 @@ export class NodeStore { async setAppState(key, value) {//{{{ return new Promise((resolve, reject) => { try { - const t = this.db.transaction('appState', 'readwrite') - const appState = t.objectStore('appState') + const t = this.db.transaction('app_state', 'readwrite') + const appState = t.objectStore('app_state') t.onerror = (event) => { console.log('transaction error', event.target.error) reject(event.target.error) @@ -129,10 +160,91 @@ export class NodeStore { }) }//}}} - async upsertTreeRecords(records) {//{{{ + async moveToSendQueue(nodeToMove, replaceWithNode) {//{{{ return new Promise((resolve, reject) => { - const t = this.db.transaction('treeNodes', 'readwrite') - const nodeStore = t.objectStore('treeNodes') + const t = this.db.transaction(['nodes', 'send_queue'], 'readwrite') + const nodeStore = t.objectStore('nodes') + const sendQueue = t.objectStore('send_queue') + t.onerror = (event) => { + console.log('transaction error', event.target.error) + reject(event.target.error) + } + t.oncomplete = () => { + resolve() + } + + // Node to be moved is first stored in the new queue. + const queueReq = sendQueue.put(nodeToMove.data) + queueReq.onsuccess = () => { + // When added to the send queue, the node is either deleted + // or replaced with a new node. + console.debug(`Queueing ${nodeToMove.UUID} (${nodeToMove.get('Name')})`) + let nodeReq + if (replaceWithNode) + nodeReq = nodeStore.put(replaceWithNode.data) + else + nodeReq = nodeStore.delete(nodeToMove.UUID) + nodeReq.onsuccess = () => { + resolve() + } + nodeReq.onerror = (event) => { + console.log(`Error moving ${nodeToMove.UUID}`, event.target.error) + reject(event.target.error) + } + + } + queueReq.onerror = (event) => { + console.log(`Error queueing ${nodeToMove.UUID}`, event.target.error) + reject(event.target.error) + } + }) + }//}}} + async copyToNodesHistory(nodeToCopy) {//{{{ + return new Promise((resolve, reject) => { + const t = this.db.transaction('nodes_history', 'readwrite') + const nodesHistory = t.objectStore('nodes_history') + t.oncomplete = () => { + resolve() + } + t.onerror = (event) => { + console.log('transaction error', event.target.error) + reject(event.target.error) + } + + const historyReq = nodesHistory.put(nodeToCopy.data) + historyReq.onerror = (event) => { + console.log(`Error copying ${nodeToCopy.UUID}`, event.target.error) + reject(event.target.error) + } + }) + }//}}} + async storeNode(node) {//{{{ + return new Promise((resolve, reject) => { + const t = this.db.transaction('nodes', 'readwrite') + const nodeStore = t.objectStore('nodes') + t.onerror = (event) => { + console.log('transaction error', event.target.error) + reject(event.target.error) + } + t.oncomplete = () => { + resolve() + } + + const nodeReq = nodeStore.put(node.data) + nodeReq.onsuccess = () => { + console.debug(`Storing ${node.UUID} (${node.get('Name')})`) + } + queueReq.onerror = (event) => { + console.log(`Error storing ${node.UUID}`, event.target.error) + reject(event.target.error) + } + }) + }//}}} + + async upsertNodeRecords(records) {//{{{ + return new Promise((resolve, reject) => { + const t = this.db.transaction('nodes', 'readwrite') + const nodeStore = t.objectStore('nodes') t.onerror = (event) => { console.log('transaction error', event.target.error) reject(event.target.error) @@ -152,29 +264,32 @@ export class NodeStore { addReq = nodeStore.delete(record.UUID) } else { op = 'upserting' + // 'modified' is a local property for tracking + // nodes needing to be synced to backend. + record.modified = 0 addReq = nodeStore.put(record) } addReq.onsuccess = () => { - console.log(`${op} ${record.UUID} (${record.Name})`) + console.debug(`${op} ${record.UUID} (${record.Name})`) } addReq.onerror = (event) => { console.log(`error ${op} ${record.UUID}`, event.target.error) reject(event.target.error) } } - }) }//}}} async getTreeNodes(parent, newLevel) {//{{{ return new Promise((resolve, reject) => { - const trx = this.db.transaction('treeNodes', 'readonly') - const nodeStore = trx.objectStore('treeNodes') - const index = nodeStore.index('parentIndex') + const trx = this.db.transaction('nodes', 'readonly') + const nodeStore = trx.objectStore('nodes') + const index = nodeStore.index('byParent') const req = index.getAll(parent) req.onsuccess = (event) => { const nodes = [] for (const i in event.target.result) { - const node = new Node(event.target.result[i], newLevel) + const nodeData = event.target.result[i] + const node = this.node(nodeData.UUID, nodeData, newLevel) nodes.push(node) } @@ -183,6 +298,7 @@ export class NodeStore { req.onerror = (event) => reject(event.target.error) }) }//}}} + async add(records) {//{{{ return new Promise((resolve, reject) => { try { @@ -193,21 +309,29 @@ export class NodeStore { reject(event.target.error) } t.oncomplete = () => { - resolve() + console.log('OK') } // records is an object, not an array. + const promises = [] for (const recordIdx in records) { const record = records[recordIdx] - const addReq = nodeStore.put(record) - addReq.onsuccess = () => { - console.log('OK!', record.ID, record.Name) - } - addReq.onerror = (event) => { - console.log('Error!', event.target.error, record.ID) - } + const addReq = nodeStore.put(record.data) + + const promise = new Promise((resolve, reject) => { + addReq.onsuccess = () => { + console.debug('OK!', record.ID, record.Name) + resolve() + } + addReq.onerror = (event) => { + console.log('Error!', event.target.error, record.ID) + reject(event.target.error) + } + }) + promises.push(promise) } + Promise.all(promises).then(() => resolve()) } catch (e) { console.log(e) } @@ -215,32 +339,59 @@ export class NodeStore { }//}}} async get(uuid) {//{{{ return new Promise((resolve, reject) => { - // Node is always returned from IndexedDB if existing there. - // Otherwise an attempt to get it from backend is executed. const trx = this.db.transaction('nodes', 'readonly') const nodeStore = trx.objectStore('nodes') const getRequest = nodeStore.get(uuid) getRequest.onsuccess = (event) => { - // Node found in IndexedDB and returned. - if (event.target.result !== undefined) { - const node = new Node(event.target.result, -1) - resolve(node) + // 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) + } + }) + }//}}} + async getNodeAncestry(node, accumulated) {//{{{ + return new Promise((resolve, reject) => { + if (accumulated === undefined) + accumulated = [] + + const nodeParentIndex = this.db + .transaction('nodes', 'readonly') + .objectStore('nodes') + + if (node.ParentUUID === '') { + resolve(accumulated) + 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 } - // Node not found and a request to the backend is made. - API.query("POST", `/node/retrieve/${uuid}`, {}) - .then(res => { - const trx = this.db.transaction('nodes', 'readwrite') - const nodeStore = trx.objectStore('nodes') - const putRequest = nodeStore.put(res.Node) - const node = new Node(res.Node, -1) - putRequest.onsuccess = () => resolve(node) - putRequest.onerror = (event) => { - reject(event.target.error) - } - }) - .catch(e => reject(e)) + const parentNode = this.node(parentNodeData.UUID, parentNodeData, -1) + this.getNodeAncestry(parentNode, accumulated.concat(parentNode)) + .then(accumulated => resolve(accumulated)) + } + }) + + }//}}} + + async 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) } }) }//}}} diff --git a/static/js/notes2.mjs b/static/js/notes2.mjs index 9afec41..5e62ffa 100644 --- a/static/js/notes2.mjs +++ b/static/js/notes2.mjs @@ -6,22 +6,25 @@ import { ROOT_NODE } from 'node_store' const html = htm.bind(h) export class Notes2 extends Component { - state = { - startNode: null, - } constructor() {//{{{ super() - this.tree = createRef() this.nodeUI = createRef() + this.state = { + startNode: null, + } + + Sync.nodes().then(durationNodes => + console.log(`Total time: ${Math.round(100 * durationNodes) / 100}s`) + ) this.getStartNode() }//}}} - render({}, { startNode }) {//{{{ + render(_props, { startNode }) {//{{{ if (startNode === null) return return html` - <${Tree} ref=${this.tree} app=${this} startNode=${startNode} /> + <${Tree} app=${this} startNode=${startNode} />
<${NodeUI} app=${this} ref=${this.nodeUI} startNode=${startNode} /> @@ -40,7 +43,7 @@ export class Notes2 extends Component { this.setState({ startNode: node }) }) }//}}} - goToNode(nodeUUID, dontPush) {//{{{ + async goToNode(nodeUUID, dontPush) {//{{{ // Don't switch notes until saved. if (this.nodeUI.current.nodeModified.value) { if (!confirm("Changes not saved. Do you want to discard changes?")) @@ -52,10 +55,11 @@ export class Notes2 extends Component { // New node is fetched in order to retrieve content and files. // Such data is unnecessary to transfer for tree/navigational purposes. - nodeStore.get(nodeUUID).then(node => { - this.nodeUI.current.setNode(node) - //this.showPage('node') - }) + const node = nodeStore.node(nodeUUID) + const ancestors = await nodeStore.getNodeAncestry(node) + this.nodeUI.current.setNode(node) + this.nodeUI.current.setCrumbs(ancestors) + this.tree.setSelected(node) }//}}} logout() {//{{{ localStorage.removeItem('session.UUID') @@ -66,10 +70,11 @@ export class Notes2 extends Component { class Tree extends Component { constructor(props) {//{{{ super(props) - this.treeNodes = {} this.treeNodeComponents = {} this.treeTrunk = [] - this.selectedTreeNode = null + this.selectedNode = null + this.expandedNodes = {} // keyed on UUID + this.props.app.tree = this this.populateFirstLevel() @@ -77,11 +82,11 @@ class Tree extends Component { render({ app }) {//{{{ const renderedTreeTrunk = this.treeTrunk.map(node => { this.treeNodeComponents[node.UUID] = createRef() - return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.startNode?.UUID} />` + return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.state.startNode?.UUID} />` }) return html`
- + ${renderedTreeTrunk}
` }//}}} @@ -90,30 +95,16 @@ class Tree extends Component { nodeStore.getTreeNodes('', 0) .then(async res => { res.sort(Node.sort) - - this.treeNodes = {} this.treeNodeComponents = {} this.treeTrunk = [] - this.selectedTreeNode = null - - // A tree of nodes is built. This requires the list of nodes - // returned from the server to be sorted in such a way that - // a parent node always appears before a child node. - // The server uses a recursive SQL query delivering this. for (const node of res) { - this.treeNodes[node.UUID] = node - + // The root node isn't supposed to be shown in the tree. + if (node.UUID === '00000000-0000-0000-0000-000000000000') + continue if (node.ParentUUID === '') this.treeTrunk.push(node) - else if (this.treeNodes[node.ParentUUD] !== undefined) - this.treeNodes[node.ParentUUID].Children.push(node) } - // When starting with an explicit node value, expanding all nodes - // on its path gives the user a sense of location. Not necessarily working - // as the start node isn't guaranteed to have returned data yet. - // XXX this.crumbsUpdateNodes() this.forceUpdate() - if (callback) callback() @@ -121,60 +112,61 @@ class Tree extends Component { .catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) }) }//}}} setSelected(node) {//{{{ - return // TODO - if (this.selectedTreeNode) - this.selectedTreeNode.selected.value = false + // The previously selected node, if any, needs to be rerendered + // to not retain its 'selected' class. + const prevUUID = this.selectedNode?.UUID + this.selectedNode = node + if (prevUUID) + this.treeNodeComponents[prevUUID]?.current.forceUpdate() - this.selectedTreeNode = this.treeNodeComponents[node.ID].current - this.selectedTreeNode.selected.value = true - this.selectedTreeNode.expanded.value = true - this.expandToTrunk(node.ID) + // And now the newly selected node is rerendered. + this.treeNodeComponents[node.UUID]?.current.forceUpdate() + + // Expanding selected nodes... I don't know... + this.setNodeExpanded(node.UUID, true) }//}}} - crumbsUpdateNodes(node) {//{{{ - console.log('crumbs', this.props.app.startNode.Crumbs) - for (const crumb in this.props.app.startNode.Crumbs) { - // Start node is loaded before the tree. - const node = this.treeNodes[crumb.ID] - if (node) - node._expanded = true - - // Tree is done before the start node. - const component = this.treeNodeComponents[crumb.ID] - if (component?.component.current) - component.current.expanded.value = true + isSelected(node) {//{{{ + return this.selectedNode?.UUID === node.UUID + }//}}} + async expandToTrunk(node) {//{{{ + // Get all ancestors from a certain node up to the highest grandparent. + const ancestry = await nodeStore.getNodeAncestry(node, []) + for (const i in ancestry) { + await nodeStore.node(ancestry[i].UUID).fetchChildren() + this.setNodeExpanded(ancestry[i].UUID, true) } - // Will be undefined when called from tree initialization - // (as tree nodes aren't rendered yet) - if (node !== undefined) - this.setSelected(node) - }//}}} - expandToTrunk(nodeUUID) {//{{{ - let node = this.treeNodes[nodeUUID] - if (node === undefined) + // Already a top node, no need to expand anything. + if (ancestry.length === 0) return - node = this.treeNodes[node.ParentUUID] - while (node !== undefined) { - this.treeNodeComponents[node.UUID].current.expanded.value = true - node = this.treeNodes[node.ParentUUID] - } + // Start the chain of by expanding the top node. + this.setNodeExpanded(ancestry[ancestry.length-1].UUID, true) + }//}}} + getNodeExpanded(UUID) {//{{{ + if (this.expandedNodes[UUID] === undefined) + this.expandedNodes[UUID] = signal(false) + return this.expandedNodes[UUID].value + }//}}} + setNodeExpanded(UUID, value) {//{{{ + // Creating a default value if it doesn't exist already. + this.getNodeExpanded(UUID) + this.expandedNodes[UUID].value = value }//}}} } class TreeNode extends Component { constructor(props) {//{{{ super(props) - this.selected = signal(props.selected) - this.expanded = signal(this.props.node._expanded) - this.children_populated = signal(false) - if (this.props.node.Level === 0) + if (this.props.node.Level === 0 || this.props.tree.getNodeExpanded(this.props.node.UUID)) this.fetchChildren() }//}}} render({ tree, node, parent }) {//{{{ // Fetch the next level of children if the parent tree node is expanded and our children thus will be visible. - if (!this.children_populated.value && parent?.expanded.value) + const selected = tree.isSelected(node) ? 'selected' : '' + + if (!this.children_populated.value && tree.getNodeExpanded(parent?.props.node.UUID)) this.fetchChildren() const children = node.Children.map(node => { @@ -186,25 +178,22 @@ class TreeNode extends Component { if (node.Children.length === 0) expandImg = html`` else { - if (this.expanded.value) + if (tree.getNodeExpanded(node.UUID)) expandImg = html`` else expandImg = html`` } - const selected = (this.selected.value ? 'selected' : '') - return html`
-
{ this.expanded.value ^= true }}>${expandImg}
-
window._notes2.current.goToNode(node.UUID)}>${node.Name}
-
${children}
+
{ tree.setNodeExpanded(node.UUID, !tree.getNodeExpanded(node.UUID)) }}>${expandImg}
+
window._notes2.current.goToNode(node.UUID)}>${node.get('Name')}
+
${children}
` }//}}} - fetchChildren() {//{{{ - this.props.node.fetchChildren().then(() => { - this.children_populated.value = true - }) + async fetchChildren() {//{{{ + await this.props.node.fetchChildren() + this.children_populated.value = true }//}}} } diff --git a/static/js/sync.mjs b/static/js/sync.mjs index a27817e..2814fe4 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -1,31 +1,84 @@ import { API } from 'api' +import { Node } from 'node' export class Sync { constructor() { this.foo = '' } - static async tree() { + static async nodes() { + let duration = 0 + const syncStart = Date.now() try { - const state = await nodeStore.getAppState('latest_sync') + // The latest sync node value is used to retrieve the changes + // from the backend. + const state = await nodeStore.getAppState('latest_sync_node') + const clientUUID = await nodeStore.getAppState('client_uuid') const oldMax = (state?.value ? state.value : 0) - let newMax = 0 + let currMax = oldMax let offset = 0 let res = { Continue: false } let batch = 0 do { batch++ - console.log(`Batch #${batch}`) - res = await API.query('POST', `/node/tree/${oldMax}/${offset}`, {}) + res = await API.query('POST', `/sync/node/${oldMax}/${offset}`, { ClientUUID: clientUUID.value }) + if (res.Nodes.length > 0) + console.log(`Node sync batch #${batch}`) offset += res.Nodes.length - newMax = res.MaxSeq - await nodeStore.upsertTreeRecords(res.Nodes) + currMax = Math.max(currMax, res.MaxSeq) + + /* Go through each node and determine if they are older than + * the node in IndexedDB. If they are, they are just history + * and can be ignored since history is currently not stored + * in the browser. + * + * If the backed node is newer, the local node is stored in + * a separate table in IndexedDB to at a later stage in the + * sync be preserved in the backend. */ + + let backendNode = null + for (const i in res.Nodes) { + backendNode = new Node(res.Nodes[i], -1) + await Sync.handleNode(backendNode) + } + } while (res.Continue) - nodeStore.setAppState('latest_sync', Math.max(oldMax, newMax)) + nodeStore.setAppState('latest_sync_node', currMax) } catch (e) { console.log('sync node tree', e) + } finally { + const syncEnd = Date.now() + duration = (syncEnd - syncStart) / 1000 + const count = await nodeStore.nodeCount() + console.log(`Node sync took ${duration}s`, count) + } + return duration + } + static async handleNode(backendNode) { + try { + /* Retrieving the local copy of this node from IndexedDB. + * The backend node can be discarded if it is older than + * the local copy since it is considered history preserved + * in the backend. */ + return nodeStore.get(backendNode.UUID) + .then(async localNode => { + if (localNode.updated() >= backendNode.updated()) { + console.log(`History from backend: ${backendNode.UUID}`) + return + } + + // local node is older than the backend node + // and moved into the send_queue table for later sync to backend. + return nodeStore.moveToSendQueue(localNode, backendNode) + }) + .catch(async e => { + // Not found in IndexedDB - OK to just insert since it only exists in backend. + return nodeStore.add([backendNode]) + }) + } catch (e) { + console.error(e) } } } diff --git a/static/less/notes2.less b/static/less/notes2.less index ae66833..f3abdc5 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -12,40 +12,24 @@ html { "tree crumbs" "tree name" "tree content" - "tree checklist" - "tree schedule" - "tree files" + //"tree checklist" + //"tree schedule" + //"tree files" "tree blank" ; grid-template-columns: min-content 1fr; - grid-template-rows: - min-content /* crumbs */ - min-content /* name */ - min-content /* content */ - min-content /* checklist */ - min-content /* schedule */ - min-content /* files */ - 1fr; /* blank */ @media only screen and (max-width: 600px) { grid-template-areas: "crumbs" "name" "content" - "checklist" - "schedule" - "files" + //"checklist" + //"schedule" + //"files" "blank" ; grid-template-columns: 1fr; - grid-template-rows: - min-content /* crumbs */ - min-content /* name */ - min-content /* content */ - min-content /* checklist */ - min-content /* schedule */ - min-content /* files */ - 1fr; /* blank */ #tree { display: none; @@ -84,6 +68,7 @@ html { .expand-toggle { + user-select: none; img { width: 16px; height: 16px; @@ -134,6 +119,14 @@ html { color: #333; border-radius: 5px; + &.node-modified { + background-color: @color1; + color: @color2; + .crumb:after { + color: @color2; + } + } + .crumb { margin-right: 8px; cursor: pointer; @@ -225,3 +218,8 @@ html { padding-top: 16px; } } + +#blank { + grid-area: blank; + height: 32px; +}