Compare commits

..

4 commits

Author SHA1 Message Date
Magnus Åhall
28111cc8eb Selecting history nodes 2026-06-06 21:27:42 +02:00
Magnus Åhall
aeca9d8559 History fetching from server 2026-06-06 20:21:11 +02:00
Magnus Åhall
9506b89453 Download history to client 2026-06-06 14:29:22 +02:00
Magnus Åhall
65a0225d74 Formatting of history 2026-06-06 13:06:48 +02:00
5 changed files with 492 additions and 76 deletions

42
main.go
View file

@ -140,6 +140,8 @@ 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("/node/history/count/{uuid}", authenticated(actionNodeHistoryCount))
http.HandleFunc("/service_worker.js", pageServiceWorker) http.HandleFunc("/service_worker.js", pageServiceWorker)
@ -328,6 +330,46 @@ 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 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) { // {{{ func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
user := getUser(r) user := getUser(r)

67
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,71 @@ 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 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) { // {{{ 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

@ -5,9 +5,6 @@
--thumbnail-width: 300px; --thumbnail-width: 300px;
--thumbnail-height: 100px; --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%); --colorize: invert(59%) sepia(71%) saturate(3270%) hue-rotate(327deg) brightness(100%) contrast(99%);
--line-color: #ccc; --line-color: #ccc;
@ -23,6 +20,11 @@ html {
filter: var(--colorize); filter: var(--colorize);
} }
button {
font-size: 1em;
padding: 4px 8px;
}
/* ------------------------------------- * /* ------------------------------------- *
* Default application grid in wide mode * * Default application grid in wide mode *
* ------------------------------------- */ * ------------------------------------- */
@ -519,52 +521,137 @@ 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;
}
.el-back-image, .group-label {
.el-back-text { font-weight: bold;
cursor: pointer; background-color: #444;
} color: #fff;
padding: 8px 32px;
display: inline-block;
margin-left: 32px;
transform: translateY(14px);
border-radius: 6px;
}
.el-node-name { .group {
margin-left: 8px; border: 1px solid #ccc;
} padding: 32px;
margin-bottom: 32px;
border-radius: 8px;
background-color: #fafafa;
.el-nodes { box-shadow:
grid-column: 1 / -1; 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; .el-stats {
grid-template-columns: min-content 1fr; margin-bottom: 16px;
grid-gap: 4px 8px; display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 8px 12px;
white-space: nowrap;
}
.history-node { .el-fetch-history-progress {
display: contents; margin-top: 16px;
} }
}
.pagination { .el-back-image,
grid-column: 1 / -1; .el-back-text {
margin-top: 16px; cursor: pointer;
}
display: grid; .el-node-name {
grid-template-columns: repeat(3, min-content); margin-left: 8px;
grid-gap: 32px; }
.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; 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; 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; 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;
}
}
}

View file

@ -456,13 +456,30 @@ 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')
.objectStore(this.storeName) .objectStore(this.storeName)
.index('byUUID') .index('byUUID')
.openCursor(uuid) .openCursor(uuid, 'prev')
let retrieved = 0 let retrieved = 0
let first = true let first = true
@ -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,94 +1,299 @@
import { CustomHTMLElement } from './lib/custom_html_element.mjs' 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 { export class N2PageHistory extends CustomHTMLElement {
static PAGESIZE = 10 static PAGESIZE = 15
static {// {{{
static {
this.tmpl = document.createElement('template') this.tmpl = document.createElement('template')
this.tmpl.innerHTML = ` this.tmpl.innerHTML = `
<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 data-el="fetch-history-progress"></div>
</div>
<div class="group-label">History</div>
<div class="group">
<div data-el="stats">
<div>History on server:</div>
<div data-el="stats-on-server"></div>
<div>History on client:</div>
<div data-el="stats-on-client"></div>
</div>
<div data-el="nodes"></div> <div data-el="nodes"></div>
<div data-el="pagination">
<div class="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>
</div> </div>
</div> </div>
`
}
constructor() { <div data-el="markdown-content"></div>
`
}// }}}
constructor() {// {{{
super() 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. // 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.elBackImage.addEventListener('click', () => _mbus.dispatch('SHOW_PAGE', { page: 'node' }))
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', async () => {
await this.downloadHistory()
await this.useNode(this.node)
this.render(true)
})
_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)
this.render() 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.node = node
this.page = 1 this.page = 1
this.nodesTotal = await nodeStore.nodesHistory.count(this.node.UUID) this.nodesTotal = await nodeStore.nodesHistory.count(this.node.UUID)
this.historyOnServerTotal = await this.getServerTotal()
this.pages = Math.ceil(this.nodesTotal / N2PageHistory.PAGESIZE) 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) if (this.page == 1)
return return
// Selecting a node on another page is wrong.
this.selectedNode = null
this.page-- this.page--
this.render() this.render()
} }// }}}
nextPage() {// {{{
nextPage() {
if (this.page >= this.pages) if (this.page >= this.pages)
return return
// Selecting a node on another page is wrong.
this.selectedNode = null
this.page++ this.page++
this.render() this.render()
} }// }}}
async render() { async getServerTotal() {// {{{
this.elNodeName.innerText = this.node.get('Name') const res = await fetch(`/node/history/count/${this.node.UUID}`, {
this.elPage.innerText = `${this.page} / ${this.pages}` headers: {
"Authorization": 'Bearer ' + localStorage.getItem('token'),
}
})
const json = await res.json()
this.elNodes.innerHTML = '' if (!json.OK) {
let nodes = await nodeStore.nodesHistory.retrievePage(this.node.UUID, N2PageHistory.PAGESIZE, this.page) alert(json.Error)
let i = 0 return
for (const n of nodes) {
i++
const div = document.createElement('div')
div.innerHTML = `
<div>${N2PageHistory.PAGESIZE * (this.page - 1) + i}</div>
<div>${n.get('Updated').replace('T', ' ')}</div>
`
div.classList.add('history-node')
this.elNodes.append(div)
} }
}
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) customElements.define('n2-pagehistory', N2PageHistory)
class N2PageHistoryNode extends CustomHTMLElement {
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<div data-el="index"></div>
<div data-el="updated"><span data-el="date"></span> <span data-el="time"></span></div>
<div data-el="size"></div>
<div data-el="name"></div>
`
}// }}}
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)