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 |
| `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.
`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);
}
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,

View File

@@ -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() {

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 .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;

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.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.

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>@@
* 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/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>@@

View File

@@ -7,9 +7,9 @@
<main class="rl-app" id="resource-loader" aria-labelledby="rl-title">
<header class="rl-hero">
<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>
<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 class="rl-hero__actions">
<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-backlog">0</span><small>backlog</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 class="rl-command-bar" aria-label="Resource actions">
@@ -165,8 +165,8 @@
<dialog class="rl-dialog" id="rl-storage-dialog">
<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>
<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>
<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>
<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 this browser's cache &amp; PDFs</button><button type="button" data-close-dialog>Close</button></footer>
</div>
</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
n39["Tag: update"]
n30 --> n39
n40["Tag: insights"]
n40["Tag: education"]
n30 --> n40
n41["Tag: emacs"]
n41["Tag: insights"]
n30 --> n41
n42["Tag: education"]
n42["Tag: reading"]
n30 --> n42
n43["Tag: reading"]
n43["Tag: emacs"]
n30 --> n43
n44["Tag: maths"]
n30 --> n44
@@ -160,36 +160,36 @@ flowchart TD
n69 --> n77
n78["Marginalia Machine"]
n69 --> n78
n79{{"rl"}}
n79{{"home"}}
root --> n79
n80["Resource Loader"]
n80["Countdown"]
n79 --> n80
n81{{"home"}}
root --> n81
n82["Countdown"]
n81 --> n82
n83["Backlog"]
n81 --> n83
n84["Competency Status Board"]
n81 --> n84
n85["hidden-soul"]
n81 --> n85
n86["Wird Tracker"]
n81 --> n86
n87["Contact"]
n81 --> n87
n88["Service"]
n81 --> n88
n89["Notes"]
n81 --> n89
n90["Categories"]
n81 --> n90
n91{{"guide"}}
n81 --> n91
n92["Setup"]
n91 --> n92
n93["Wird Tracker — Technical Guide"]
n91 --> n93
n81["Backlog"]
n79 --> n81
n82["Competency Status Board"]
n79 --> n82
n83["hidden-soul"]
n79 --> n83
n84["Wird Tracker"]
n79 --> n84
n85["Contact"]
n79 --> n85
n86["Service"]
n79 --> n86
n87["Notes"]
n79 --> n87
n88["Categories"]
n79 --> n88
n89{{"guide"}}
n79 --> n89
n90["Setup"]
n89 --> n90
n91["Wird Tracker — Technical Guide"]
n89 --> n91
n92{{"rl"}}
root --> n92
n93["Resource Loader"]
n92 --> n93
click n1 "index.html" "Home"
click n2 "recently-updated.html" "Recently Updated"
click n4 "blogs/blogs-intro.html" "Blogs Introduction"
@@ -213,10 +213,10 @@ flowchart TD
click n37 "tags/website.html" "Tag: website"
click n38 "tags/life.html" "Tag: life"
click n39 "tags/update.html" "Tag: update"
click n40 "tags/insights.html" "Tag: insights"
click n41 "tags/emacs.html" "Tag: emacs"
click n42 "tags/education.html" "Tag: education"
click n43 "tags/reading.html" "Tag: reading"
click n40 "tags/education.html" "Tag: education"
click n41 "tags/insights.html" "Tag: insights"
click n42 "tags/reading.html" "Tag: reading"
click n43 "tags/emacs.html" "Tag: emacs"
click n44 "tags/maths.html" "Tag: maths"
click n46 "posts/posts-intro.html" "Posts Introduction"
click n47 "posts/posts-list.html" "Posts List"
@@ -249,18 +249,18 @@ flowchart TD
click n76 "play/terminal.html" "Archive Terminal"
click n77 "play/study.html" "Study Lamp"
click n78 "play/poem.html" "Marginalia Machine"
click n80 "rl/index.html" "Resource Loader"
click n82 "home/countdown.html" "Countdown"
click n83 "home/backlog.html" "Backlog"
click n84 "home/status.html" "Competency Status Board"
click n85 "home/hidden-soul.html" "hidden-soul"
click n86 "home/wird-tracker.html" "Wird Tracker"
click n87 "home/contact.html" "Contact"
click n88 "home/services.html" "Service"
click n89 "home/notes.html" "Notes"
click n90 "home/categories.html" "Categories"
click n92 "home/guide/setup.html" "Setup"
click n93 "home/guide/wird-tracker-guide.html" "Wird Tracker — Technical Guide"
click n80 "home/countdown.html" "Countdown"
click n81 "home/backlog.html" "Backlog"
click n82 "home/status.html" "Competency Status Board"
click n83 "home/hidden-soul.html" "hidden-soul"
click n84 "home/wird-tracker.html" "Wird Tracker"
click n85 "home/contact.html" "Contact"
click n86 "home/services.html" "Service"
click n87 "home/notes.html" "Notes"
click n88 "home/categories.html" "Categories"
click n90 "home/guide/setup.html" "Setup"
click n91 "home/guide/wird-tracker-guide.html" "Wird Tracker — Technical Guide"
click n93 "rl/index.html" "Resource Loader"
#+end_src
* Pages
@@ -303,10 +303,10 @@ flowchart TD
- [[file:tags/website.org][Tag: website]]
- [[file:tags/life.org][Tag: life]]
- [[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/insights.org][Tag: insights]]
- [[file:tags/reading.org][Tag: reading]]
- [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/maths.org][Tag: maths]]
- posts
- [[file:posts/posts-intro.org][Posts Introduction]]
@@ -342,8 +342,6 @@ flowchart TD
- [[file:play/terminal.org][Archive Terminal]]
- [[file:play/study.org][Study Lamp]]
- [[file:play/poem.org][Marginalia Machine]]
- rl
- [[file:rl/index.org][Resource Loader]]
- home
- [[file:home/countdown.org][Countdown]]
- [[file:home/backlog.org][Backlog]]
@@ -356,4 +354,6 @@ flowchart TD
- [[file:home/categories.org][Categories]]
- guide
- [[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"));
});
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", () => {
let data = Model.addVault(library(), "Second", "#abcdef");
assert.equal(data.vaults.length, 2);