From 8b421ea59ea4616f85d407c0c8ccbf011fcc739a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Mon, 1 Jun 2026 14:27:41 +0200 Subject: [PATCH] Upload files to IndexedDB --- html_template/pkg.go | 8 +- static/css/notes2.css | 23 ++++ static/images/file_icons/application_pdf.svg | 107 +++++++++++++++++++ static/images/file_icons/generic.svg | 100 +++++++++++++++++ static/js/app.mjs | 10 ++ static/js/file.mjs | 81 ++++++++++++++ static/js/lib/custom_html_element.mjs | 8 +- static/js/marked_position.mjs | 60 ++++++++--- static/js/node_store.mjs | 103 ++++++++++++------ static/js/{node.mjs => page_node.mjs} | 74 ++++++++----- static/js/page_storage.mjs | 28 +++++ static/js/sync.mjs | 7 +- static/service_worker.js | 3 +- views/layouts/main.gotmpl | 2 +- views/pages/notes2.gotmpl | 24 ++++- 15 files changed, 539 insertions(+), 99 deletions(-) create mode 100644 static/images/file_icons/application_pdf.svg create mode 100644 static/images/file_icons/generic.svg create mode 100644 static/js/file.mjs rename static/js/{node.mjs => page_node.mjs} (83%) create mode 100644 static/js/page_storage.mjs diff --git a/html_template/pkg.go b/html_template/pkg.go index 4140f89..9abfc12 100644 --- a/html_template/pkg.go +++ b/html_template/pkg.go @@ -66,8 +66,6 @@ func (e *Engine) ReloadTemplates() { // {{{ } // }}} func (e *Engine) StaticResource(w http.ResponseWriter, r *http.Request) { // {{{ - var err error - // URLs with pattern /(css|images)/v1.0.0/foobar are stripped of the version. // To get rid of problems with cached content in browser on a new version release, // while also not disabling cache altogether. @@ -83,11 +81,7 @@ func (e *Engine) StaticResource(w http.ResponseWriter, r *http.Request) { // {{{ r.URL.Path = fmt.Sprintf("/%s/%s", comp[1], comp[2]) if e.DevMode { - p := fmt.Sprintf("static/%s/%s", comp[1], comp[2]) - _, err = os.Stat(p) - if err == nil { - e.staticLocalFS.ServeHTTP(w, r) - } + e.staticLocalFS.ServeHTTP(w, r) return } } diff --git a/static/css/notes2.css b/static/css/notes2.css index 8351858..31e1f1f 100644 --- a/static/css/notes2.css +++ b/static/css/notes2.css @@ -145,6 +145,29 @@ html { } } +[id^="page-"] { + display: none; +} + +#main-page { + display: contents; + + &.node { + #page-node { + display: contents; + } + } + + &.storage { + #page-storage { + display: contents; + n2-pagestorage { + grid-area: content; + } + } + } +} + #crumbs { grid-area: crumbs; display: grid; diff --git a/static/images/file_icons/application_pdf.svg b/static/images/file_icons/application_pdf.svg new file mode 100644 index 0000000..e503d4d --- /dev/null +++ b/static/images/file_icons/application_pdf.svg @@ -0,0 +1,107 @@ + + + +file-outline + + + + + + + + + + + + diff --git a/static/images/file_icons/generic.svg b/static/images/file_icons/generic.svg new file mode 100644 index 0000000..7ca6c2b --- /dev/null +++ b/static/images/file_icons/generic.svg @@ -0,0 +1,100 @@ + + + +file-outline + + + + + + + + + + + + diff --git a/static/js/app.mjs b/static/js/app.mjs index dc60b59..feeec3a 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -29,6 +29,14 @@ export class App { this.goToNode(node.nodeUUID, node.dontPush, node.dontExpand) }) + _mbus.subscribe('SHOW_PAGE', ({ detail: { data: { page } } }) => { + const classList = document.querySelector('#main-page').classList + classList.forEach(e => + classList.remove(e) + ) + classList.add(page) + }) + window.addEventListener('keydown', event => this.keyHandler(event)) window.addEventListener('popstate', event => this.popState(event)) document.getElementById('notes2').addEventListener('click', event => { @@ -36,6 +44,8 @@ export class App { document.getElementById('node-content')?.focus() }) + _mbus.dispatch('SHOW_PAGE', { page: 'node' }) + window._sync = new Sync() // I think it is uncomfortable having the sync running as soon as the page load. diff --git a/static/js/file.mjs b/static/js/file.mjs new file mode 100644 index 0000000..2674737 --- /dev/null +++ b/static/js/file.mjs @@ -0,0 +1,81 @@ +import { CustomHTMLElement } from "./lib/custom_html_element.mjs"; + +export class N2File extends CustomHTMLElement { + static { + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` + + +
+ ` + } + constructor() { + super(true) + + this.addEventListener('click', event => { + event.preventDefault() + event.stopPropagation() + + window.open( + URL.createObjectURL(this.file), + (event.ctrlKey || event.shiftKey) ? '_blank' : '_self', + ) + }) + + this.render() + } + + async render() { + const src = this.getAttribute('src') + + // N2's db:// URLs are fetched from IndexedDB. + if (src.toLowerCase().match('^db://[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')) { + // image population has to happen asynchronously, + // while Marked lib has to be returned a string when exiting this function. + // populateImg makes sure this returned img element exists and then populates it + // with the image from IndexedDB. + const file = await globalThis.nodeStore.files.get(src.slice(5)) + if (!file) + return + this.file = file.file + + if (file.file.type.startsWith('image/')) + this.elImage.src = URL.createObjectURL(file.file) + else { + // Check for and use an existing MIME type icon. + // Place them in static/images/file_icons/ and replace the slash with an underscore. + const url = `/images/${_VERSION}/file_icons/${file.file.type.replaceAll('/', '_')}.svg` + const res = await fetch(url) + if (res.ok) + this.elImage.src = url + + this.elFilename.innerText = file.file.name + this.elFilename.style.display = 'block' + } + } else + this.elImage.src = src + } +} +customElements.define('n2-file', N2File) diff --git a/static/js/lib/custom_html_element.mjs b/static/js/lib/custom_html_element.mjs index dedb5d8..2cec808 100644 --- a/static/js/lib/custom_html_element.mjs +++ b/static/js/lib/custom_html_element.mjs @@ -1,10 +1,10 @@ export class CustomHTMLElement extends HTMLElement { - constructor() {// {{{ + constructor(useShadow) {// {{{ super() - this.appendChild(this.constructor.tmpl.content.cloneNode(true)) - - this.querySelectorAll('*').forEach(el => { + const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this + workOn.appendChild(this.constructor.tmpl.content.cloneNode(true)) + workOn.querySelectorAll('*').forEach(el => { const field = el.dataset.field if (field !== undefined) { const fieldName = this.toElementName('field', field) diff --git a/static/js/marked_position.mjs b/static/js/marked_position.mjs index 5c9c0ff..62a6996 100644 --- a/static/js/marked_position.mjs +++ b/static/js/marked_position.mjs @@ -92,18 +92,22 @@ function escapeHtmlEntities(html, encode) {// {{{ export class MarkedPosition { constructor() {// {{{ - window.setpos = (event) => { - event.stopPropagation() - event.preventDefault() - - _mbus.dispatch('MARKDOWN_EDIT', { - position: { - start: event.target.dataset.offsetStart, - end: event.target.dataset.offsetEnd, - } - }) - } + window.setpos = (event) => this.setpos(event) + this.render() + }// }}} + setpos(event) {// {{{ + event.stopPropagation() + event.preventDefault() + _mbus.dispatch('MARKDOWN_EDIT', { + position: { + start: event.target.closest('[data-offset-start]').dataset.offsetStart, + end: event.target.closest('[data-offset-start]').dataset.offsetEnd, + } + }) + }// }}} + render() {// {{{ + const markedObject = this this.marked = new Marked() this.marked.use(markedTokenPosition()) this.marked.use({ @@ -265,7 +269,6 @@ export class MarkedPosition { }, image(token) { - if (token.tokens) { token.text = this.parser.parseInline(token.tokens, this.parser.textRenderer) } @@ -274,12 +277,11 @@ export class MarkedPosition { return escapeHtmlEntities(token.text) } token.href = cleanHref - - let out = `${escapeHtmlEntities(token.text)} { + const target = document.getElementById(id) + if (target) { + observer.disconnect() + return target + } + }) + + observer.observe(document.documentElement, { + childList: true, + subtree: true + }) + }// }}} + async populateImg(fileID, elementID) {// {{{ + let img = await globalThis.nodeStore.files.get(fileID) + const el = await this.whenElementExist(elementID) + + el.src = URL.createObjectURL(img.file) + }// }}} } diff --git a/static/js/node_store.mjs b/static/js/node_store.mjs index e849e29..d29923f 100644 --- a/static/js/node_store.mjs +++ b/static/js/node_store.mjs @@ -12,10 +12,11 @@ export class NodeStore { this.nodes = {} this.sendQueue = null this.nodesHistory = null + this.files = null }//}}} initializeDB() {//{{{ return new Promise((resolve, reject) => { - const req = indexedDB.open('notes', 7) + const req = indexedDB.open('notes', 8) // Schema upgrades for IndexedDB. // These can start from different points depending on updates to Notes2 since a device was online. @@ -24,6 +25,7 @@ export class NodeStore { let appState let sendQueue let nodesHistory + let files const db = event.target.result const trx = event.target.transaction @@ -61,6 +63,10 @@ export class NodeStore { case 7: trx.objectStore('nodes_history').createIndex('byUUID', 'UUID', { unique: false }) break + + case 8: + files = db.createObjectStore('files', { keyPath: 'UUID' }) + break } } } @@ -69,6 +75,7 @@ export class NodeStore { this.db = event.target.result this.sendQueue = new SimpleNodeStore(this.db, 'send_queue') this.nodesHistory = new SimpleNodeStore(this.db, 'nodes_history') + this.files = new SimpleNodeStore(this.db, 'files') this.initializeRootNode() .then(() => resolve()) } @@ -159,39 +166,6 @@ export class NodeStore { }) }//}}} - /* - upsertNodeRecords(records) {//{{{ - return new Promise((resolve, reject) => { - const t = this.db.transaction('nodes', 'readwrite') - const nodeStore = t.objectStore('nodes') - t.onerror = (event) => { - console.log('transaction error', event.target.error) - reject(event.target.error) - } - t.oncomplete = () => { - resolve() - } - - // records is an object, not an array. - for (const i in records) { - const record = records[i] - - let addReq - let op - if (record.Deleted) { - op = 'deleting' - addReq = nodeStore.delete(record.UUID) - } else { - op = 'upserting' - // 'modified' is a local property for tracking - // nodes needing to be synced to backend. - record.modified = 0 - addReq = nodeStore.put(record) - } - } - }) - }//}}} - */ getTreeNodes(parent, newLevel) {//{{{ return new Promise((resolve, reject) => { // Parent of toplevel nodes is ROOT_NODE in indexedDB. @@ -376,8 +350,20 @@ class SimpleNodeStore { } }) }//}}} + get(key) {//{{{ + return new Promise((resolve, _reject) => { + const req = this.db + .transaction(['nodes', this.storeName], 'readonly') + .objectStore(this.storeName) + .get(key) + + req.onsuccess = (event) => { + resolve(event.target.result) + } + }) + }//}}} retrieve(limit) {//{{{ - return new Promise((resolve, reject) => { + return new Promise((resolve, _reject) => { const cursorReq = this.db .transaction(['nodes', this.storeName], 'readonly') .objectStore(this.storeName) @@ -433,4 +419,51 @@ class SimpleNodeStore { }//}}} } +export class StoreFile { + static createFromFileObject(f) { + const obj = new StoreFile() + obj.name = f.name + obj.size = f.size + obj.mime = f.type + return obj + } + constructor() { + this.name = '' + this.size = 0 + this.mime = '' + + this.objectURL = null // URL.createObjectURL(blob) + } + data() { + return { + } + } +} + +export function uuidv7() { + // random bytes + const value = new Uint8Array(16) + crypto.getRandomValues(value) + + // current timestamp in ms + const timestamp = BigInt(Date.now()) + + // timestamp + value[0] = Number((timestamp >> 40n) & 0xffn) + value[1] = Number((timestamp >> 32n) & 0xffn) + value[2] = Number((timestamp >> 24n) & 0xffn) + value[3] = Number((timestamp >> 16n) & 0xffn) + value[4] = Number((timestamp >> 8n) & 0xffn) + value[5] = Number(timestamp & 0xffn) + + // version and variant + value[6] = (value[6] & 0x0f) | 0x70 + value[8] = (value[8] & 0x3f) | 0x80 + + const str = Array.from(value) + .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/node.mjs b/static/js/page_node.mjs similarity index 83% rename from static/js/node.mjs rename to static/js/page_node.mjs index 7959831..86c29c0 100644 --- a/static/js/node.mjs +++ b/static/js/page_node.mjs @@ -1,8 +1,8 @@ -import { ROOT_NODE } from 'node_store' +import { ROOT_NODE, uuidv7, StoreFile } from 'node_store' import { CustomHTMLElement } from './lib/custom_html_element.mjs' import { MarkedPosition } from './marked_position.mjs' -export class N2NodeUI extends CustomHTMLElement { +export class N2PageNodeUI extends CustomHTMLElement { static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = ` @@ -72,6 +72,7 @@ export class N2NodeUI extends CustomHTMLElement { } }) this.elNodeContent.addEventListener('input', event => this.contentChanged(event)) + this.elNodeContent.addEventListener('paste', async (event) => this.pasteHandler(event)) this.elIconMarkdown.addEventListener('click', () => this.showMarkdown(!this.showMarkdown())) this.showMarkdown(true) @@ -118,6 +119,47 @@ export class N2NodeUI extends CustomHTMLElement { return this.classList.contains('show-markdown') } }// }}} + async pasteHandler(event) { + const clipboardItems = event.clipboardData?.items + if (!clipboardItems) + return + + for (const item of clipboardItems) { + switch (item.kind) { + case 'string': + continue + + case 'file': + const file = item.getAsFile() + if (!file) + throw new Error("Couldn't convert image to file object.") + const uuid = uuidv7() + await globalThis.nodeStore.files.add({ data: { UUID: uuid, file: file }}) + + const [start, end] = [this.elNodeContent.selectionStart, this.elNodeContent.selectionEnd] + this.elNodeContent.setRangeText(`![${file.name}](db://${uuid})`, start, end, 'select'); + + break + + default: + alert(`Unknown paste type of '${item.kind}'`) + } + } + } + + // Example usage: Displaying the image or preparing it for upload + handleImageBlob(blob) { + // 1. Create a local URL to preview it in an tag if needed + const localUrl = URL.createObjectURL(blob) + console.log('Local preview URL:', localUrl) + + // 2. Or prepare it for a FormData upload + const formData = new FormData() + formData.append('image', blob, 'pasted-image.png') + + // fetch('/upload', { method: 'POST', body: formData }) + } + editMarkdown(data) {// {{{ this.showMarkdown(false) this.elNodeContent.selectionStart = data.position.start @@ -125,7 +167,7 @@ export class N2NodeUI extends CustomHTMLElement { this.elNodeContent.focus() }// }}} } -customElements.define('n2-nodeui', N2NodeUI) +customElements.define('n2-nodeui', N2PageNodeUI) export class Node { static sort(a, b) {//{{{ @@ -260,30 +302,4 @@ export class Node { }//}}} } -function uuidv7() { - // random bytes - const value = new Uint8Array(16) - crypto.getRandomValues(value) - - // current timestamp in ms - const timestamp = BigInt(Date.now()) - - // timestamp - value[0] = Number((timestamp >> 40n) & 0xffn) - value[1] = Number((timestamp >> 32n) & 0xffn) - value[2] = Number((timestamp >> 24n) & 0xffn) - value[3] = Number((timestamp >> 16n) & 0xffn) - value[4] = Number((timestamp >> 8n) & 0xffn) - value[5] = Number(timestamp & 0xffn) - - // version and variant - value[6] = (value[6] & 0x0f) | 0x70 - value[8] = (value[8] & 0x3f) | 0x80 - - const str = Array.from(value) - .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_storage.mjs b/static/js/page_storage.mjs new file mode 100644 index 0000000..931a718 --- /dev/null +++ b/static/js/page_storage.mjs @@ -0,0 +1,28 @@ +import { CustomHTMLElement } from "./lib/custom_html_element.mjs" + +export class N2PageStorage extends CustomHTMLElement { + static { + this.tmpl = document.createElement('template') + this.tmpl.innerHTML = ` +

Local storage

+
+
+
+ ` + } + constructor() { + super() + + window._mbus.subscribe('SHOW_PAGE', () => this.render()) + } + async render() { + const countNodes = await globalThis.nodeStore.nodeCount() + const countQueuedNodes = await globalThis.nodeStore.sendQueue.count() + const countHistoryNodes = await globalThis.nodeStore.nodesHistory.count() + + this.elCountNodes.innerText = countNodes + this.elCountQueuedNodes.innerText = countQueuedNodes + this.elCountHistoryNodes.innerText = countHistoryNodes + } +} +customElements.define('n2-pagestorage', N2PageStorage) diff --git a/static/js/sync.mjs b/static/js/sync.mjs index 9b58cf7..e432f15 100644 --- a/static/js/sync.mjs +++ b/static/js/sync.mjs @@ -9,6 +9,9 @@ export class Sync { }//}}} async run() {//{{{ + // XXX - Delete me + return + try { let duration = 0 // in ms @@ -163,13 +166,13 @@ export class Sync { } export class N2SyncProgress extends CustomHTMLElement { - static { + static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
0 / 0
` - } + }// }}} constructor() {//{{{ super() diff --git a/static/service_worker.js b/static/service_worker.js index 1717eef..80b79a1 100644 --- a/static/service_worker.js +++ b/static/service_worker.js @@ -29,7 +29,8 @@ const CACHED_ASSETS = [ '/js/{{ .VERSION }}/lib/sjcl.js', '/js/{{ .VERSION }}/marked_position.mjs', '/js/{{ .VERSION }}/mbus.mjs', - '/js/{{ .VERSION }}/node.mjs', + '/js/{{ .VERSION }}/page_node.mjs', + '/js/{{ .VERSION }}/page_storage.mjs', '/js/{{ .VERSION }}/node_store.mjs', '/js/{{ .VERSION }}/notes2.mjs', '/js/{{ .VERSION }}/sync.mjs', diff --git a/views/layouts/main.gotmpl b/views/layouts/main.gotmpl index c744166..ead95a9 100644 --- a/views/layouts/main.gotmpl +++ b/views/layouts/main.gotmpl @@ -14,7 +14,7 @@ "checklist": "/js/{{ .VERSION }}/checklist.mjs", "crypto": "/js/{{ .VERSION }}/crypto.mjs", "node_store": "/js/{{ .VERSION }}/node_store.mjs", - "node": "/js/{{ .VERSION }}/node.mjs", + "node": "/js/{{ .VERSION }}/page_node.mjs", "tree": "/js/{{ .VERSION }}/tree.mjs" } } diff --git a/views/pages/notes2.gotmpl b/views/pages/notes2.gotmpl index 77b74a6..80c084a 100644 --- a/views/pages/notes2.gotmpl +++ b/views/pages/notes2.gotmpl @@ -1,18 +1,34 @@ {{ define "page" }}
-
- - + +
+ +
+ +
+ + +
+
+ + +
+