diff --git a/main.go b/main.go index 10d578b..5bfdc84 100644 --- a/main.go +++ b/main.go @@ -140,6 +140,8 @@ func main() { // {{{ http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer)) http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve)) + http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve)) + http.HandleFunc("/node/history/count/{uuid}", authenticated(actionNodeHistoryCount)) http.HandleFunc("/service_worker.js", pageServiceWorker) @@ -328,6 +330,46 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ "Node": node, }) } // }}} +func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{ + user := getUser(r) + var err error + + uuid := r.PathValue("uuid") + offset, err := strconv.Atoi(r.PathValue("offset")) + if err != nil { + responseError(w, err) + return + } + + nodes, hasMore, err := RetrieveNodeHistory(user.UserID, uuid, offset) + if err != nil { + responseError(w, err) + return + } + + responseData(w, map[string]any{ + "OK": true, + "Nodes": nodes, + "HasMore": hasMore, + }) +} // }}} +func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{ + user := getUser(r) + var err error + + uuid := r.PathValue("uuid") + + count, err := RetrieveNodeHistoryCount(user.UserID, uuid) + if err != nil { + responseError(w, err) + return + } + + responseData(w, map[string]any{ + "OK": true, + "Count": count, + }) +} // }}} func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ user := getUser(r) diff --git a/node.go b/node.go index ffcc89f..0beb1b1 100644 --- a/node.go +++ b/node.go @@ -3,8 +3,8 @@ package main import ( // External werr "git.gibonuddevalla.se/go/wrappederror" - "github.com/jmoiron/sqlx" "github.com/derektata/lorem/ipsum" + "github.com/jmoiron/sqlx" // Standard "database/sql" @@ -248,6 +248,71 @@ func RetrieveNode(userID int, nodeUUID string) (node Node, err error) { // {{{ return } // }}} +func RetrieveNodeHistory(userID int, nodeUUID string, offset int) (nodes []Node, hasMore bool, err error) { // {{{ + nodes = []Node{} + + var rows *sqlx.Rows + rows, err = db.Queryx(` + SELECT + uuid, + user_id, + name, + created, + updated, + content, + content_encrypted + FROM node_history + WHERE + user_id = $1 AND + uuid = $2 + LIMIT $3 OFFSET $4 + `, + userID, + nodeUUID, + SYNC_PAGINATION+1, + offset, + ) + if err != nil { + err = werr.Wrap(err) + return + } + defer rows.Close() + + for rows.Next() { + node := Node{} + if err = rows.StructScan(&node); err != nil { + err = werr.Wrap(err) + return + } + nodes = append(nodes, node) + } + + if len(nodes) > SYNC_PAGINATION { + hasMore = true + nodes = nodes[0 : len(nodes)-1] + } + return +} // }}} +func RetrieveNodeHistoryCount(userID int, nodeUUID string) (count int, err error) { // {{{ + var row *sql.Row + row = db.QueryRow(` + SELECT + COUNT(*) + FROM node_history + WHERE + user_id = $1 AND + uuid = $2 + `, + userID, + nodeUUID, + ) + if err = row.Scan(&count); err != nil { + err = werr.Wrap(err) + return + } + + return +} // }}} func NodeCrumbs(nodeUUID string) (nodes []Node, err error) { // {{{ var rows *sqlx.Rows rows, err = db.Queryx(` diff --git a/static/css/notes2.css b/static/css/notes2.css index a41afda..28ee7a2 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -5,9 +5,6 @@ --thumbnail-width: 300px; --thumbnail-height: 100px; - /* - --colorize: invert(10%) sepia(61%) saturate(5017%) hue-rotate(323deg) brightness(90%) contrast(109%); - */ --colorize: invert(59%) sepia(71%) saturate(3270%) hue-rotate(327deg) brightness(100%) contrast(99%); --line-color: #ccc; @@ -23,6 +20,11 @@ html { filter: var(--colorize); } +button { + font-size: 1em; + padding: 4px 8px; +} + /* ------------------------------------- * * Default application grid in wide mode * * ------------------------------------- */ @@ -519,52 +521,137 @@ dialog.op { n2-pagehistory { - .layout { + + .back, + .node-name { display: grid; grid-template-columns: min-content 1fr; grid-gap: 8px; align-items: center; + margin-bottom: 16px; + } - .el-back-image, - .el-back-text { - cursor: pointer; - } + .group-label { + font-weight: bold; + background-color: #444; + color: #fff; + padding: 8px 32px; + display: inline-block; + margin-left: 32px; + transform: translateY(14px); + border-radius: 6px; + } - .el-node-name { - margin-left: 8px; - } + .group { + border: 1px solid #ccc; + padding: 32px; + margin-bottom: 32px; + border-radius: 8px; + background-color: #fafafa; - .el-nodes { - grid-column: 1 / -1; + box-shadow: + rgba(0, 0, 0, 0.4) 0px 2px 4px, + rgba(0, 0, 0, 0.3) 0px 7px 13px -3px, + rgba(0, 0, 0, 0.2) 0px -3px 0px inset; + } - display: grid; - grid-template-columns: min-content 1fr; - grid-gap: 4px 8px; + .el-stats { + margin-bottom: 16px; + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 8px 12px; + white-space: nowrap; + } - .history-node { - display: contents; - } - } + .el-fetch-history-progress { + margin-top: 16px; + } - .pagination { - grid-column: 1 / -1; - margin-top: 16px; + .el-back-image, + .el-back-text { + cursor: pointer; + } - display: grid; - grid-template-columns: repeat(3, min-content); - grid-gap: 32px; + .el-node-name { + margin-left: 8px; + } + + .el-nodes { + grid-column: 1 / -1; + + display: grid; + grid-template-columns: min-content minmax(min-content, max-content) min-content 1fr; + + background-color: var(--line-color); + gap: 1px; + border: 1px solid var(--line-color); + + n2-pagehistorynode>* { + padding: 8px 12px; + background-color: #fff; white-space: nowrap; - user-select: none; + } - .el-prev { + n2-pagehistorynode { + + &.selected .el-index:after { + position: absolute; + left: -20px; + + content: '>'; + color: var(--color1); font-weight: bold; - cursor: pointer; + margin-right: 8px; } - .el-next { + .el-index { + position: relative; + text-align: right; + } + + .el-updated { + white-space: initial; + } + + .el-date { + white-space: nowrap; font-weight: bold; - cursor: pointer; + } + + .el-time { + white-space: nowrap; + color: #555; + } + + .el-name { + white-space: initial; + /*overflow-wrap: anywhere;*/ + word-break: break-all; + color: var(--color1); } } } } + +.el-pagination { + grid-column: 1 / -1; + margin-top: 16px; + + display: grid; + grid-template-columns: repeat(3, min-content); + grid-gap: 16px; + align-items: center; + white-space: nowrap; + user-select: none; + + .el-prev, + .el-next { + font-weight: bold; + cursor: pointer; + border: 1px solid #aaa; + background-color: #eee; + padding: 8px 16px; + border-radius: 4px; + } +} +} diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index d3b95de..9e8a672 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -456,13 +456,30 @@ class NodeHistoryStore extends SimpleNodeStore { request.onerror = (event) => reject(event.target.error) }) }//}}} - retrievePage(uuid, perPage, page) { + hasNode(uuid, updated) { + return new Promise((resolve, reject) => { + const req = this.db + .transaction(['nodes', this.storeName], 'readonly') + .objectStore(this.storeName) + .getKey([uuid, updated]) + + req.onsuccess = (event) => { + resolve(event.target.result !== undefined) + } + + req.onerror = (event) => { + console.log(event.target.error) + reject(event.target.error) + } + }) + } + retrievePage(uuid, perPage, page) {// {{{ return new Promise((resolve, _reject) => { const cursor = this.db .transaction(['nodes', this.storeName], 'readonly') .objectStore(this.storeName) .index('byUUID') - .openCursor(uuid) + .openCursor(uuid, 'prev') let retrieved = 0 let first = true @@ -497,10 +514,10 @@ class NodeHistoryStore extends SimpleNodeStore { } } }) - } + }// }}} } -export function uuidv7() { +export function uuidv7() {// {{{ // random bytes const value = new Uint8Array(16) crypto.getRandomValues(value) @@ -524,6 +541,6 @@ export function uuidv7() { .map((b) => b.toString(16).padStart(2, "0")) .join("") return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}` -} +}// }}} // vim: foldmethod=marker diff --git a/static/js/page_history.mjs b/static/js/page_history.mjs index 434f47b..37404c7 100644 --- a/static/js/page_history.mjs +++ b/static/js/page_history.mjs @@ -1,94 +1,299 @@ import { CustomHTMLElement } from './lib/custom_html_element.mjs' +import { Node } from './page_node.mjs' +import { MarkedPosition } from './marked_position.mjs' + export class N2PageHistory extends CustomHTMLElement { - static PAGESIZE = 10 - - static { + static PAGESIZE = 15 + static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = ` -
+
Back to node
+
+

+
+ +
Actions
+
+ +
+
+ + +
History
+
+
+
History on server:
+
+ +
History on client:
+
+
- - - ` - } - constructor() { +
+ ` + }// }}} + + constructor() {// {{{ super() + this.selectedNode = null + + this.setAttribute('tabindex', '-1') + this.addEventListener('keydown', event => this.keyHandler(event)) // Connect back icon and text to give the user a way back to the node. this.elBackImage.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'node' })) this.elBackText.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'node' })) - this.elPrev.addEventListener('click', ()=>this.prevPage()) - this.elNext.addEventListener('click', ()=>this.nextPage()) + this.elPrev.addEventListener('click', () => this.prevPage()) + this.elNext.addEventListener('click', () => this.nextPage()) + this.elDownloadHistory.addEventListener('click', async () => { + await this.downloadHistory() + await this.useNode(this.node) + this.render(true) + + }) _mbus.subscribe('NODE_UI_OPEN', async (event) => { await this.useNode(event.detail.data) this.render() }) - } - async useNode(node) { + _mbus.subscribe('HISTORY_NODE_SELECTED', (event) => { + this.selectedNode = event.detail.data.historyNode + }) + }// }}} + async render(keepFetchHistoryProgress) {// {{{ + this.elNodeName.innerText = this.node.get('Name') + this.elPage.innerText = `${this.page} / ${this.pages}` + this.elStatsOnClient.innerText = `${this.nodesTotal}` + this.elStatsOnServer.innerText = `${this.historyOnServerTotal}` + + if (this.nodesTotal <= N2PageHistory.PAGESIZE) + this.elPagination.style.display = 'none' + else + this.elPagination.style.display = '' + + let nodes = await nodeStore.nodesHistory.retrievePage(this.node.UUID, N2PageHistory.PAGESIZE, this.page) + let i = 0 + let divs = nodes.map(n => { + i++ + const index = 1 + this.nodesTotal - (N2PageHistory.PAGESIZE * (this.page - 1) + i) + const div = new N2PageHistoryNode(n, index) + div.render() + return div + }) + this.elNodes.replaceChildren(...divs) + + if (!keepFetchHistoryProgress) + this.elFetchHistoryProgress.innerText = '' + + // Select the first node. + if (!this.selectedNode) { + this.elNodes.firstElementChild?.select() + } + + // Any selected history node is rendered with markdown. + /* + this.marked = new MarkedPosition() + this.elNodeMarkdown.innerHTML = this.marked.parse(this.elNodeContent.value) + */ + }// }}} + + async useNode(node) {// {{{ this.node = node this.page = 1 this.nodesTotal = await nodeStore.nodesHistory.count(this.node.UUID) + this.historyOnServerTotal = await this.getServerTotal() this.pages = Math.ceil(this.nodesTotal / N2PageHistory.PAGESIZE) - } + }// }}} + keyHandler(event) {// {{{ + switch (event.key) { + case 'ArrowLeft': + this.prevPage() + break + case 'ArrowRight': + this.nextPage() + break + case 'ArrowUp': + const prevNode = this.selectedNode?.previousElementSibling + if (prevNode) + prevNode.select() + break + case 'ArrowDown': + const nextNode = this.selectedNode?.nextElementSibling + if (nextNode) + nextNode.select() + break + } + }// }}} - - prevPage() { + prevPage() {// {{{ if (this.page == 1) return + + // Selecting a node on another page is wrong. + this.selectedNode = null this.page-- this.render() - } - - nextPage() { + }// }}} + nextPage() {// {{{ if (this.page >= this.pages) return + // Selecting a node on another page is wrong. + this.selectedNode = null this.page++ this.render() - } + }// }}} - async render() { - this.elNodeName.innerText = this.node.get('Name') - this.elPage.innerText = `${this.page} / ${this.pages}` + async getServerTotal() {// {{{ + const res = await fetch(`/node/history/count/${this.node.UUID}`, { + headers: { + "Authorization": 'Bearer ' + localStorage.getItem('token'), + } + }) + const json = await res.json() - this.elNodes.innerHTML = '' - let nodes = await nodeStore.nodesHistory.retrievePage(this.node.UUID, N2PageHistory.PAGESIZE, this.page) - let i = 0 - for (const n of nodes) { - i++ - const div = document.createElement('div') - div.innerHTML = ` -
${N2PageHistory.PAGESIZE * (this.page - 1) + i}
-
${n.get('Updated').replace('T', ' ')}
- ` - div.classList.add('history-node') - this.elNodes.append(div) + if (!json.OK) { + alert(json.Error) + return } - } + + return json.Count + }// }}} + async downloadHistory() {// {{{ + try { + const nodes = [] + let offset = 0 + let hasMore = true + + while (hasMore) { + const history = await this.downloadHistoryPage(offset) + hasMore = history.HasMore + for (const nodeData of history.Nodes) { + nodes.push(new Node(nodeData)) + } + offset = nodes.length + this.elFetchHistoryProgress.innerText = `${nodes.length} fetched.` + } + + let num = 0 + for (const node of nodes) { + const ok = await nodeStore.nodesHistory.hasNode(node.UUID, node.get('Updated')) + if (ok) num++ + await nodeStore.nodesHistory.add(node) + } + + this.elFetchHistoryProgress.innerText = `${nodes.length} fetched - all history fetched.` + } catch (e) { + console.error(e) + alert(e) + } + }// }}} + async downloadHistoryPage(offset) {// {{{ + const res = await fetch(`/node/history/retrieve/${this.node.UUID}/${offset}`, { + headers: { + "Authorization": 'Bearer ' + localStorage.getItem('token'), + } + }) + const json = await res.json() + + if (!json.OK) { + alert(json.Error) + return + } + + return json + }// }}} } customElements.define('n2-pagehistory', N2PageHistory) + + +class N2PageHistoryNode extends CustomHTMLElement { + static {// {{{ + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` +
+
+
+
+ ` + }// }}} + constructor(node, index) {// {{{ + super() + + this.node = node + this.index = index + + this.style.display = 'contents' + this.selected = false + + this.addEventListener('click', () => this.select()) + + // Another history node has been selected. + _mbus.subscribe('HISTORY_NODE_SELECTED', (event) => { + if (this.node.get('Updated') == event.detail.data.historyNode.node.get('Updated')) + return + this.selected = false + this.render() + }) + }// }}} + + select() {// {{{ + this.selected = true + // Other nodes are told to unselect and rerender. + _mbus.dispatch('HISTORY_NODE_SELECTED', { historyNode: this }) + this.render() + }// }}} + render() {// {{{ + const date = this.node.get('Updated').slice(0, 10) + const time = this.node.get('Updated').slice(11, 19) + + if (this.selected) + this.classList.add('selected') + else + this.classList.remove('selected') + + this.elIndex.innerText = this.index + this.elDate.innerText = date + this.elTime.innerText = time + this.elSize.innerText = this.node.get('Content').length + this.elName.innerText = this.node.get('Name') + }// }}} + formatSize(s) {// {{{ + let div = 1 + let unit = 'B' + if (s >= 1048576) { + div = 1048576 + unit = 'MB' + } else if (s >= 1024) { + div = 1024 + unit = 'kB' + } + + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 0 + }).format(Math.round(s / div)) + ' ' + unit + }// }}} +} +customElements.define('n2-pagehistorynode', N2PageHistoryNode)