Add Resource Loader SPA and JS test harness
All checks were successful
Build Org Website / build (push) Successful in 38s

This commit is contained in:
gitea-actions
2026-08-19 15:03:41 +01:00
parent 75af3e1f0e
commit b8bb1944dd
22 changed files with 2111 additions and 206 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: all clean norm test test-elisp author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help .PHONY: all clean norm test test-elisp test-js author author-install author-start author-stop author-stop-legacy author-restart author-status author-logs author-health author-diagnostics help
VENV := .venv VENV := .venv
PY := $(VENV)/bin/python PY := $(VENV)/bin/python
@@ -28,6 +28,9 @@ test:
test-elisp: test-elisp:
emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit emacs -Q --batch -l lisp/tests/build-site-tests.el -f ert-run-tests-batch-and-exit
test-js:
node --test tests/*.test.js
$(VENV): $(VENV):
@echo "Generating virtual environment" @echo "Generating virtual environment"
python3 -m venv $(VENV) python3 -m venv $(VENV)
@@ -84,6 +87,7 @@ help:
@echo " make norm - Move backup files" @echo " make norm - Move backup files"
@echo " make test - Run authoring server tests" @echo " make test - Run authoring server tests"
@echo " make test-elisp - Run Emacs Lisp build tests" @echo " make test-elisp - Run Emacs Lisp build tests"
@echo " make test-js - Run browser-model JavaScript tests"
@echo " make author - Install and start the authoring UI service" @echo " make author - Install and start the authoring UI service"
@echo " make author-install - Install and enable the authoring UI service" @echo " make author-install - Install and enable the authoring UI service"
@echo " make author-stop - Stop the authoring UI service" @echo " make author-stop - Stop the authoring UI service"

View File

@@ -32,6 +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 |
| `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 |
@@ -279,6 +280,8 @@ These APIs are not implemented here. Production routing must serve `output/` and
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`.
## Authoring service ## Authoring service
The browser authoring UI is a separate sibling repository at: The browser authoring UI is a separate sibling repository at:

View File

@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"vaults": [
{
"id": "vault-work",
"name": "Work",
"colour": "#526d82",
"folders": [],
"resources": [],
"createdAt": "2026-08-19T00:00:00.000Z",
"updatedAt": "2026-08-19T00:00:00.000Z"
},
{
"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"
}
]
}

View File

@@ -0,0 +1,229 @@
(function (root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
root.ResourceLoaderModel = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
const SCHEMA_VERSION = 1;
const TYPES = ["book", "article", "video"];
const FORMATS = ["physical", "pdf", "web"];
const STATUSES = ["backlog", "shelf", "active", "completed", "archived"];
function id() {
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return `rl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function now() {
return new Date().toISOString();
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function emptyVault(name, colour) {
const stamp = now();
return {
id: id(),
name,
colour: colour || "#9b6b43",
folders: [],
resources: [],
createdAt: stamp,
updatedAt: stamp,
};
}
function createSeed() {
return {
schemaVersion: SCHEMA_VERSION,
vaults: [emptyVault("Work", "#526d82"), emptyVault("Personal Study", "#9b6b43")],
};
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function validateLibrary(input) {
assert(input && typeof input === "object" && !Array.isArray(input), "The JSON root must be an object.");
assert(input.schemaVersion === SCHEMA_VERSION, `Unsupported schemaVersion; expected ${SCHEMA_VERSION}.`);
assert(Array.isArray(input.vaults), "vaults must be an array.");
const vaultIds = new Set();
input.vaults.forEach((vault, vaultIndex) => {
assert(vault && typeof vault === "object", `Vault ${vaultIndex + 1} is invalid.`);
assert(typeof vault.id === "string" && vault.id, `Vault ${vaultIndex + 1} needs an id.`);
assert(!vaultIds.has(vault.id), `Duplicate vault id: ${vault.id}`);
vaultIds.add(vault.id);
assert(typeof vault.name === "string" && vault.name.trim(), `Vault ${vaultIndex + 1} needs a name.`);
assert(Array.isArray(vault.folders), `folders must be an array in ${vault.name}.`);
assert(Array.isArray(vault.resources), `resources must be an array in ${vault.name}.`);
const folderIds = new Set();
vault.folders.forEach((folder) => {
assert(folder && typeof folder.id === "string" && folder.id, `A folder in ${vault.name} needs an id.`);
assert(!folderIds.has(folder.id), `Duplicate folder id: ${folder.id}`);
folderIds.add(folder.id);
assert(typeof folder.name === "string" && folder.name.trim(), `A folder in ${vault.name} needs a name.`);
});
const resourceIds = new Set();
vault.resources.forEach((resource) => {
assert(resource && typeof resource.id === "string" && resource.id, `A resource in ${vault.name} needs an id.`);
assert(!resourceIds.has(resource.id), `Duplicate resource id: ${resource.id}`);
resourceIds.add(resource.id);
assert(typeof resource.title === "string" && resource.title.trim(), "Every resource needs a title.");
assert(TYPES.includes(resource.type), `Invalid resource type: ${resource.type}`);
assert(FORMATS.includes(resource.format), `Invalid resource format: ${resource.format}`);
assert(STATUSES.includes(resource.status), `Invalid resource status: ${resource.status}`);
assert(!resource.folderId || folderIds.has(resource.folderId), `Resource ${resource.title} refers to a missing folder.`);
assert(!resource.sessions || Array.isArray(resource.sessions), `sessions must be an array for ${resource.title}.`);
});
});
return true;
}
function normaliseLibrary(input) {
validateLibrary(input);
const data = clone(input);
data.vaults.forEach((vault) => {
vault.colour = vault.colour || "#9b6b43";
vault.createdAt = vault.createdAt || now();
vault.updatedAt = vault.updatedAt || vault.createdAt;
vault.folders.forEach((folder) => {
folder.createdAt = folder.createdAt || now();
folder.updatedAt = folder.updatedAt || folder.createdAt;
});
vault.resources.forEach((resource) => {
resource.creators = Array.isArray(resource.creators) ? resource.creators : [];
resource.tags = Array.isArray(resource.tags) ? resource.tags : [];
resource.sessions = Array.isArray(resource.sessions) ? resource.sessions : [];
resource.folderId = resource.folderId || null;
resource.createdAt = resource.createdAt || now();
resource.updatedAt = resource.updatedAt || resource.createdAt;
});
});
return data;
}
function mergeById(existing, incoming) {
const map = new Map(existing.map((item) => [item.id, clone(item)]));
incoming.forEach((item) => map.set(item.id, clone(item)));
return Array.from(map.values());
}
function mergeLibraries(current, incoming) {
const base = normaliseLibrary(current);
const addition = normaliseLibrary(incoming);
const vaultMap = new Map(base.vaults.map((vault) => [vault.id, vault]));
addition.vaults.forEach((newVault) => {
const oldVault = vaultMap.get(newVault.id);
if (!oldVault) {
vaultMap.set(newVault.id, newVault);
return;
}
vaultMap.set(newVault.id, {
...oldVault,
...newVault,
folders: mergeById(oldVault.folders, newVault.folders),
resources: mergeById(oldVault.resources, newVault.resources),
});
});
const merged = { schemaVersion: SCHEMA_VERSION, vaults: Array.from(vaultMap.values()) };
return normaliseLibrary(merged);
}
function progressFor(resource) {
const sessions = Array.isArray(resource.sessions) ? resource.sessions : [];
const furthest = sessions.reduce((maximum, session) => {
const value = Number(session.end);
return Number.isFinite(value) ? Math.max(maximum, value) : maximum;
}, 0);
let total = 100;
if (resource.type === "book") total = Number(resource.pageCount) || 0;
if (resource.type === "video") total = Number(resource.durationMinutes) || 0;
if (!total) return { current: furthest, total: 0, percent: resource.status === "completed" ? 100 : 0 };
const percent = Math.max(0, Math.min(100, Math.round((furthest / total) * 100)));
return { current: furthest, total, percent: resource.status === "completed" ? 100 : percent };
}
function addVault(library, name, colour) {
const data = normaliseLibrary(library);
data.vaults.push(emptyVault(name.trim(), colour));
return data;
}
function renameVault(library, vaultId, name) {
const data = normaliseLibrary(library);
const vault = data.vaults.find((item) => item.id === vaultId);
assert(vault, "Vault not found.");
vault.name = name.trim();
vault.updatedAt = now();
return data;
}
function deleteVault(library, vaultId) {
const data = normaliseLibrary(library);
assert(data.vaults.length > 1, "Keep at least one vault.");
data.vaults = data.vaults.filter((vault) => vault.id !== vaultId);
return data;
}
function addFolder(library, vaultId, name) {
const data = normaliseLibrary(library);
const vault = data.vaults.find((item) => item.id === vaultId);
assert(vault, "Vault not found.");
const stamp = now();
vault.folders.push({ id: id(), name: name.trim(), createdAt: stamp, updatedAt: stamp });
vault.updatedAt = stamp;
return data;
}
function deleteFolder(library, vaultId, folderId) {
const data = normaliseLibrary(library);
const vault = data.vaults.find((item) => item.id === vaultId);
assert(vault, "Vault not found.");
vault.folders = vault.folders.filter((folder) => folder.id !== folderId);
vault.resources.forEach((resource) => {
if (resource.folderId === folderId) resource.folderId = null;
});
vault.updatedAt = now();
return data;
}
function serialise(library) {
const data = normaliseLibrary(library);
data.exportedAt = now();
data.vaults.forEach((vault) => vault.resources.forEach((resource) => {
if (resource.attachmentId) resource.attachmentState = "reattach-required-after-import";
}));
return JSON.stringify(data, null, 2);
}
return {
SCHEMA_VERSION,
TYPES,
FORMATS,
STATUSES,
id,
now,
createSeed,
validateLibrary,
normaliseLibrary,
mergeLibraries,
progressFor,
addVault,
renameVault,
deleteVault,
addFolder,
deleteFolder,
serialise,
};
});

View File

@@ -0,0 +1,731 @@
(function () {
"use strict";
const root = document.getElementById("resource-loader");
const Model = globalThis.ResourceLoaderModel;
if (!root || !Model) return;
const DB_NAME = "zxh-resource-loader";
const DB_VERSION = 1;
const META_STORE = "metadata";
const ATTACHMENT_STORE = "attachments";
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
const state = {
db: null,
library: null,
vaultId: null,
folderId: "all",
view: localStorage.getItem("rl-view") || "list",
importData: null,
nameAction: null,
pendingImport: null,
pdfUrls: new Map(),
thumbnailUrls: new Map(),
};
const els = {
vaultSelect: $("#rl-vault-select"),
folderList: $("#rl-folder-list"),
resourceList: $("#rl-resource-list"),
contextLabel: $("#rl-context-label"),
search: $("#rl-search"),
typeFilter: $("#rl-filter-type"),
statusFilter: $("#rl-filter-status"),
tagFilter: $("#rl-filter-tag"),
sort: $("#rl-sort"),
resourceDialog: $("#rl-resource-dialog"),
resourceForm: $("#rl-resource-form"),
sessionDialog: $("#rl-session-dialog"),
nameDialog: $("#rl-name-dialog"),
vaultDialog: $("#rl-vault-dialog"),
importDialog: $("#rl-import-dialog"),
storageDialog: $("#rl-storage-dialog"),
pdfInput: $("#rl-pdf-input"),
jsonInput: $("#rl-json-input"),
loading: $("#rl-loading"),
toast: $("#rl-toast"),
};
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(META_STORE)) db.createObjectStore(META_STORE);
if (!db.objectStoreNames.contains(ATTACHMENT_STORE)) db.createObjectStore(ATTACHMENT_STORE, { keyPath: "id" });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function request(storeName, mode, operation) {
return new Promise((resolve, reject) => {
const transaction = state.db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const result = operation(store);
transaction.oncomplete = () => resolve(result && result.result);
transaction.onerror = () => reject(transaction.error || (result && result.error));
transaction.onabort = () => reject(transaction.error || new Error("Storage operation was aborted."));
});
}
function getMetadata(key) { return request(META_STORE, "readonly", (store) => store.get(key)); }
function setMetadata(key, value) { return request(META_STORE, "readwrite", (store) => store.put(value, key)); }
function getAttachment(id) { return id ? request(ATTACHMENT_STORE, "readonly", (store) => store.get(id)) : Promise.resolve(null); }
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(); }
async function saveLibrary() {
await setMetadata("library", state.library);
}
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());
} catch (error) {
console.warn(error);
return Model.createSeed();
}
}
function currentVault() {
return state.library.vaults.find((vault) => vault.id === state.vaultId) || state.library.vaults[0];
}
function currentResource(id) {
return currentVault().resources.find((resource) => resource.id === id);
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
function titleCase(value) {
return String(value || "").replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function splitList(value) {
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
}
function showLoading(message) {
els.loading.querySelector("p").textContent = message || "Working…";
els.loading.classList.add("is-visible");
}
function hideLoading() { els.loading.classList.remove("is-visible"); }
let toastTimer;
function toast(message, error) {
clearTimeout(toastTimer);
els.toast.textContent = message;
els.toast.classList.toggle("is-error", Boolean(error));
els.toast.classList.add("is-visible");
toastTimer = setTimeout(() => els.toast.classList.remove("is-visible"), 3600);
}
function closeDialog(dialog) {
if (dialog && dialog.open) dialog.close();
}
function setValue(id, value) {
const element = document.getElementById(id);
if (element) element.value = value == null ? "" : value;
}
function renderVaultSelect() {
const previous = state.vaultId;
els.vaultSelect.innerHTML = state.library.vaults.map((vault) =>
`<option value="${escapeHtml(vault.id)}">${escapeHtml(vault.name)}</option>`).join("");
state.vaultId = state.library.vaults.some((vault) => vault.id === previous) ? previous : state.library.vaults[0].id;
els.vaultSelect.value = state.vaultId;
}
function resourceCount(folderId) {
const resources = currentVault().resources;
if (folderId === "all") return resources.length;
if (folderId === "unfiled") return resources.filter((item) => !item.folderId).length;
return resources.filter((item) => item.folderId === folderId).length;
}
function folderRow(id, label, editable) {
return `<div class="rl-folder-row${state.folderId === id ? " is-active" : ""}" data-folder-row="${escapeHtml(id)}">
<button type="button" data-folder="${escapeHtml(id)}"><span>${escapeHtml(label)}</span><span>${resourceCount(id)}</span></button>
${editable ? `<button type="button" class="rl-folder-menu" data-folder-menu="${escapeHtml(id)}" aria-label="Manage ${escapeHtml(label)}">•••</button>` : ""}
</div>`;
}
function renderFolders() {
const vault = currentVault();
if (!vault.folders.some((folder) => folder.id === state.folderId) && !["all", "unfiled"].includes(state.folderId)) state.folderId = "all";
els.folderList.innerHTML = folderRow("all", "All resources", false) + folderRow("unfiled", "Unfiled", false) +
'<div class="rl-folder-rule" aria-hidden="true"></div>' +
vault.folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => folderRow(folder.id, folder.name, true)).join("");
els.folderList.querySelectorAll("[data-folder]").forEach((button) => button.addEventListener("click", () => {
state.folderId = button.dataset.folder;
render();
}));
els.folderList.querySelectorAll("[data-folder-menu]").forEach((button) => button.addEventListener("click", () => manageFolder(button.dataset.folderMenu)));
}
function populateTagFilter() {
const previous = els.tagFilter.value;
const tags = Array.from(new Set(currentVault().resources.flatMap((resource) => resource.tags || []))).sort((a, b) => a.localeCompare(b));
els.tagFilter.innerHTML = '<option value="">All tags</option>' + tags.map((tag) => `<option value="${escapeHtml(tag)}">${escapeHtml(tag)}</option>`).join("");
els.tagFilter.value = tags.includes(previous) ? previous : "";
}
function filteredResources() {
const query = els.search.value.trim().toLocaleLowerCase();
let resources = currentVault().resources.filter((resource) => {
if (state.folderId === "unfiled" && resource.folderId) return false;
if (!["all", "unfiled"].includes(state.folderId) && resource.folderId !== state.folderId) return false;
if (els.typeFilter.value && resource.type !== els.typeFilter.value) return false;
if (els.statusFilter.value && resource.status !== els.statusFilter.value) return false;
if (els.tagFilter.value && !(resource.tags || []).includes(els.tagFilter.value)) return false;
const haystack = [resource.title, ...(resource.creators || []), ...(resource.tags || []), resource.description, resource.notes].join(" ").toLocaleLowerCase();
return !query || haystack.includes(query);
});
resources.sort((a, b) => {
if (els.sort.value === "title") return a.title.localeCompare(b.title);
if (els.sort.value === "progress") return Model.progressFor(b).percent - Model.progressFor(a).percent;
return String(b.updatedAt).localeCompare(String(a.updatedAt));
});
return resources;
}
async function thumbnailUrl(id) {
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);
state.thumbnailUrls.set(id, url);
return url;
}
function cardMarkup(resource) {
const progress = Model.progressFor(resource);
const initial = (resource.title || "?").trim().charAt(0).toUpperCase();
const creators = (resource.creators || []).join(", ") || "Unknown creator";
return `<article class="rl-resource-card" tabindex="0" role="button" data-resource-id="${escapeHtml(resource.id)}" aria-label="Open ${escapeHtml(resource.title)}">
<span class="rl-resource-cover" data-thumb="${escapeHtml(resource.thumbnailId || "")}">${escapeHtml(initial)}</span>
<span class="rl-resource-title"><strong>${escapeHtml(resource.title)}</strong><small>${escapeHtml(creators)}</small><small>${escapeHtml((resource.tags || []).slice(0, 3).join(" · "))}</small></span>
<span class="rl-badges"><span class="rl-badge">${escapeHtml(resource.type)}</span><span class="rl-badge">${escapeHtml(resource.status)}</span><span class="rl-badge">${escapeHtml(resource.format)}</span></span>
<span class="rl-progress"><span class="rl-progress__track"><span class="rl-progress__fill" style="width:${progress.percent}%"></span></span><small>${progress.percent}% complete</small></span>
<span class="rl-card-arrow" aria-hidden="true"></span>
</article>`;
}
async function hydrateThumbnails(container) {
const targets = Array.from(container.querySelectorAll("[data-thumb]")).filter((item) => item.dataset.thumb);
await Promise.all(targets.map(async (target) => {
const url = await thumbnailUrl(target.dataset.thumb);
if (url && target.isConnected) target.innerHTML = `<img src="${url}" alt="" />`;
}));
}
function bindResourceCards() {
els.resourceList.querySelectorAll("[data-resource-id]").forEach((card) => {
const open = () => openResourceDialog(currentResource(card.dataset.resourceId));
card.addEventListener("click", open);
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); open(); }
});
});
}
function renderResources() {
const resources = filteredResources();
const folder = currentVault().folders.find((item) => item.id === state.folderId);
els.contextLabel.textContent = state.folderId === "all" ? "All resources" : state.folderId === "unfiled" ? "Unfiled" : folder ? folder.name : "All resources";
els.resourceList.classList.toggle("is-grid", state.view === "grid");
$("#rl-view-list").classList.toggle("is-active", state.view === "list");
$("#rl-view-grid").classList.toggle("is-active", state.view === "grid");
$("#rl-view-list").setAttribute("aria-pressed", String(state.view === "list"));
$("#rl-view-grid").setAttribute("aria-pressed", String(state.view === "grid"));
if (!resources.length) {
const filtered = currentVault().resources.length > 0;
els.resourceList.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">⌑</span><h3>${filtered ? "Nothing matches this view" : "Your shelf is ready"}</h3><p>${filtered ? "Try changing the folder, filters, or search." : "Add a physical book, save a link, or import a PDF to begin."}</p><button type="button" class="rl-primary" data-empty-add>Add resource</button></div>`;
els.resourceList.querySelector("[data-empty-add]").addEventListener("click", () => openResourceDialog());
return;
}
els.resourceList.innerHTML = resources.map(cardMarkup).join("");
bindResourceCards();
hydrateThumbnails(els.resourceList);
}
function renderStats() {
const resources = currentVault().resources;
$("#rl-stat-total").textContent = resources.length;
$("#rl-stat-active").textContent = resources.filter((item) => item.status === "active").length;
$("#rl-stat-backlog").textContent = resources.filter((item) => ["backlog", "shelf"].includes(item.status)).length;
$("#rl-stat-completed").textContent = resources.filter((item) => item.status === "completed").length;
}
function render() {
renderVaultSelect();
renderFolders();
populateTagFilter();
renderStats();
renderResources();
}
function populateFolderField(selected) {
const field = $("#rl-field-folder");
field.innerHTML = '<option value="">Unfiled</option>' + currentVault().folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => `<option value="${escapeHtml(folder.id)}">${escapeHtml(folder.name)}</option>`).join("");
field.value = selected || "";
}
function resetCover() {
$("#rl-cover-preview").innerHTML = "<span>Cover</span>";
}
async function showCover(id) {
resetCover();
const url = await thumbnailUrl(id);
if (url) $("#rl-cover-preview").innerHTML = `<img src="${url}" alt="Imported PDF cover" />`;
}
function renderSessions(resource) {
const section = $("#rl-session-section");
if (!resource) { section.hidden = true; return; }
section.hidden = false;
const sessions = (resource.sessions || []).slice().sort((a, b) => String(b.date).localeCompare(String(a.date)));
$("#rl-session-list").innerHTML = sessions.length ? sessions.map((session) => `<div class="rl-session-row">
<time datetime="${escapeHtml(session.date)}">${escapeHtml(session.date)}</time>
<strong>${escapeHtml(session.start)}${escapeHtml(session.end)} ${escapeHtml(session.unit)}</strong>
<span>${escapeHtml(session.note || "")}</span>
<button type="button" class="rl-danger" data-delete-session="${escapeHtml(session.id)}">Delete</button>
</div>`).join("") : '<p class="rl-help">No sessions logged yet.</p>';
$("#rl-session-list").querySelectorAll("[data-delete-session]").forEach((button) => button.addEventListener("click", async () => {
if (!confirm("Delete this session?")) return;
resource.sessions = resource.sessions.filter((item) => item.id !== button.dataset.deleteSession);
resource.updatedAt = Model.now();
await saveLibrary();
renderSessions(resource);
render();
toast("Session deleted.");
}));
}
async function openResourceDialog(resource, draft) {
els.resourceForm.reset();
resetCover();
const item = resource || draft || null;
state.pendingImport = draft ? { attachmentId: draft.attachmentId, thumbnailId: draft.thumbnailId } : null;
$("#rl-resource-dialog-title").textContent = resource ? "Edit resource" : draft ? "Review imported PDF" : "Add resource";
setValue("rl-resource-id", resource ? resource.id : "");
setValue("rl-attachment-id", item && item.attachmentId);
setValue("rl-thumbnail-id", item && item.thumbnailId);
setValue("rl-field-title", item && item.title);
setValue("rl-field-creators", item && (item.creators || []).join(", "));
setValue("rl-field-type", item ? item.type : "book");
setValue("rl-field-format", item ? item.format : "physical");
setValue("rl-field-status", item ? item.status : "backlog");
setValue("rl-field-pages", item && item.pageCount);
setValue("rl-field-duration", item && item.durationMinutes);
setValue("rl-field-url", item && item.url);
setValue("rl-field-publisher", item && item.publisher);
setValue("rl-field-published", item && item.published);
setValue("rl-field-tags", item && (item.tags || []).join(", "));
setValue("rl-field-description", item && item.description);
setValue("rl-field-notes", item && item.notes);
populateFolderField(item && item.folderId ? item.folderId : (!["all", "unfiled"].includes(state.folderId) ? state.folderId : ""));
$("#rl-delete-resource").hidden = !resource;
$("#rl-open-resource").hidden = !(item && (item.url || item.attachmentId));
renderSessions(resource);
if (item && item.thumbnailId) showCover(item.thumbnailId);
els.resourceDialog.showModal();
$("#rl-field-title").focus();
}
function formResource(existing) {
const stamp = Model.now();
return {
id: existing ? existing.id : Model.id(),
title: $("#rl-field-title").value.trim(),
creators: splitList($("#rl-field-creators").value),
type: $("#rl-field-type").value,
format: $("#rl-field-format").value,
status: $("#rl-field-status").value,
folderId: $("#rl-field-folder").value || null,
pageCount: Number($("#rl-field-pages").value) || null,
durationMinutes: Number($("#rl-field-duration").value) || null,
url: $("#rl-field-url").value.trim(),
publisher: $("#rl-field-publisher").value.trim(),
published: $("#rl-field-published").value.trim(),
tags: splitList($("#rl-field-tags").value),
description: $("#rl-field-description").value.trim(),
notes: $("#rl-field-notes").value.trim(),
attachmentId: $("#rl-attachment-id").value || null,
thumbnailId: $("#rl-thumbnail-id").value || null,
sessions: existing ? existing.sessions || [] : [],
createdAt: existing ? existing.createdAt : stamp,
updatedAt: stamp,
};
}
async function saveResource(event) {
event.preventDefault();
const vault = currentVault();
const existing = currentResource($("#rl-resource-id").value);
const resource = formResource(existing);
if (!resource.title) return;
if (existing) vault.resources[vault.resources.findIndex((item) => item.id === existing.id)] = resource;
else vault.resources.push(resource);
vault.updatedAt = Model.now();
await saveLibrary();
state.pendingImport = null;
closeDialog(els.resourceDialog);
render();
toast(existing ? "Resource updated." : "Resource added.");
}
async function removeResource() {
const resource = currentResource($("#rl-resource-id").value);
if (!resource || !confirm(`Delete “${resource.title}”? This also removes its retained PDF.`)) return;
await Promise.all([deleteAttachment(resource.attachmentId), deleteAttachment(resource.thumbnailId)]);
currentVault().resources = currentVault().resources.filter((item) => item.id !== resource.id);
currentVault().updatedAt = Model.now();
await saveLibrary();
closeDialog(els.resourceDialog);
render();
toast("Resource deleted.");
}
function openSessionDialog() {
const resource = currentResource($("#rl-resource-id").value);
if (!resource) return;
const labels = resource.type === "book" ? ["Start page", "End page", "pages"] : resource.type === "video" ? ["Start minute", "End minute", "minutes"] : ["Start percentage", "End percentage", "percent"];
$("#rl-session-start-label").textContent = labels[0];
$("#rl-session-end-label").textContent = labels[1];
$("#rl-session-form").dataset.unit = labels[2];
$("#rl-session-form").reset();
$("#rl-session-date").value = new Date().toISOString().slice(0, 10);
const progress = Model.progressFor(resource);
$("#rl-session-start").value = progress.current || 0;
els.sessionDialog.showModal();
}
async function saveSession(event) {
event.preventDefault();
const resource = currentResource($("#rl-resource-id").value);
if (!resource) return;
const start = Number($("#rl-session-start").value);
const end = Number($("#rl-session-end").value);
if (end < start) { toast("The end position must be at or after the start.", true); return; }
if (resource.type === "article" && end > 100) { toast("Article progress cannot exceed 100%.", true); return; }
resource.sessions.push({ id: Model.id(), date: $("#rl-session-date").value, start, end, unit: event.currentTarget.dataset.unit, note: $("#rl-session-note").value.trim(), createdAt: Model.now() });
resource.updatedAt = Model.now();
if (Model.progressFor(resource).percent >= 100) resource.status = "completed";
else if (["backlog", "shelf"].includes(resource.status)) resource.status = "active";
await saveLibrary();
closeDialog(els.sessionDialog);
renderSessions(resource);
render();
toast("Session logged.");
}
async function openLinkedResource() {
const resource = currentResource($("#rl-resource-id").value);
if (!resource) return;
if (resource.attachmentId) {
const record = await getAttachment(resource.attachmentId);
if (!record || !record.blob) { toast("This PDF is not attached in this browser. Import it again to restore the file.", true); return; }
if (state.pdfUrls.has(resource.attachmentId)) URL.revokeObjectURL(state.pdfUrls.get(resource.attachmentId));
const url = URL.createObjectURL(record.blob);
state.pdfUrls.set(resource.attachmentId, url);
window.open(url, "_blank", "noopener");
return;
}
if (resource.url) window.open(resource.url, "_blank", "noopener");
}
function openNameDialog(action, title, value, colour) {
state.nameAction = action;
$("#rl-name-title").textContent = title;
$("#rl-name-input").value = value || "";
$("#rl-colour-field").hidden = !String(action).includes("vault");
$("#rl-name-colour").value = colour || "#9b6b43";
$("#rl-name-delete").hidden = !String(action).startsWith("rename-folder:");
els.nameDialog.showModal();
$("#rl-name-input").focus();
}
async function submitName(event) {
event.preventDefault();
const name = $("#rl-name-input").value.trim();
if (!name) return;
const action = state.nameAction || "";
if (action === "add-folder") state.library = Model.addFolder(state.library, state.vaultId, name);
if (action.startsWith("rename-folder:")) {
const folder = currentVault().folders.find((item) => item.id === action.split(":")[1]);
folder.name = name; folder.updatedAt = Model.now(); currentVault().updatedAt = Model.now();
}
if (action === "add-vault") {
state.library = Model.addVault(state.library, name, $("#rl-name-colour").value);
state.vaultId = state.library.vaults[state.library.vaults.length - 1].id;
state.folderId = "all";
}
if (action.startsWith("rename-vault:")) {
state.library = Model.renameVault(state.library, action.split(":")[1], name);
const vault = state.library.vaults.find((item) => item.id === action.split(":")[1]);
vault.colour = $("#rl-name-colour").value;
}
await saveLibrary();
closeDialog(els.nameDialog);
render();
renderVaultManager();
toast("Saved.");
}
function manageFolder(folderId) {
const folder = currentVault().folders.find((item) => item.id === folderId);
if (!folder) return;
openNameDialog(`rename-folder:${folderId}`, "Manage folder", folder.name);
}
async function deleteNamedFolder() {
const action = state.nameAction || "";
if (!action.startsWith("rename-folder:")) return;
const folderId = action.split(":")[1];
const folder = currentVault().folders.find((item) => item.id === folderId);
if (!folder || !confirm(`Delete “${folder.name}”? Its resources will move to Unfiled.`)) return;
state.library = Model.deleteFolder(state.library, state.vaultId, folderId);
await saveLibrary();
closeDialog(els.nameDialog);
render();
toast("Folder deleted; resources moved to Unfiled.");
}
async function cleanupPendingImport() {
const pending = state.pendingImport;
if (!pending) return;
state.pendingImport = null;
await Promise.all([deleteAttachment(pending.attachmentId), deleteAttachment(pending.thumbnailId)]).catch(() => {});
}
function renderVaultManager() {
$("#rl-vault-list").innerHTML = state.library.vaults.map((vault) => `<div class="rl-vault-row">
<span class="rl-vault-dot" style="--vault-colour:${escapeHtml(vault.colour)}"></span><strong>${escapeHtml(vault.name)}</strong>
<button type="button" data-rename-vault="${escapeHtml(vault.id)}">Rename</button>
<button type="button" class="rl-danger" data-delete-vault="${escapeHtml(vault.id)}" ${state.library.vaults.length === 1 ? "disabled" : ""}>Delete</button>
</div>`).join("");
$("#rl-vault-list").querySelectorAll("[data-rename-vault]").forEach((button) => button.addEventListener("click", () => {
const vault = state.library.vaults.find((item) => item.id === button.dataset.renameVault);
closeDialog(els.vaultDialog);
openNameDialog(`rename-vault:${vault.id}`, "Rename vault", vault.name, vault.colour);
}));
$("#rl-vault-list").querySelectorAll("[data-delete-vault]").forEach((button) => button.addEventListener("click", async () => {
const vault = state.library.vaults.find((item) => item.id === button.dataset.deleteVault);
if (!confirm(`Delete the “${vault.name}” vault and all of its records? Retained PDFs will also be removed.`)) return;
await Promise.all(vault.resources.flatMap((resource) => [deleteAttachment(resource.attachmentId), deleteAttachment(resource.thumbnailId)]));
state.library = Model.deleteVault(state.library, vault.id);
state.vaultId = state.library.vaults[0].id;
state.folderId = "all";
await saveLibrary();
renderVaultManager(); render(); toast("Vault deleted.");
}));
}
function exportJson() {
const blob = new Blob([Model.serialise(state.library)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `resource-loader-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast("JSON backup exported. PDF files are not included.");
}
async function readJsonFile(file) {
try {
const data = JSON.parse(await file.text());
Model.validateLibrary(data);
state.importData = Model.normaliseLibrary(data);
const vaults = state.importData.vaults.length;
const folders = state.importData.vaults.reduce((sum, vault) => sum + vault.folders.length, 0);
const resources = state.importData.vaults.reduce((sum, vault) => sum + vault.resources.length, 0);
$("#rl-import-summary").innerHTML = `<div><strong>${vaults}</strong><span>vaults</span></div><div><strong>${folders}</strong><span>folders</span></div><div><strong>${resources}</strong><span>resources</span></div>`;
els.importDialog.showModal();
} catch (error) {
toast(`Import rejected: ${error.message}`, true);
} finally {
els.jsonInput.value = "";
}
}
async function applyImport(mode) {
if (!state.importData) return;
if (mode === "replace" && !confirm("Replace every vault and resource? Retained PDF files will also be cleared.")) return;
if (mode === "replace") await request(ATTACHMENT_STORE, "readwrite", (store) => store.clear());
state.library = mode === "merge" ? Model.mergeLibraries(state.library, state.importData) : state.importData;
state.vaultId = state.library.vaults[0] && state.library.vaults[0].id;
state.folderId = "all";
await saveLibrary();
state.importData = null;
closeDialog(els.importDialog);
render();
toast(mode === "merge" ? "Library merged." : "Library replaced.");
}
function cleanPdfTitle(filename) {
return filename.replace(/\.pdf$/i, "").replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w/g, (letter) => letter.toUpperCase());
}
async function pdfCover(page) {
const viewport = page.getViewport({ scale: 0.7 });
const canvas = document.createElement("canvas");
const maximum = 420;
const scale = Math.min(1, maximum / viewport.width);
const scaled = page.getViewport({ scale: 0.7 * scale });
canvas.width = Math.ceil(scaled.width);
canvas.height = Math.ceil(scaled.height);
await page.render({ canvasContext: canvas.getContext("2d"), viewport: scaled }).promise;
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", .78));
}
async function importPdf(file) {
if (!file || (file.type && file.type !== "application/pdf") || !file.name.toLowerCase().endsWith(".pdf")) {
toast("Choose a PDF file.", true); return;
}
showLoading("Reading PDF metadata…");
let attachmentId;
let thumbnailId;
try {
const pdfjs = await import("/assets/scripts/vendor/pdf.min.mjs");
pdfjs.GlobalWorkerOptions.workerSrc = "/assets/scripts/vendor/pdf.worker.min.mjs";
const bytes = new Uint8Array(await file.arrayBuffer());
const documentTask = pdfjs.getDocument({ data: bytes });
const pdf = await documentTask.promise;
const metadata = await pdf.getMetadata().catch(() => ({ info: {}, metadata: null }));
const info = metadata.info || {};
attachmentId = Model.id();
await putAttachment({ id: attachmentId, kind: "pdf", name: file.name, type: file.type || "application/pdf", size: file.size, blob: file, createdAt: Model.now() });
const cover = await pdfCover(await pdf.getPage(1)).catch(() => null);
if (cover) {
thumbnailId = Model.id();
await putAttachment({ id: thumbnailId, kind: "thumbnail", name: `${file.name}.jpg`, type: "image/jpeg", size: cover.size, blob: cover, createdAt: Model.now() });
}
const keywords = Array.isArray(info.Keywords) ? info.Keywords : splitList(info.Keywords || "");
const draft = {
title: String(info.Title || "").trim() || cleanPdfTitle(file.name),
creators: splitList(info.Author || ""),
type: "book", format: "pdf", status: "shelf", pageCount: pdf.numPages,
description: String(info.Subject || "").trim(), tags: keywords,
publisher: String(info.Producer || info.Creator || "").trim(),
published: String(info.CreationDate || "").replace(/^D:/, "").slice(0, 8),
notes: "", attachmentId, thumbnailId: thumbnailId || null,
};
await pdf.destroy();
hideLoading();
await openResourceDialog(null, draft);
toast("PDF metadata loaded. Review it before saving.");
} catch (error) {
hideLoading();
if (attachmentId) await deleteAttachment(attachmentId).catch(() => {});
if (thumbnailId) await deleteAttachment(thumbnailId).catch(() => {});
const reason = error && error.name === "PasswordException" ? "The PDF is password protected." : error && error.name === "QuotaExceededError" ? "Browser storage is full." : "The PDF could not be read.";
toast(`${reason} No resource was created.`, true);
console.error(error);
} finally {
els.pdfInput.value = "";
}
}
async function showStorage() {
els.storageDialog.showModal();
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
const used = estimate.usage || 0;
const quota = estimate.quota || 0;
const format = (bytes) => bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
$("#rl-storage-summary").textContent = `${format(used)} used of approximately ${format(quota)} available to this site.`;
} else $("#rl-storage-summary").textContent = "This browser does not report storage estimates.";
}
async function requestPersistence() {
if (!navigator.storage || !navigator.storage.persist) { toast("Persistent storage is not supported by this browser.", true); return; }
const granted = await navigator.storage.persist();
toast(granted ? "Persistent storage granted." : "The browser did not grant persistent storage.", !granted);
}
async function clearData() {
if (!confirm("Clear every Resource Loader vault, record, session, and retained PDF from this browser?")) return;
closeDialog(els.storageDialog);
state.db.close();
await new Promise((resolve, reject) => {
const deletion = indexedDB.deleteDatabase(DB_NAME);
deletion.onsuccess = resolve; deletion.onerror = () => reject(deletion.error); deletion.onblocked = resolve;
});
localStorage.removeItem("rl-view");
location.reload();
}
function bindEvents() {
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(); });
$("#rl-view-grid").addEventListener("click", () => { state.view = "grid"; localStorage.setItem("rl-view", state.view); renderResources(); });
$("#rl-add-resource").addEventListener("click", () => openResourceDialog());
$("#rl-add-folder").addEventListener("click", () => openNameDialog("add-folder", "Add folder"));
$("#rl-manage-vaults").addEventListener("click", () => { renderVaultManager(); els.vaultDialog.showModal(); });
$("#rl-add-vault").addEventListener("click", () => { closeDialog(els.vaultDialog); openNameDialog("add-vault", "Add vault", "", "#9b6b43"); });
els.resourceForm.addEventListener("submit", saveResource);
$("#rl-delete-resource").addEventListener("click", removeResource);
$("#rl-open-resource").addEventListener("click", openLinkedResource);
$("#rl-add-session").addEventListener("click", openSessionDialog);
$("#rl-session-form").addEventListener("submit", saveSession);
$("#rl-name-form").addEventListener("submit", submitName);
$("#rl-name-delete").addEventListener("click", deleteNamedFolder);
$("#rl-import-pdf").addEventListener("click", () => els.pdfInput.click());
els.pdfInput.addEventListener("change", () => importPdf(els.pdfInput.files[0]));
$("#rl-import-json").addEventListener("click", () => els.jsonInput.click());
els.jsonInput.addEventListener("change", () => readJsonFile(els.jsonInput.files[0]));
$("#rl-export-json").addEventListener("click", exportJson);
$("#rl-import-merge").addEventListener("click", () => applyImport("merge"));
$("#rl-import-replace").addEventListener("click", () => applyImport("replace"));
$("#rl-storage-button").addEventListener("click", showStorage);
$("#rl-request-persistence").addEventListener("click", requestPersistence);
$("#rl-clear-data").addEventListener("click", clearData);
$$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => closeDialog(button.closest("dialog"))));
$$('dialog').forEach((dialog) => dialog.addEventListener("click", (event) => { if (event.target === dialog) closeDialog(dialog); }));
els.resourceDialog.addEventListener("close", cleanupPendingImport);
window.addEventListener("beforeunload", () => {
state.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
});
}
async function initialise() {
showLoading("Opening your library…");
try {
state.db = await openDatabase();
state.library = await loadLibrary();
if (!state.library.vaults.length) state.library = Model.createSeed();
state.vaultId = state.library.vaults[0].id;
await saveLibrary();
bindEvents();
render();
} catch (error) {
console.error(error);
root.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">!</span><h3>Library storage is unavailable</h3><p>${escapeHtml(error.message || "This browser could not open IndexedDB.")}</p></div>`;
} finally {
hideLoading();
}
}
initialise();
})();

28
assets/scripts/vendor/pdf.min.mjs vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

177
assets/scripts/vendor/pdfjs-LICENSE.txt vendored Normal file
View File

@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@@ -194,7 +194,8 @@
} }
.db-qn-item--writing, .db-qn-item--writing,
.db-qn-item--notes { border-left-color: var(--db-blue); } .db-qn-item--notes,
.db-qn-item--reading { border-left-color: var(--db-blue); }
.db-qn-item--career, .db-qn-item--career,
.db-qn-item--wird { border-left-color: var(--db-green); } .db-qn-item--wird { border-left-color: var(--db-green); }
.db-qn-item--lima, .db-qn-item--lima,

View File

@@ -0,0 +1,385 @@
.rl-app,
.rl-dialog {
--rl-ink: var(--fg);
--rl-paper: color-mix(in oklab, var(--surface) 94%, #efe5d4 6%);
--rl-paper-deep: color-mix(in oklab, var(--surface-soft) 82%, #d9bea0 18%);
--rl-line: color-mix(in oklab, var(--border) 80%, #9b6b43 20%);
--rl-accent: color-mix(in oklab, var(--accent) 54%, #9b6b43 46%);
--rl-accent-strong: color-mix(in oklab, var(--accent) 36%, #744729 64%);
--rl-muted: var(--muted);
font-family: var(--font-body);
}
.rl-app [hidden],
.rl-dialog [hidden] {
display: none !important;
}
.rl-app {
min-height: 72vh;
color: var(--rl-ink);
background:
radial-gradient(circle at 92% 4%, color-mix(in oklab, var(--rl-accent) 12%, transparent) 0, transparent 28rem),
linear-gradient(180deg, color-mix(in oklab, var(--rl-paper) 86%, transparent), transparent 36rem);
}
.rl-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
padding: 2.25rem 2rem 1.7rem;
border: 1px solid var(--rl-line);
border-radius: 18px 18px 0 0;
background: var(--rl-paper);
box-shadow: 0 18px 50px color-mix(in oklab, #000 8%, transparent);
}
.rl-hero h1,
.rl-library h2,
.rl-sidebar h2,
.rl-dialog h2,
.rl-dialog h3 {
font-family: Georgia, "Times New Roman", serif;
color: var(--heading);
margin: 0;
}
.rl-hero h1 {
font-size: clamp(2.1rem, 5vw, 4.25rem);
line-height: .95;
letter-spacing: -.04em;
}
.rl-hero__copy > p:last-child {
max-width: 42rem;
margin: .8rem 0 0;
color: var(--rl-muted);
}
.rl-eyebrow {
margin: 0 0 .45rem;
color: var(--rl-accent-strong);
font-size: .72rem;
font-weight: 850;
letter-spacing: .14em;
text-transform: uppercase;
}
.rl-hero__actions {
display: flex;
align-items: flex-end;
gap: .6rem;
min-width: min(100%, 22rem);
}
.rl-vault-picker {
display: grid;
gap: .35rem;
flex: 1;
color: var(--rl-muted);
font-size: .72rem;
font-weight: 750;
letter-spacing: .08em;
text-transform: uppercase;
}
.rl-app button,
.rl-app input,
.rl-app select,
.rl-dialog button,
.rl-dialog input,
.rl-dialog select,
.rl-dialog textarea {
box-sizing: border-box;
border: 1px solid var(--rl-line);
border-radius: 9px;
color: var(--rl-ink);
background: var(--surface);
font: inherit;
}
.rl-app button,
.rl-dialog button {
min-height: 2.55rem;
padding: .55rem .85rem;
font-size: .86rem;
font-weight: 800;
cursor: pointer;
}
.rl-app button:hover,
.rl-dialog button:hover,
.rl-app button:focus-visible,
.rl-dialog button:focus-visible {
border-color: var(--rl-accent);
background: color-mix(in oklab, var(--rl-accent) 9%, var(--surface));
}
.rl-app :focus-visible,
.rl-dialog :focus-visible {
outline: 3px solid color-mix(in oklab, var(--rl-accent) 38%, transparent);
outline-offset: 2px;
}
.rl-app .rl-primary,
.rl-dialog .rl-primary {
border-color: var(--rl-accent-strong);
color: #fffaf2;
background: var(--rl-accent-strong);
}
.rl-app .rl-primary:hover,
.rl-dialog .rl-primary:hover {
color: white;
background: color-mix(in oklab, var(--rl-accent-strong) 88%, #000);
}
.rl-icon-button { white-space: nowrap; }
.rl-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(8rem, 1.4fr);
border-inline: 1px solid var(--rl-line);
border-bottom: 1px solid var(--rl-line);
background: var(--rl-paper-deep);
}
.rl-summary > div {
display: flex;
align-items: baseline;
gap: .45rem;
padding: .9rem 1.15rem;
border-right: 1px solid var(--rl-line);
}
.rl-summary > div:last-child { border-right: 0; }
.rl-summary span { font-family: Georgia, serif; font-size: 1.55rem; font-weight: 800; color: var(--heading); }
.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-command-bar {
display: flex;
flex-wrap: wrap;
gap: .55rem;
padding: 1rem;
border-inline: 1px solid var(--rl-line);
border-bottom: 1px solid var(--rl-line);
background: color-mix(in oklab, var(--surface) 88%, transparent);
}
.rl-workspace {
display: grid;
grid-template-columns: 15.5rem minmax(0, 1fr);
min-height: 36rem;
border-inline: 1px solid var(--rl-line);
border-bottom: 1px solid var(--rl-line);
border-radius: 0 0 18px 18px;
overflow: hidden;
background: var(--rl-paper);
}
.rl-sidebar {
padding: 1.3rem .9rem;
border-right: 1px solid var(--rl-line);
background: color-mix(in oklab, var(--rl-paper-deep) 72%, var(--surface));
}
.rl-sidebar__head,
.rl-library__toolbar,
.rl-sessions__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.rl-sidebar h2,
.rl-library h2 { font-size: 1.4rem; }
.rl-folder-list {
display: grid;
gap: .25rem;
margin-top: 1rem;
}
.rl-folder-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .25rem; }
.rl-folder-row > button:first-child {
display: flex;
justify-content: space-between;
align-items: center;
gap: .5rem;
width: 100%;
border-color: transparent;
text-align: left;
background: transparent;
}
.rl-folder-row > button:first-child span:last-child { color: var(--rl-muted); font-size: .75rem; }
.rl-folder-row.is-active > button:first-child { border-color: var(--rl-line); color: var(--heading); background: var(--surface); box-shadow: 0 5px 12px color-mix(in oklab, #000 6%, transparent); }
.rl-folder-menu { min-width: 2.45rem; padding-inline: .55rem !important; }
.rl-folder-rule { height: 1px; margin: .55rem .4rem; background: var(--rl-line); }
.rl-library { min-width: 0; padding: 1.35rem; }
.rl-library__toolbar { margin-bottom: 1rem; }
.rl-view-toggle { display: flex; gap: .2rem; padding: .2rem; border: 1px solid var(--rl-line); border-radius: 10px; }
.rl-view-toggle button { min-height: 2rem; padding: .3rem .65rem; border-color: transparent; background: transparent; }
.rl-view-toggle button.is-active { background: var(--rl-paper-deep); color: var(--heading); }
.rl-filters {
display: grid;
grid-template-columns: minmax(12rem, 2fr) repeat(4, minmax(7rem, 1fr));
gap: .55rem;
margin-bottom: 1rem;
}
.rl-filters input,
.rl-filters select,
.rl-vault-picker select { width: 100%; min-height: 2.65rem; padding: .55rem .7rem; }
.rl-resource-list { display: grid; gap: .6rem; }
.rl-resource-list.is-grid { grid-template-columns: repeat(auto-fill, minmax(13.5rem, 1fr)); }
.rl-resource-card {
display: grid;
grid-template-columns: 3.5rem minmax(0, 1.25fr) minmax(8rem, .8fr) minmax(8rem, .65fr) auto;
align-items: center;
gap: .9rem;
width: 100%;
padding: .65rem;
border: 1px solid var(--rl-line);
border-radius: 12px;
color: var(--rl-ink);
background: var(--surface);
box-shadow: 0 5px 14px color-mix(in oklab, #000 5%, transparent);
text-align: left;
cursor: pointer;
}
.rl-resource-card:hover { transform: translateY(-1px); border-color: var(--rl-accent); }
.rl-resource-cover {
display: grid;
place-items: center;
width: 3.5rem;
aspect-ratio: 3 / 4;
overflow: hidden;
border-radius: 7px;
color: color-mix(in oklab, var(--rl-accent-strong) 70%, white);
background: linear-gradient(135deg, var(--rl-accent-strong), color-mix(in oklab, var(--rl-accent) 40%, var(--surface)));
font-family: Georgia, serif;
font-size: 1.3rem;
font-weight: 800;
}
.rl-resource-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: 0 !important; }
.rl-resource-title { min-width: 0; }
.rl-resource-title strong { display: block; overflow: hidden; color: var(--heading); font-family: Georgia, serif; font-size: 1rem; text-overflow: ellipsis; white-space: nowrap; }
.rl-resource-title small,
.rl-resource-meta { display: block; margin-top: .25rem; color: var(--rl-muted); font-size: .76rem; }
.rl-badges { display: flex; flex-wrap: wrap; gap: .3rem; }
.rl-badge { padding: .22rem .42rem; border-radius: 999px; color: var(--rl-accent-strong); background: color-mix(in oklab, var(--rl-accent) 13%, var(--surface)); font-size: .68rem; font-weight: 800; text-transform: capitalize; }
.rl-progress { min-width: 7rem; }
.rl-progress__track { height: .42rem; overflow: hidden; border-radius: 999px; background: var(--rl-paper-deep); }
.rl-progress__fill { display: block; height: 100%; border-radius: inherit; background: var(--rl-accent-strong); }
.rl-progress small { display: block; margin-top: .3rem; color: var(--rl-muted); font-size: .7rem; text-align: right; }
.rl-card-arrow { color: var(--rl-muted); font-size: 1.2rem; }
.rl-resource-list.is-grid .rl-resource-card { grid-template-columns: 3.8rem minmax(0, 1fr); align-items: start; min-height: 9rem; }
.rl-resource-list.is-grid .rl-badges,
.rl-resource-list.is-grid .rl-progress { grid-column: 1 / -1; }
.rl-resource-list.is-grid .rl-card-arrow { display: none; }
.rl-empty { padding: 4rem 1.5rem; border: 1px dashed var(--rl-line); border-radius: 14px; text-align: center; background: color-mix(in oklab, var(--surface) 58%, transparent); }
.rl-empty__mark { display: block; margin-bottom: .8rem; color: var(--rl-accent); font-family: Georgia, serif; font-size: 3rem; }
.rl-empty h3 { margin: 0; color: var(--heading); font-family: Georgia, serif; }
.rl-empty p { max-width: 32rem; margin: .5rem auto 1rem; color: var(--rl-muted); }
.rl-dialog { width: min(60rem, calc(100% - 2rem)); max-height: calc(100vh - 2rem); padding: 0; border: 1px solid var(--rl-line); border-radius: 16px; color: var(--rl-ink); background: var(--surface); box-shadow: 0 24px 80px rgba(0,0,0,.3); }
.rl-dialog::backdrop { background: rgba(12, 10, 8, .62); backdrop-filter: blur(3px); }
.rl-dialog__shell { display: flex; flex-direction: column; max-height: calc(100vh - 2rem); }
.rl-dialog__shell--small { width: min(32rem, 100%); margin: auto; }
.rl-dialog__head,
.rl-dialog__foot { display: flex; align-items: center; gap: .6rem; padding: 1rem 1.2rem; border-bottom: 1px solid var(--rl-line); background: var(--rl-paper); }
.rl-dialog__head { justify-content: space-between; }
.rl-dialog__foot { justify-content: flex-end; border-top: 1px solid var(--rl-line); border-bottom: 0; }
.rl-dialog__body { overflow: auto; padding: 1.2rem; }
.rl-dialog__spacer { flex: 1; }
.rl-close { width: 2.55rem; padding: 0 !important; font-size: 1.45rem !important; }
.rl-danger { color: #a12d2d !important; border-color: color-mix(in oklab, #a12d2d 45%, var(--rl-line)) !important; }
.rl-resource-dialog .rl-dialog__body { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: 1.2rem; }
.rl-cover-preview { display: grid; place-items: center; align-self: start; aspect-ratio: 3/4; overflow: hidden; border: 1px solid var(--rl-line); border-radius: 10px; color: var(--rl-muted); background: var(--rl-paper-deep); font-family: Georgia, serif; }
.rl-cover-preview img { width: 100%; height: 100%; object-fit: cover; }
.rl-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .85rem; }
.rl-field { display: grid; gap: .35rem; color: var(--rl-muted); font-size: .78rem; font-weight: 750; }
.rl-field--wide { grid-column: 1 / -1; }
.rl-field input,
.rl-field select,
.rl-field textarea { width: 100%; min-height: 2.55rem; padding: .55rem .65rem; resize: vertical; color: var(--rl-ink); }
.rl-sessions { grid-column: 1 / -1; padding-top: 1rem; border-top: 1px solid var(--rl-line); }
.rl-session-row { display: grid; grid-template-columns: 7rem minmax(8rem, auto) minmax(0, 1fr) auto; gap: .7rem; align-items: center; padding: .55rem 0; border-bottom: 1px solid var(--rl-line); font-size: .82rem; }
.rl-session-row small { color: var(--rl-muted); }
.rl-session-row button { min-height: 2rem; padding: .25rem .55rem; }
.rl-vault-row { display: grid; grid-template-columns: .8rem minmax(0, 1fr) auto auto; gap: .5rem; align-items: center; padding: .65rem 0; border-bottom: 1px solid var(--rl-line); }
.rl-vault-dot { width: .7rem; height: .7rem; border-radius: 50%; background: var(--vault-colour); }
.rl-import-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
.rl-import-summary div { padding: .8rem; border-radius: 10px; background: var(--rl-paper-deep); text-align: center; }
.rl-import-summary strong { display: block; color: var(--heading); font-family: Georgia, serif; font-size: 1.6rem; }
.rl-help { color: var(--rl-muted); font-size: .82rem; }
.rl-loading { display: none; position: fixed; inset: 0; z-index: 1000; place-items: center; align-content: center; color: white; background: rgba(12, 10, 8, .68); }
.rl-loading.is-visible { display: grid; }
.rl-loading span { width: 2.3rem; height: 2.3rem; border: 3px solid rgba(255,255,255,.3); border-top-color: white; border-radius: 50%; animation: rl-spin .8s linear infinite; }
.rl-loading p { margin: .8rem 0; }
@keyframes rl-spin { to { transform: rotate(360deg); } }
.rl-toast { position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: 1100; max-width: min(25rem, calc(100vw - 2.4rem)); padding: .8rem 1rem; border: 1px solid var(--rl-line); border-radius: 10px; color: var(--rl-ink); background: var(--surface); box-shadow: 0 16px 45px rgba(0,0,0,.22); opacity: 0; transform: translateY(1rem); pointer-events: none; transition: .2s ease; }
.rl-toast.is-visible { opacity: 1; transform: translateY(0); }
.rl-toast.is-error { border-color: #a12d2d; }
@media (max-width: 900px) {
.rl-hero { align-items: stretch; flex-direction: column; }
.rl-summary { grid-template-columns: repeat(4, 1fr); }
.rl-summary__privacy { grid-column: 1 / -1; justify-content: flex-start !important; border-top: 1px solid var(--rl-line); }
.rl-workspace { grid-template-columns: 1fr; }
.rl-sidebar { border-right: 0; border-bottom: 1px solid var(--rl-line); }
.rl-folder-list { display: flex; overflow-x: auto; padding-bottom: .25rem; }
.rl-folder-row { flex: 0 0 auto; }
.rl-folder-row > button:first-child { width: auto; }
.rl-folder-rule { width: 1px; height: auto; margin: .3rem; }
.rl-filters { grid-template-columns: repeat(4, 1fr); }
.rl-search { grid-column: 1 / -1; }
.rl-resource-card { grid-template-columns: 3.5rem minmax(0, 1fr) auto; }
.rl-resource-card .rl-badges,
.rl-resource-card .rl-progress { grid-column: 2 / -1; }
}
@media (max-width: 620px) {
.rl-hero { padding: 1.4rem 1rem; border-radius: 12px 12px 0 0; }
.rl-hero__actions { align-items: stretch; flex-direction: column; min-width: 0; }
.rl-summary { grid-template-columns: repeat(2, 1fr); }
.rl-summary > div:nth-child(2) { border-right: 0; }
.rl-summary > div:nth-child(-n+2) { border-bottom: 1px solid var(--rl-line); }
.rl-command-bar button { flex: 1 1 8rem; }
.rl-library { padding: 1rem .7rem; }
.rl-library__toolbar { align-items: flex-start; }
.rl-filters { grid-template-columns: 1fr; }
.rl-search { grid-column: auto; }
.rl-resource-list.is-grid { grid-template-columns: 1fr; }
.rl-resource-card { grid-template-columns: 3.5rem minmax(0, 1fr); }
.rl-resource-card .rl-badges,
.rl-resource-card .rl-progress { grid-column: 1 / -1; }
.rl-card-arrow { display: none; }
.rl-resource-dialog .rl-dialog__body { grid-template-columns: 1fr; }
.rl-cover-preview { width: 7rem; }
.rl-form-grid { grid-template-columns: 1fr; }
.rl-field--wide { grid-column: auto; }
.rl-dialog__foot { flex-wrap: wrap; }
.rl-dialog__spacer { display: none; }
.rl-session-row { grid-template-columns: 1fr auto; }
.rl-session-row span:nth-child(3) { grid-column: 1 / -1; }
}
@media (prefers-reduced-motion: reduce) {
.rl-resource-card,
.rl-toast { transition: none; }
.rl-loading span { animation: none; }
}

View File

@@ -907,3 +907,9 @@ at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/build-logs/gite
2026-08-19T10:54:19.3330239+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python. 2026-08-19T10:54:19.3330239+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-08-19T10:54:19.4064725+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0 2026-08-19T10:54:19.4064725+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-08-19T10:54:19.6747895+01:00 [INFO] Sent authoring server test notification. 2026-08-19T10:54:19.6747895+01:00 [INFO] Sent authoring server test notification.
2026-08-19T12:04:10.3904563+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
2026-08-19T12:04:10.3976508+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
2026-08-19T12:04:11.1814264+01:00 [INFO] Sent build status notification with 1 embed(s).
2026-08-19T12:04:11.2002015+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
2026-08-19T12:04:11.2739438+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
2026-08-19T12:04:11.5577806+01:00 [INFO] Sent authoring server test notification.

0
home/#backlog.org# Normal file → Executable file
View File

0
home/.#backlog.org Normal file → Executable file
View File

View File

@@ -47,6 +47,11 @@
<span class="db-qn-label">Posts</span> <span class="db-qn-label">Posts</span>
<span class="db-qn-meta">notes</span> <span class="db-qn-meta">notes</span>
</a> </a>
<a class="db-qn-item db-qn-item--reading" href="/rl/" data-keywords="resource loader library reading books pdf articles videos vault backlog shelf">
<span class="db-qn-icon"></span>
<span class="db-qn-label">Resource Loader</span>
<span class="db-qn-meta">private library</span>
</a>
<a class="db-qn-item db-qn-item--career" href="/posts/career/career-list.html" data-keywords="career work engineering probation objectives"> <a class="db-qn-item db-qn-item--career" href="/posts/career/career-list.html" data-keywords="career work engineering probation objectives">
<span class="db-qn-icon"></span> <span class="db-qn-icon"></span>
<span class="db-qn-label">Career</span> <span class="db-qn-label">Career</span>

View File

@@ -32,6 +32,7 @@
"misc.css" "misc.css"
"media.css" "media.css"
"wird-tracker.css" "wird-tracker.css"
"pages/resource-loader.css"
"zhd.css" "zhd.css"
"play.css" "play.css"
"hidden-details.css") "hidden-details.css")
@@ -58,6 +59,8 @@
"pages/sitemap-interactive.js" "pages/sitemap-interactive.js"
"pages/wird-tracker.js" "pages/wird-tracker.js"
"pages/home-dashboard.js" "pages/home-dashboard.js"
"pages/resource-loader-model.js"
"pages/resource-loader.js"
"pages/play.js" "pages/play.js"
"features/hidden-details.js") "features/hidden-details.js")
"\n") "\n")

View File

@@ -31,7 +31,7 @@
("org-assets" ("org-assets"
:base-directory ,(site-path "assets/") :base-directory ,(site-path "assets/")
:base-extension "css\\|js\\|png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\|pdf\\|mp4\\|webm\\|mov\\|ogg\\|wav\\|woff\\|woff2\\|ttf" :base-extension "css\\|js\\|mjs\\|txt\\|png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\|pdf\\|mp4\\|webm\\|mov\\|ogg\\|wav\\|woff\\|woff2\\|ttf"
:publishing-directory ,(output-path "assets/") :publishing-directory ,(output-path "assets/")
:recursive t :recursive t
:publishing-function org-publish-attachment) :publishing-function org-publish-attachment)

View File

@@ -259,6 +259,32 @@
(let* ((info (list :input-file temp-file)) (let* ((info (list :input-file temp-file))
(output "\\documentclass{article}") (output "\\documentclass{article}")
(result (z/org-html-add-body-classes output 'latex info))) (result (z/org-html-add-body-classes output 'latex info)))
(should (string= output result)))))) (should (string= output result))))))
;; ── Resource Loader SPA ─────────────────────────────────────────────────────
(ert-deftest test/resource-loader-assets-registered ()
"The Resource Loader's loaders should be present in the shared head."
(should (string-match-p "pages/resource-loader\\.css" z/shared-head))
(should (string-match-p "pages/resource-loader-model\\.js" z/shared-head))
(should (string-match-p "pages/resource-loader\\.js" z/shared-head)))
(ert-deftest test/resource-loader-publishes-with-site-shell ()
"The /rl source should publish with the normal preamble and postamble."
(let ((html
(with-temp-buffer
(insert-file-contents (site-path "rl/index.org"))
(setq-local buffer-file-name (site-path "rl/index.org"))
(org-mode)
(org-export-as
'z-html nil nil nil
(list :html-preamble z/preamble
:html-postamble z/postamble
:html-head z/shared-head
:with-toc nil
:section-numbers nil)))))
(should (string-match-p "id=\"resource-loader\"" html))
(should (string-match-p "class=\"banner-header\"" html))
(should (string-match-p "<footer>" html))))
;;; build-site-tests.el ends here ;;; build-site-tests.el ends here

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">19-08-2026 10:58</span>@@ - [[file:career/career-list.org][Career List]] @@html:<span class="post-date">19-08-2026 12:47</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

@@ -2,6 +2,8 @@
#+OPTIONS: toc:nil num:nil #+OPTIONS: toc:nil num:nil
* Recently Updated (top 26 files) * Recently Updated (top 26 files)
- [[file:home/.#backlog.org][.#backlog]] @@html:<span class="post-date">2026-08-19 10:59</span>@@
- [[file:rl/index.org][Resource Loader]] @@html:<span class="post-date">2026-08-19 00:00</span>@@
- [[file:blogs/2026/08-august/16-08-week-review.org][[16-08-2026] - Weekly Review]] @@html:<span class="post-date">2026-08-16 12:00</span>@@ - [[file:blogs/2026/08-august/16-08-week-review.org][[16-08-2026] - Weekly Review]] @@html:<span class="post-date">2026-08-16 12:00</span>@@
- [[file:blogs/2026/08-august/09-08-week-review.org][[09-08-2026] - Weekly Review]] @@html:<span class="post-date">2026-08-09 12:00</span>@@ - [[file:blogs/2026/08-august/09-08-week-review.org][[09-08-2026] - Weekly Review]] @@html:<span class="post-date">2026-08-09 12:00</span>@@
- [[file:blogs/2026/08-august/02-08-week-review.org][[02-08-2026] - Weekly Review]] @@html:<span class="post-date">2026-08-02 12:00</span>@@ - [[file:blogs/2026/08-august/02-08-week-review.org][[02-08-2026] - Weekly Review]] @@html:<span class="post-date">2026-08-02 12:00</span>@@
@@ -26,5 +28,3 @@
- [[file:blogs/2026/05-may/working-on-eid-day-27-05-26.org][Working on Eid day]] @@html:<span class="post-date">2026-05-27 16:02</span>@@ - [[file:blogs/2026/05-may/working-on-eid-day-27-05-26.org][Working on Eid day]] @@html:<span class="post-date">2026-05-27 16:02</span>@@
- [[file:blogs/2026/05-may/24-05-week-review.org][[24-05-2026] - Weekly Review]] @@html:<span class="post-date">2026-05-24 12:00</span>@@ - [[file:blogs/2026/05-may/24-05-week-review.org][[24-05-2026] - Weekly Review]] @@html:<span class="post-date">2026-05-24 12:00</span>@@
- [[file:blogs/2026/05-may/17-05-week-review.org][[17-05-2026] - Weekly Review]] @@html:<span class="post-date">2026-05-17 12:00</span>@@ - [[file:blogs/2026/05-may/17-05-week-review.org][[17-05-2026] - Weekly Review]] @@html:<span class="post-date">2026-05-17 12:00</span>@@
- [[file:blogs/2026/05-may/poppadoms-num-13-05.org][Poppadoms num]] @@html:<span class="post-date">2026-05-13 23:29</span>@@
- [[file:blogs/2026/05-may/using-gifs-13-05.org][Sans spins for us!]] @@html:<span class="post-date">2026-05-13 13:43</span>@@

175
rl/index.org Normal file
View File

@@ -0,0 +1,175 @@
#+TITLE: Resource Loader
#+OPTIONS: num:nil title:nil toc:nil
#+NO_SIDENOTES: t
#+DATE: <2026-08-19 Wed>
#+BEGIN_EXPORT html
<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>
<h1 id="rl-title">Resource Loader</h1>
<p>Keep books, papers, articles, and videos together. Your vaults stay in this browser.</p>
</div>
<div class="rl-hero__actions">
<label class="rl-vault-picker" for="rl-vault-select">
<span>Current vault</span>
<select id="rl-vault-select" aria-label="Current vault"></select>
</label>
<button type="button" class="rl-icon-button" id="rl-manage-vaults">Manage vaults</button>
</div>
</header>
<section class="rl-summary" aria-label="Vault summary">
<div><span id="rl-stat-total">0</span><small>resources</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-completed">0</span><small>completed</small></div>
<div class="rl-summary__privacy"><span aria-hidden="true"></span><small>stored locally</small></div>
</section>
<section class="rl-command-bar" aria-label="Resource actions">
<button type="button" class="rl-primary" id="rl-add-resource">Add resource</button>
<button type="button" id="rl-import-pdf">Import PDF</button>
<button type="button" id="rl-import-json">Import JSON</button>
<button type="button" id="rl-export-json">Export JSON</button>
<button type="button" id="rl-storage-button">Storage</button>
<input class="visually-hidden" id="rl-pdf-input" type="file" accept="application/pdf,.pdf" />
<input class="visually-hidden" id="rl-json-input" type="file" accept="application/json,.json" />
</section>
<div class="rl-workspace">
<aside class="rl-sidebar" aria-label="Library folders">
<div class="rl-sidebar__head">
<div>
<p class="rl-eyebrow">Browse</p>
<h2>Folders</h2>
</div>
<button type="button" class="rl-icon-button" id="rl-add-folder" aria-label="Add folder">+ Add</button>
</div>
<nav class="rl-folder-list" id="rl-folder-list"></nav>
</aside>
<section class="rl-library" aria-labelledby="rl-library-title">
<header class="rl-library__toolbar">
<div>
<p class="rl-eyebrow" id="rl-context-label">All resources</p>
<h2 id="rl-library-title">Library</h2>
</div>
<div class="rl-view-toggle" aria-label="Display mode">
<button type="button" id="rl-view-list" class="is-active" aria-pressed="true">List</button>
<button type="button" id="rl-view-grid" aria-pressed="false">Grid</button>
</div>
</header>
<div class="rl-filters">
<label class="rl-search">
<span class="visually-hidden">Search resources</span>
<input id="rl-search" type="search" placeholder="Search title, creator, tag…" autocomplete="off" />
</label>
<label><span class="visually-hidden">Resource type</span><select id="rl-filter-type">
<option value="">All types</option><option value="book">Books</option><option value="article">Articles</option><option value="video">Videos</option>
</select></label>
<label><span class="visually-hidden">Resource status</span><select id="rl-filter-status">
<option value="">All statuses</option><option value="backlog">Backlog</option><option value="shelf">Shelf</option><option value="active">Active</option><option value="completed">Completed</option><option value="archived">Archived</option>
</select></label>
<label><span class="visually-hidden">Resource tag</span><select id="rl-filter-tag"><option value="">All tags</option></select></label>
<label><span class="visually-hidden">Sort resources</span><select id="rl-sort">
<option value="updated">Recently updated</option><option value="title">Title</option><option value="progress">Progress</option>
</select></label>
</div>
<div class="rl-resource-list" id="rl-resource-list" aria-live="polite"></div>
</section>
</div>
</main>
<dialog class="rl-dialog rl-resource-dialog" id="rl-resource-dialog">
<form method="dialog" class="rl-dialog__shell" id="rl-resource-form">
<header class="rl-dialog__head">
<div><p class="rl-eyebrow">Library record</p><h2 id="rl-resource-dialog-title">Add resource</h2></div>
<button type="button" class="rl-close" data-close-dialog aria-label="Close">×</button>
</header>
<div class="rl-dialog__body">
<input type="hidden" id="rl-resource-id" />
<input type="hidden" id="rl-attachment-id" />
<input type="hidden" id="rl-thumbnail-id" />
<div class="rl-cover-preview" id="rl-cover-preview" aria-label="Cover preview"><span>Cover</span></div>
<div class="rl-form-grid">
<label class="rl-field rl-field--wide">Title <input id="rl-field-title" required maxlength="300" /></label>
<label class="rl-field rl-field--wide">Creators <input id="rl-field-creators" placeholder="Separate names with commas" /></label>
<label class="rl-field">Type <select id="rl-field-type"><option value="book">Book</option><option value="article">Article</option><option value="video">Video</option></select></label>
<label class="rl-field">Format <select id="rl-field-format"><option value="physical">Physical</option><option value="pdf">PDF</option><option value="web">Web</option></select></label>
<label class="rl-field">Status <select id="rl-field-status"><option value="backlog">Backlog</option><option value="shelf">Shelf</option><option value="active">Active</option><option value="completed">Completed</option><option value="archived">Archived</option></select></label>
<label class="rl-field">Folder <select id="rl-field-folder"><option value="">Unfiled</option></select></label>
<label class="rl-field">Pages <input id="rl-field-pages" type="number" min="0" inputmode="numeric" /></label>
<label class="rl-field">Duration (minutes) <input id="rl-field-duration" type="number" min="0" inputmode="numeric" /></label>
<label class="rl-field rl-field--wide">URL <input id="rl-field-url" type="url" placeholder="https://…" /></label>
<label class="rl-field">Publisher / channel <input id="rl-field-publisher" /></label>
<label class="rl-field">Published <input id="rl-field-published" placeholder="Year or date" /></label>
<label class="rl-field rl-field--wide">Tags <input id="rl-field-tags" placeholder="Separate tags with commas" /></label>
<label class="rl-field rl-field--wide">Description <textarea id="rl-field-description" rows="3"></textarea></label>
<label class="rl-field rl-field--wide">Notes <textarea id="rl-field-notes" rows="4"></textarea></label>
</div>
<section class="rl-sessions" id="rl-session-section" hidden>
<div class="rl-sessions__head"><h3>Progress sessions</h3><button type="button" id="rl-add-session">Log session</button></div>
<div id="rl-session-list"></div>
</section>
</div>
<footer class="rl-dialog__foot">
<button type="button" class="rl-danger" id="rl-delete-resource" hidden>Delete</button>
<span class="rl-dialog__spacer"></span>
<button type="button" id="rl-open-resource" hidden>Open</button>
<button type="button" data-close-dialog>Cancel</button>
<button type="submit" class="rl-primary">Save resource</button>
</footer>
</form>
</dialog>
<dialog class="rl-dialog" id="rl-session-dialog">
<form method="dialog" class="rl-dialog__shell rl-dialog__shell--small" id="rl-session-form">
<header class="rl-dialog__head"><div><p class="rl-eyebrow">Reading activity</p><h2>Log a session</h2></div><button type="button" class="rl-close" data-close-dialog aria-label="Close">×</button></header>
<div class="rl-dialog__body rl-form-grid">
<label class="rl-field">Date <input id="rl-session-date" type="date" required /></label>
<label class="rl-field"><span id="rl-session-start-label">Start page</span><input id="rl-session-start" type="number" min="0" required /></label>
<label class="rl-field"><span id="rl-session-end-label">End page</span><input id="rl-session-end" type="number" min="0" required /></label>
<label class="rl-field rl-field--wide">Note <textarea id="rl-session-note" rows="3"></textarea></label>
</div>
<footer class="rl-dialog__foot"><button type="button" data-close-dialog>Cancel</button><button type="submit" class="rl-primary">Save session</button></footer>
</form>
</dialog>
<dialog class="rl-dialog" id="rl-name-dialog">
<form method="dialog" class="rl-dialog__shell rl-dialog__shell--small" id="rl-name-form">
<header class="rl-dialog__head"><div><p class="rl-eyebrow" id="rl-name-kicker">Organise</p><h2 id="rl-name-title">Add folder</h2></div><button type="button" class="rl-close" data-close-dialog aria-label="Close">×</button></header>
<div class="rl-dialog__body"><label class="rl-field">Name <input id="rl-name-input" required maxlength="80" /></label><label class="rl-field" id="rl-colour-field" hidden>Colour <input id="rl-name-colour" type="color" value="#9b6b43" /></label></div>
<footer class="rl-dialog__foot"><button type="button" class="rl-danger" id="rl-name-delete" hidden>Delete</button><span class="rl-dialog__spacer"></span><button type="button" data-close-dialog>Cancel</button><button type="submit" class="rl-primary">Save</button></footer>
</form>
</dialog>
<dialog class="rl-dialog" id="rl-vault-dialog">
<div class="rl-dialog__shell rl-dialog__shell--small">
<header class="rl-dialog__head"><div><p class="rl-eyebrow">Separate collections</p><h2>Manage vaults</h2></div><button type="button" class="rl-close" data-close-dialog aria-label="Close">×</button></header>
<div class="rl-dialog__body"><div id="rl-vault-list"></div><button type="button" class="rl-primary" id="rl-add-vault">Add vault</button></div>
</div>
</dialog>
<dialog class="rl-dialog" id="rl-import-dialog">
<div class="rl-dialog__shell rl-dialog__shell--small">
<header class="rl-dialog__head"><div><p class="rl-eyebrow">JSON preview</p><h2>Import library</h2></div><button type="button" class="rl-close" data-close-dialog aria-label="Close">×</button></header>
<div class="rl-dialog__body"><div class="rl-import-summary" id="rl-import-summary"></div><p class="rl-help">PDF files are not contained in JSON backups and must be reattached in this browser.</p></div>
<footer class="rl-dialog__foot"><button type="button" data-close-dialog>Cancel</button><button type="button" id="rl-import-replace">Replace all</button><button type="button" class="rl-primary" id="rl-import-merge">Merge</button></footer>
</div>
</dialog>
<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>
</dialog>
<div class="rl-loading" id="rl-loading" role="status"><span></span><p>Opening your library…</p></div>
<div class="rl-toast" id="rl-toast" role="status" aria-live="polite"></div>
#+END_EXPORT

View File

@@ -58,134 +58,138 @@ flowchart TD
n23 --> n26 n23 --> n26
n27{{"[[file:blogs/2025/12-december/28-12-week-review.org][[28-12-2025] - Weekly Review]]"}} n27{{"[[file:blogs/2025/12-december/28-12-week-review.org][[28-12-2025] - Weekly Review]]"}}
n23 --> n27 n23 --> n27
n28{{"home"}} n28{{"lima"}}
root --> n28 root --> n28
n29["Countdown"] n29["Lima"]
n28 --> n29 n28 --> n29
n30["Backlog"] n30{{"tags"}}
n28 --> n30 root --> n30
n31["Competency Status Board"] n31["Tag: review"]
n28 --> n31 n30 --> n31
n32["hidden-soul"] n32["Tag: life"]
n28 --> n32 n30 --> n32
n33["Wird Tracker"] n33["Tag: introduction"]
n28 --> n33 n30 --> n33
n34["Contact"] n34["Tag: learning"]
n28 --> n34 n30 --> n34
n35["Service"] n35["Tag: notes"]
n28 --> n35 n30 --> n35
n36["Notes"] n36["Tag: review"]
n28 --> n36 n30 --> n36
n37["Categories"] n37["Tag: website"]
n28 --> n37 n30 --> n37
n38{{"guide"}} n38["Tag: life"]
n28 --> n38 n30 --> n38
n39["Setup"] n39["Tag: education"]
n38 --> n39 n30 --> n39
n40["Wird Tracker — Technical Guide"] n40["Tag: update"]
n38 --> n40 n30 --> n40
n41{{"lima"}} n41["Tag: insights"]
root --> n41 n30 --> n41
n42["Lima"] n42["Tag: emacs"]
n41 --> n42 n30 --> n42
n43{{"tags"}} n43["Tag: maths"]
root --> n43 n30 --> n43
n44["Tag: review"] n44["Tag: reading"]
n43 --> n44 n30 --> n44
n45["Tag: life"] n45{{"posts"}}
n43 --> n45 root --> n45
n46["Tag: introduction"] n46["Posts Introduction"]
n43 --> n46 n45 --> n46
n47["Tag: learning"] n47["Posts List"]
n43 --> n47 n45 --> n47
n48["Tag: notes"] n48{{"career"}}
n43 --> n48 n45 --> n48
n49["Tag: review"] n49["SOLID Principles"]
n43 --> n49 n48 --> n49
n50["Tag: website"] n50["OWASP Top Ten"]
n43 --> n50 n48 --> n50
n51["Tag: update"] n51["Retrospectives"]
n43 --> n51 n48 --> n51
n52["Tag: life"] n52["Lean"]
n43 --> n52 n48 --> n52
n53["Tag: education"] n53["Invest Principles"]
n43 --> n53 n48 --> n53
n54["Tag: insights"] n54["Career Introduction"]
n43 --> n54 n48 --> n54
n55["Tag: emacs"] n55["Management of self training"]
n43 --> n55 n48 --> n55
n56["Tag: reading"] n56["Wireframe Designs"]
n43 --> n56 n48 --> n56
n57["Tag: maths"] n57["Requirements, features, user stories, tasks, walking skeletons"]
n43 --> n57 n48 --> n57
n58{{"posts"}} n58["Benefits of Normalisation"]
root --> n58 n48 --> n58
n59["Posts Introduction"] n59["Datamarts, Airflow and DAG's"]
n58 --> n59 n48 --> n59
n60["Posts List"] n60["Probation Objectives:"]
n58 --> n60 n48 --> n60
n61{{"career"}} n61["Database Permissions, Roles, and Accounts"]
n58 --> n61 n48 --> n61
n62["SOLID Principles"] n62["Monitoring and Logging"]
n61 --> n62 n48 --> n62
n63["OWASP Top Ten"] n63["Pipelines and how they work (as well as CI/CD)"]
n61 --> n63 n48 --> n63
n64["Retrospectives"] n64["Restful API"]
n61 --> n64 n48 --> n64
n65["Lean"] n65["Understands the Javascript language"]
n61 --> n65 n48 --> n65
n66["Invest Principles"] n66["High Availability, Disaster Recovery and Business Continuity"]
n61 --> n66 n48 --> n66
n67["Career Introduction"] n67["Cross-Site Scripting (XSS)"]
n61 --> n67 n48 --> n67
n68["Management of self training"] n68["Career List"]
n61 --> n68 n48 --> n68
n69["Wireframe Designs"] n69{{"play"}}
n61 --> n69 root --> n69
n70["Requirements, features, user stories, tasks, walking skeletons"] n70["Sigil Press"]
n61 --> n70 n69 --> n70
n71["Benefits of Normalisation"] n71["Ink Pond"]
n61 --> n71 n69 --> n71
n72["Datamarts, Airflow and DAG's"] n72["Bookshelf Sort"]
n61 --> n72 n69 --> n72
n73["Probation Objectives:"] n73["Constellation Desk"]
n61 --> n73 n69 --> n73
n74["Database Permissions, Roles, and Accounts"] n74["Memory Cabinet"]
n61 --> n74 n69 --> n74
n75["Monitoring and Logging"] n75["Play"]
n61 --> n75 n69 --> n75
n76["Pipelines and how they work (as well as CI/CD)"] n76["Archive Terminal"]
n61 --> n76 n69 --> n76
n77["Restful API"] n77["Study Lamp"]
n61 --> n77 n69 --> n77
n78["Understands the Javascript language"] n78["Marginalia Machine"]
n61 --> n78 n69 --> n78
n79["High Availability, Disaster Recovery and Business Continuity"] n79{{"home"}}
n61 --> n79 root --> n79
n80["Cross-Site Scripting (XSS)"] n80["Countdown"]
n61 --> n80 n79 --> n80
n81["Career List"] n81["Backlog"]
n61 --> n81 n79 --> n81
n82{{"play"}} n82["Competency Status Board"]
root --> n82 n79 --> n82
n83["Sigil Press"] n83["hidden-soul"]
n82 --> n83 n79 --> n83
n84["Ink Pond"] n84["Wird Tracker"]
n82 --> n84 n79 --> n84
n85["Bookshelf Sort"] n85["Contact"]
n82 --> n85 n79 --> n85
n86["Constellation Desk"] n86["Service"]
n82 --> n86 n79 --> n86
n87["Memory Cabinet"] n87["Notes"]
n82 --> n87 n79 --> n87
n88["Play"] n88["Categories"]
n82 --> n88 n79 --> n88
n89["Archive Terminal"] n89{{"guide"}}
n82 --> n89 n79 --> n89
n90["Study Lamp"] n90["Setup"]
n82 --> n90 n89 --> n90
n91["Marginalia Machine"] n91["Wird Tracker — Technical Guide"]
n82 --> n91 n89 --> n91
n92{{"rl"}}
root --> n92
n93["Resource Loader"]
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"
@@ -199,63 +203,64 @@ flowchart TD
click n14 "blogs/2025/08-august/hilberts.hotel.html" "Hilbert's Hotel" click n14 "blogs/2025/08-august/hilberts.hotel.html" "Hilbert's Hotel"
click n15 "blogs/2025/08-august/benefits-of-reading.html" "Benefits of Reading" click n15 "blogs/2025/08-august/benefits-of-reading.html" "Benefits of Reading"
click n16 "blogs/2025/08-august/third-time.html" "Third Time" click n16 "blogs/2025/08-august/third-time.html" "Third Time"
click n29 "home/countdown.html" "Countdown" click n29 "lima/lima-list.html" "Lima"
click n30 "home/backlog.html" "Backlog" click n31 "tags/review.sync-conflict-20260328-203248-VT6366A.html" "Tag: review"
click n31 "home/status.html" "Competency Status Board" click n32 "tags/life.sync-conflict-20260417-233423-VT6366A.html" "Tag: life"
click n32 "home/hidden-soul.html" "hidden-soul" click n33 "tags/introduction.html" "Tag: introduction"
click n33 "home/wird-tracker.html" "Wird Tracker" click n34 "tags/learning.html" "Tag: learning"
click n34 "home/contact.html" "Contact" click n35 "tags/notes.html" "Tag: notes"
click n35 "home/services.html" "Service" click n36 "tags/review.html" "Tag: review"
click n36 "home/notes.html" "Notes" click n37 "tags/website.html" "Tag: website"
click n37 "home/categories.html" "Categories" click n38 "tags/life.html" "Tag: life"
click n39 "home/guide/setup.html" "Setup" click n39 "tags/education.html" "Tag: education"
click n40 "home/guide/wird-tracker-guide.html" "Wird Tracker — Technical Guide" click n40 "tags/update.html" "Tag: update"
click n42 "lima/lima-list.html" "Lima" click n41 "tags/insights.html" "Tag: insights"
click n44 "tags/review.sync-conflict-20260328-203248-VT6366A.html" "Tag: review" click n42 "tags/emacs.html" "Tag: emacs"
click n45 "tags/life.sync-conflict-20260417-233423-VT6366A.html" "Tag: life" click n43 "tags/maths.html" "Tag: maths"
click n46 "tags/introduction.html" "Tag: introduction" click n44 "tags/reading.html" "Tag: reading"
click n47 "tags/learning.html" "Tag: learning" click n46 "posts/posts-intro.html" "Posts Introduction"
click n48 "tags/notes.html" "Tag: notes" click n47 "posts/posts-list.html" "Posts List"
click n49 "tags/review.html" "Tag: review" click n49 "posts/career/solid-principles.html" "SOLID Principles"
click n50 "tags/website.html" "Tag: website" click n50 "posts/career/owasp.html" "OWASP Top Ten"
click n51 "tags/update.html" "Tag: update" click n51 "posts/career/retrospectives.html" "Retrospectives"
click n52 "tags/life.html" "Tag: life" click n52 "posts/career/lean.html" "Lean"
click n53 "tags/education.html" "Tag: education" click n53 "posts/career/invest-principles.html" "Invest Principles"
click n54 "tags/insights.html" "Tag: insights" click n54 "posts/career/career-intro.html" "Career Introduction"
click n55 "tags/emacs.html" "Tag: emacs" click n55 "posts/career/management-of-self.html" "Management of self training"
click n56 "tags/reading.html" "Tag: reading" click n56 "posts/career/wireframe-designs.html" "Wireframe Designs"
click n57 "tags/maths.html" "Tag: maths" click n57 "posts/career/requirements-features.html" "Requirements, features, user stories, tasks, walking skeletons"
click n59 "posts/posts-intro.html" "Posts Introduction" click n58 "posts/career/normalisation.html" "Benefits of Normalisation"
click n60 "posts/posts-list.html" "Posts List" click n59 "posts/career/airflow.html" "Datamarts, Airflow and DAG's"
click n62 "posts/career/solid-principles.html" "SOLID Principles" click n60 "posts/career/probation-objectives.html" "Probation Objectives:"
click n63 "posts/career/owasp.html" "OWASP Top Ten" click n61 "posts/career/database-permissions.html" "Database Permissions, Roles, and Accounts"
click n64 "posts/career/retrospectives.html" "Retrospectives" click n62 "posts/career/monitoring-and-logging.html" "Monitoring and Logging"
click n65 "posts/career/lean.html" "Lean" click n63 "posts/career/pipelines.html" "Pipelines and how they work (as well as CI/CD)"
click n66 "posts/career/invest-principles.html" "Invest Principles" click n64 "posts/career/restful-api.html" "Restful API"
click n67 "posts/career/career-intro.html" "Career Introduction" click n65 "posts/career/javascript.html" "Understands the Javascript language"
click n68 "posts/career/management-of-self.html" "Management of self training" click n66 "posts/career/ha-dr.html" "High Availability, Disaster Recovery and Business Continuity"
click n69 "posts/career/wireframe-designs.html" "Wireframe Designs" click n67 "posts/career/cross-site-scripting-xss.html" "Cross-Site Scripting (XSS)"
click n70 "posts/career/requirements-features.html" "Requirements, features, user stories, tasks, walking skeletons" click n68 "posts/career/career-list.html" "Career List"
click n71 "posts/career/normalisation.html" "Benefits of Normalisation" click n70 "play/sigil.html" "Sigil Press"
click n72 "posts/career/airflow.html" "Datamarts, Airflow and DAG's" click n71 "play/ink.html" "Ink Pond"
click n73 "posts/career/probation-objectives.html" "Probation Objectives:" click n72 "play/bookshelf.html" "Bookshelf Sort"
click n74 "posts/career/database-permissions.html" "Database Permissions, Roles, and Accounts" click n73 "play/constellation.html" "Constellation Desk"
click n75 "posts/career/monitoring-and-logging.html" "Monitoring and Logging" click n74 "play/memory.html" "Memory Cabinet"
click n76 "posts/career/pipelines.html" "Pipelines and how they work (as well as CI/CD)" click n75 "play/play.html" "Play"
click n77 "posts/career/restful-api.html" "Restful API" click n76 "play/terminal.html" "Archive Terminal"
click n78 "posts/career/javascript.html" "Understands the Javascript language" click n77 "play/study.html" "Study Lamp"
click n79 "posts/career/ha-dr.html" "High Availability, Disaster Recovery and Business Continuity" click n78 "play/poem.html" "Marginalia Machine"
click n80 "posts/career/cross-site-scripting-xss.html" "Cross-Site Scripting (XSS)" click n80 "home/countdown.html" "Countdown"
click n81 "posts/career/career-list.html" "Career List" click n81 "home/backlog.html" "Backlog"
click n83 "play/sigil.html" "Sigil Press" click n82 "home/status.html" "Competency Status Board"
click n84 "play/ink.html" "Ink Pond" click n83 "home/hidden-soul.html" "hidden-soul"
click n85 "play/bookshelf.html" "Bookshelf Sort" click n84 "home/wird-tracker.html" "Wird Tracker"
click n86 "play/constellation.html" "Constellation Desk" click n85 "home/contact.html" "Contact"
click n87 "play/memory.html" "Memory Cabinet" click n86 "home/services.html" "Service"
click n88 "play/play.html" "Play" click n87 "home/notes.html" "Notes"
click n89 "play/terminal.html" "Archive Terminal" click n88 "home/categories.html" "Categories"
click n90 "play/study.html" "Study Lamp" click n90 "home/guide/setup.html" "Setup"
click n91 "play/poem.html" "Marginalia Machine" click n91 "home/guide/wird-tracker-guide.html" "Wird Tracker — Technical Guide"
click n93 "rl/index.html" "Resource Loader"
#+end_src #+end_src
* Pages * Pages
@@ -286,19 +291,6 @@ flowchart TD
- [[file:blogs/2025/12-december/14-12-week-review.org][[14-12-2025] - Weekly Review]] - [[file:blogs/2025/12-december/14-12-week-review.org][[14-12-2025] - Weekly Review]]
- [[file:blogs/2025/12-december/21-12-week-review.org][[21-12-2025] - Weekly Review]] - [[file:blogs/2025/12-december/21-12-week-review.org][[21-12-2025] - Weekly Review]]
- [[file:blogs/2025/12-december/28-12-week-review.org][[28-12-2025] - Weekly Review]] - [[file:blogs/2025/12-december/28-12-week-review.org][[28-12-2025] - Weekly Review]]
- home
- [[file:home/countdown.org][Countdown]]
- [[file:home/backlog.org][Backlog]]
- [[file:home/status.org][Competency Status Board]]
- [[file:home/hidden-soul.org][hidden-soul]]
- [[file:home/wird-tracker.org][Wird Tracker]]
- [[file:home/contact.org][Contact]]
- [[file:home/services.org][Service]]
- [[file:home/notes.org][Notes]]
- [[file:home/categories.org][Categories]]
- guide
- [[file:home/guide/setup.org][Setup]]
- [[file:home/guide/wird-tracker-guide.org][Wird Tracker — Technical Guide]]
- lima - lima
- [[file:lima/lima-list.org][Lima]] - [[file:lima/lima-list.org][Lima]]
- tags - tags
@@ -309,13 +301,13 @@ flowchart TD
- [[file:tags/notes.org][Tag: notes]] - [[file:tags/notes.org][Tag: notes]]
- [[file:tags/review.org][Tag: review]] - [[file:tags/review.org][Tag: review]]
- [[file:tags/website.org][Tag: website]] - [[file:tags/website.org][Tag: website]]
- [[file:tags/update.org][Tag: update]]
- [[file:tags/life.org][Tag: life]] - [[file:tags/life.org][Tag: life]]
- [[file:tags/education.org][Tag: education]] - [[file:tags/education.org][Tag: education]]
- [[file:tags/update.org][Tag: update]]
- [[file:tags/insights.org][Tag: insights]] - [[file:tags/insights.org][Tag: insights]]
- [[file:tags/emacs.org][Tag: emacs]] - [[file:tags/emacs.org][Tag: emacs]]
- [[file:tags/reading.org][Tag: reading]]
- [[file:tags/maths.org][Tag: maths]] - [[file:tags/maths.org][Tag: maths]]
- [[file:tags/reading.org][Tag: reading]]
- posts - posts
- [[file:posts/posts-intro.org][Posts Introduction]] - [[file:posts/posts-intro.org][Posts Introduction]]
- [[file:posts/posts-list.org][Posts List]] - [[file:posts/posts-list.org][Posts List]]
@@ -350,3 +342,18 @@ 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]]
- home
- [[file:home/countdown.org][Countdown]]
- [[file:home/backlog.org][Backlog]]
- [[file:home/status.org][Competency Status Board]]
- [[file:home/hidden-soul.org][hidden-soul]]
- [[file:home/wird-tracker.org][Wird Tracker]]
- [[file:home/contact.org][Contact]]
- [[file:home/services.org][Service]]
- [[file:home/notes.org][Notes]]
- [[file:home/categories.org][Categories]]
- guide
- [[file:home/guide/setup.org][Setup]]
- [[file:home/guide/wird-tracker-guide.org][Wird Tracker — Technical Guide]]
- rl
- [[file:rl/index.org][Resource Loader]]

View File

@@ -0,0 +1,71 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const Model = require("../assets/scripts/pages/resource-loader-model.js");
function resource(overrides = {}) {
return {
id: "resource-1", title: "A useful book", creators: ["Reader One"],
type: "book", format: "physical", status: "active", folderId: null,
tags: [], sessions: [], createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z", ...overrides,
};
}
function library(resources = []) {
return {
schemaVersion: 1,
vaults: [{
id: "vault-1", name: "Test vault", colour: "#123456",
folders: [{ id: "folder-1", name: "Research" }], resources,
createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
}],
};
}
test("seed contains the two initial empty vaults", () => {
const seed = Model.createSeed();
assert.equal(Model.validateLibrary(seed), true);
assert.deepEqual(seed.vaults.map((vault) => vault.name), ["Work", "Personal Study"]);
assert.ok(seed.vaults.every((vault) => vault.resources.length === 0));
});
test("schema validation rejects unsupported and dangling records", () => {
assert.throws(() => Model.validateLibrary({ schemaVersion: 2, vaults: [] }), /schemaVersion/);
assert.throws(() => Model.validateLibrary(library([resource({ folderId: "missing" })])), /missing folder/);
});
test("merge replaces matching ids and keeps unrelated records", () => {
const current = library([resource(), resource({ id: "resource-2", title: "Keep me" })]);
const incoming = library([resource({ title: "Imported title", status: "completed" })]);
const merged = Model.mergeLibraries(current, incoming);
assert.equal(merged.vaults[0].resources.length, 2);
assert.equal(merged.vaults[0].resources.find((item) => item.id === "resource-1").title, "Imported title");
assert.ok(merged.vaults[0].resources.some((item) => item.id === "resource-2"));
});
test("vault and folder operations preserve records safely", () => {
let data = Model.addVault(library(), "Second", "#abcdef");
assert.equal(data.vaults.length, 2);
data = Model.renameVault(data, data.vaults[1].id, "Renamed");
assert.equal(data.vaults[1].name, "Renamed");
data = Model.deleteVault(data, data.vaults[1].id);
assert.equal(data.vaults.length, 1);
data.vaults[0].resources.push(resource({ folderId: "folder-1" }));
data = Model.deleteFolder(data, "vault-1", "folder-1");
assert.equal(data.vaults[0].folders.length, 0);
assert.equal(data.vaults[0].resources[0].folderId, null);
});
test("progress is derived for pages, minutes, and article percentages", () => {
assert.equal(Model.progressFor(resource({ pageCount: 200, sessions: [{ end: 80 }] })).percent, 40);
assert.equal(Model.progressFor(resource({ type: "video", format: "web", durationMinutes: 60, sessions: [{ end: 45 }] })).percent, 75);
assert.equal(Model.progressFor(resource({ type: "article", format: "web", sessions: [{ end: 35 }] })).percent, 35);
assert.equal(Model.progressFor(resource({ status: "completed" })).percent, 100);
});
test("serialization round-trips metadata and marks PDF reattachment", () => {
const parsed = JSON.parse(Model.serialise(library([resource({ format: "pdf", attachmentId: "blob-1" })])));
assert.equal(Model.validateLibrary(parsed), true);
assert.equal(parsed.vaults[0].resources[0].attachmentState, "reattach-required-after-import");
assert.ok(parsed.exportedAt);
});