diff --git a/design.drawio b/design.drawio index bf766ef..f1191f1 100644 --- a/design.drawio +++ b/design.drawio @@ -1,6 +1,6 @@ - + - + @@ -10,33 +10,36 @@ - - + + + + + - + - + - + - + @@ -45,10 +48,50 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/file.go b/file.go index 51a09fc..9ad8457 100644 --- a/file.go +++ b/file.go @@ -44,20 +44,20 @@ func AddFile(userID int, file *File) (err error) { // {{{ err = rows.Scan(&file.ID) return } // }}} -func Files(userID, nodeID, fileID int) (files []File, err error) { // {{{ +func Files(userID int, nodeUUID string, fileID int) (files []File, err error) { // {{{ var rows *sqlx.Rows rows, err = db.Queryx( `SELECT * FROM file WHERE user_id = $1 AND - node_id = $2 AND + node_uuid = $2 AND CASE $3::int WHEN 0 THEN true ELSE id = $3 END`, userID, - nodeID, + nodeUUID, fileID, ) if err != nil { diff --git a/main.go b/main.go index d6994fa..f86a8ab 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ const VERSION = "v1" const CONTEXT_USER = 1 var ( + FlagGenerate bool FlagDev bool FlagConfig string FlagCreateUser string @@ -56,6 +57,7 @@ func init() { // {{{ flag.StringVar(&FlagConfig, "config", cfgFilename, "Configuration file") flag.BoolVar(&FlagDev, "dev", false, "Use local files instead of embedded files") + flag.BoolVar(&FlagGenerate, "generate", false, "Generate test data") flag.StringVar(&FlagCreateUser, "create-user", "", "Username for creating a new user") flag.StringVar(&FlagChangePassword, "change-password", "", "Change the password for the given username") flag.Parse() @@ -85,6 +87,16 @@ func main() { // {{{ // The session manager contains authentication, authorization and session settings. AuthManager, err = authentication.NewManager(db, Log, config.JWT.Secret, config.JWT.ExpireDays) + // Generate test data? + if FlagGenerate { + err := TestData() + if err != nil { + fmt.Printf("%s\n", err) + return + } + return + } + // A new user? if FlagCreateUser != "" { createNewUser(FlagCreateUser) @@ -111,7 +123,7 @@ func main() { // {{{ http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) http.HandleFunc("/node/tree/{timestamp}/{offset}", authenticated(actionNodeTree)) - http.HandleFunc("/node/retrieve/{id}", authenticated(actionNodeRetrieve)) + http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve)) http.HandleFunc("/service_worker.js", pageServiceWorker) @@ -241,17 +253,14 @@ func actionNodeTree(w http.ResponseWriter, r *http.Request) { // {{{ MaxSeq uint64 Continue bool }{true, nodes, maxSeq, moreRowsExist}) - Log.Debug("tree", "nodes", nodes) w.Write(j) } // }}} func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ user := getUser(r) var err error - idStr := r.PathValue("id") - id, _ := strconv.Atoi(idStr) - - node, err := RetrieveNode(user.ID, id) + uuid := r.PathValue("uuid") + node, err := RetrieveNode(user.ID, uuid) if err != nil { responseError(w, err) return diff --git a/node.go b/node.go index 60a88a2..18f7c0a 100644 --- a/node.go +++ b/node.go @@ -38,15 +38,13 @@ type TreeNode struct { } type Node struct { - ID int - UserID int `db:"user_id"` - ParentID int `db:"parent_id"` - CryptoKeyID int `db:"crypto_key_id"` + 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 - Children []Node - Crumbs []Node Files []File Complete bool Level int @@ -58,7 +56,7 @@ type Node struct { } func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ - const LIMIT = 8 + const LIMIT = 100 var rows *sqlx.Rows rows, err = db.Queryx(` SELECT @@ -84,7 +82,7 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 LIMIT $2 OFFSET $3 `, userID, - LIMIT + 1, + LIMIT+1, offset, synced, ) @@ -122,244 +120,58 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 return } // }}} -func RetrieveNode(userID, nodeID int) (node Node, err error) { // {{{ - if nodeID == 0 { - return RootNode(userID) - } - - var rows *sqlx.Rows - rows, err = db.Queryx(` - WITH RECURSIVE recurse AS ( - SELECT - id, - user_id, - COALESCE(parent_id, 0) AS parent_id, - COALESCE(crypto_key_id, 0) AS crypto_key_id, - name, - content, - content_encrypted, - markdown, - 0 AS level - FROM node - WHERE - user_id = $1 AND - id = $2 - - UNION - - SELECT - n.id, - n.user_id, - n.parent_id, - COALESCE(n.crypto_key_id, 0) AS crypto_key_id, - n.name, - '' AS content, - '' AS content_encrypted, - false AS markdown, - r.level + 1 AS level - FROM node n - INNER JOIN recurse r ON n.parent_id = r.id AND r.level = 0 - WHERE - n.user_id = $1 - ) - - SELECT * FROM recurse ORDER BY level ASC - `, - userID, - nodeID, - ) - if err != nil { - return - } - defer rows.Close() - - type resultRow struct { - Node - Level int - } - - node = Node{} - node.Children = []Node{} - for rows.Next() { - row := resultRow{} - if err = rows.StructScan(&row); err != nil { - return - } - - if row.Level == 0 { - node.ID = row.ID - node.UserID = row.UserID - node.ParentID = row.ParentID - node.CryptoKeyID = row.CryptoKeyID - node.Name = row.Name - node.Complete = true - node.Markdown = row.Markdown - - if node.CryptoKeyID > 0 { - node.Content = row.ContentEncrypted - } else { - node.Content = row.Content - } - - node.retrieveChecklist() - } - - if row.Level == 1 { - node.Children = append(node.Children, Node{ - ID: row.ID, - UserID: row.UserID, - ParentID: row.ParentID, - CryptoKeyID: row.CryptoKeyID, - Name: row.Name, - }) - } - } - - node.Crumbs, err = NodeCrumbs(node.ID) - node.Files, err = Files(userID, node.ID, 0) - - return -} // }}} -func RootNode(userID int) (node Node, err error) { // {{{ - var rows *sqlx.Rows - rows, err = db.Queryx(` - SELECT - id, - user_id, - 0 AS parent_id, - name - FROM node - WHERE - user_id = $1 AND - parent_id IS NULL - `, - userID, - ) - if err != nil { - return - } - defer rows.Close() - - node.Name = "Start" - node.UserID = userID - node.Complete = true - node.Children = []Node{} - node.Crumbs = []Node{} - node.Files = []File{} - for rows.Next() { - row := Node{} - if err = rows.StructScan(&row); err != nil { - return - } - - node.Children = append(node.Children, Node{ - ID: row.ID, - UserID: row.UserID, - ParentID: row.ParentID, - Name: row.Name, - }) - } - - return -} // }}} -func (node *Node) retrieveChecklist() (err error) { // {{{ - var rows *sqlx.Rows - rows, err = db.Queryx(` +func RetrieveNode(userID int, nodeUUID string) (node Node, err error) { // {{{ + var rows *sqlx.Row + rows = db.QueryRowx(` SELECT - g.id AS group_id, - g.order AS group_order, - g.label AS group_label, - - COALESCE(i.id, 0) AS item_id, - COALESCE(i.order, 0) AS item_order, - COALESCE(i.label, '') AS item_label, - COALESCE(i.checked, false) AS checked - - FROM public.checklist_group g - LEFT JOIN public.checklist_item i ON i.checklist_group_id = g.id + uuid, + user_id, + COALESCE(parent_uuid, '') AS parent_uuid, + /*COALESCE(crypto_key_id, 0) AS crypto_key_id,*/ + name, + content, + content_encrypted, + markdown, + 0 AS level + FROM node WHERE - g.node_id = $1 - ORDER BY - g.order DESC, - i.order DESC - `, node.ID) - if err != nil { + user_id = $1 AND + uuid = $2 + + `, + userID, + nodeUUID, + ) + node = Node{} + if err = rows.StructScan(&node); err != nil { return } - defer rows.Close() - - groups := make(map[int]*ChecklistGroup) - var found bool - var group *ChecklistGroup - var item ChecklistItem - for rows.Next() { - row := struct { - GroupID int `db:"group_id"` - GroupOrder int `db:"group_order"` - GroupLabel string `db:"group_label"` - - ItemID int `db:"item_id"` - ItemOrder int `db:"item_order"` - ItemLabel string `db:"item_label"` - Checked bool - }{} - err = rows.StructScan(&row) - if err != nil { - return - } - - if group, found = groups[row.GroupID]; !found { - group = new(ChecklistGroup) - group.ID = row.GroupID - group.NodeID = node.ID - group.Order = row.GroupOrder - group.Label = row.GroupLabel - group.Items = []ChecklistItem{} - groups[group.ID] = group - } - - item = ChecklistItem{} - item.ID = row.ItemID - item.GroupID = row.GroupID - item.Order = row.ItemOrder - item.Label = row.ItemLabel - item.Checked = row.Checked - - if item.ID > 0 { - group.Items = append(group.Items, item) - } - } - - node.ChecklistGroups = []ChecklistGroup{} - for _, group := range groups { - node.ChecklistGroups = append(node.ChecklistGroups, *group) - } return } // }}} -func NodeCrumbs(nodeID int) (nodes []Node, err error) { // {{{ +func NodeCrumbs(nodeUUID string) (nodes []Node, err error) { // {{{ var rows *sqlx.Rows rows, err = db.Queryx(` WITH RECURSIVE nodes AS ( SELECT - id, - COALESCE(parent_id, 0) AS parent_id, + uuid, + COALESCE(parent_uuid, '') AS parent_uuid, name FROM node WHERE - id = $1 + uuid = $1 UNION SELECT - n.id, - COALESCE(n.parent_id, 0) AS parent_id, + n.uuid, + COALESCE(n.parent_uuid, 0) AS parent_uuid, n.name FROM node n - INNER JOIN nodes nr ON n.id = nr.parent_id + INNER JOIN nodes nr ON n.uuid = nr.parent_uuid ) SELECT * FROM nodes - `, nodeID) + `, nodeUUID) if err != nil { return } @@ -375,3 +187,49 @@ func NodeCrumbs(nodeID int) (nodes []Node, err error) { // {{{ } return } // }}} + +func TestData() (err error) { + for range 10 { + hash1, name1, _ := generateOneTestNode("", "G") + for range 10 { + hash2, name2, _ := generateOneTestNode(hash1, name1) + for range 10 { + hash3, name3, _ := generateOneTestNode(hash2, name2) + for range 10 { + generateOneTestNode(hash3, name3) + } + } + + } + } + return +} + +func generateOneTestNode(parentUUID, parentPath string) (hash, name string, err error) { + var sqlParentUUID sql.NullString + if parentUUID != "" { + sqlParentUUID.String = parentUUID + sqlParentUUID.Valid = true + } + query := ` + INSERT INTO node(user_id, parent_uuid, name) + VALUES( + 1, + $1, + CONCAT( + $2::text, + '-', + LPAD(nextval('test_data')::text, 4, '0') + ) + ) + RETURNING uuid, name` + + var row *sql.Row + row = db.QueryRow(query, sqlParentUUID, parentPath) + err = row.Scan(&hash, &name) + if err != nil { + return + } + + return +} diff --git a/static/css/notes2.css b/static/css/notes2.css index c3e26ba..f31e3e9 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -2,16 +2,43 @@ html { background-color: #fff; } #notes2 { - display: grid; - grid-template-columns: min-content 1fr; min-height: 100vh; + display: grid; + 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" "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; + } } #tree { + grid-area: tree; padding: 16px; background-color: #333; color: #ddd; z-index: 100; } +#tree #logo { + display: grid; + position: relative; + justify-items: center; + margin-bottom: 32px; + margin-left: 24px; + margin-right: 24px; +} +#tree #logo img { + width: 128px; + left: -20px; +} #tree .node { display: grid; grid-template-columns: 24px min-content; @@ -44,6 +71,9 @@ html { display: none; } #crumbs { + grid-area: crumbs; + display: grid; + justify-items: center; margin: 16px; } .crumbs { @@ -52,7 +82,7 @@ html { padding: 8px 16px; background: #e4e4e4; color: #333; - border-radius: 6px; + border-radius: 5px; } .crumbs .crumb { margin-right: 8px; @@ -72,12 +102,21 @@ html { content: ''; margin-left: 0px; } +#name { + color: #666; + font-weight: bold; + text-align: center; + font-size: 1.15em; + margin-top: 32px; + margin-bottom: 16px; +} /* ============================================================= * * Textarea replicates the height of an element expanding height * * ============================================================= */ .grow-wrap { /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ display: grid; + grid-area: content; font-size: 1em; } .grow-wrap::after { @@ -109,16 +148,7 @@ html { grid-area: 1 / 1 / 2 / 2; } /* ============================================================= */ -.node-name { - background: #fff; - color: #000; - text-align: center; - font-weight: bold; - margin-top: 32px; - margin-bottom: 32px; - font-size: 1.5em; -} -.node-content { +#node-content { justify-self: center; word-wrap: break-word; font-family: monospace; @@ -129,7 +159,7 @@ html { border: none; outline: none; } -.node-content:invalid { +#node-content:invalid { background: #f5f5f5; padding-top: 16px; } diff --git a/static/js/lib/node_modules/.package-lock.json b/static/js/lib/node_modules/.package-lock.json index 837af37..441fdf4 100644 --- a/static/js/lib/node_modules/.package-lock.json +++ b/static/js/lib/node_modules/.package-lock.json @@ -13,6 +13,15 @@ "engines": { "node": ">= 18" } + }, + "node_modules/preact": { + "version": "10.25.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.25.1.tgz", + "integrity": "sha512-frxeZV2vhQSohQwJ7FvlqC40ze89+8friponWUFeVEkaCfhC6Eu4V0iND5C9CXz8JLndV07QRDeXzH1+Anz5Og==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } } } } diff --git a/static/js/lib/package-lock.json b/static/js/lib/package-lock.json index 9290485..821b7bb 100644 --- a/static/js/lib/package-lock.json +++ b/static/js/lib/package-lock.json @@ -5,7 +5,8 @@ "packages": { "": { "dependencies": { - "marked": "^11.1.1" + "marked": "^11.1.1", + "preact": "^10.25.1" } }, "node_modules/marked": { @@ -18,6 +19,15 @@ "engines": { "node": ">= 18" } + }, + "node_modules/preact": { + "version": "10.25.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.25.1.tgz", + "integrity": "sha512-frxeZV2vhQSohQwJ7FvlqC40ze89+8friponWUFeVEkaCfhC6Eu4V0iND5C9CXz8JLndV07QRDeXzH1+Anz5Og==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } } } } diff --git a/static/js/lib/package.json b/static/js/lib/package.json index 8498f90..28ef3fc 100644 --- a/static/js/lib/package.json +++ b/static/js/lib/package.json @@ -1,5 +1,6 @@ { "dependencies": { - "marked": "^11.1.1" + "marked": "^11.1.1", + "preact": "^10.25.1" } } diff --git a/static/js/node.mjs b/static/js/node.mjs index 0095ca8..98eef87 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -1,9 +1,7 @@ import { h, Component, createRef } from 'preact' import htm from 'htm' import { signal } from 'preact/signals' -import { Keys, Key } from 'key' -import Crypto from 'crypto' -import { Checklist, ChecklistGroup } from 'checklist' +import { ROOT_NODE } from 'node_store' const html = htm.bind(h) export class NodeUI extends Component { @@ -13,11 +11,12 @@ export class NodeUI extends Component { this.node = signal(null) this.nodeContent = createRef() this.nodeProperties = createRef() + this.nodeModified = signal(false) this.keys = signal([]) this.page = signal('node') window.addEventListener('popstate', evt => { - if (evt.state?.hasOwnProperty('nodeID')) - this.goToNode(evt.state.nodeID, true) + if (evt.state?.hasOwnProperty('nodeUUID')) + this.goToNode(evt.state.nodeUUID, true) else this.goToNode(0, true) }) @@ -29,7 +28,25 @@ export class NodeUI extends Component { return const node = this.node.value - document.title = `N: ${node.Name}` + document.title = node.Name + + return html` +
+
+
_notes2.current.goToNode(ROOT_NODE)}>Start
+
Minnie
+
Fluffy
+
Chili
+
+
+
${node.Name}
+ <${NodeContent} key=${node.UUID} node=${node} ref=${this.nodeContent} /> + ` + + + + + return let crumbs = [ html`
this.goToNode(0)}>Start
` @@ -39,14 +56,6 @@ export class NodeUI extends Component { html`
this.goToNode(node.ID)}>${node.Name}
` ).reverse()) - const children = node.Children.sort((a, b) => { - if (a.Name.toLowerCase() > b.Name.toLowerCase()) return 1 - if (a.Name.toLowerCase() < b.Name.toLowerCase()) return -1 - return 0 - }).map(child => html` -
this.goToNode(child.ID)}>${child.Name}
- `) - let modified = '' if (this.props.app.nodeModified.value) modified = 'modified' @@ -134,21 +143,16 @@ export class NodeUI extends Component { ` }//}}} async componentDidMount() {//{{{ - // When rendered and fetching the node, keys could be needed in order to - // decrypt the content. - /* TODO - implement keys. - await this.retrieveKeys() */ - - this.props.app.startNode.retrieve(node => { - this.node.value = node - - // The tree isn't guaranteed to have loaded yet. This is also run from - // the tree code, in case the node hasn't loaded. - this.props.app.tree.crumbsUpdateNodes(node) - }) + _notes2.current.goToNode(this.props.startNode.UUID, true) + }//}}} + setNode(node) {//{{{ + this.nodeModified.value = false + this.node.value = node }//}}} keyHandler(evt) {//{{{ + return + let handled = true // All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. @@ -202,120 +206,7 @@ export class NodeUI extends Component { evt.stopPropagation() } }//}}} - showMenu(evt) {//{{{ - evt.stopPropagation() - this.menu.value = true - }//}}} - logout() {//{{{ - window.localStorage.removeItem('session.UUID') - location.href = '/' - }//}}} - goToNode(nodeID, dontPush) {//{{{ - if (this.props.app.nodeModified.value) { - if (!confirm("Changes not saved. Do you want to discard changes?")) - return - } - - if (!dontPush) - history.pushState({ nodeID }, '', `/notes2#${nodeID}`) - - // New node is fetched in order to retrieve content and files. - // Such data is unnecessary to transfer for tree/navigational purposes. - const node = new Node(this.props.app, nodeID) - node.retrieve(node => { - this.props.app.nodeModified.value = false - this.node.value = node - this.showPage('node') - - // Tree needs to know another node is selected, in order to render any - // previously selected node not selected. - //this.props.app.tree.setSelected(node) - - // Hide tree toggle, as this would be the next natural action to do manually anyway. - // At least in mobile mode. - document.getElementById('app').classList.remove('toggle-tree') - }) - }//}}} - createNode(evt) {//{{{ - if (evt) - evt.stopPropagation() - let name = prompt("Name") - if (!name) - return - this.node.value.create(name, nodeID => { - console.log('before', this.props.app.startNode) - this.props.app.startNode = new Node(this.props.app, nodeID) - console.log('after', this.props.app.startNode) - this.props.app.tree.retrieve(() => { - this.goToNode(nodeID) - }) - }) - }//}}} - saveNode() {//{{{ - let content = this.node.value.content() - this.node.value.setContent(content) - this.node.value.save(() => { - this.props.app.nodeModified.value = false - this.node.value.retrieve() - }) - }//}}} - renameNode() {//{{{ - let name = prompt("New name") - if (!name) - return - - this.node.value.rename(name, () => { - this.goToNode(this.node.value.ID) - this.menu.value = false - }) - }//}}} - deleteNode() {//{{{ - if (!confirm("Do you want to delete this note and all sub-notes?")) - return - this.node.value.delete(() => { - this.goToNode(this.node.value.ParentID) - this.menu.value = false - }) - }//}}} - - async retrieveKeys() {//{{{ - return new Promise((resolve, reject) => { - /* TODO - implement keys in IndexedDB - this.props.app.request('/key/retrieve', {}) - .then(res => { - this.keys.value = res.Keys.map(keyData => new Key(keyData, this.keyCounter)) - resolve(this.keys.value) - }) - .catch(reject) - */ - }) - }//}}} - keyCounter() {//{{{ - return window._app.current.request('/key/counter', {}) - .then(res => BigInt(res.Counter)) - .catch(window._app.current.responseError) - }//}}} - getKey(id) {//{{{ - let keys = this.keys.value - for (let i = 0; i < keys.length; i++) - if (keys[i].ID == id) - return keys[i] - return null - }//}}} - - showPage(pg) {//{{{ - this.page.value = pg - }//}}} - showChecklist() {//{{{ - return (this.node.value.ChecklistGroups && this.node.value.ChecklistGroups.length > 0) | this.node.value.ShowChecklist.value - }//}}} - toggleChecklist() {//{{{ - this.node.value.ShowChecklist.value = !this.node.value.ShowChecklist.value - }//}}} - toggleMarkdown() {//{{{ - this.node.value.RenderMarkdown.value = !this.node.value.RenderMarkdown.value - }//}}} } class NodeContent extends Component { @@ -355,7 +246,7 @@ class NodeContent extends Component { const contentResizeObserver = new ResizeObserver(entries => { for (const idx in entries) { const w = entries[idx].contentRect.width - document.getElementById('crumbs').style.width = `${w}px` + document.querySelector('#crumbs .crumbs').style.width = `${w}px` } }); @@ -367,7 +258,7 @@ class NodeContent extends Component { this.resize() }//}}} contentChanged(evt) {//{{{ - window._app.current.nodeModified.value = true + _notes2.current.nodeUI.current.nodeModified.value = true const content = evt.target.value this.props.node.setContent(content) this.resize() @@ -377,98 +268,24 @@ class NodeContent extends Component { if (textarea) textarea.parentNode.dataset.replicatedValue = textarea.value }//}}} - unlock() {//{{{ - let pass = prompt(`Password for "${this.props.model.description}"`) - if (!pass) - return - - try { - this.props.model.unlock(pass) - this.forceUpdate() - } catch (err) { - alert(err) - } - }//}}} -} - -class MarkdownContent extends Component { - render({ content }) {//{{{ - return html`
` - }//}}} - componentDidMount() {//{{{ - const markdown = document.getElementById('markdown') - if (markdown) - markdown.innerHTML = marked.parse(this.props.content) - }//}}} -} - -class NodeEvents extends Component { - render({ events }) {//{{{ - if (events.length == 0) - return html`` - - const eventElements = events.map(evt => { - const dt = evt.Time.split('T') - return html`
${dt[0]} ${dt[1].slice(0, 5)}
` - }) - return html` -
-
Schedule events
- ${eventElements} -
- ` - }//}}} -} - -class NodeFiles extends Component { - render({ node }) {//{{{ - if (node.Files === null || node.Files.length == 0) - return - - let files = node.Files - .sort((a, b) => { - if (a.Filename.toUpperCase() < b.Filename.toUpperCase()) return -1 - if (a.Filename.toUpperCase() > b.Filename.toUpperCase()) return 1 - return 0 - }) - .map(file => - html` -
node.download(file.ID)}>${file.Filename}
-
${this.formatSize(file.Size)}
- ` - ) - - return html` -
-
Files
-
- ${files} -
-
- ` - }//}}} - formatSize(size) {//{{{ - if (size < 1048576) { - return `${Math.round(size / 1024)} KiB` - } else { - return `${Math.round(size / 1048576)} MiB` - } - }//}}} } export class Node { - constructor(app, nodeID) {//{{{ - this.app = app - this.ID = nodeID - this.ParentID = 0 - this.UserID = 0 - this.CryptoKeyID = 0 - this.Name = '' - this.RenderMarkdown = signal(false) + constructor(nodeData, level) {//{{{ + this.Level = level + + 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.RenderMarkdown = signal(nodeData.RenderMarkdown) this.Markdown = false this.ShowChecklist = signal(false) - this._content = '' - this.Children = [] + this._content = nodeData.Content this.Crumbs = [] this.Files = [] this._decrypted = false @@ -478,658 +295,40 @@ export class Node { // it doesn't control it afterwards. // Used to expand the crumbs upon site loading. }//}}} - retrieve(callback) {//{{{ - nodeStore.get(this.ID).then(node => { - this.ParentID = node.ParentID - this.UserID = node.UserID - this.CryptoKeyID = node.CryptoKeyID - this.Name = node.Name - this._content = node.Content - this.Children = node.Children - this.Crumbs = node.Crumbs - this.Files = node.Files - this.Markdown = node.Markdown - //this.RenderMarkdown.value = this.Markdown - this.initChecklist(node.ChecklistGroups) - callback(this) - }) - .catch(e => { console.log(e); alert(e) }) - - /* TODO - implement schedules - this.app.request('/schedule/list', { NodeID: this.ID }) - .then(res => { - this.ScheduleEvents.value = res.ScheduleEvents - }) - */ - + hasFetchedChildren() {//{{{ + return this._children_fetched }//}}} - delete(callback) {//{{{ - this.app.request('/node/delete', { - NodeID: this.ID, - }) - .then(callback) - .catch(this.app.responseError) - }//}}} - create(name, callback) {//{{{ - this.app.request('/node/create', { - Name: name.trim(), - ParentID: this.ID, - }) - .then(res => { - callback(res.Node.ID) - }) - .catch(this.app.responseError) - }//}}} - async save(callback) {//{{{ - try { - await this.#encrypt() + async fetchChildren() {//{{{ + if (this._children_fetched) + return this.Children + this.Children = await nodeStore.getTreeNodes(this.UUID, this.Level + 1) + this._children_fetched = true + return this.Children - let req = { - NodeID: this.ID, - Content: this._content, - CryptoKeyID: this.CryptoKeyID, - Markdown: this.Markdown, - TimeOffset: -(new Date().getTimezoneOffset()), - } - this.app.request('/node/update', req) - .then(callback) - .catch(this.app.responseError) - } catch (err) { - this.app.responseError(err) - } - }//}}} - rename(name, callback) {//{{{ - this.app.request('/node/rename', { - Name: name.trim(), - NodeID: this.ID, - }) - .then(callback) - .catch(this.app.responseError) - }//}}} - download(fileID) {//{{{ - let headers = { - 'Content-Type': 'application/json', - } - - if (this.app.session.UUID !== '') - headers['X-Session-Id'] = this.app.session.UUID - - let fname = "" - fetch("/node/download", { - method: 'POST', - headers, - body: JSON.stringify({ - NodeID: this.ID, - FileID: fileID, - }), - }) - .then(response => { - let match = response.headers.get('content-disposition').match(/filename="([^"]*)"/) - fname = match[1] - return response.blob() - }) - .then(blob => { - let url = window.URL.createObjectURL(blob) - let a = document.createElement('a') - a.href = url - a.download = fname - document.body.appendChild(a) // we need to append the element to the dom -> otherwise it will not work in firefox - a.click() - a.remove() //afterwards we remove the element again - }) }//}}} content() {//{{{ + /* TODO - implement crypto if (this.CryptoKeyID != 0 && !this._decrypted) this.#decrypt() + */ return this._content }//}}} setContent(new_content) {//{{{ this._content = new_content + /* TODO - implement crypto if (this.CryptoKeyID == 0) // Logic behind plaintext not being decrypted is that // only encrypted values can be in a decrypted state. this._decrypted = false else this._decrypted = true + */ }//}}} - async setCryptoKey(new_key) {//{{{ - return this.#encrypt(true, new_key) - }//}}} - #decrypt() {//{{{ - if (this.CryptoKeyID == 0 || this._decrypted) - return - - let obj_key = this.app.nodeUI.current.getKey(this.CryptoKeyID) - if (obj_key === null || obj_key.ID != this.CryptoKeyID) - throw ('Invalid key') - - // Ask user to unlock key first - var pass = null - while (pass || obj_key.status() == 'locked') { - pass = prompt(`Password for "${obj_key.description}"`) - if (!pass) - throw new Error(`Key "${obj_key.description}" is locked`) - - try { - obj_key.unlock(pass) - } catch (err) { - alert(err) - } - pass = null - } - - if (obj_key.status() == 'locked') - throw new Error(`Key "${obj_key.description}" is locked`) - - let crypto = new Crypto(obj_key.key) - this._decrypted = true - this._content = sjcl.codec.utf8String.fromBits( - crypto.decrypt(this._content) - ) - }//}}} - async #encrypt(change_key = false, new_key = null) {//{{{ - // Nothing to do if not changing key and already encrypted. - if (!change_key && this.CryptoKeyID != 0 && !this._decrypted) - return this._content - - let content = this.content() - - // Changing key to no encryption or already at no encryption - - // set to not decrypted (only encrypted values can be - // decrypted) and return plain value. - if ((change_key && new_key === null) || (!change_key && this.CryptoKeyID == 0)) { - this._decrypted = false - this.CryptoKeyID = 0 - return content - } - - let key_id = change_key ? new_key.ID : this.CryptoKeyID - let obj_key = this.app.nodeUI.current.getKey(key_id) - if (obj_key === null || obj_key.ID != key_id) - throw ('Invalid key') - - if (obj_key.status() == 'locked') - throw new Error(`Key "${obj_key.description}" is locked`) - - let crypto = new Crypto(obj_key.key) - let content_bits = sjcl.codec.utf8String.toBits(content) - let counter = await this.app.nodeUI.current.keyCounter() - this.CryptoKeyID = obj_key.ID - this._content = crypto.encrypt(content_bits, counter, true) - this._decrypted = false - return this._content - }//}}} - initChecklist(checklistData) {//{{{ - if (checklistData === undefined || checklistData === null) - return - this.ChecklistGroups = checklistData.map(groupData => { - return new ChecklistGroup(groupData) - }) + static sort(a, b) {//{{{ + if (a.Name < b.Name) return -1 + if (a.Name > b.Name) return 0 + return 0 }//}}} } -class Menu extends Component { - render({ nodeui }) {//{{{ - return html` -
nodeui.menu.value = false}>
- - ` - }//}}} -} -class UploadUI extends Component { - constructor(props) {//{{{ - super(props) - this.file = createRef() - this.filelist = signal([]) - this.fileRefs = [] - this.progressRefs = [] - }//}}} - render({ nodeui }) {//{{{ - let filelist = this.filelist.value - let files = [] - for (let i = 0; i < filelist.length; i++) { - files.push(html`
${filelist.item(i).name}
`) - } - - return html` -
nodeui.showPage('node')}>
-
- this.upload()} multiple /> -
- ${files} -
-
- ` - }//}}} - componentDidMount() {//{{{ - this.file.current.focus() - }//}}} - - upload() {//{{{ - let nodeID = this.props.nodeui.node.value.ID - this.fileRefs = [] - this.progressRefs = [] - - let input = this.file.current - this.filelist.value = input.files - for (let i = 0; i < input.files.length; i++) { - this.fileRefs.push(createRef()) - this.progressRefs.push(createRef()) - - this.postFile( - input.files[i], - nodeID, - progress => { - this.progressRefs[i].current.innerHTML = `${progress}%` - }, - res => { - this.props.nodeui.node.value.Files.push(res.File) - this.props.nodeui.forceUpdate() - - this.fileRefs[i].current.classList.add("done") - this.progressRefs[i].current.classList.add("done") - - this.props.nodeui.showPage('node') - }) - } - }//}}} - postFile(file, nodeID, progressCallback, doneCallback) {//{{{ - var formdata = new FormData() - formdata.append('file', file) - formdata.append('NodeID', nodeID) - - var request = new XMLHttpRequest() - - request.addEventListener("error", () => { - window._app.current.responseError({ upload: "An unknown error occured" }) - }) - - request.addEventListener("loadend", () => { - if (request.status != 200) { - window._app.current.responseError({ upload: request.statusText }) - return - } - - let response = JSON.parse(request.response) - if (!response.OK) { - window._app.current.responseError({ upload: response.Error }) - return - } - - doneCallback(response) - }) - - request.upload.addEventListener('progress', evt => { - var fileSize = file.size - - if (evt.loaded <= fileSize) - progressCallback(Math.round(evt.loaded / fileSize * 100)) - if (evt.loaded == evt.total) - progressCallback(100) - }) - - request.open('post', '/node/upload') - request.setRequestHeader("X-Session-Id", window._app.current.session.UUID) - //request.timeout = 45000 - request.send(formdata) - }//}}} -} -class NodeProperties extends Component { - constructor(props) {//{{{ - super(props) - this.props.nodeui.retrieveKeys() - this.selected_key_id = 0 - }//}}} - render({ nodeui }) {//{{{ - let keys = nodeui.keys.value - .sort((a, b) => { - if (a.description < b.description) return -1 - if (a.description > b.description) return 1 - return 0 - }) - .map(key => { - this.props.nodeui.keys.value.some(uikey => { - if (uikey.ID == nodeui.node.value.ID) { - this.selected_key_id = nodeui.node.value.ID - return true - } - }) - - if (nodeui.node.value.CryptoKeyID == key.ID) - this.selected_key_id = key.ID - - return html` -
- this.selected_key_id = key.ID} /> - -
` - }) - - return html` -
-

Note properties

- -
These properties are only for this note.
- -
- nodeui.node.value.Markdown = evt.target.checked} /> - - -
- -

Encryption

-
- this.selected_key_id = 0} /> - -
- ${keys} - - -
- ` - }//}}} - async save() {//{{{ - let nodeui = this.props.nodeui - let node = nodeui.node.value - - // Find the actual key object used for encryption - let new_key = nodeui.getKey(this.selected_key_id) - let current_key = nodeui.getKey(node.CryptoKeyID) - - if (current_key && current_key.status() == 'locked') { - alert("Decryption key is locked and can not be used.") - return - } - - if (new_key && new_key.status() == 'locked') { - alert("Key is locked and can not be used.") - return - } - - await node.setCryptoKey(new_key) - - if (node.Markdown != node.RenderMarkdown.value) - node.RenderMarkdown.value = node.Markdown - - node.save(() => this.props.nodeui.showPage('node')) - }//}}} -} - -class Search extends Component { - constructor() {//{{{ - super() - this.state = { - matches: [], - results_returned: false, - } - }//}}} - render({ nodeui }, { matches, results_returned }) {//{{{ - let match_elements = [ - html`

Results

`, - ] - let matched_nodes = matches.map(node => html` -
nodeui.goToNode(node.ID)}> - ${node.Name} -
- `) - match_elements.push(html`
${matched_nodes}
`) - - return html` - ` - }//}}} - componentDidMount() {//{{{ - document.getElementById('search-for').focus() - }//}}} - - keyHandler(evt) {//{{{ - let handled = true - - switch (evt.key.toUpperCase()) { - case 'ENTER': - this.search() - break - - default: - handled = false - } - - if (handled) { - evt.preventDefault() - evt.stopPropagation() - } - }//}}} - search() {//{{{ - let Search = document.getElementById('search-for').value - - window._app.current.request('/node/search', { Search }) - .then(res => { - this.setState({ - matches: res.Nodes, - results_returned: true, - - }) - }) - .catch(window._app.current.responseError) - }//}}} -} - -class ProfileSettings extends Component { - render({ nodeui }, { }) {//{{{ - return html` -
-

User settings

- -

Password

-
-
Current
- this.keyHandler(evt)} /> - -
New
- this.keyHandler(evt)} /> - -
Repeat
- this.keyHandler(evt)} /> -
- - -
` - }//}}} - componentDidMount() {//{{{ - document.getElementById('current-password').focus() - }//}}} - - keyHandler(evt) {//{{{ - let handled = true - - switch (evt.key.toUpperCase()) { - case 'ENTER': - this.updatePassword() - break - - default: - handled = false - } - - if (handled) { - evt.preventDefault() - evt.stopPropagation() - } - }//}}} - updatePassword() {//{{{ - let curr_pass = document.getElementById('current-password').value - let pass1 = document.getElementById('new-password1').value - let pass2 = document.getElementById('new-password2').value - - try { - if (pass1.length < 4) { - throw new Error('Password has to be at least 4 characters long') - } - - if (pass1 != pass2) { - throw new Error(`Passwords don't match`) - } - - window._app.current.request('/user/password', { - CurrentPassword: curr_pass, - NewPassword: pass1, - }) - .then(res => { - if (res.CurrentPasswordOK) - alert('Password is changed successfully') - else - alert('Current password is invalid') - }) - } catch (err) { - alert(err.message) - } - - }//}}} -} - -class ScheduleEventList extends Component { - static CALENDAR = Symbol('CALENDAR') - static LIST = Symbol('LIST') - constructor() {//{{{ - super() - this.tab = signal(ScheduleEventList.CALENDAR) - }//}}} - render() {//{{{ - var tab - switch (this.tab.value) { - case ScheduleEventList.CALENDAR: - tab = html`<${ScheduleCalendarTab} />` - break; - case ScheduleEventList.LIST: - tab = html`<${ScheduleEventListTab} />` - break; - } - - return html` -
-
-
-
this.tab.value = ScheduleEventList.CALENDAR} class="tab ${this.tab.value == ScheduleEventList.CALENDAR ? 'selected' : ''}">Calendar
-
this.tab.value = ScheduleEventList.LIST} class="tab ${this.tab.value == ScheduleEventList.LIST ? 'selected' : ''}">List
-
-
-
- ${tab} -
-
-
- ` - }//}}} -} - -class ScheduleEventListTab extends Component { - constructor() {//{{{ - super() - this.events = signal(null) - this.retrieveFutureEvents() - }//}}} - render() {//{{{ - if (this.events.value === null) - return - - let events = this.events.value.sort((a, b) => { - if (a.Time < b.Time) return -1 - if (a.Time > b.Time) return 1 - return 0 - }).map(evt => { - const dt = evt.Time.split('T') - const remind = () => { - if (evt.RemindMinutes > 0) - return html`${evt.RemindMinutes} min` - } - const nodeLink = () => html`${evt.Node.Name}` - - - return html` -
${dt[0]}
-
${dt[1].slice(0, 5)}
-
<${remind} />
-
${evt.Description}
-
<${nodeLink} />
- ` - }) - - return html` -
-
Date
-
Time
-
Reminder
-
Event
-
Node
- ${events} -
- ` - }//}}} - retrieveFutureEvents() {//{{{ - _app.current.request('/schedule/list') - .then(data => { - this.events.value = data.ScheduleEvents - }) - }//}}} -} - -class ScheduleCalendarTab extends Component { - constructor() {//{{{ - super() - }//}}} - componentDidMount() { - let calendarEl = document.getElementById('fullcalendar'); - this.calendar = new FullCalendar.Calendar(calendarEl, { - initialView: 'dayGridMonth', - events: this.events, - eventTimeFormat: { - hour12: false, - hour: '2-digit', - minute: '2-digit', - }, - firstDay: 1, - aspectRatio: 2.5, - }); - this.calendar.render(); - } - render() { - return html`
` - } - events(info, successCallback, failureCallback) { - const req = { - StartDate: info.startStr, - EndDate: info.endStr, - } - _app.current.request('/schedule/list', req) - .then(data => { - const fullcalendarEvents = data.ScheduleEvents.map(sch => { - return { - title: sch.Description, - start: sch.Time, - url: `/notes2#${sch.Node.ID}`, - } - }) - successCallback(fullcalendarEvents) - }) - .catch(err => failureCallback(err)) - } -} - - // vim: foldmethod=marker diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index b93e669..57909b7 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -1,4 +1,7 @@ import { API } from 'api' +import { Node } from 'node' + +export const ROOT_NODE = '00000000-0000-0000-0000-000000000000' export class NodeStore { constructor() {//{{{ @@ -47,7 +50,9 @@ export class NodeStore { req.onsuccess = (event) => { this.db = event.target.result - resolve() + this.initializeRootNode().then(() => + resolve() + ) } req.onerror = (event) => { @@ -55,6 +60,35 @@ export class NodeStore { } }) }//}}} + 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. + const trx = this.db.transaction('nodes', 'readwrite') + const nodes = trx.objectStore('nodes') + const getRequest = nodes.get(ROOT_NODE) + getRequest.onsuccess = (event) => { + // Root node exists - nice! + if (event.target.result !== undefined) { + resolve(event.target.result) + return + } + + const putRequest = nodes.put({ + UUID: ROOT_NODE, + Name: 'Notes2', + Content: 'Hello, World!', + }) + putRequest.onsuccess = (event) => { + resolve(event.target.result) + } + putRequest.onerror = (event) => { + reject(event.target.error) + } + } + getRequest.onerror = (event) => reject(event.target.error) + }) + }//}}} async getAppState(key) {//{{{ return new Promise((resolve, reject) => { @@ -95,7 +129,7 @@ export class NodeStore { }) }//}}} - async updateTreeRecords(records) {//{{{ + async upsertTreeRecords(records) {//{{{ return new Promise((resolve, reject) => { const t = this.db.transaction('treeNodes', 'readwrite') const nodeStore = t.objectStore('treeNodes') @@ -131,6 +165,24 @@ export class NodeStore { }) }//}}} + 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 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) + nodes.push(node) + + } + resolve(nodes) + } + req.onerror = (event) => reject(event.target.error) + }) + }//}}} async add(records) {//{{{ return new Promise((resolve, reject) => { try { @@ -161,27 +213,29 @@ export class NodeStore { } }) }//}}} - async get(id) {//{{{ + 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(id) + const getRequest = nodeStore.get(uuid) getRequest.onsuccess = (event) => { // Node found in IndexedDB and returned. if (event.target.result !== undefined) { - resolve(event.target.result) + const node = new Node(event.target.result, -1) + resolve(node) return } // Node not found and a request to the backend is made. - API.query("POST", `/node/retrieve/${id}`, {}) + 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) - putRequest.onsuccess = () => resolve(res.Node) + const node = new Node(res.Node, -1) + putRequest.onsuccess = () => resolve(node) putRequest.onerror = (event) => { reject(event.target.error) } @@ -190,15 +244,6 @@ export class NodeStore { } }) }//}}} - async getTreeNodes() {//{{{ - return new Promise((resolve, reject) => { - const trx = this.db.transaction('nodes', 'readonly') - const nodeStore = trx.objectStore('nodes') - const req = nodeStore.getAll() - req.onsuccess = (event) => resolve(event.target.result) - req.onerror = (event) => reject(event.target.error) - }) - }//}}} } // vim: foldmethod=marker diff --git a/static/js/app.mjs b/static/js/notes2.mjs similarity index 53% rename from static/js/app.mjs rename to static/js/notes2.mjs index 999e238..0666018 100644 --- a/static/js/app.mjs +++ b/static/js/notes2.mjs @@ -1,46 +1,65 @@ import { h, Component, createRef } from 'preact' import { signal } from 'preact/signals' import htm from 'htm' -import { API } from 'api' import { Node, NodeUI } from 'node' +import { ROOT_NODE } from 'node_store' const html = htm.bind(h) -export class Notes2 { +export class Notes2 extends Component { + state = { + startNode: null, + } constructor() {//{{{ - this.startNode = null - this.tree = null + super() + this.tree = createRef() this.nodeUI = createRef() - this.nodeModified = signal(false) - this.setStartNode() + + this.getStartNode() }//}}} - render() {//{{{ + render({}, { startNode }) {//{{{ + if (startNode === null) + return + return html` - <${Tree} ref=${this.tree} app=${this} /> -
- <${NodeUI} app=${this} ref=${this.nodeUI} /> + <${Tree} ref=${this.tree} app=${this} startNode=${startNode} /> + +
+ <${NodeUI} app=${this} ref=${this.nodeUI} startNode=${startNode} />
` }//}}} - setStartNode() {//{{{ - /* - const urlParams = new URLSearchParams(window.location.search) - const nodeID = urlParams.get('node') - */ + getStartNode() {//{{{ + let nodeUUID = ROOT_NODE + // Is a UUID provided on the URI as an anchor? const parts = document.URL.split('#') - const nodeID = parts[1] + if (parts[1]?.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) + nodeUUID = parts[1] - this.startNode = new Node(this, nodeID ? Number.parseInt(nodeID) : 0) + nodeStore.get(nodeUUID).then(node => { + this.setState({ startNode: node }) + }) }//}}} + 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?")) + return + } - treeGet() {//{{{ - const req = {} - API.query('POST', '/node/tree', req) - .then(response => { - console.log(response.Nodes) - nodeStore.add(response.Nodes) - }) - .catch(e => console.log(e.type, e.error)) + if (!dontPush) + history.pushState({ nodeUUID }, '', `/notes2#${nodeUUID}`) + + // 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') + }) + }//}}} + logout() {//{{{ + localStorage.removeItem('session.UUID') + location.href = '/' }//}}} } @@ -53,19 +72,25 @@ class Tree extends Component { this.selectedTreeNode = null this.props.app.tree = this - this.retrieve() + this.populateFirstLevel() }//}}} render({ app }) {//{{{ const renderedTreeTrunk = this.treeTrunk.map(node => { - this.treeNodeComponents[node.ID] = createRef() - return html`<${TreeNode} key=${`treenode_${node.ID}`} tree=${this} node=${node} ref=${this.treeNodeComponents[node.ID]} selected=${node.ID === app.startNode.ID} />` + 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`
${renderedTreeTrunk}
` + return html` +
+ + ${renderedTreeTrunk} +
` }//}}} - retrieve(callback = null) {//{{{ - nodeStore.getTreeNodes() - .then(res => { + populateFirstLevel(callback = null) {//{{{ + nodeStore.getTreeNodes('', 0) + .then(async res => { + res.sort(Node.sort) + this.treeNodes = {} this.treeNodeComponents = {} this.treeTrunk = [] @@ -75,26 +100,13 @@ class Tree extends Component { // 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 nodeData of res) { - const node = new Node( - this, - nodeData.ID, - ) - node.Children = [] - node.Crumbs = [] - node.Files = [] - node.Level = nodeData.Level - node.Name = nodeData.Name - node.ParentID = nodeData.ParentID - node.Updated = nodeData.Updated - node.UserID = nodeData.UserID + for (const node of res) { + this.treeNodes[node.UUID] = node - this.treeNodes[node.ID] = node - - if (node.ParentID === 0) + if (node.ParentUUID === '') this.treeTrunk.push(node) - else if (this.treeNodes[node.ParentID] !== undefined) - this.treeNodes[node.ParentID].Children.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 @@ -137,15 +149,15 @@ class Tree extends Component { if (node !== undefined) this.setSelected(node) }//}}} - expandToTrunk(nodeID) {//{{{ - let node = this.treeNodes[nodeID] + expandToTrunk(nodeUUID) {//{{{ + let node = this.treeNodes[nodeUUID] if (node === undefined) return - node = this.treeNodes[node.ParentID] + node = this.treeNodes[node.ParentUUID] while (node !== undefined) { - this.treeNodeComponents[node.ID].current.expanded.value = true - node = this.treeNodes[node.ParentID] + this.treeNodeComponents[node.UUID].current.expanded.value = true + node = this.treeNodes[node.ParentUUID] } }//}}} } @@ -155,12 +167,19 @@ class TreeNode extends Component { 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.fetchChildren() }//}}} - render({ tree, node }) {//{{{ + 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) + this.fetchChildren() const children = node.Children.map(node => { - tree.treeNodeComponents[node.ID] = createRef() - return html`<${TreeNode} key=${`treenode_${node.ID}`} tree=${tree} node=${node} ref=${tree.treeNodeComponents[node.ID]} selected=${node.ID === tree.props.app.startNode.ID} />` + tree.treeNodeComponents[node.UUID] = createRef() + return html`<${TreeNode} key=${`treenode_${node.UUID}`} tree=${tree} node=${node} parent=${this} ref=${tree.treeNodeComponents[node.UUID]} selected=${node.UUID === tree.props.app.startNode?.UUID} />` }) let expandImg = '' @@ -173,16 +192,20 @@ class TreeNode extends Component { expandImg = html`` } - const selected = (this.selected.value ? 'selected' : '') return html`
{ this.expanded.value ^= true }}>${expandImg}
-
window._notes2.current.nodeUI.current.goToNode(node.ID)}>${node.Name}
+
window._notes2.current.goToNode(node.UUID)}>${node.Name}
${children}
` }//}}} + fetchChildren() {//{{{ + this.props.node.fetchChildren().then(() => { + this.children_populated.value = true + }) + }//}}} } // vim: foldmethod=marker diff --git a/static/js/sync.mjs b/static/js/sync.mjs index 080cfcb..a27817e 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -1,10 +1,14 @@ import { API } from 'api' export class Sync { + constructor() { + this.foo = '' + } + static async tree() { try { const state = await nodeStore.getAppState('latest_sync') - let oldMax = (state?.value ? state.value : 0) + const oldMax = (state?.value ? state.value : 0) let newMax = 0 let offset = 0 @@ -16,40 +20,12 @@ export class Sync { res = await API.query('POST', `/node/tree/${oldMax}/${offset}`, {}) offset += res.Nodes.length newMax = res.MaxSeq - await nodeStore.updateTreeRecords(res.Nodes) + await nodeStore.upsertTreeRecords(res.Nodes) } while (res.Continue) nodeStore.setAppState('latest_sync', Math.max(oldMax, newMax)) } catch (e) { console.log('sync node tree', e) } - - - /* - nodeStore.getAppState('latest_sync') - .then(state => { - if (state !== null) { - oldMax = state.value - return state.value - } - return 0 - }) - .then(async sequence => { - let offset = 0 - let res = { Continue: false } - try { - do { - res = await API.query('POST', `/node/tree/${sequence}/${offset}`, {}) - offset += res.Nodes.length - newMax = res.MaxSeq - await nodeStore.updateTreeRecords(res.Nodes) - } while (res.Continue) - } catch (e) { - return new Promise((_, reject) => reject(e)) - } - }) - .then(() => nodeStore.setAppState('latest_sync', Math.max(oldMax, newMax))) - .catch(e => console.log('sync', e)) - */ } } diff --git a/static/less/notes2.less b/static/less/notes2.less index 4ef3027..ae66833 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -5,18 +5,75 @@ html { } #notes2 { - display: grid; - grid-template-columns: min-content 1fr; min-height: 100vh; + + display: grid; + 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) { + 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 */ + + #tree { + display: none; + } + } } #tree { - //grid-area: tree; + grid-area: tree; padding: 16px; background-color: #333; color: #ddd; z-index: 100; // Over crumbs shadow + #logo { + display: grid; + position: relative; + justify-items: center; + margin-bottom: 32px; + margin-left: 24px; + margin-right: 24px; + img { + width: 128px; + left: -20px; + + } + } + .node { display: grid; grid-template-columns: 24px min-content; @@ -62,17 +119,20 @@ html { } #crumbs { - //grid-area: crumbs; + grid-area: crumbs; + display: grid; + justify-items: center; margin: 16px; } .crumbs { + background: #e4e4e4; display: flex; flex-wrap: wrap; padding: 8px 16px; background: #e4e4e4; color: #333; - border-radius: 6px; + border-radius: 5px; .crumb { margin-right: 8px; @@ -97,13 +157,22 @@ html { } +#name { + color: @color3; + font-weight: bold; + text-align: center; + font-size: 1.15em; + margin-top: 32px; + margin-bottom: 16px; +} + /* ============================================================= * * Textarea replicates the height of an element expanding height * * ============================================================= */ .grow-wrap { /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ display: grid; - //grid-area: content; + grid-area: content; font-size: 1.0em; } .grow-wrap::after { @@ -139,18 +208,8 @@ html { grid-area: 1 / 1 / 2 / 2; } /* ============================================================= */ -.node-name { - background: #fff; - color: #000; - text-align: center; - font-weight: bold; - margin-top: 32px; - margin-bottom: 32px; - font-size: 1.5em; -} -.node-content { - //grid-area: content; +#node-content { justify-self: center; word-wrap: break-word; font-family: monospace; @@ -160,7 +219,6 @@ html { resize: none; border: none; outline: none; - &:invalid { background: #f5f5f5; diff --git a/static/service_worker.js b/static/service_worker.js index d702ee3..e10e3b0 100644 --- a/static/service_worker.js +++ b/static/service_worker.js @@ -15,7 +15,7 @@ const CACHED_ASSETS = [ '/js/{{ .VERSION }}/api.mjs', '/js/{{ .VERSION }}/node_store.mjs', - '/js/{{ .VERSION }}/app.mjs', + '/js/{{ .VERSION }}/notes2.mjs', '/js/{{ .VERSION }}/key.mjs', '/js/{{ .VERSION }}/crypto.mjs', '/js/{{ .VERSION }}/checklist.mjs', diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index cd4c43b..5cc30cf 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -10,7 +10,7 @@ import 'preact/debug' import 'preact/devtools' {{- end }} import { NodeStore } from 'node_store' -import { Notes2 } from "/js/{{ .VERSION }}/app.mjs" +import { Notes2 } from "/js/{{ .VERSION }}/notes2.mjs" import { API } from 'api' if (!API.hasAuthenticationToken()) {