diff --git a/design.drawio b/design.drawio index f1191f1..bf766ef 100644 --- a/design.drawio +++ b/design.drawio @@ -1,6 +1,6 @@ - + - + @@ -10,36 +10,33 @@ - - - - - + + - + - + - + - + @@ -48,50 +45,10 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/file.go b/file.go index 9ad8457..51a09fc 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 int, nodeUUID string, fileID int) (files []File, err error) { // {{{ +func Files(userID, nodeID, fileID int) (files []File, err error) { // {{{ var rows *sqlx.Rows rows, err = db.Queryx( `SELECT * FROM file WHERE user_id = $1 AND - node_uuid = $2 AND + node_id = $2 AND CASE $3::int WHEN 0 THEN true ELSE id = $3 END`, userID, - nodeUUID, + nodeID, fileID, ) if err != nil { diff --git a/main.go b/main.go index f86a8ab..d6994fa 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,6 @@ const VERSION = "v1" const CONTEXT_USER = 1 var ( - FlagGenerate bool FlagDev bool FlagConfig string FlagCreateUser string @@ -57,7 +56,6 @@ 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() @@ -87,16 +85,6 @@ 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) @@ -123,7 +111,7 @@ func main() { // {{{ http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler) http.HandleFunc("/node/tree/{timestamp}/{offset}", authenticated(actionNodeTree)) - http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve)) + http.HandleFunc("/node/retrieve/{id}", authenticated(actionNodeRetrieve)) http.HandleFunc("/service_worker.js", pageServiceWorker) @@ -253,14 +241,17 @@ 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 - uuid := r.PathValue("uuid") - node, err := RetrieveNode(user.ID, uuid) + idStr := r.PathValue("id") + id, _ := strconv.Atoi(idStr) + + node, err := RetrieveNode(user.ID, id) if err != nil { responseError(w, err) return diff --git a/node.go b/node.go index 18f7c0a..60a88a2 100644 --- a/node.go +++ b/node.go @@ -38,13 +38,15 @@ type TreeNode struct { } type Node struct { - UUID string - UserID int `db:"user_id"` - ParentUUID string `db:"parent_uuid"` - CryptoKeyID int `db:"crypto_key_id"` + ID int + UserID int `db:"user_id"` + ParentID int `db:"parent_id"` + CryptoKeyID int `db:"crypto_key_id"` Name string Content string Updated time.Time + Children []Node + Crumbs []Node Files []File Complete bool Level int @@ -56,7 +58,7 @@ type Node struct { } func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint64, moreRowsExist bool, err error) { // {{{ - const LIMIT = 100 + const LIMIT = 8 var rows *sqlx.Rows rows, err = db.Queryx(` SELECT @@ -82,7 +84,7 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 LIMIT $2 OFFSET $3 `, userID, - LIMIT+1, + LIMIT + 1, offset, synced, ) @@ -120,58 +122,244 @@ func NodeTree(userID, offset int, synced uint64) (nodes []TreeNode, maxSeq uint6 return } // }}} -func RetrieveNode(userID int, nodeUUID string) (node Node, err error) { // {{{ - var rows *sqlx.Row - rows = db.QueryRowx(` - SELECT - 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 - user_id = $1 AND - uuid = $2 - +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, - nodeUUID, + nodeID, ) - node = Node{} - if err = rows.StructScan(&node); err != nil { + 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 NodeCrumbs(nodeUUID string) (nodes []Node, err error) { // {{{ +func (node *Node) retrieveChecklist() (err error) { // {{{ + var rows *sqlx.Rows + rows, err = db.Queryx(` + 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 + WHERE + g.node_id = $1 + ORDER BY + g.order DESC, + i.order DESC + `, node.ID) + if 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) { // {{{ var rows *sqlx.Rows rows, err = db.Queryx(` WITH RECURSIVE nodes AS ( SELECT - uuid, - COALESCE(parent_uuid, '') AS parent_uuid, + id, + COALESCE(parent_id, 0) AS parent_id, name FROM node WHERE - uuid = $1 + id = $1 UNION SELECT - n.uuid, - COALESCE(n.parent_uuid, 0) AS parent_uuid, + n.id, + COALESCE(n.parent_id, 0) AS parent_id, n.name FROM node n - INNER JOIN nodes nr ON n.uuid = nr.parent_uuid + INNER JOIN nodes nr ON n.id = nr.parent_id ) SELECT * FROM nodes - `, nodeUUID) + `, nodeID) if err != nil { return } @@ -187,49 +375,3 @@ func NodeCrumbs(nodeUUID string) (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 f31e3e9..c3e26ba 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -2,43 +2,16 @@ html { background-color: #fff; } #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-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; - } + min-height: 100vh; } #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; @@ -71,9 +44,6 @@ html { display: none; } #crumbs { - grid-area: crumbs; - display: grid; - justify-items: center; margin: 16px; } .crumbs { @@ -82,7 +52,7 @@ html { padding: 8px 16px; background: #e4e4e4; color: #333; - border-radius: 5px; + border-radius: 6px; } .crumbs .crumb { margin-right: 8px; @@ -102,21 +72,12 @@ 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 { @@ -148,7 +109,16 @@ html { grid-area: 1 / 1 / 2 / 2; } /* ============================================================= */ -#node-content { +.node-name { + background: #fff; + color: #000; + text-align: center; + font-weight: bold; + margin-top: 32px; + margin-bottom: 32px; + font-size: 1.5em; +} +.node-content { justify-self: center; word-wrap: break-word; font-family: monospace; @@ -159,7 +129,7 @@ html { border: none; outline: none; } -#node-content:invalid { +.node-content:invalid { background: #f5f5f5; padding-top: 16px; } diff --git a/static/js/notes2.mjs b/static/js/app.mjs similarity index 52% rename from static/js/notes2.mjs rename to static/js/app.mjs index 9afec41..999e238 100644 --- a/static/js/notes2.mjs +++ b/static/js/app.mjs @@ -1,65 +1,46 @@ 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 extends Component { - state = { - startNode: null, - } +export class Notes2 { constructor() {//{{{ - super() - this.tree = createRef() + this.startNode = null + this.tree = null this.nodeUI = createRef() - - this.getStartNode() + this.nodeModified = signal(false) + this.setStartNode() }//}}} - render({}, { startNode }) {//{{{ - if (startNode === null) - return - + render() {//{{{ return html` - <${Tree} ref=${this.tree} app=${this} startNode=${startNode} /> - -
- <${NodeUI} app=${this} ref=${this.nodeUI} startNode=${startNode} /> + <${Tree} ref=${this.tree} app=${this} /> +
+ <${NodeUI} app=${this} ref=${this.nodeUI} />
` }//}}} - getStartNode() {//{{{ - let nodeUUID = ROOT_NODE + setStartNode() {//{{{ + /* + const urlParams = new URLSearchParams(window.location.search) + const nodeID = urlParams.get('node') + */ - // Is a UUID provided on the URI as an anchor? const parts = document.URL.split('#') - 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] + const nodeID = parts[1] - nodeStore.get(nodeUUID).then(node => { - this.setState({ startNode: node }) - }) + this.startNode = new Node(this, nodeID ? Number.parseInt(nodeID) : 0) }//}}} - 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 - } - 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 = '/' + 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)) }//}}} } @@ -72,25 +53,19 @@ class Tree extends Component { this.selectedTreeNode = null this.props.app.tree = this - this.populateFirstLevel() + this.retrieve() }//}}} 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} />` + 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} />` }) - return html` -
- - ${renderedTreeTrunk} -
` + return html`
${renderedTreeTrunk}
` }//}}} - populateFirstLevel(callback = null) {//{{{ - nodeStore.getTreeNodes('', 0) - .then(async res => { - res.sort(Node.sort) - + retrieve(callback = null) {//{{{ + nodeStore.getTreeNodes() + .then(res => { this.treeNodes = {} this.treeNodeComponents = {} this.treeTrunk = [] @@ -100,13 +75,26 @@ 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 node of res) { - this.treeNodes[node.UUID] = node + 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 - if (node.ParentUUID === '') + this.treeNodes[node.ID] = node + + if (node.ParentID === 0) this.treeTrunk.push(node) - else if (this.treeNodes[node.ParentUUD] !== undefined) - this.treeNodes[node.ParentUUID].Children.push(node) + else if (this.treeNodes[node.ParentID] !== undefined) + this.treeNodes[node.ParentID].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 @@ -149,15 +137,15 @@ class Tree extends Component { if (node !== undefined) this.setSelected(node) }//}}} - expandToTrunk(nodeUUID) {//{{{ - let node = this.treeNodes[nodeUUID] + expandToTrunk(nodeID) {//{{{ + let node = this.treeNodes[nodeID] if (node === undefined) return - node = this.treeNodes[node.ParentUUID] + node = this.treeNodes[node.ParentID] while (node !== undefined) { - this.treeNodeComponents[node.UUID].current.expanded.value = true - node = this.treeNodes[node.ParentUUID] + this.treeNodeComponents[node.ID].current.expanded.value = true + node = this.treeNodes[node.ParentID] } }//}}} } @@ -167,19 +155,12 @@ 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, 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() + render({ tree, node }) {//{{{ const children = node.Children.map(node => { - 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} />` + 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} />` }) let expandImg = '' @@ -192,20 +173,16 @@ class TreeNode extends Component { expandImg = html`` } + const selected = (this.selected.value ? 'selected' : '') return html`
{ this.expanded.value ^= true }}>${expandImg}
-
window._notes2.current.goToNode(node.UUID)}>${node.Name}
+
window._notes2.current.nodeUI.current.goToNode(node.ID)}>${node.Name}
${children}
` }//}}} - fetchChildren() {//{{{ - this.props.node.fetchChildren().then(() => { - this.children_populated.value = true - }) - }//}}} } // vim: foldmethod=marker diff --git a/static/js/lib/node_modules/.package-lock.json b/static/js/lib/node_modules/.package-lock.json index 441fdf4..837af37 100644 --- a/static/js/lib/node_modules/.package-lock.json +++ b/static/js/lib/node_modules/.package-lock.json @@ -13,15 +13,6 @@ "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 821b7bb..9290485 100644 --- a/static/js/lib/package-lock.json +++ b/static/js/lib/package-lock.json @@ -5,8 +5,7 @@ "packages": { "": { "dependencies": { - "marked": "^11.1.1", - "preact": "^10.25.1" + "marked": "^11.1.1" } }, "node_modules/marked": { @@ -19,15 +18,6 @@ "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 28ef3fc..8498f90 100644 --- a/static/js/lib/package.json +++ b/static/js/lib/package.json @@ -1,6 +1,5 @@ { "dependencies": { - "marked": "^11.1.1", - "preact": "^10.25.1" + "marked": "^11.1.1" } } diff --git a/static/js/node.mjs b/static/js/node.mjs index 98eef87..0095ca8 100644 --- a/static/js/node.mjs +++ b/static/js/node.mjs @@ -1,7 +1,9 @@ import { h, Component, createRef } from 'preact' import htm from 'htm' import { signal } from 'preact/signals' -import { ROOT_NODE } from 'node_store' +import { Keys, Key } from 'key' +import Crypto from 'crypto' +import { Checklist, ChecklistGroup } from 'checklist' const html = htm.bind(h) export class NodeUI extends Component { @@ -11,12 +13,11 @@ 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('nodeUUID')) - this.goToNode(evt.state.nodeUUID, true) + if (evt.state?.hasOwnProperty('nodeID')) + this.goToNode(evt.state.nodeID, true) else this.goToNode(0, true) }) @@ -28,25 +29,7 @@ export class NodeUI extends Component { return const node = this.node.value - 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 + document.title = `N: ${node.Name}` let crumbs = [ html`
this.goToNode(0)}>Start
` @@ -56,6 +39,14 @@ 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' @@ -143,16 +134,21 @@ export class NodeUI extends Component { ` }//}}} async componentDidMount() {//{{{ - _notes2.current.goToNode(this.props.startNode.UUID, true) - }//}}} - setNode(node) {//{{{ - this.nodeModified.value = false - this.node.value = node + // 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) + }) }//}}} keyHandler(evt) {//{{{ - return - let handled = true // All keybindings is Alt+Shift, since the popular browsers at the time (2023) allows to override thees. @@ -206,7 +202,120 @@ 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 { @@ -246,7 +355,7 @@ class NodeContent extends Component { const contentResizeObserver = new ResizeObserver(entries => { for (const idx in entries) { const w = entries[idx].contentRect.width - document.querySelector('#crumbs .crumbs').style.width = `${w}px` + document.getElementById('crumbs').style.width = `${w}px` } }); @@ -258,7 +367,7 @@ class NodeContent extends Component { this.resize() }//}}} contentChanged(evt) {//{{{ - _notes2.current.nodeUI.current.nodeModified.value = true + window._app.current.nodeModified.value = true const content = evt.target.value this.props.node.setContent(content) this.resize() @@ -268,24 +377,98 @@ 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(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) + constructor(app, nodeID) {//{{{ + this.app = app + this.ID = nodeID + this.ParentID = 0 + this.UserID = 0 + this.CryptoKeyID = 0 + this.Name = '' + this.RenderMarkdown = signal(false) this.Markdown = false this.ShowChecklist = signal(false) - this._content = nodeData.Content + this._content = '' + this.Children = [] this.Crumbs = [] this.Files = [] this._decrypted = false @@ -295,40 +478,658 @@ export class Node { // it doesn't control it afterwards. // Used to expand the crumbs upon site loading. }//}}} - hasFetchedChildren() {//{{{ - return this._children_fetched - }//}}} - 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 + 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 + }) + */ }//}}} + 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() + + 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 - */ }//}}} - static sort(a, b) {//{{{ - if (a.Name < b.Name) return -1 - if (a.Name > b.Name) return 0 - return 0 + 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) + }) }//}}} } +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 57909b7..b93e669 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -1,7 +1,4 @@ import { API } from 'api' -import { Node } from 'node' - -export const ROOT_NODE = '00000000-0000-0000-0000-000000000000' export class NodeStore { constructor() {//{{{ @@ -50,9 +47,7 @@ export class NodeStore { req.onsuccess = (event) => { this.db = event.target.result - this.initializeRootNode().then(() => - resolve() - ) + resolve() } req.onerror = (event) => { @@ -60,35 +55,6 @@ 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) => { @@ -129,7 +95,7 @@ export class NodeStore { }) }//}}} - async upsertTreeRecords(records) {//{{{ + async updateTreeRecords(records) {//{{{ return new Promise((resolve, reject) => { const t = this.db.transaction('treeNodes', 'readwrite') const nodeStore = t.objectStore('treeNodes') @@ -165,24 +131,6 @@ 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 { @@ -213,29 +161,27 @@ export class NodeStore { } }) }//}}} - async get(uuid) {//{{{ + async get(id) {//{{{ 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) + const getRequest = nodeStore.get(id) getRequest.onsuccess = (event) => { // Node found in IndexedDB and returned. if (event.target.result !== undefined) { - const node = new Node(event.target.result, -1) - resolve(node) + resolve(event.target.result) return } // Node not found and a request to the backend is made. - API.query("POST", `/node/retrieve/${uuid}`, {}) + API.query("POST", `/node/retrieve/${id}`, {}) .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.onsuccess = () => resolve(res.Node) putRequest.onerror = (event) => { reject(event.target.error) } @@ -244,6 +190,15 @@ 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/sync.mjs b/static/js/sync.mjs index a27817e..080cfcb 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -1,14 +1,10 @@ import { API } from 'api' export class Sync { - constructor() { - this.foo = '' - } - static async tree() { try { const state = await nodeStore.getAppState('latest_sync') - const oldMax = (state?.value ? state.value : 0) + let oldMax = (state?.value ? state.value : 0) let newMax = 0 let offset = 0 @@ -20,12 +16,40 @@ export class Sync { res = await API.query('POST', `/node/tree/${oldMax}/${offset}`, {}) offset += res.Nodes.length newMax = res.MaxSeq - await nodeStore.upsertTreeRecords(res.Nodes) + await nodeStore.updateTreeRecords(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 ae66833..4ef3027 100644 --- a/static/less/notes2.less +++ b/static/less/notes2.less @@ -5,75 +5,18 @@ 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-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; - } - } + min-height: 100vh; } #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; @@ -119,20 +62,17 @@ html { } #crumbs { - grid-area: crumbs; - display: grid; - justify-items: center; + //grid-area: crumbs; margin: 16px; } .crumbs { - background: #e4e4e4; display: flex; flex-wrap: wrap; padding: 8px 16px; background: #e4e4e4; color: #333; - border-radius: 5px; + border-radius: 6px; .crumb { margin-right: 8px; @@ -157,22 +97,13 @@ 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 { @@ -208,8 +139,18 @@ 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 { + //grid-area: content; justify-self: center; word-wrap: break-word; font-family: monospace; @@ -219,6 +160,7 @@ html { resize: none; border: none; outline: none; + &:invalid { background: #f5f5f5; diff --git a/static/service_worker.js b/static/service_worker.js index e10e3b0..d702ee3 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 }}/notes2.mjs', + '/js/{{ .VERSION }}/app.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 f633692..cd4c43b 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -10,11 +10,8 @@ import 'preact/debug' import 'preact/devtools' {{- end }} import { NodeStore } from 'node_store' -import { Notes2 } from "/js/{{ .VERSION }}/notes2.mjs" +import { Notes2 } from "/js/{{ .VERSION }}/app.mjs" import { API } from 'api' -import { Sync } from 'sync' - -window.Sync = Sync if (!API.hasAuthenticationToken()) { location.href = '/login'