From 7a60d33610f303a09bc431a94c8719defd363c05 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Thu, 20 Aug 2026 14:03:52 +0100 Subject: [PATCH] Auto commit --- README.md | 7 +- assets/scripts/pages/resource-loader-model.js | 32 ++ assets/scripts/pages/resource-loader.js | 109 ++++++- assets/styles/pages/resource-loader.css | 2 + build-logs/gitea-build-monitor.log | 7 + main.exe | Bin posts/posts-list.org | 2 +- rl/index.org | 10 +- rl/resource-loader-2026-08-20.json | 284 ++++++++++++++++++ sitemap.org | 106 +++---- tests/resource-loader.test.js | 17 ++ 11 files changed, 504 insertions(+), 72 deletions(-) mode change 100644 => 100755 main.exe create mode 100644 rl/resource-loader-2026-08-20.json diff --git a/README.md b/README.md index 127ca6f..0bc67ea 100755 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ flowchart LR | `posts/` | Longer-lived posts; `posts/career/` has its own index | Source, except generated list pages | | `home/` | Utility and dashboard-style pages | Source, except `categories.org` | | `play/` | Interactive and experimental pages | Source | -| `rl/` | Browser-local Resource Loader SPA | Source | +| `rl/` | Server-synced Resource Loader SPA | Source | | `lima/` | Org or Markdown pages with special attachment handling | Source, except `lima-list.org` | | `assets/` | Browser code, styles, fonts, images, and content manifests | Mostly source; some manifests/scripts are generated externally | | `lisp/` | Modular Emacs Lisp publishing system | Source | @@ -275,12 +275,13 @@ The published HTML is static, but some browser modules rely on same-origin APIs: | Notes board | `pages/notes.js` | `/api/notes` | | Wird tracker | `pages/wird-tracker.js` | `/api/wird`, `/api/wird/motalah`, `/api/calibre/books` | | Competency board | `pages/competency-status-board.js` | `/api/competencies/items...` | +| Resource Loader | `pages/resource-loader.js` | `/api/resource-loader`, `/api/resource-loader/thumbnails/:id` | These APIs are not implemented here. Production routing must serve `output/` and proxy `/api/` to the appropriate backend. When changing an endpoint contract, coordinate the static client and its external service. The hidden-details feature uses browser `localStorage`/`sessionStorage` for visit and discovery state and may call Open-Meteo after explicit geolocation interaction. Its friendly content source is `assets/content/hidden-details.json`; `assets/scripts/features/hidden-details.js` is generated by the external hidden-narrative authoring workflow. Treat both files as a pair and do not hand-edit only the generated JavaScript. -The Resource Loader at `/rl/` is intentionally backend-free. It seeds Work and Personal Study vaults from `assets/content/resource-loader.json`, then stores library metadata and retained PDFs in IndexedDB under `zxh-resource-loader`. JSON exports contain metadata and reading sessions, not PDF binaries. Its page code lives in `assets/scripts/pages/resource-loader.js`; the separately testable schema/progress operations live in `resource-loader-model.js` and run with `make test-js`. +The Resource Loader at `/rl/` loads and saves metadata through the same-origin `/api/resource-loader` backend, with ETags protecting concurrent edits. Generated cover thumbnails use `/api/resource-loader/thumbnails/:id`. IndexedDB under `zxh-resource-loader` remains an offline cache and retains imported PDF binaries only on the device where they were added. JSON exports contain metadata and reading sessions, not PDF binaries. Its page code lives in `assets/scripts/pages/resource-loader.js`; the separately testable schema/progress/sync operations live in `resource-loader-model.js` and run with `make test-js`. ## Authoring service @@ -401,4 +402,4 @@ Static file serving does not provide `/api/...`. Run/proxy the relevant external ### The full `make` command fails outside the production host -`make` runs a hard-coded permission helper with `sudo`. Use `make build` followed by `make search`, or the portable commands above, on another machine. \ No newline at end of file +`make` runs a hard-coded permission helper with `sudo`. Use `make build` followed by `make search`, or the portable commands above, on another machine. diff --git a/assets/scripts/pages/resource-loader-model.js b/assets/scripts/pages/resource-loader-model.js index 5108cae..ef83f27 100755 --- a/assets/scripts/pages/resource-loader-model.js +++ b/assets/scripts/pages/resource-loader-model.js @@ -140,6 +140,37 @@ return normaliseLibrary(merged); } + function newestByTimestamp(remote, local) { + const remoteStamp = String(remote.updatedAt || remote.createdAt || ""); + const localStamp = String(local.updatedAt || local.createdAt || ""); + return clone(localStamp >= remoteStamp ? local : remote); + } + + function mergeSyncItems(remoteItems, localItems, mergeMatch) { + const items = new Map(remoteItems.map((item) => [item.id, clone(item)])); + localItems.forEach((local) => { + const remote = items.get(local.id); + items.set(local.id, remote ? mergeMatch(remote, local) : clone(local)); + }); + return Array.from(items.values()); + } + + function mergeForSync(remoteLibrary, localLibrary) { + const remote = normaliseLibrary(remoteLibrary); + const local = normaliseLibrary(localLibrary); + const vaults = mergeSyncItems(remote.vaults, local.vaults, (remoteVault, localVault) => { + const vault = newestByTimestamp(remoteVault, localVault); + vault.folders = mergeSyncItems(remoteVault.folders, localVault.folders, newestByTimestamp); + vault.resources = mergeSyncItems(remoteVault.resources, localVault.resources, (remoteResource, localResource) => { + const resource = newestByTimestamp(remoteResource, localResource); + resource.sessions = mergeSyncItems(remoteResource.sessions || [], localResource.sessions || [], newestByTimestamp); + return resource; + }); + return vault; + }); + return normaliseLibrary({ schemaVersion: SCHEMA_VERSION, vaults }); + } + function progressFor(resource) { const sessions = Array.isArray(resource.sessions) ? resource.sessions : []; const furthest = sessions.reduce((maximum, session) => { @@ -218,6 +249,7 @@ validateLibrary, normaliseLibrary, mergeLibraries, + mergeForSync, progressFor, addVault, renameVault, diff --git a/assets/scripts/pages/resource-loader.js b/assets/scripts/pages/resource-loader.js index 8534f23..d612af4 100755 --- a/assets/scripts/pages/resource-loader.js +++ b/assets/scripts/pages/resource-loader.js @@ -9,6 +9,7 @@ const DB_VERSION = 1; const META_STORE = "metadata"; const ATTACHMENT_STORE = "attachments"; + const API_URL = "/api/resource-loader"; const $ = (selector) => document.querySelector(selector); const $$ = (selector) => Array.from(document.querySelectorAll(selector)); const state = { @@ -22,6 +23,10 @@ pendingImport: null, pdfUrls: new Map(), thumbnailUrls: new Map(), + uploadedThumbnails: new Set(), + serverEtag: null, + syncQueue: Promise.resolve(), + eventsBound: false, }; let pdfJsPromise; @@ -54,6 +59,7 @@ jsonInput: $("#rl-json-input"), loading: $("#rl-loading"), toast: $("#rl-toast"), + syncStatus: $("#rl-sync-status"), }; function openDatabase() { @@ -86,21 +92,93 @@ function putAttachment(record) { return request(ATTACHMENT_STORE, "readwrite", (store) => store.put(record)); } function deleteAttachment(id) { return id ? request(ATTACHMENT_STORE, "readwrite", (store) => store.delete(id)) : Promise.resolve(); } + function setSyncStatus(status, label) { + if (!els.syncStatus) return; + els.syncStatus.dataset.state = status; + els.syncStatus.querySelector("small").textContent = label; + } + + async function fetchServerLibrary() { + const response = await fetch(API_URL, { cache: "no-store", credentials: "same-origin" }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`Server library request failed (${response.status}).`); + const library = Model.normaliseLibrary(await response.json()); + state.serverEtag = response.headers.get("ETag"); + return library; + } + + async function uploadLocalThumbnails(library) { + const ids = Array.from(new Set(library.vaults.flatMap((vault) => vault.resources.map((resource) => resource.thumbnailId).filter(Boolean)))); + await Promise.all(ids.map(async (id) => { + if (state.uploadedThumbnails.has(id)) return; + const attachment = await getAttachment(id); + if (!attachment || !attachment.blob || attachment.kind !== "thumbnail") return; + const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, { + method: "PUT", + credentials: "same-origin", + headers: { "Content-Type": attachment.type || attachment.blob.type || "image/jpeg" }, + body: attachment.blob, + }); + if (!response.ok) throw new Error(`Thumbnail upload failed (${response.status}).`); + state.uploadedThumbnails.add(id); + })); + } + + async function pushServerLibrary(library, allowMerge) { + setSyncStatus("syncing", "syncing…"); + const headers = { "Content-Type": "application/json" }; + if (state.serverEtag) headers["If-Match"] = state.serverEtag; + const response = await fetch(API_URL, { + method: "PUT", + credentials: "same-origin", + headers, + body: JSON.stringify(library), + }); + if (response.status === 412 && allowMerge) { + const remote = await fetchServerLibrary(); + const merged = Model.mergeForSync(remote, library); + state.library = merged; + await setMetadata("library", merged); + if (state.eventsBound) render(); + toast("Changes from another device were merged."); + return pushServerLibrary(merged, false); + } + if (!response.ok) throw new Error(`Server save failed (${response.status}).`); + state.serverEtag = response.headers.get("ETag"); + await uploadLocalThumbnails(library); + setSyncStatus("synced", "synced to server"); + } + async function saveLibrary() { - await setMetadata("library", state.library); + await setMetadata("library", Model.normaliseLibrary(state.library)); + state.syncQueue = state.syncQueue.catch(() => {}).then(async () => { + try { + await pushServerLibrary(Model.normaliseLibrary(state.library), true); + } catch (error) { + console.warn("Resource Loader server sync is unavailable.", error); + setSyncStatus("offline", "offline · saved here"); + } + }); + return state.syncQueue; } async function loadLibrary() { const saved = await getMetadata("library"); - if (saved) return Model.normaliseLibrary(saved); try { - const response = await fetch("/assets/content/resource-loader.json", { cache: "no-store" }); - if (!response.ok) throw new Error("Seed library could not be loaded."); - return Model.normaliseLibrary(await response.json()); + const remote = await fetchServerLibrary(); + if (remote) { + setSyncStatus("synced", "synced to server"); + await setMetadata("library", remote); + return remote; + } } catch (error) { - console.warn(error); - return Model.createSeed(); + console.warn("Resource Loader server is unavailable; using this browser's cache.", error); + setSyncStatus("offline", "offline · using cache"); } + if (saved) return Model.normaliseLibrary(saved); + const response = await fetch("/assets/content/resource-loader.json", { cache: "no-store" }); + if (response.ok) return Model.normaliseLibrary(await response.json()); + return Model.createSeed(); } function currentVault() { @@ -216,8 +294,17 @@ if (!id) return null; if (state.thumbnailUrls.has(id)) return state.thumbnailUrls.get(id); const attachment = await getAttachment(id); - if (!attachment || !attachment.blob) return null; - const url = URL.createObjectURL(attachment.blob); + let blob = attachment && attachment.blob; + if (!blob) { + try { + const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, { credentials: "same-origin" }); + if (response.ok) blob = await response.blob(); + } catch (error) { + console.warn(`Thumbnail ${id} is unavailable.`, error); + } + } + if (!blob) return null; + const url = URL.createObjectURL(blob); state.thumbnailUrls.set(id, url); return url; } @@ -718,7 +805,7 @@ } async function clearData() { - if (!confirm("Clear every Resource Loader vault, record, session, and retained PDF from this browser?")) return; + if (!confirm("Clear this browser's offline cache and retained PDFs? Server metadata and thumbnails will remain available.")) return; closeDialog(els.storageDialog); state.db.close(); await new Promise((resolve, reject) => { @@ -730,6 +817,7 @@ } function bindEvents() { + state.eventsBound = true; els.vaultSelect.addEventListener("change", () => { state.vaultId = els.vaultSelect.value; state.folderId = "all"; render(); }); [els.search, els.typeFilter, els.statusFilter, els.tagFilter, els.sort].forEach((control) => control.addEventListener(control === els.search ? "input" : "change", renderResources)); $("#rl-view-list").addEventListener("click", () => { state.view = "list"; localStorage.setItem("rl-view", state.view); renderResources(); }); @@ -763,6 +851,7 @@ state.pdfUrls.forEach((url) => URL.revokeObjectURL(url)); state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url)); }); + window.addEventListener("online", () => saveLibrary()); } async function initialise() { diff --git a/assets/styles/pages/resource-loader.css b/assets/styles/pages/resource-loader.css index 9c0ed60..df814e1 100755 --- a/assets/styles/pages/resource-loader.css +++ b/assets/styles/pages/resource-loader.css @@ -158,6 +158,8 @@ .rl-summary small { color: var(--rl-muted); font-size: .72rem; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; } .rl-summary .rl-summary__privacy { justify-content: flex-end; } .rl-summary__privacy span { color: #4a8a5b; font-size: .8rem; } +.rl-summary__privacy[data-state="syncing"] span { color: #a87831; } +.rl-summary__privacy[data-state="offline"] span { color: #a13f35; } .rl-command-bar { display: flex; diff --git a/build-logs/gitea-build-monitor.log b/build-logs/gitea-build-monitor.log index da980af..a25be68 100755 --- a/build-logs/gitea-build-monitor.log +++ b/build-logs/gitea-build-monitor.log @@ -937,3 +937,10 @@ at , /home/zaine/master-folder/org-platform/org_web/build-logs/gite 2026-08-20T11:32:52.2510149+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python. 2026-08-20T11:32:52.3284574+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0 2026-08-20T11:32:52.6620246+01:00 [INFO] Sent authoring server test notification. +2026-08-20T11:34:32.1361264+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success +2026-08-20T11:34:32.1453561+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web. +2026-08-20T11:34:32.6512992+01:00 [ERROR] Failed to analyze job 841: Cannot bind argument to parameter 'LogText' because it is an empty string. +2026-08-20T11:34:33.2869130+01:00 [INFO] Sent build status notification with 1 embed(s). +2026-08-20T11:34:33.3081787+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python. +2026-08-20T11:34:33.4006115+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0 +2026-08-20T11:34:33.7249113+01:00 [INFO] Sent authoring server test notification. diff --git a/main.exe b/main.exe old mode 100644 new mode 100755 diff --git a/posts/posts-list.org b/posts/posts-list.org index 6a86895..0fb65d0 100755 --- a/posts/posts-list.org +++ b/posts/posts-list.org @@ -4,7 +4,7 @@ See the categories: @@html:Categories@@ * Posts: -- [[file:career/career-list.org][Career List]] @@html:@@ +- [[file:career/career-list.org][Career List]] @@html:@@ - [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:@@ @@html:@@ @@html:@@ - [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:@@ @@html:@@ @@html:@@ - [[file:career/javascript.org][Understands the Javascript language]] @@html:@@ @@html:@@ @@html:@@ diff --git a/rl/index.org b/rl/index.org index fffacd4..070d882 100755 --- a/rl/index.org +++ b/rl/index.org @@ -7,9 +7,9 @@
-

Private browser library

+

Private synced library

Resource Loader

-

Keep books, papers, articles, and videos together. Your vaults stay in this browser.

+

Keep books, papers, articles, and videos together. Metadata and covers follow you across devices.