Add Resource Loader SPA and JS test harness
All checks were successful
Build Org Website / build (push) Successful in 38s
All checks were successful
Build Org Website / build (push) Successful in 38s
This commit is contained in:
6
Makefile
6
Makefile
@@ -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
|
||||
PY := $(VENV)/bin/python
|
||||
@@ -28,6 +28,9 @@ test:
|
||||
test-elisp:
|
||||
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):
|
||||
@echo "Generating virtual environment"
|
||||
python3 -m venv $(VENV)
|
||||
@@ -84,6 +87,7 @@ help:
|
||||
@echo " make norm - Move backup files"
|
||||
@echo " make test - Run authoring server 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 - Install and enable the authoring UI service"
|
||||
@echo " make author-stop - Stop the authoring UI service"
|
||||
|
||||
@@ -32,6 +32,7 @@ flowchart LR
|
||||
| `posts/` | Longer-lived posts; `posts/career/` has its own index | Source, except generated list pages |
|
||||
| `home/` | Utility and dashboard-style pages | Source, except `categories.org` |
|
||||
| `play/` | Interactive and experimental pages | Source |
|
||||
| `rl/` | Browser-local Resource Loader SPA | Source |
|
||||
| `lima/` | Org or Markdown pages with special attachment handling | Source, except `lima-list.org` |
|
||||
| `assets/` | Browser code, styles, fonts, images, and content manifests | Mostly source; some manifests/scripts are generated externally |
|
||||
| `lisp/` | Modular Emacs Lisp publishing system | Source |
|
||||
@@ -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 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
|
||||
|
||||
The browser authoring UI is a separate sibling repository at:
|
||||
|
||||
23
assets/content/resource-loader.json
Normal file
23
assets/content/resource-loader.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
229
assets/scripts/pages/resource-loader-model.js
Normal file
229
assets/scripts/pages/resource-loader-model.js
Normal 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,
|
||||
};
|
||||
});
|
||||
731
assets/scripts/pages/resource-loader.js
Normal file
731
assets/scripts/pages/resource-loader.js
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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
28
assets/scripts/vendor/pdf.min.mjs
vendored
Normal file
File diff suppressed because one or more lines are too long
31
assets/scripts/vendor/pdf.worker.min.mjs
vendored
Normal file
31
assets/scripts/vendor/pdf.worker.min.mjs
vendored
Normal file
File diff suppressed because one or more lines are too long
177
assets/scripts/vendor/pdfjs-LICENSE.txt
vendored
Normal file
177
assets/scripts/vendor/pdfjs-LICENSE.txt
vendored
Normal 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
|
||||
@@ -194,7 +194,8 @@
|
||||
}
|
||||
|
||||
.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--wird { border-left-color: var(--db-green); }
|
||||
.db-qn-item--lima,
|
||||
|
||||
385
assets/styles/pages/resource-loader.css
Normal file
385
assets/styles/pages/resource-loader.css
Normal 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; }
|
||||
}
|
||||
@@ -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.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-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
0
home/#backlog.org#
Normal file → Executable file
0
home/.#backlog.org
Normal file → Executable file
0
home/.#backlog.org
Normal file → Executable file
@@ -47,6 +47,11 @@
|
||||
<span class="db-qn-label">Posts</span>
|
||||
<span class="db-qn-meta">notes</span>
|
||||
</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">
|
||||
<span class="db-qn-icon">⌘</span>
|
||||
<span class="db-qn-label">Career</span>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"misc.css"
|
||||
"media.css"
|
||||
"wird-tracker.css"
|
||||
"pages/resource-loader.css"
|
||||
"zhd.css"
|
||||
"play.css"
|
||||
"hidden-details.css")
|
||||
@@ -58,6 +59,8 @@
|
||||
"pages/sitemap-interactive.js"
|
||||
"pages/wird-tracker.js"
|
||||
"pages/home-dashboard.js"
|
||||
"pages/resource-loader-model.js"
|
||||
"pages/resource-loader.js"
|
||||
"pages/play.js"
|
||||
"features/hidden-details.js")
|
||||
"\n")
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
("org-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/")
|
||||
:recursive t
|
||||
:publishing-function org-publish-attachment)
|
||||
|
||||
@@ -261,4 +261,30 @@
|
||||
(result (z/org-html-add-body-classes output 'latex info)))
|
||||
(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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
||||
|
||||
* Posts:
|
||||
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">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/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>@@
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
#+OPTIONS: toc:nil num:nil
|
||||
|
||||
* 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/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>@@
|
||||
@@ -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/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/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
175
rl/index.org
Normal 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
|
||||
403
sitemap.org
403
sitemap.org
@@ -58,134 +58,138 @@ flowchart TD
|
||||
n23 --> n26
|
||||
n27{{"[[file:blogs/2025/12-december/28-12-week-review.org][[28-12-2025] - Weekly Review]]"}}
|
||||
n23 --> n27
|
||||
n28{{"home"}}
|
||||
n28{{"lima"}}
|
||||
root --> n28
|
||||
n29["Countdown"]
|
||||
n29["Lima"]
|
||||
n28 --> n29
|
||||
n30["Backlog"]
|
||||
n28 --> n30
|
||||
n31["Competency Status Board"]
|
||||
n28 --> n31
|
||||
n32["hidden-soul"]
|
||||
n28 --> n32
|
||||
n33["Wird Tracker"]
|
||||
n28 --> n33
|
||||
n34["Contact"]
|
||||
n28 --> n34
|
||||
n35["Service"]
|
||||
n28 --> n35
|
||||
n36["Notes"]
|
||||
n28 --> n36
|
||||
n37["Categories"]
|
||||
n28 --> n37
|
||||
n38{{"guide"}}
|
||||
n28 --> n38
|
||||
n39["Setup"]
|
||||
n38 --> n39
|
||||
n40["Wird Tracker — Technical Guide"]
|
||||
n38 --> n40
|
||||
n41{{"lima"}}
|
||||
root --> n41
|
||||
n42["Lima"]
|
||||
n41 --> n42
|
||||
n43{{"tags"}}
|
||||
root --> n43
|
||||
n44["Tag: review"]
|
||||
n43 --> n44
|
||||
n45["Tag: life"]
|
||||
n43 --> n45
|
||||
n46["Tag: introduction"]
|
||||
n43 --> n46
|
||||
n47["Tag: learning"]
|
||||
n43 --> n47
|
||||
n48["Tag: notes"]
|
||||
n43 --> n48
|
||||
n49["Tag: review"]
|
||||
n43 --> n49
|
||||
n50["Tag: website"]
|
||||
n43 --> n50
|
||||
n51["Tag: update"]
|
||||
n43 --> n51
|
||||
n52["Tag: life"]
|
||||
n43 --> n52
|
||||
n53["Tag: education"]
|
||||
n43 --> n53
|
||||
n54["Tag: insights"]
|
||||
n43 --> n54
|
||||
n55["Tag: emacs"]
|
||||
n43 --> n55
|
||||
n56["Tag: reading"]
|
||||
n43 --> n56
|
||||
n57["Tag: maths"]
|
||||
n43 --> n57
|
||||
n58{{"posts"}}
|
||||
root --> n58
|
||||
n59["Posts Introduction"]
|
||||
n58 --> n59
|
||||
n60["Posts List"]
|
||||
n58 --> n60
|
||||
n61{{"career"}}
|
||||
n58 --> n61
|
||||
n62["SOLID Principles"]
|
||||
n61 --> n62
|
||||
n63["OWASP Top Ten"]
|
||||
n61 --> n63
|
||||
n64["Retrospectives"]
|
||||
n61 --> n64
|
||||
n65["Lean"]
|
||||
n61 --> n65
|
||||
n66["Invest Principles"]
|
||||
n61 --> n66
|
||||
n67["Career Introduction"]
|
||||
n61 --> n67
|
||||
n68["Management of self training"]
|
||||
n61 --> n68
|
||||
n69["Wireframe Designs"]
|
||||
n61 --> n69
|
||||
n70["Requirements, features, user stories, tasks, walking skeletons"]
|
||||
n61 --> n70
|
||||
n71["Benefits of Normalisation"]
|
||||
n61 --> n71
|
||||
n72["Datamarts, Airflow and DAG's"]
|
||||
n61 --> n72
|
||||
n73["Probation Objectives:"]
|
||||
n61 --> n73
|
||||
n74["Database Permissions, Roles, and Accounts"]
|
||||
n61 --> n74
|
||||
n75["Monitoring and Logging"]
|
||||
n61 --> n75
|
||||
n76["Pipelines and how they work (as well as CI/CD)"]
|
||||
n61 --> n76
|
||||
n77["Restful API"]
|
||||
n61 --> n77
|
||||
n78["Understands the Javascript language"]
|
||||
n61 --> n78
|
||||
n79["High Availability, Disaster Recovery and Business Continuity"]
|
||||
n61 --> n79
|
||||
n80["Cross-Site Scripting (XSS)"]
|
||||
n61 --> n80
|
||||
n81["Career List"]
|
||||
n61 --> n81
|
||||
n82{{"play"}}
|
||||
root --> n82
|
||||
n83["Sigil Press"]
|
||||
n82 --> n83
|
||||
n84["Ink Pond"]
|
||||
n82 --> n84
|
||||
n85["Bookshelf Sort"]
|
||||
n82 --> n85
|
||||
n86["Constellation Desk"]
|
||||
n82 --> n86
|
||||
n87["Memory Cabinet"]
|
||||
n82 --> n87
|
||||
n88["Play"]
|
||||
n82 --> n88
|
||||
n89["Archive Terminal"]
|
||||
n82 --> n89
|
||||
n90["Study Lamp"]
|
||||
n82 --> n90
|
||||
n91["Marginalia Machine"]
|
||||
n82 --> n91
|
||||
n30{{"tags"}}
|
||||
root --> n30
|
||||
n31["Tag: review"]
|
||||
n30 --> n31
|
||||
n32["Tag: life"]
|
||||
n30 --> n32
|
||||
n33["Tag: introduction"]
|
||||
n30 --> n33
|
||||
n34["Tag: learning"]
|
||||
n30 --> n34
|
||||
n35["Tag: notes"]
|
||||
n30 --> n35
|
||||
n36["Tag: review"]
|
||||
n30 --> n36
|
||||
n37["Tag: website"]
|
||||
n30 --> n37
|
||||
n38["Tag: life"]
|
||||
n30 --> n38
|
||||
n39["Tag: education"]
|
||||
n30 --> n39
|
||||
n40["Tag: update"]
|
||||
n30 --> n40
|
||||
n41["Tag: insights"]
|
||||
n30 --> n41
|
||||
n42["Tag: emacs"]
|
||||
n30 --> n42
|
||||
n43["Tag: maths"]
|
||||
n30 --> n43
|
||||
n44["Tag: reading"]
|
||||
n30 --> n44
|
||||
n45{{"posts"}}
|
||||
root --> n45
|
||||
n46["Posts Introduction"]
|
||||
n45 --> n46
|
||||
n47["Posts List"]
|
||||
n45 --> n47
|
||||
n48{{"career"}}
|
||||
n45 --> n48
|
||||
n49["SOLID Principles"]
|
||||
n48 --> n49
|
||||
n50["OWASP Top Ten"]
|
||||
n48 --> n50
|
||||
n51["Retrospectives"]
|
||||
n48 --> n51
|
||||
n52["Lean"]
|
||||
n48 --> n52
|
||||
n53["Invest Principles"]
|
||||
n48 --> n53
|
||||
n54["Career Introduction"]
|
||||
n48 --> n54
|
||||
n55["Management of self training"]
|
||||
n48 --> n55
|
||||
n56["Wireframe Designs"]
|
||||
n48 --> n56
|
||||
n57["Requirements, features, user stories, tasks, walking skeletons"]
|
||||
n48 --> n57
|
||||
n58["Benefits of Normalisation"]
|
||||
n48 --> n58
|
||||
n59["Datamarts, Airflow and DAG's"]
|
||||
n48 --> n59
|
||||
n60["Probation Objectives:"]
|
||||
n48 --> n60
|
||||
n61["Database Permissions, Roles, and Accounts"]
|
||||
n48 --> n61
|
||||
n62["Monitoring and Logging"]
|
||||
n48 --> n62
|
||||
n63["Pipelines and how they work (as well as CI/CD)"]
|
||||
n48 --> n63
|
||||
n64["Restful API"]
|
||||
n48 --> n64
|
||||
n65["Understands the Javascript language"]
|
||||
n48 --> n65
|
||||
n66["High Availability, Disaster Recovery and Business Continuity"]
|
||||
n48 --> n66
|
||||
n67["Cross-Site Scripting (XSS)"]
|
||||
n48 --> n67
|
||||
n68["Career List"]
|
||||
n48 --> n68
|
||||
n69{{"play"}}
|
||||
root --> n69
|
||||
n70["Sigil Press"]
|
||||
n69 --> n70
|
||||
n71["Ink Pond"]
|
||||
n69 --> n71
|
||||
n72["Bookshelf Sort"]
|
||||
n69 --> n72
|
||||
n73["Constellation Desk"]
|
||||
n69 --> n73
|
||||
n74["Memory Cabinet"]
|
||||
n69 --> n74
|
||||
n75["Play"]
|
||||
n69 --> n75
|
||||
n76["Archive Terminal"]
|
||||
n69 --> n76
|
||||
n77["Study Lamp"]
|
||||
n69 --> n77
|
||||
n78["Marginalia Machine"]
|
||||
n69 --> n78
|
||||
n79{{"home"}}
|
||||
root --> n79
|
||||
n80["Countdown"]
|
||||
n79 --> n80
|
||||
n81["Backlog"]
|
||||
n79 --> n81
|
||||
n82["Competency Status Board"]
|
||||
n79 --> n82
|
||||
n83["hidden-soul"]
|
||||
n79 --> n83
|
||||
n84["Wird Tracker"]
|
||||
n79 --> n84
|
||||
n85["Contact"]
|
||||
n79 --> n85
|
||||
n86["Service"]
|
||||
n79 --> n86
|
||||
n87["Notes"]
|
||||
n79 --> n87
|
||||
n88["Categories"]
|
||||
n79 --> n88
|
||||
n89{{"guide"}}
|
||||
n79 --> n89
|
||||
n90["Setup"]
|
||||
n89 --> n90
|
||||
n91["Wird Tracker — Technical Guide"]
|
||||
n89 --> n91
|
||||
n92{{"rl"}}
|
||||
root --> n92
|
||||
n93["Resource Loader"]
|
||||
n92 --> n93
|
||||
click n1 "index.html" "Home"
|
||||
click n2 "recently-updated.html" "Recently Updated"
|
||||
click n4 "blogs/blogs-intro.html" "Blogs Introduction"
|
||||
@@ -199,63 +203,64 @@ flowchart TD
|
||||
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 n16 "blogs/2025/08-august/third-time.html" "Third Time"
|
||||
click n29 "home/countdown.html" "Countdown"
|
||||
click n30 "home/backlog.html" "Backlog"
|
||||
click n31 "home/status.html" "Competency Status Board"
|
||||
click n32 "home/hidden-soul.html" "hidden-soul"
|
||||
click n33 "home/wird-tracker.html" "Wird Tracker"
|
||||
click n34 "home/contact.html" "Contact"
|
||||
click n35 "home/services.html" "Service"
|
||||
click n36 "home/notes.html" "Notes"
|
||||
click n37 "home/categories.html" "Categories"
|
||||
click n39 "home/guide/setup.html" "Setup"
|
||||
click n40 "home/guide/wird-tracker-guide.html" "Wird Tracker — Technical Guide"
|
||||
click n42 "lima/lima-list.html" "Lima"
|
||||
click n44 "tags/review.sync-conflict-20260328-203248-VT6366A.html" "Tag: review"
|
||||
click n45 "tags/life.sync-conflict-20260417-233423-VT6366A.html" "Tag: life"
|
||||
click n46 "tags/introduction.html" "Tag: introduction"
|
||||
click n47 "tags/learning.html" "Tag: learning"
|
||||
click n48 "tags/notes.html" "Tag: notes"
|
||||
click n49 "tags/review.html" "Tag: review"
|
||||
click n50 "tags/website.html" "Tag: website"
|
||||
click n51 "tags/update.html" "Tag: update"
|
||||
click n52 "tags/life.html" "Tag: life"
|
||||
click n53 "tags/education.html" "Tag: education"
|
||||
click n54 "tags/insights.html" "Tag: insights"
|
||||
click n55 "tags/emacs.html" "Tag: emacs"
|
||||
click n56 "tags/reading.html" "Tag: reading"
|
||||
click n57 "tags/maths.html" "Tag: maths"
|
||||
click n59 "posts/posts-intro.html" "Posts Introduction"
|
||||
click n60 "posts/posts-list.html" "Posts List"
|
||||
click n62 "posts/career/solid-principles.html" "SOLID Principles"
|
||||
click n63 "posts/career/owasp.html" "OWASP Top Ten"
|
||||
click n64 "posts/career/retrospectives.html" "Retrospectives"
|
||||
click n65 "posts/career/lean.html" "Lean"
|
||||
click n66 "posts/career/invest-principles.html" "Invest Principles"
|
||||
click n67 "posts/career/career-intro.html" "Career Introduction"
|
||||
click n68 "posts/career/management-of-self.html" "Management of self training"
|
||||
click n69 "posts/career/wireframe-designs.html" "Wireframe Designs"
|
||||
click n70 "posts/career/requirements-features.html" "Requirements, features, user stories, tasks, walking skeletons"
|
||||
click n71 "posts/career/normalisation.html" "Benefits of Normalisation"
|
||||
click n72 "posts/career/airflow.html" "Datamarts, Airflow and DAG's"
|
||||
click n73 "posts/career/probation-objectives.html" "Probation Objectives:"
|
||||
click n74 "posts/career/database-permissions.html" "Database Permissions, Roles, and Accounts"
|
||||
click n75 "posts/career/monitoring-and-logging.html" "Monitoring and Logging"
|
||||
click n76 "posts/career/pipelines.html" "Pipelines and how they work (as well as CI/CD)"
|
||||
click n77 "posts/career/restful-api.html" "Restful API"
|
||||
click n78 "posts/career/javascript.html" "Understands the Javascript language"
|
||||
click n79 "posts/career/ha-dr.html" "High Availability, Disaster Recovery and Business Continuity"
|
||||
click n80 "posts/career/cross-site-scripting-xss.html" "Cross-Site Scripting (XSS)"
|
||||
click n81 "posts/career/career-list.html" "Career List"
|
||||
click n83 "play/sigil.html" "Sigil Press"
|
||||
click n84 "play/ink.html" "Ink Pond"
|
||||
click n85 "play/bookshelf.html" "Bookshelf Sort"
|
||||
click n86 "play/constellation.html" "Constellation Desk"
|
||||
click n87 "play/memory.html" "Memory Cabinet"
|
||||
click n88 "play/play.html" "Play"
|
||||
click n89 "play/terminal.html" "Archive Terminal"
|
||||
click n90 "play/study.html" "Study Lamp"
|
||||
click n91 "play/poem.html" "Marginalia Machine"
|
||||
click n29 "lima/lima-list.html" "Lima"
|
||||
click n31 "tags/review.sync-conflict-20260328-203248-VT6366A.html" "Tag: review"
|
||||
click n32 "tags/life.sync-conflict-20260417-233423-VT6366A.html" "Tag: life"
|
||||
click n33 "tags/introduction.html" "Tag: introduction"
|
||||
click n34 "tags/learning.html" "Tag: learning"
|
||||
click n35 "tags/notes.html" "Tag: notes"
|
||||
click n36 "tags/review.html" "Tag: review"
|
||||
click n37 "tags/website.html" "Tag: website"
|
||||
click n38 "tags/life.html" "Tag: life"
|
||||
click n39 "tags/education.html" "Tag: education"
|
||||
click n40 "tags/update.html" "Tag: update"
|
||||
click n41 "tags/insights.html" "Tag: insights"
|
||||
click n42 "tags/emacs.html" "Tag: emacs"
|
||||
click n43 "tags/maths.html" "Tag: maths"
|
||||
click n44 "tags/reading.html" "Tag: reading"
|
||||
click n46 "posts/posts-intro.html" "Posts Introduction"
|
||||
click n47 "posts/posts-list.html" "Posts List"
|
||||
click n49 "posts/career/solid-principles.html" "SOLID Principles"
|
||||
click n50 "posts/career/owasp.html" "OWASP Top Ten"
|
||||
click n51 "posts/career/retrospectives.html" "Retrospectives"
|
||||
click n52 "posts/career/lean.html" "Lean"
|
||||
click n53 "posts/career/invest-principles.html" "Invest Principles"
|
||||
click n54 "posts/career/career-intro.html" "Career Introduction"
|
||||
click n55 "posts/career/management-of-self.html" "Management of self training"
|
||||
click n56 "posts/career/wireframe-designs.html" "Wireframe Designs"
|
||||
click n57 "posts/career/requirements-features.html" "Requirements, features, user stories, tasks, walking skeletons"
|
||||
click n58 "posts/career/normalisation.html" "Benefits of Normalisation"
|
||||
click n59 "posts/career/airflow.html" "Datamarts, Airflow and DAG's"
|
||||
click n60 "posts/career/probation-objectives.html" "Probation Objectives:"
|
||||
click n61 "posts/career/database-permissions.html" "Database Permissions, Roles, and Accounts"
|
||||
click n62 "posts/career/monitoring-and-logging.html" "Monitoring and Logging"
|
||||
click n63 "posts/career/pipelines.html" "Pipelines and how they work (as well as CI/CD)"
|
||||
click n64 "posts/career/restful-api.html" "Restful API"
|
||||
click n65 "posts/career/javascript.html" "Understands the Javascript language"
|
||||
click n66 "posts/career/ha-dr.html" "High Availability, Disaster Recovery and Business Continuity"
|
||||
click n67 "posts/career/cross-site-scripting-xss.html" "Cross-Site Scripting (XSS)"
|
||||
click n68 "posts/career/career-list.html" "Career List"
|
||||
click n70 "play/sigil.html" "Sigil Press"
|
||||
click n71 "play/ink.html" "Ink Pond"
|
||||
click n72 "play/bookshelf.html" "Bookshelf Sort"
|
||||
click n73 "play/constellation.html" "Constellation Desk"
|
||||
click n74 "play/memory.html" "Memory Cabinet"
|
||||
click n75 "play/play.html" "Play"
|
||||
click n76 "play/terminal.html" "Archive Terminal"
|
||||
click n77 "play/study.html" "Study Lamp"
|
||||
click n78 "play/poem.html" "Marginalia Machine"
|
||||
click n80 "home/countdown.html" "Countdown"
|
||||
click n81 "home/backlog.html" "Backlog"
|
||||
click n82 "home/status.html" "Competency Status Board"
|
||||
click n83 "home/hidden-soul.html" "hidden-soul"
|
||||
click n84 "home/wird-tracker.html" "Wird Tracker"
|
||||
click n85 "home/contact.html" "Contact"
|
||||
click n86 "home/services.html" "Service"
|
||||
click n87 "home/notes.html" "Notes"
|
||||
click n88 "home/categories.html" "Categories"
|
||||
click n90 "home/guide/setup.html" "Setup"
|
||||
click n91 "home/guide/wird-tracker-guide.html" "Wird Tracker — Technical Guide"
|
||||
click n93 "rl/index.html" "Resource Loader"
|
||||
#+end_src
|
||||
|
||||
* Pages
|
||||
@@ -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/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]]
|
||||
- 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
|
||||
- [[file:lima/lima-list.org][Lima]]
|
||||
- tags
|
||||
@@ -309,13 +301,13 @@ flowchart TD
|
||||
- [[file:tags/notes.org][Tag: notes]]
|
||||
- [[file:tags/review.org][Tag: review]]
|
||||
- [[file:tags/website.org][Tag: website]]
|
||||
- [[file:tags/update.org][Tag: update]]
|
||||
- [[file:tags/life.org][Tag: life]]
|
||||
- [[file:tags/education.org][Tag: education]]
|
||||
- [[file:tags/update.org][Tag: update]]
|
||||
- [[file:tags/insights.org][Tag: insights]]
|
||||
- [[file:tags/emacs.org][Tag: emacs]]
|
||||
- [[file:tags/reading.org][Tag: reading]]
|
||||
- [[file:tags/maths.org][Tag: maths]]
|
||||
- [[file:tags/reading.org][Tag: reading]]
|
||||
- posts
|
||||
- [[file:posts/posts-intro.org][Posts Introduction]]
|
||||
- [[file:posts/posts-list.org][Posts List]]
|
||||
@@ -350,3 +342,18 @@ flowchart TD
|
||||
- [[file:play/terminal.org][Archive Terminal]]
|
||||
- [[file:play/study.org][Study Lamp]]
|
||||
- [[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]]
|
||||
71
tests/resource-loader.test.js
Normal file
71
tests/resource-loader.test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user