Download history to client

This commit is contained in:
Magnus Åhall 2026-06-06 14:29:22 +02:00
parent 65a0225d74
commit 9506b89453
5 changed files with 249 additions and 75 deletions

26
main.go
View file

@ -25,7 +25,7 @@ import (
const VERSION = "v11" const VERSION = "v11"
const CONTEXT_USER = 1 const CONTEXT_USER = 1
const SYNC_PAGINATION = 200 const SYNC_PAGINATION = 20
var ( var (
FlagGenerate bool FlagGenerate bool
@ -140,6 +140,7 @@ func main() { // {{{
http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer)) http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer))
http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve)) http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve))
http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve))
http.HandleFunc("/service_worker.js", pageServiceWorker) http.HandleFunc("/service_worker.js", pageServiceWorker)
@ -328,6 +329,29 @@ func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
"Node": node, "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 actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUser(r)

47
node.go
View file

@ -3,8 +3,8 @@ package main
import ( import (
// External // External
werr "git.gibonuddevalla.se/go/wrappederror" werr "git.gibonuddevalla.se/go/wrappederror"
"github.com/jmoiron/sqlx"
"github.com/derektata/lorem/ipsum" "github.com/derektata/lorem/ipsum"
"github.com/jmoiron/sqlx"
// Standard // Standard
"database/sql" "database/sql"
@ -248,6 +248,51 @@ func RetrieveNode(userID int, nodeUUID string) (node Node, err error) { // {{{
return 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 NodeCrumbs(nodeUUID string) (nodes []Node, err error) { // {{{ func NodeCrumbs(nodeUUID string) (nodes []Node, err error) { // {{{
var rows *sqlx.Rows var rows *sqlx.Rows
rows, err = db.Queryx(` rows, err = db.Queryx(`

View file

@ -516,11 +516,39 @@ dialog.op {
n2-pagehistory { n2-pagehistory {
.layout {
.back,
.node-name {
display: grid; display: grid;
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
grid-gap: 8px; grid-gap: 8px;
align-items: center; align-items: center;
margin-bottom: 16px;
}
.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;
}
.group {
border: 1px solid #ccc;
padding: 32px;
margin-bottom: 32px;
border-radius: 8px;
background-color: #f8f8f8;
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;
}
.el-back-image, .el-back-image,
.el-back-text { .el-back-text {
@ -578,25 +606,25 @@ n2-pagehistory {
} }
} }
.pagination { .el-pagination {
grid-column: 1 / -1; grid-column: 1 / -1;
margin-top: 16px; margin-top: 16px;
display: grid; display: grid;
grid-template-columns: repeat(3, min-content); grid-template-columns: repeat(3, min-content);
grid-gap: 32px; grid-gap: 16px;
align-items: center;
white-space: nowrap; white-space: nowrap;
user-select: none; user-select: none;
.el-prev { .el-prev,
font-weight: bold;
cursor: pointer;
}
.el-next { .el-next {
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
} border: 1px solid #aaa;
background-color: #eee;
padding: 8px 16px;
border-radius: 4px;
} }
} }
} }

View file

@ -456,7 +456,24 @@ class NodeHistoryStore extends SimpleNodeStore {
request.onerror = (event) => reject(event.target.error) 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) => { return new Promise((resolve, _reject) => {
const cursor = this.db const cursor = this.db
.transaction(['nodes', this.storeName], 'readonly') .transaction(['nodes', this.storeName], 'readonly')
@ -497,10 +514,10 @@ class NodeHistoryStore extends SimpleNodeStore {
} }
} }
}) })
} }// }}}
} }
export function uuidv7() { export function uuidv7() {// {{{
// random bytes // random bytes
const value = new Uint8Array(16) const value = new Uint8Array(16)
crypto.getRandomValues(value) crypto.getRandomValues(value)
@ -524,6 +541,6 @@ export function uuidv7() {
.map((b) => b.toString(16).padStart(2, "0")) .map((b) => b.toString(16).padStart(2, "0"))
.join("") .join("")
return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}` return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`
} }// }}}
// vim: foldmethod=marker // vim: foldmethod=marker

View file

@ -1,7 +1,8 @@
import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { CustomHTMLElement } from './lib/custom_html_element.mjs'
import { Node } from './page_node.mjs'
export class N2PageHistory extends CustomHTMLElement { export class N2PageHistory extends CustomHTMLElement {
static PAGESIZE = 25 static PAGESIZE = 15
static { static {
this.tmpl = document.createElement('template') this.tmpl = document.createElement('template')
@ -9,22 +10,30 @@ export class N2PageHistory extends CustomHTMLElement {
<style> <style>
n2-pagehistory { n2-pagehistory {
margin-top: 32px; margin-top: 32px;
.layout {
}
} }
</style> </style>
<div class="layout"> <div class="back">
<img data-el="back-image" src="/images/${_VERSION}/icon_back.svg" class="colorize"> <img data-el="back-image" src="/images/${_VERSION}/icon_back.svg" class="colorize">
<div data-el="back-text">Back to node</div> <div data-el="back-text">Back to node</div>
</div>
<div class="node-name">
<img src="/images/${_VERSION}/icon_history.svg" class="colorize"> <img src="/images/${_VERSION}/icon_history.svg" class="colorize">
<h1 data-el="node-name"></h1> <h1 data-el="node-name"></h1>
</div>
<div class="group-label">Actions</div>
<div class="group">
<button data-el="download-history">Fetch all history from server</button>
</div>
<div class="group-label">History</div>
<div class="group">
<div data-el="nodes"></div> <div data-el="nodes"></div>
<div class="pagination"> <div data-el="pagination">
<div data-el="prev">&lt;</div> <div data-el="prev">&lt;</div>
<div data-el="page"></div> <div data-el="page"></div>
<div data-el="next">&gt;</div> <div data-el="next">&gt;</div>
@ -44,6 +53,7 @@ export class N2PageHistory extends CustomHTMLElement {
this.elBackText.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.elPrev.addEventListener('click', () => this.prevPage())
this.elNext.addEventListener('click', () => this.nextPage()) this.elNext.addEventListener('click', () => this.nextPage())
this.elDownloadHistory.addEventListener('click', () => this.downloadHistory())
_mbus.subscribe('NODE_UI_OPEN', async (event) => { _mbus.subscribe('NODE_UI_OPEN', async (event) => {
await this.useNode(event.detail.data) await this.useNode(event.detail.data)
@ -104,6 +114,11 @@ export class N2PageHistory extends CustomHTMLElement {
this.elNodeName.innerText = this.node.get('Name') this.elNodeName.innerText = this.node.get('Name')
this.elPage.innerText = `${this.page} / ${this.pages}` this.elPage.innerText = `${this.page} / ${this.pages}`
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 nodes = await nodeStore.nodesHistory.retrievePage(this.node.UUID, N2PageHistory.PAGESIZE, this.page)
let i = 0 let i = 0
let divs = nodes.map(n => { let divs = nodes.map(n => {
@ -124,5 +139,50 @@ export class N2PageHistory extends CustomHTMLElement {
}) })
this.elNodes.replaceChildren(...divs) this.elNodes.replaceChildren(...divs)
} }
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
}
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)
}
console.log(num)
} 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) customElements.define('n2-pagehistory', N2PageHistory)