diff --git a/main.go b/main.go index 34545e6..f86a8ab 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,6 @@ import ( "encoding/json" "flag" "fmt" - "io" "log/slog" "net/http" "path" @@ -25,7 +24,6 @@ import ( const VERSION = "v1" const CONTEXT_USER = 1 -const SYNC_PAGINATION = 250 var ( FlagGenerate bool @@ -124,8 +122,7 @@ func main() { // {{{ http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) - http.HandleFunc("/sync/node/{sequence}/{offset}", authenticated(actionSyncNode)) - + http.HandleFunc("/node/tree/{timestamp}/{offset}", authenticated(actionNodeTree)) http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve)) http.HandleFunc("/service_worker.js", pageServiceWorker) @@ -204,10 +201,7 @@ 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 @@ -241,40 +235,22 @@ func pageSync(w http.ResponseWriter, r *http.Request) { // {{{ } } // }}} -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 - } - +func actionNodeTree(w http.ResponseWriter, r *http.Request) { // {{{ user := getUser(r) - changedFrom, _ := strconv.Atoi(r.PathValue("sequence")) + changedFrom, _ := strconv.Atoi(r.PathValue("timestamp")) offset, _ := strconv.Atoi(r.PathValue("offset")) - nodes, maxSeq, moreRowsExist, err := Nodes(user.ID, offset, uint64(changedFrom), request.ClientUUID) + nodes, maxSeq, moreRowsExist, err := NodeTree(user.ID, offset, uint64(changedFrom)) 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 []Node - MaxSeq uint64 + OK bool + Nodes []TreeNode + MaxSeq uint64 Continue bool }{true, nodes, maxSeq, moreRowsExist}) w.Write(j) diff --git a/node.go b/node.go index 83b1c5a..18f7c0a 100644 --- a/node.go +++ b/node.go @@ -38,23 +38,21 @@ type TreeNode struct { } type Node struct { - 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 + 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 + 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) { // {{{ @@ -74,8 +72,7 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 FROM public.node WHERE - user_id = $1 AND - NOT history AND ( + user_id = $1 AND ( created_seq > $4 OR updated_seq > $4 OR deleted_seq > $4 @@ -94,6 +91,11 @@ 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() { @@ -118,71 +120,6 @@ 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 deleted file mode 100644 index 7ac464d..0000000 --- a/sql/00004.sql +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index a366070..0000000 --- a/sql/00005.sql +++ /dev/null @@ -1 +0,0 @@ -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 30f207a..f31e3e9 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -4,13 +4,17 @@ html { #notes2 { min-height: 100vh; display: grid; - grid-template-areas: "tree crumbs" "tree name" "tree content" "tree blank"; + grid-template-areas: "tree crumbs" "tree name" "tree content" "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) { #notes2 { - grid-template-areas: "crumbs" "name" "content" "blank"; + grid-template-areas: "crumbs" "name" "content" "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 */ } #notes2 #tree { display: none; @@ -41,9 +45,6 @@ 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; @@ -83,13 +84,6 @@ 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; @@ -169,7 +163,3 @@ 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 d234789..98eef87 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -14,12 +14,11 @@ 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')) - _notes2.current.goToNode(evt.state.nodeUUID, true) + this.goToNode(evt.state.nodeUUID, true) else - _notes2.current.goToNode('00000000-0000-0000-0000-000000000000', true) + this.goToNode(0, true) }) window.addEventListener('keydown', evt => this.keyHandler(evt)) @@ -29,32 +28,19 @@ export class NodeUI extends Component { return const node = this.node.value - 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')}
` - ) + document.title = node.Name return html` -
this.saveNode()}> -
- ${crumbDivs} +
+
+
_notes2.current.goToNode(ROOT_NODE)}>Start
+
Minnie
+
Fluffy
+
Chili
-
${node.get('Name')}
+
${node.Name}
<${NodeContent} key=${node.UUID} node=${node} ref=${this.nodeContent} /> -
` @@ -70,6 +56,10 @@ 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 = '' @@ -154,30 +144,15 @@ 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. @@ -187,7 +162,6 @@ export class NodeUI extends Component { return switch (evt.key.toUpperCase()) { - /* case 'C': this.showPage('node') break @@ -208,14 +182,12 @@ 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') @@ -224,7 +196,6 @@ export class NodeUI extends Component { case 'F': this.showPage('search') break - */ default: handled = false @@ -256,13 +227,11 @@ class NodeContent extends Component { ` } - /* let element if (node.RenderMarkdown.value) element = html`<${MarkdownContent} key='markdown-content' content=${content} />` else - */ - const element = html` + element = html`
@@ -290,7 +259,8 @@ class NodeContent extends Component { }//}}} contentChanged(evt) {//{{{ _notes2.current.nodeUI.current.nodeModified.value = true - this.props.node.setContent(evt.target.value) + const content = evt.target.value + this.props.node.setContent(content) this.resize() }//}}} resize() {//{{{ @@ -301,25 +271,17 @@ 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._content = this.data.Content - this._modified = false - - /* + this.UUID = nodeData.UUID + this.ParentUUID = nodeData.ParentUUID + this.UserID = nodeData.UserID + this.CryptoKeyID = nodeData.CryptoKeyID + this.Name = nodeData.Name this.RenderMarkdown = signal(nodeData.RenderMarkdown) this.Markdown = false this.ShowChecklist = signal(false) @@ -332,15 +294,6 @@ 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 @@ -351,16 +304,15 @@ 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 @@ -372,10 +324,10 @@ export class Node { this._decrypted = true */ }//}}} - save() {//{{{ - this.data.Content = this._content - this.data.Updated = new Date().toISOString() - this._modified = false + static sort(a, b) {//{{{ + if (a.Name < b.Name) return -1 + if (a.Name > b.Name) return 0 + return 0 }//}}} } diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index a1a8a1a..57909b7 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -1,3 +1,4 @@ +import { API } from 'api' import { Node } from 'node' export const ROOT_NODE = '00000000-0000-0000-0000-000000000000' @@ -9,19 +10,18 @@ export class NodeStore { } this.db = null - this.nodes = {} }//}}} async initializeDB() {//{{{ return new Promise((resolve, reject) => { - const req = indexedDB.open('notes', 7) + const req = indexedDB.open('notes', 3) + // 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,43 +31,28 @@ 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('byName', 'Name', { unique: false }) + nodes.createIndex('nameIndex', 'Name', { unique: false }) break case 2: - trx.objectStore('nodes').createIndex('byParent', 'ParentUUID', { unique: false }) + trx.objectStore('treeNodes').createIndex('parentIndex', 'ParentUUID', { unique: false }) break case 3: - 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 + appState = db.createObjectStore('appState', { keyPath: 'key' }) } } } req.onsuccess = (event) => { this.db = event.target.result - this.initializeRootNode() - .then(() => this.initializeClientUUID()) - .then(() => resolve()) + this.initializeRootNode().then(() => + resolve() + ) } req.onerror = (event) => { @@ -75,7 +60,7 @@ export class NodeStore { } }) }//}}} - async initializeRootNode() {//{{{ + 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. @@ -93,8 +78,6 @@ export class NodeStore { UUID: ROOT_NODE, Name: 'Notes2', Content: 'Hello, World!', - Updated: new Date().toISOString(), - ParentUUID: '', }) putRequest.onsuccess = (event) => { resolve(event.target.result) @@ -106,25 +89,11 @@ 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('app_state', 'readonly') - const appState = trx.objectStore('app_state') + const trx = this.db.transaction('appState', 'readonly') + const appState = trx.objectStore('appState') const getRequest = appState.get(key) getRequest.onsuccess = (event) => { if (event.target.result !== undefined) { @@ -139,8 +108,8 @@ export class NodeStore { async setAppState(key, value) {//{{{ return new Promise((resolve, reject) => { try { - const t = this.db.transaction('app_state', 'readwrite') - const appState = t.objectStore('app_state') + const t = this.db.transaction('appState', 'readwrite') + const appState = t.objectStore('appState') t.onerror = (event) => { console.log('transaction error', event.target.error) reject(event.target.error) @@ -160,91 +129,10 @@ export class NodeStore { }) }//}}} - async moveToSendQueue(nodeToMove, replaceWithNode) {//{{{ + async upsertTreeRecords(records) {//{{{ return new Promise((resolve, reject) => { - 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') + const t = this.db.transaction('treeNodes', 'readwrite') + const nodeStore = t.objectStore('treeNodes') t.onerror = (event) => { console.log('transaction error', event.target.error) reject(event.target.error) @@ -264,32 +152,29 @@ 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.debug(`${op} ${record.UUID} (${record.Name})`) + console.log(`${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('nodes', 'readonly') - const nodeStore = trx.objectStore('nodes') - const index = nodeStore.index('byParent') + const trx = this.db.transaction('treeNodes', 'readonly') + const nodeStore = trx.objectStore('treeNodes') + const index = nodeStore.index('parentIndex') const req = index.getAll(parent) req.onsuccess = (event) => { const nodes = [] for (const i in event.target.result) { - const nodeData = event.target.result[i] - const node = this.node(nodeData.UUID, nodeData, newLevel) + const node = new Node(event.target.result[i], newLevel) nodes.push(node) } @@ -298,7 +183,6 @@ export class NodeStore { req.onerror = (event) => reject(event.target.error) }) }//}}} - async add(records) {//{{{ return new Promise((resolve, reject) => { try { @@ -309,29 +193,21 @@ export class NodeStore { reject(event.target.error) } t.oncomplete = () => { - console.log('OK') + resolve() } // records is an object, not an array. - const promises = [] for (const recordIdx in records) { const record = records[recordIdx] - 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) + 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) + } } - Promise.all(promises).then(() => resolve()) } catch (e) { console.log(e) } @@ -339,59 +215,32 @@ 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 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") + // Node found in IndexedDB and returned. + if (event.target.result !== undefined) { + const node = new Node(event.target.result, -1) + resolve(node) return } - 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) + // 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)) } }) }//}}} diff --git a/static/js/notes2.mjs b/static/js/notes2.mjs index 5e62ffa..9afec41 100644 --- a/static/js/notes2.mjs +++ b/static/js/notes2.mjs @@ -6,25 +6,22 @@ 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(_props, { startNode }) {//{{{ + render({}, { startNode }) {//{{{ if (startNode === null) return return html` - <${Tree} app=${this} startNode=${startNode} /> + <${Tree} ref=${this.tree} app=${this} startNode=${startNode} />
<${NodeUI} app=${this} ref=${this.nodeUI} startNode=${startNode} /> @@ -43,7 +40,7 @@ export class Notes2 extends Component { this.setState({ startNode: node }) }) }//}}} - async goToNode(nodeUUID, dontPush) {//{{{ + 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?")) @@ -55,11 +52,10 @@ 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. - 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) + nodeStore.get(nodeUUID).then(node => { + this.nodeUI.current.setNode(node) + //this.showPage('node') + }) }//}}} logout() {//{{{ localStorage.removeItem('session.UUID') @@ -70,11 +66,10 @@ export class Notes2 extends Component { class Tree extends Component { constructor(props) {//{{{ super(props) + this.treeNodes = {} this.treeNodeComponents = {} this.treeTrunk = [] - this.selectedNode = null - this.expandedNodes = {} // keyed on UUID - + this.selectedTreeNode = null this.props.app.tree = this this.populateFirstLevel() @@ -82,11 +77,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.state.startNode?.UUID} />` + return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.UUID]} selected=${node.UUID === app.startNode?.UUID} />` }) return html`
- + ${renderedTreeTrunk}
` }//}}} @@ -95,16 +90,30 @@ 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) { - // The root node isn't supposed to be shown in the tree. - if (node.UUID === '00000000-0000-0000-0000-000000000000') - continue + this.treeNodes[node.UUID] = node + 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() @@ -112,61 +121,60 @@ class Tree extends Component { .catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) }) }//}}} setSelected(node) {//{{{ - // 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() + return // TODO + if (this.selectedTreeNode) + this.selectedTreeNode.selected.value = false - // And now the newly selected node is rerendered. - this.treeNodeComponents[node.UUID]?.current.forceUpdate() + this.selectedTreeNode = this.treeNodeComponents[node.ID].current + this.selectedTreeNode.selected.value = true + this.selectedTreeNode.expanded.value = true + this.expandToTrunk(node.ID) + }//}}} + 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 - // Expanding selected nodes... I don't know... - this.setNodeExpanded(node.UUID, 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) + // Tree is done before the start node. + const component = this.treeNodeComponents[crumb.ID] + if (component?.component.current) + component.current.expanded.value = true } - // Already a top node, no need to expand anything. - if (ancestry.length === 0) + // 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) return - // 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 + node = this.treeNodes[node.ParentUUID] + while (node !== undefined) { + this.treeNodeComponents[node.UUID].current.expanded.value = true + node = this.treeNodes[node.ParentUUID] + } }//}}} } 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 || this.props.tree.getNodeExpanded(this.props.node.UUID)) + if (this.props.node.Level === 0) 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. - const selected = tree.isSelected(node) ? 'selected' : '' - - if (!this.children_populated.value && tree.getNodeExpanded(parent?.props.node.UUID)) + if (!this.children_populated.value && parent?.expanded.value) this.fetchChildren() const children = node.Children.map(node => { @@ -178,22 +186,25 @@ class TreeNode extends Component { if (node.Children.length === 0) expandImg = html`` else { - if (tree.getNodeExpanded(node.UUID)) + if (this.expanded.value) expandImg = html`` else expandImg = html`` } + const selected = (this.selected.value ? 'selected' : '') + return html`
-
{ tree.setNodeExpanded(node.UUID, !tree.getNodeExpanded(node.UUID)) }}>${expandImg}
-
window._notes2.current.goToNode(node.UUID)}>${node.get('Name')}
-
${children}
+
{ this.expanded.value ^= true }}>${expandImg}
+
window._notes2.current.goToNode(node.UUID)}>${node.Name}
+
${children}
` }//}}} - async fetchChildren() {//{{{ - await this.props.node.fetchChildren() - this.children_populated.value = true + fetchChildren() {//{{{ + this.props.node.fetchChildren().then(() => { + this.children_populated.value = true + }) }//}}} } diff --git a/static/js/sync.mjs b/static/js/sync.mjs index 2814fe4..a27817e 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -1,84 +1,31 @@ import { API } from 'api' -import { Node } from 'node' export class Sync { constructor() { this.foo = '' } - static async nodes() { - let duration = 0 - const syncStart = Date.now() + static async tree() { try { - // 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 state = await nodeStore.getAppState('latest_sync') const oldMax = (state?.value ? state.value : 0) - let currMax = oldMax + let newMax = 0 let offset = 0 let res = { Continue: false } let batch = 0 do { batch++ - res = await API.query('POST', `/sync/node/${oldMax}/${offset}`, { ClientUUID: clientUUID.value }) - if (res.Nodes.length > 0) - console.log(`Node sync batch #${batch}`) + console.log(`Batch #${batch}`) + res = await API.query('POST', `/node/tree/${oldMax}/${offset}`, {}) offset += res.Nodes.length - 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) - } - + newMax = res.MaxSeq + await nodeStore.upsertTreeRecords(res.Nodes) } while (res.Continue) - nodeStore.setAppState('latest_sync_node', currMax) + nodeStore.setAppState('latest_sync', Math.max(oldMax, newMax)) } 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 f3abdc5..ae66833 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -12,24 +12,40 @@ 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; @@ -68,7 +84,6 @@ html { .expand-toggle { - user-select: none; img { width: 16px; height: 16px; @@ -119,14 +134,6 @@ html { color: #333; border-radius: 5px; - &.node-modified { - background-color: @color1; - color: @color2; - .crumb:after { - color: @color2; - } - } - .crumb { margin-right: 8px; cursor: pointer; @@ -218,8 +225,3 @@ html { padding-top: 16px; } } - -#blank { - grid-area: blank; - height: 32px; -}