Auto commit
All checks were successful
Build Org Website / build (push) Successful in 40s

This commit is contained in:
gitea-actions
2026-08-20 14:03:52 +01:00
parent 0b4a68b81b
commit 7a60d33610
11 changed files with 504 additions and 72 deletions

View File

@@ -32,7 +32,7 @@ flowchart LR
| `posts/` | Longer-lived posts; `posts/career/` has its own index | Source, except generated list pages | | `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` | | `home/` | Utility and dashboard-style pages | Source, except `categories.org` |
| `play/` | Interactive and experimental pages | Source | | `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` | | `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 | | `assets/` | Browser code, styles, fonts, images, and content manifests | Mostly source; some manifests/scripts are generated externally |
| `lisp/` | Modular Emacs Lisp publishing system | Source | | `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` | | Notes board | `pages/notes.js` | `/api/notes` |
| Wird tracker | `pages/wird-tracker.js` | `/api/wird`, `/api/wird/motalah`, `/api/calibre/books` | | Wird tracker | `pages/wird-tracker.js` | `/api/wird`, `/api/wird/motalah`, `/api/calibre/books` |
| Competency board | `pages/competency-status-board.js` | `/api/competencies/items...` | | 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. 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 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 ## 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 ### 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. `make` runs a hard-coded permission helper with `sudo`. Use `make build` followed by `make search`, or the portable commands above, on another machine.

View File

@@ -140,6 +140,37 @@
return normaliseLibrary(merged); 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) { function progressFor(resource) {
const sessions = Array.isArray(resource.sessions) ? resource.sessions : []; const sessions = Array.isArray(resource.sessions) ? resource.sessions : [];
const furthest = sessions.reduce((maximum, session) => { const furthest = sessions.reduce((maximum, session) => {
@@ -218,6 +249,7 @@
validateLibrary, validateLibrary,
normaliseLibrary, normaliseLibrary,
mergeLibraries, mergeLibraries,
mergeForSync,
progressFor, progressFor,
addVault, addVault,
renameVault, renameVault,

View File

@@ -9,6 +9,7 @@
const DB_VERSION = 1; const DB_VERSION = 1;
const META_STORE = "metadata"; const META_STORE = "metadata";
const ATTACHMENT_STORE = "attachments"; const ATTACHMENT_STORE = "attachments";
const API_URL = "/api/resource-loader";
const $ = (selector) => document.querySelector(selector); const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector)); const $$ = (selector) => Array.from(document.querySelectorAll(selector));
const state = { const state = {
@@ -22,6 +23,10 @@
pendingImport: null, pendingImport: null,
pdfUrls: new Map(), pdfUrls: new Map(),
thumbnailUrls: new Map(), thumbnailUrls: new Map(),
uploadedThumbnails: new Set(),
serverEtag: null,
syncQueue: Promise.resolve(),
eventsBound: false,
}; };
let pdfJsPromise; let pdfJsPromise;
@@ -54,6 +59,7 @@
jsonInput: $("#rl-json-input"), jsonInput: $("#rl-json-input"),
loading: $("#rl-loading"), loading: $("#rl-loading"),
toast: $("#rl-toast"), toast: $("#rl-toast"),
syncStatus: $("#rl-sync-status"),
}; };
function openDatabase() { function openDatabase() {
@@ -86,21 +92,93 @@
function putAttachment(record) { return request(ATTACHMENT_STORE, "readwrite", (store) => store.put(record)); } 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 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() { 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() { async function loadLibrary() {
const saved = await getMetadata("library"); const saved = await getMetadata("library");
if (saved) return Model.normaliseLibrary(saved);
try { try {
const response = await fetch("/assets/content/resource-loader.json", { cache: "no-store" }); const remote = await fetchServerLibrary();
if (!response.ok) throw new Error("Seed library could not be loaded."); if (remote) {
return Model.normaliseLibrary(await response.json()); setSyncStatus("synced", "synced to server");
await setMetadata("library", remote);
return remote;
}
} catch (error) { } catch (error) {
console.warn(error); console.warn("Resource Loader server is unavailable; using this browser's cache.", error);
return Model.createSeed(); 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() { function currentVault() {
@@ -216,8 +294,17 @@
if (!id) return null; if (!id) return null;
if (state.thumbnailUrls.has(id)) return state.thumbnailUrls.get(id); if (state.thumbnailUrls.has(id)) return state.thumbnailUrls.get(id);
const attachment = await getAttachment(id); const attachment = await getAttachment(id);
if (!attachment || !attachment.blob) return null; let blob = attachment && attachment.blob;
const url = URL.createObjectURL(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); state.thumbnailUrls.set(id, url);
return url; return url;
} }
@@ -718,7 +805,7 @@
} }
async function clearData() { 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); closeDialog(els.storageDialog);
state.db.close(); state.db.close();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -730,6 +817,7 @@
} }
function bindEvents() { function bindEvents() {
state.eventsBound = true;
els.vaultSelect.addEventListener("change", () => { state.vaultId = els.vaultSelect.value; state.folderId = "all"; render(); }); 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)); [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(); }); $("#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.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url)); state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
}); });
window.addEventListener("online", () => saveLibrary());
} }
async function initialise() { async function initialise() {

View File

@@ -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 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 .rl-summary__privacy { justify-content: flex-end; }
.rl-summary__privacy span { color: #4a8a5b; font-size: .8rem; } .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 { .rl-command-bar {
display: flex; display: flex;

View File

@@ -937,3 +937,10 @@ at <ScriptBlock>, /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.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.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: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.

0
main.exe Normal file → Executable file
View File

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@ See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts: * Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">20-08-2026 01:01</span>@@ - [[file:career/career-list.org][Career List]] @@html:<span class="post-date">20-08-2026 11:34</span>@@
- [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@ - [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@ - [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@ - [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@

View File

@@ -7,9 +7,9 @@
<main class="rl-app" id="resource-loader" aria-labelledby="rl-title"> <main class="rl-app" id="resource-loader" aria-labelledby="rl-title">
<header class="rl-hero"> <header class="rl-hero">
<div class="rl-hero__copy"> <div class="rl-hero__copy">
<p class="rl-eyebrow">Private browser library</p> <p class="rl-eyebrow">Private synced library</p>
<h1 id="rl-title">Resource Loader</h1> <h1 id="rl-title">Resource Loader</h1>
<p>Keep books, papers, articles, and videos together. Your vaults stay in this browser.</p> <p>Keep books, papers, articles, and videos together. Metadata and covers follow you across devices.</p>
</div> </div>
<div class="rl-hero__actions"> <div class="rl-hero__actions">
<label class="rl-vault-picker" for="rl-vault-select"> <label class="rl-vault-picker" for="rl-vault-select">
@@ -25,7 +25,7 @@
<div><span id="rl-stat-active">0</span><small>active</small></div> <div><span id="rl-stat-active">0</span><small>active</small></div>
<div><span id="rl-stat-backlog">0</span><small>backlog</small></div> <div><span id="rl-stat-backlog">0</span><small>backlog</small></div>
<div><span id="rl-stat-completed">0</span><small>completed</small></div> <div><span id="rl-stat-completed">0</span><small>completed</small></div>
<div class="rl-summary__privacy"><span aria-hidden="true"></span><small>stored locally</small></div> <div class="rl-summary__privacy" id="rl-sync-status" data-state="syncing"><span aria-hidden="true"></span><small>connecting…</small></div>
</section> </section>
<section class="rl-command-bar" aria-label="Resource actions"> <section class="rl-command-bar" aria-label="Resource actions">
@@ -165,8 +165,8 @@
<dialog class="rl-dialog" id="rl-storage-dialog"> <dialog class="rl-dialog" id="rl-storage-dialog">
<div class="rl-dialog__shell rl-dialog__shell--small"> <div class="rl-dialog__shell rl-dialog__shell--small">
<header class="rl-dialog__head"><div><p class="rl-eyebrow">This browser</p><h2>Local storage</h2></div><button type="button" class="rl-close" data-close-dialog aria-label="Close">×</button></header> <header class="rl-dialog__head"><div><p class="rl-eyebrow">This browser</p><h2>Local storage</h2></div><button type="button" class="rl-close" data-close-dialog aria-label="Close">×</button></header>
<div class="rl-dialog__body"><p id="rl-storage-summary">Calculating storage use…</p><p class="rl-help">Export JSON regularly. Clearing browser data removes vault metadata and retained PDFs.</p><button type="button" id="rl-request-persistence">Request persistent storage</button></div> <div class="rl-dialog__body"><p id="rl-storage-summary">Calculating storage use…</p><p class="rl-help">Metadata and thumbnails sync to the server. PDFs remain only in the browser where they were imported.</p><button type="button" id="rl-request-persistence">Request persistent storage</button></div>
<footer class="rl-dialog__foot"><button type="button" class="rl-danger" id="rl-clear-data">Clear Resource Loader data</button><button type="button" data-close-dialog>Close</button></footer> <footer class="rl-dialog__foot"><button type="button" class="rl-danger" id="rl-clear-data">Clear this browser's cache &amp; PDFs</button><button type="button" data-close-dialog>Close</button></footer>
</div> </div>
</dialog> </dialog>

View File

@@ -0,0 +1,284 @@
{
"schemaVersion": 1,
"vaults": [
{
"id": "vault-work",
"name": "Work",
"colour": "#526d82",
"folders": [
{
"id": "d277fa97-1f4a-4386-aba3-d1b9990020f3",
"name": "Books",
"createdAt": "2026-08-19T14:26:23.797Z",
"updatedAt": "2026-08-19T14:26:23.797Z"
},
{
"id": "d35a57a9-8090-4f06-beff-9d3013dada38",
"name": "Articles",
"createdAt": "2026-08-19T14:38:08.613Z",
"updatedAt": "2026-08-19T14:38:08.613Z"
}
],
"resources": [
{
"id": "31e35b5e-3ba4-4f70-8075-5ef95ee6d1c1",
"title": "Quick Start Kubernetes",
"creators": [
"Nigel Poulton"
],
"type": "book",
"format": "pdf",
"status": "active",
"folderId": "d277fa97-1f4a-4386-aba3-d1b9990020f3",
"pageCount": 102,
"durationMinutes": null,
"url": "",
"publisher": "XeTeX 0.99998",
"published": "20230714",
"tags": [],
"description": "",
"notes": "",
"attachmentId": "4f55a614-1f1d-467c-a6ec-6a8e4710d6a7",
"thumbnailId": "22a8f1f9-3284-4b11-b2ee-76a0439deb4d",
"sessions": [
{
"id": "92b5f660-9f41-4c11-8662-64307062ac9e",
"date": "2026-08-19",
"start": 0,
"end": 25,
"unit": "pages",
"note": "",
"createdAt": "2026-08-19T14:26:05.106Z"
}
],
"createdAt": "2026-08-19T14:25:50.144Z",
"updatedAt": "2026-08-19T14:26:31.349Z",
"attachmentState": "reattach-required-after-import"
},
{
"id": "6c3206c8-9421-40e8-8157-783bcef4c2d8",
"title": "You Wont Make It. It Makes You.",
"creators": [],
"type": "article",
"format": "web",
"status": "backlog",
"folderId": "d35a57a9-8090-4f06-beff-9d3013dada38",
"pageCount": null,
"durationMinutes": null,
"url": "https://medium.com/westenberg/you-wont-make-it-it-makes-you-5580fb0ae28c?sharedUserId=nworb",
"publisher": "Medium",
"published": "",
"tags": [],
"description": "",
"notes": "",
"attachmentId": null,
"thumbnailId": null,
"sessions": [],
"createdAt": "2026-08-19T14:29:00.526Z",
"updatedAt": "2026-08-19T14:38:13.471Z"
},
{
"id": "818c243e-4a38-4392-88a5-5622b8d45730",
"title": "Agile Testing A Practical Guide for Testers and Agile Teams by Lisa Crispin and Janet Gregory",
"creators": [
"Lisa Crispin",
"Janet Gregory"
],
"type": "book",
"format": "physical",
"status": "shelf",
"folderId": "d277fa97-1f4a-4386-aba3-d1b9990020f3",
"pageCount": 573,
"durationMinutes": null,
"url": "",
"publisher": "iText 2.1.4 (by lowagie.com)",
"published": "20110315",
"tags": [],
"description": "",
"notes": "",
"attachmentId": "2b489f35-1d4c-4086-996d-10ae9d7892d9",
"thumbnailId": "93d3c67b-7c39-476b-b3c6-144aac8be448",
"sessions": [],
"createdAt": "2026-08-20T11:12:31.407Z",
"updatedAt": "2026-08-20T11:12:31.407Z",
"attachmentState": "reattach-required-after-import"
},
{
"id": "801d464e-e6d2-4a97-b9c5-904de80336ff",
"title": "Clean Code A Handbook Of Agile Software Craftsmanship Robert C. Martin",
"creators": [],
"type": "book",
"format": "physical",
"status": "shelf",
"folderId": "d277fa97-1f4a-4386-aba3-d1b9990020f3",
"pageCount": 462,
"durationMinutes": null,
"url": "",
"publisher": "",
"published": "20080916",
"tags": [],
"description": "",
"notes": "",
"attachmentId": "3302329d-ca36-4028-ab36-ca89c84dbe9f",
"thumbnailId": "e727a0e3-0d0e-4e17-a394-d77eee7606ca",
"sessions": [],
"createdAt": "2026-08-20T11:12:51.287Z",
"updatedAt": "2026-08-20T11:12:51.287Z",
"attachmentState": "reattach-required-after-import"
},
{
"id": "968f0e4e-aec2-4ed1-8aac-372b609ae594",
"title": "Code Complete, Second Edition eBook",
"creators": [
"Steve McConnell"
],
"type": "book",
"format": "physical",
"status": "shelf",
"folderId": "d277fa97-1f4a-4386-aba3-d1b9990020f3",
"pageCount": 952,
"durationMinutes": null,
"url": "",
"publisher": "Acrobat Distiller 5.0 (Windows)",
"published": "20130506",
"tags": [
"V413HAV"
],
"description": "",
"notes": "",
"attachmentId": "f105daf1-fb4e-4661-8a26-61f7a82d2f06",
"thumbnailId": "0a9e1ffa-55e4-41fb-b6c3-ef34320ec123",
"sessions": [],
"createdAt": "2026-08-20T11:13:05.046Z",
"updatedAt": "2026-08-20T11:13:05.046Z",
"attachmentState": "reattach-required-after-import"
},
{
"id": "1afa8601-5db0-4e37-932b-3c00ccf13614",
"title": "Head First: Design Patterns",
"creators": [
"Freeman",
"Eric"
],
"type": "book",
"format": "physical",
"status": "shelf",
"folderId": "d277fa97-1f4a-4386-aba3-d1b9990020f3",
"pageCount": 867,
"durationMinutes": null,
"url": "",
"publisher": "calibre 3.15.0 [https://calibre-ebook.com]",
"published": "20180121",
"tags": [
"COMPUTERS / Software Development & Engineering / General"
],
"description": "<p>This edition of Head First Design Patterns—now updated for Java 8—shows you the tried-and-true, road-tested patterns used by developers to create functional, elegant, reusable, and flexible software. By the time you finish this book, youll be able to take advantage of the best design practices and experiences of those who have fought the beast of software design and triumphed.</p>",
"notes": "",
"attachmentId": "414d1ac4-6934-4191-83de-4164ef4f4e1c",
"thumbnailId": "0df8ad55-2811-4342-a411-471a5f2e3e1e",
"sessions": [],
"createdAt": "2026-08-20T11:13:14.758Z",
"updatedAt": "2026-08-20T11:13:14.758Z",
"attachmentState": "reattach-required-after-import"
},
{
"id": "9c9f2902-583b-4d36-96c8-13d0911c5b8e",
"title": "Grokking Algorithms",
"creators": [
"Aditya Y. Bhargava"
],
"type": "book",
"format": "pdf",
"status": "shelf",
"folderId": "d277fa97-1f4a-4386-aba3-d1b9990020f3",
"pageCount": 258,
"durationMinutes": null,
"url": "",
"publisher": "Adobe PDF Library 11.0; modified using iText 2.1.7 by 1T3XT",
"published": "20160501",
"tags": [],
"description": "",
"notes": "",
"attachmentId": "b3ae5709-316d-4f02-a810-7515dfa9d073",
"thumbnailId": "2fa590f1-3198-4c6f-bef6-ac90df1d51a3",
"sessions": [],
"createdAt": "2026-08-20T11:13:23.182Z",
"updatedAt": "2026-08-20T11:13:23.182Z",
"attachmentState": "reattach-required-after-import"
},
{
"id": "cc4084e8-ee45-485c-b71c-903e896c55db",
"title": "The Clean Coder: A Code of Conduct For Professional Programmers",
"creators": [
"Robert C. Martin"
],
"type": "book",
"format": "physical",
"status": "shelf",
"folderId": "d277fa97-1f4a-4386-aba3-d1b9990020f3",
"pageCount": 244,
"durationMinutes": null,
"url": "",
"publisher": "3-Heights(TM) PDF Security Shell 4.8.25.2 (http://www.pdf-tools.com) / pdcat (www.pdf-tools.com)",
"published": "20110428",
"tags": [],
"description": "",
"notes": "",
"attachmentId": "18670834-863f-47fc-ab4b-c2f3f963e2bf",
"thumbnailId": "90d29780-d110-4525-8efe-cd333b836fc9",
"sessions": [],
"createdAt": "2026-08-20T11:13:32.620Z",
"updatedAt": "2026-08-20T11:13:32.620Z",
"attachmentState": "reattach-required-after-import"
},
{
"id": "dd1265d0-00a7-44ad-8f35-9a0fb14a657c",
"title": "The Toyota Way - Jeffrey Liker",
"creators": [
"Jeffrey Liker"
],
"type": "book",
"format": "pdf",
"status": "completed",
"folderId": "d277fa97-1f4a-4386-aba3-d1b9990020f3",
"pageCount": 151,
"durationMinutes": null,
"url": "",
"publisher": "calibre 3.22.1 [https://calibre-ebook.com]",
"published": "20190513",
"tags": [],
"description": "",
"notes": "",
"attachmentId": "0ad4f3e8-303b-4872-ae61-a3c323f510a6",
"thumbnailId": "6f17fcf2-0ce5-41be-b25e-9d3192b6146c",
"sessions": [
{
"id": "1112c865-2990-4f8d-858a-b3fa18f05001",
"date": "2026-08-20",
"start": 0,
"end": 151,
"unit": "pages",
"note": "",
"createdAt": "2026-08-20T11:14:03.816Z"
}
],
"createdAt": "2026-08-20T11:13:46.554Z",
"updatedAt": "2026-08-20T11:14:08.850Z",
"attachmentState": "reattach-required-after-import"
}
],
"createdAt": "2026-08-19T00:00:00.000Z",
"updatedAt": "2026-08-20T11:14:08.850Z"
},
{
"id": "vault-personal-study",
"name": "Personal Study",
"colour": "#9b6b43",
"folders": [],
"resources": [],
"createdAt": "2026-08-19T00:00:00.000Z",
"updatedAt": "2026-08-19T00:00:00.000Z"
}
],
"exportedAt": "2026-08-20T11:15:11.246Z"
}

View File

@@ -82,13 +82,13 @@ flowchart TD
n30 --> n38 n30 --> n38
n39["Tag: update"] n39["Tag: update"]
n30 --> n39 n30 --> n39
n40["Tag: insights"] n40["Tag: education"]
n30 --> n40 n30 --> n40
n41["Tag: emacs"] n41["Tag: insights"]
n30 --> n41 n30 --> n41
n42["Tag: education"] n42["Tag: reading"]
n30 --> n42 n30 --> n42
n43["Tag: reading"] n43["Tag: emacs"]
n30 --> n43 n30 --> n43
n44["Tag: maths"] n44["Tag: maths"]
n30 --> n44 n30 --> n44
@@ -160,36 +160,36 @@ flowchart TD
n69 --> n77 n69 --> n77
n78["Marginalia Machine"] n78["Marginalia Machine"]
n69 --> n78 n69 --> n78
n79{{"rl"}} n79{{"home"}}
root --> n79 root --> n79
n80["Resource Loader"] n80["Countdown"]
n79 --> n80 n79 --> n80
n81{{"home"}} n81["Backlog"]
root --> n81 n79 --> n81
n82["Countdown"] n82["Competency Status Board"]
n81 --> n82 n79 --> n82
n83["Backlog"] n83["hidden-soul"]
n81 --> n83 n79 --> n83
n84["Competency Status Board"] n84["Wird Tracker"]
n81 --> n84 n79 --> n84
n85["hidden-soul"] n85["Contact"]
n81 --> n85 n79 --> n85
n86["Wird Tracker"] n86["Service"]
n81 --> n86 n79 --> n86
n87["Contact"] n87["Notes"]
n81 --> n87 n79 --> n87
n88["Service"] n88["Categories"]
n81 --> n88 n79 --> n88
n89["Notes"] n89{{"guide"}}
n81 --> n89 n79 --> n89
n90["Categories"] n90["Setup"]
n81 --> n90 n89 --> n90
n91{{"guide"}} n91["Wird Tracker — Technical Guide"]
n81 --> n91 n89 --> n91
n92["Setup"] n92{{"rl"}}
n91 --> n92 root --> n92
n93["Wird Tracker — Technical Guide"] n93["Resource Loader"]
n91 --> n93 n92 --> n93
click n1 "index.html" "Home" click n1 "index.html" "Home"
click n2 "recently-updated.html" "Recently Updated" click n2 "recently-updated.html" "Recently Updated"
click n4 "blogs/blogs-intro.html" "Blogs Introduction" click n4 "blogs/blogs-intro.html" "Blogs Introduction"
@@ -213,10 +213,10 @@ flowchart TD
click n37 "tags/website.html" "Tag: website" click n37 "tags/website.html" "Tag: website"
click n38 "tags/life.html" "Tag: life" click n38 "tags/life.html" "Tag: life"
click n39 "tags/update.html" "Tag: update" click n39 "tags/update.html" "Tag: update"
click n40 "tags/insights.html" "Tag: insights" click n40 "tags/education.html" "Tag: education"
click n41 "tags/emacs.html" "Tag: emacs" click n41 "tags/insights.html" "Tag: insights"
click n42 "tags/education.html" "Tag: education" click n42 "tags/reading.html" "Tag: reading"
click n43 "tags/reading.html" "Tag: reading" click n43 "tags/emacs.html" "Tag: emacs"
click n44 "tags/maths.html" "Tag: maths" click n44 "tags/maths.html" "Tag: maths"
click n46 "posts/posts-intro.html" "Posts Introduction" click n46 "posts/posts-intro.html" "Posts Introduction"
click n47 "posts/posts-list.html" "Posts List" click n47 "posts/posts-list.html" "Posts List"
@@ -249,18 +249,18 @@ flowchart TD
click n76 "play/terminal.html" "Archive Terminal" click n76 "play/terminal.html" "Archive Terminal"
click n77 "play/study.html" "Study Lamp" click n77 "play/study.html" "Study Lamp"
click n78 "play/poem.html" "Marginalia Machine" click n78 "play/poem.html" "Marginalia Machine"
click n80 "rl/index.html" "Resource Loader" click n80 "home/countdown.html" "Countdown"
click n82 "home/countdown.html" "Countdown" click n81 "home/backlog.html" "Backlog"
click n83 "home/backlog.html" "Backlog" click n82 "home/status.html" "Competency Status Board"
click n84 "home/status.html" "Competency Status Board" click n83 "home/hidden-soul.html" "hidden-soul"
click n85 "home/hidden-soul.html" "hidden-soul" click n84 "home/wird-tracker.html" "Wird Tracker"
click n86 "home/wird-tracker.html" "Wird Tracker" click n85 "home/contact.html" "Contact"
click n87 "home/contact.html" "Contact" click n86 "home/services.html" "Service"
click n88 "home/services.html" "Service" click n87 "home/notes.html" "Notes"
click n89 "home/notes.html" "Notes" click n88 "home/categories.html" "Categories"
click n90 "home/categories.html" "Categories" click n90 "home/guide/setup.html" "Setup"
click n92 "home/guide/setup.html" "Setup" click n91 "home/guide/wird-tracker-guide.html" "Wird Tracker — Technical Guide"
click n93 "home/guide/wird-tracker-guide.html" "Wird Tracker — Technical Guide" click n93 "rl/index.html" "Resource Loader"
#+end_src #+end_src
* Pages * Pages
@@ -303,10 +303,10 @@ flowchart TD
- [[file:tags/website.org][Tag: website]] - [[file:tags/website.org][Tag: website]]
- [[file:tags/life.org][Tag: life]] - [[file:tags/life.org][Tag: life]]
- [[file:tags/update.org][Tag: update]] - [[file:tags/update.org][Tag: update]]
- [[file:tags/insights.org][Tag: insights]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/education.org][Tag: education]] - [[file:tags/education.org][Tag: education]]
- [[file:tags/insights.org][Tag: insights]]
- [[file:tags/reading.org][Tag: reading]] - [[file:tags/reading.org][Tag: reading]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/maths.org][Tag: maths]] - [[file:tags/maths.org][Tag: maths]]
- posts - posts
- [[file:posts/posts-intro.org][Posts Introduction]] - [[file:posts/posts-intro.org][Posts Introduction]]
@@ -342,8 +342,6 @@ flowchart TD
- [[file:play/terminal.org][Archive Terminal]] - [[file:play/terminal.org][Archive Terminal]]
- [[file:play/study.org][Study Lamp]] - [[file:play/study.org][Study Lamp]]
- [[file:play/poem.org][Marginalia Machine]] - [[file:play/poem.org][Marginalia Machine]]
- rl
- [[file:rl/index.org][Resource Loader]]
- home - home
- [[file:home/countdown.org][Countdown]] - [[file:home/countdown.org][Countdown]]
- [[file:home/backlog.org][Backlog]] - [[file:home/backlog.org][Backlog]]
@@ -356,4 +354,6 @@ flowchart TD
- [[file:home/categories.org][Categories]] - [[file:home/categories.org][Categories]]
- guide - guide
- [[file:home/guide/setup.org][Setup]] - [[file:home/guide/setup.org][Setup]]
- [[file:home/guide/wird-tracker-guide.org][Wird Tracker — Technical Guide]] - [[file:home/guide/wird-tracker-guide.org][Wird Tracker — Technical Guide]]
- rl
- [[file:rl/index.org][Resource Loader]]

View File

@@ -43,6 +43,23 @@ test("merge replaces matching ids and keeps unrelated records", () => {
assert.ok(merged.vaults[0].resources.some((item) => item.id === "resource-2")); assert.ok(merged.vaults[0].resources.some((item) => item.id === "resource-2"));
}); });
test("server sync keeps newer fields and sessions from both devices", () => {
const remote = library([resource({
title: "Remote title",
updatedAt: "2026-01-02T00:00:00.000Z",
sessions: [{ id: "remote-session", end: 20, createdAt: "2026-01-02T00:00:00.000Z" }],
})]);
const local = library([resource({
title: "Newer local title",
updatedAt: "2026-01-03T00:00:00.000Z",
sessions: [{ id: "local-session", end: 30, createdAt: "2026-01-03T00:00:00.000Z" }],
})]);
const merged = Model.mergeForSync(remote, local);
const synced = merged.vaults[0].resources[0];
assert.equal(synced.title, "Newer local title");
assert.deepEqual(synced.sessions.map((session) => session.id).sort(), ["local-session", "remote-session"]);
});
test("vault and folder operations preserve records safely", () => { test("vault and folder operations preserve records safely", () => {
let data = Model.addVault(library(), "Second", "#abcdef"); let data = Model.addVault(library(), "Second", "#abcdef");
assert.equal(data.vaults.length, 2); assert.equal(data.vaults.length, 2);