Make Resource Loader server-backed and drop local metadata cache
All checks were successful
Build Org Website / build (push) Successful in 50s
All checks were successful
Build Org Website / build (push) Successful in 50s
This commit is contained in:
@@ -281,7 +281,7 @@ These APIs are not implemented here. Production routing must serve `output/` and
|
|||||||
|
|
||||||
The hidden-details feature uses browser `localStorage`/`sessionStorage` for visit and discovery state and may call Open-Meteo after explicit geolocation interaction. Its friendly content source is `assets/content/hidden-details.json`; `assets/scripts/features/hidden-details.js` is generated by the external hidden-narrative authoring workflow. Treat both files as a pair and do not hand-edit only the generated JavaScript.
|
The hidden-details feature uses browser `localStorage`/`sessionStorage` for visit and discovery state and may call Open-Meteo after explicit geolocation interaction. Its friendly content source is `assets/content/hidden-details.json`; `assets/scripts/features/hidden-details.js` is generated by the external hidden-narrative authoring workflow. Treat both files as a pair and do not hand-edit only the generated JavaScript.
|
||||||
|
|
||||||
The Resource Loader at `/rl/` loads and saves metadata through the same-origin `/api/resource-loader` backend, with ETags protecting concurrent edits. Generated cover thumbnails use `/api/resource-loader/thumbnails/:id`. IndexedDB under `zxh-resource-loader` remains an offline cache and retains imported PDF binaries only on the device where they were added. JSON exports contain metadata and reading sessions, not PDF binaries. Its page code lives in `assets/scripts/pages/resource-loader.js`; the separately testable schema/progress/sync operations live in `resource-loader-model.js` and run with `make test-js`.
|
The Resource Loader at `/rl/` uses the same-origin `/api/resource-loader` backend as its sole metadata source, with ETags protecting concurrent edits. There is no browser metadata fallback. Generated cover thumbnails use `/api/resource-loader/thumbnails/:id` and are removed from local storage after upload. IndexedDB under `zxh-resource-loader` retains only imported PDF binaries on the device where they were added. JSON exports contain metadata and reading sessions, not PDF binaries. Its page code lives in `assets/scripts/pages/resource-loader.js`; the separately testable schema/progress/sync operations live in `resource-loader-model.js` and run with `make test-js`.
|
||||||
|
|
||||||
## Authoring service
|
## Authoring service
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
if (!root || !Model) return;
|
if (!root || !Model) return;
|
||||||
|
|
||||||
const DB_NAME = "zxh-resource-loader";
|
const DB_NAME = "zxh-resource-loader";
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 2;
|
||||||
const META_STORE = "metadata";
|
|
||||||
const ATTACHMENT_STORE = "attachments";
|
const ATTACHMENT_STORE = "attachments";
|
||||||
const API_URL = "/api/resource-loader";
|
const API_URL = "/api/resource-loader";
|
||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
@@ -67,7 +66,7 @@
|
|||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
const db = request.result;
|
const db = request.result;
|
||||||
if (!db.objectStoreNames.contains(META_STORE)) db.createObjectStore(META_STORE);
|
if (db.objectStoreNames.contains("metadata")) db.deleteObjectStore("metadata");
|
||||||
if (!db.objectStoreNames.contains(ATTACHMENT_STORE)) db.createObjectStore(ATTACHMENT_STORE, { keyPath: "id" });
|
if (!db.objectStoreNames.contains(ATTACHMENT_STORE)) db.createObjectStore(ATTACHMENT_STORE, { keyPath: "id" });
|
||||||
};
|
};
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
@@ -86,11 +85,21 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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 putAttachment(record) { return request(ATTACHMENT_STORE, "readwrite", (store) => store.put(record)); }
|
||||||
function deleteAttachment(id) { return id ? request(ATTACHMENT_STORE, "readwrite", (store) => store.delete(id)) : Promise.resolve(); }
|
function deleteAttachment(id) { return id ? request(ATTACHMENT_STORE, "readwrite", (store) => store.delete(id)) : Promise.resolve(); }
|
||||||
|
function deleteStoredThumbnails() {
|
||||||
|
return request(ATTACHMENT_STORE, "readwrite", (store) => {
|
||||||
|
const cursorRequest = store.openCursor();
|
||||||
|
cursorRequest.onsuccess = () => {
|
||||||
|
const cursor = cursorRequest.result;
|
||||||
|
if (!cursor) return;
|
||||||
|
if (cursor.value && cursor.value.kind === "thumbnail") cursor.delete();
|
||||||
|
cursor.continue();
|
||||||
|
};
|
||||||
|
return cursorRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setSyncStatus(status, label) {
|
function setSyncStatus(status, label) {
|
||||||
if (!els.syncStatus) return;
|
if (!els.syncStatus) return;
|
||||||
@@ -120,8 +129,10 @@
|
|||||||
body: attachment.blob,
|
body: attachment.blob,
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`Thumbnail upload failed (${response.status}).`);
|
if (!response.ok) throw new Error(`Thumbnail upload failed (${response.status}).`);
|
||||||
|
await deleteAttachment(id);
|
||||||
state.uploadedThumbnails.add(id);
|
state.uploadedThumbnails.add(id);
|
||||||
}));
|
}));
|
||||||
|
await deleteStoredThumbnails();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pushServerLibrary(library, allowMerge) {
|
async function pushServerLibrary(library, allowMerge) {
|
||||||
@@ -138,7 +149,6 @@
|
|||||||
const remote = await fetchServerLibrary();
|
const remote = await fetchServerLibrary();
|
||||||
const merged = Model.mergeForSync(remote, library);
|
const merged = Model.mergeForSync(remote, library);
|
||||||
state.library = merged;
|
state.library = merged;
|
||||||
await setMetadata("library", merged);
|
|
||||||
if (state.eventsBound) render();
|
if (state.eventsBound) render();
|
||||||
toast("Changes from another device were merged.");
|
toast("Changes from another device were merged.");
|
||||||
return pushServerLibrary(merged, false);
|
return pushServerLibrary(merged, false);
|
||||||
@@ -150,36 +160,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveLibrary() {
|
async function saveLibrary() {
|
||||||
await setMetadata("library", Model.normaliseLibrary(state.library));
|
|
||||||
state.syncQueue = state.syncQueue.catch(() => {}).then(async () => {
|
state.syncQueue = state.syncQueue.catch(() => {}).then(async () => {
|
||||||
try {
|
try {
|
||||||
await pushServerLibrary(Model.normaliseLibrary(state.library), true);
|
await pushServerLibrary(Model.normaliseLibrary(state.library), true);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Resource Loader server sync is unavailable.", error);
|
console.error("Resource Loader could not save to the server.", error);
|
||||||
setSyncStatus("offline", "offline · saved here");
|
setSyncStatus("error", "server unavailable");
|
||||||
|
toast("The change was not saved. Reloading the shared server library.", true);
|
||||||
|
try {
|
||||||
|
const remote = await fetchServerLibrary();
|
||||||
|
if (remote) {
|
||||||
|
state.library = remote;
|
||||||
|
if (state.eventsBound) render();
|
||||||
|
}
|
||||||
|
} catch (reloadError) {
|
||||||
|
console.warn("The authoritative server library could not be reloaded.", reloadError);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return state.syncQueue;
|
return state.syncQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLibrary() {
|
async function loadLibrary() {
|
||||||
const saved = await getMetadata("library");
|
|
||||||
try {
|
|
||||||
const remote = await fetchServerLibrary();
|
const remote = await fetchServerLibrary();
|
||||||
if (remote) {
|
if (!remote) throw new Error("The server Resource Loader library has not been initialised.");
|
||||||
setSyncStatus("synced", "synced to server");
|
setSyncStatus("synced", "synced to server");
|
||||||
await setMetadata("library", remote);
|
|
||||||
return remote;
|
return remote;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.warn("Resource Loader server is unavailable; using this browser's cache.", error);
|
|
||||||
setSyncStatus("offline", "offline · using cache");
|
|
||||||
}
|
|
||||||
if (saved) return Model.normaliseLibrary(saved);
|
|
||||||
const response = await fetch("/assets/content/resource-loader.json", { cache: "no-store" });
|
|
||||||
if (response.ok) return Model.normaliseLibrary(await response.json());
|
|
||||||
return Model.createSeed();
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentVault() {
|
function currentVault() {
|
||||||
return state.library.vaults.find((vault) => vault.id === state.vaultId) || state.library.vaults[0];
|
return state.library.vaults.find((vault) => vault.id === state.vaultId) || state.library.vaults[0];
|
||||||
@@ -297,7 +306,7 @@
|
|||||||
let blob = attachment && attachment.blob;
|
let blob = attachment && attachment.blob;
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, { credentials: "same-origin" });
|
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, { cache: "no-store", credentials: "same-origin" });
|
||||||
if (response.ok) blob = await response.blob();
|
if (response.ok) blob = await response.blob();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Thumbnail ${id} is unavailable.`, error);
|
console.warn(`Thumbnail ${id} is unavailable.`, error);
|
||||||
@@ -438,7 +447,7 @@
|
|||||||
if (!confirm("Delete this session?")) return;
|
if (!confirm("Delete this session?")) return;
|
||||||
resource.sessions = resource.sessions.filter((item) => item.id !== button.dataset.deleteSession);
|
resource.sessions = resource.sessions.filter((item) => item.id !== button.dataset.deleteSession);
|
||||||
resource.updatedAt = Model.now();
|
resource.updatedAt = Model.now();
|
||||||
await saveLibrary();
|
if (!await saveLibrary()) return;
|
||||||
renderSessions(resource);
|
renderSessions(resource);
|
||||||
render();
|
render();
|
||||||
toast("Session deleted.");
|
toast("Session deleted.");
|
||||||
@@ -512,7 +521,7 @@
|
|||||||
if (existing) vault.resources[vault.resources.findIndex((item) => item.id === existing.id)] = resource;
|
if (existing) vault.resources[vault.resources.findIndex((item) => item.id === existing.id)] = resource;
|
||||||
else vault.resources.push(resource);
|
else vault.resources.push(resource);
|
||||||
vault.updatedAt = Model.now();
|
vault.updatedAt = Model.now();
|
||||||
await saveLibrary();
|
if (!await saveLibrary()) return;
|
||||||
state.pendingImport = null;
|
state.pendingImport = null;
|
||||||
closeDialog(els.resourceDialog);
|
closeDialog(els.resourceDialog);
|
||||||
render();
|
render();
|
||||||
@@ -522,10 +531,11 @@
|
|||||||
async function removeResource() {
|
async function removeResource() {
|
||||||
const resource = currentResource($("#rl-resource-id").value);
|
const resource = currentResource($("#rl-resource-id").value);
|
||||||
if (!resource || !confirm(`Delete “${resource.title}”? This also removes its retained PDF.`)) return;
|
if (!resource || !confirm(`Delete “${resource.title}”? This also removes its retained PDF.`)) return;
|
||||||
await Promise.all([deleteAttachment(resource.attachmentId), deleteAttachment(resource.thumbnailId)]);
|
const attachmentIds = [resource.attachmentId, resource.thumbnailId];
|
||||||
currentVault().resources = currentVault().resources.filter((item) => item.id !== resource.id);
|
currentVault().resources = currentVault().resources.filter((item) => item.id !== resource.id);
|
||||||
currentVault().updatedAt = Model.now();
|
currentVault().updatedAt = Model.now();
|
||||||
await saveLibrary();
|
if (!await saveLibrary()) return;
|
||||||
|
await Promise.all(attachmentIds.map(deleteAttachment));
|
||||||
closeDialog(els.resourceDialog);
|
closeDialog(els.resourceDialog);
|
||||||
render();
|
render();
|
||||||
toast("Resource deleted.");
|
toast("Resource deleted.");
|
||||||
@@ -557,7 +567,7 @@
|
|||||||
resource.updatedAt = Model.now();
|
resource.updatedAt = Model.now();
|
||||||
if (Model.progressFor(resource).percent >= 100) resource.status = "completed";
|
if (Model.progressFor(resource).percent >= 100) resource.status = "completed";
|
||||||
else if (["backlog", "shelf"].includes(resource.status)) resource.status = "active";
|
else if (["backlog", "shelf"].includes(resource.status)) resource.status = "active";
|
||||||
await saveLibrary();
|
if (!await saveLibrary()) return;
|
||||||
closeDialog(els.sessionDialog);
|
closeDialog(els.sessionDialog);
|
||||||
renderSessions(resource);
|
renderSessions(resource);
|
||||||
render();
|
render();
|
||||||
@@ -610,7 +620,7 @@
|
|||||||
const vault = state.library.vaults.find((item) => item.id === action.split(":")[1]);
|
const vault = state.library.vaults.find((item) => item.id === action.split(":")[1]);
|
||||||
vault.colour = $("#rl-name-colour").value;
|
vault.colour = $("#rl-name-colour").value;
|
||||||
}
|
}
|
||||||
await saveLibrary();
|
if (!await saveLibrary()) return;
|
||||||
closeDialog(els.nameDialog);
|
closeDialog(els.nameDialog);
|
||||||
render();
|
render();
|
||||||
renderVaultManager();
|
renderVaultManager();
|
||||||
@@ -630,7 +640,7 @@
|
|||||||
const folder = currentVault().folders.find((item) => item.id === folderId);
|
const folder = currentVault().folders.find((item) => item.id === folderId);
|
||||||
if (!folder || !confirm(`Delete “${folder.name}”? Its resources will move to Unfiled.`)) return;
|
if (!folder || !confirm(`Delete “${folder.name}”? Its resources will move to Unfiled.`)) return;
|
||||||
state.library = Model.deleteFolder(state.library, state.vaultId, folderId);
|
state.library = Model.deleteFolder(state.library, state.vaultId, folderId);
|
||||||
await saveLibrary();
|
if (!await saveLibrary()) return;
|
||||||
closeDialog(els.nameDialog);
|
closeDialog(els.nameDialog);
|
||||||
render();
|
render();
|
||||||
toast("Folder deleted; resources moved to Unfiled.");
|
toast("Folder deleted; resources moved to Unfiled.");
|
||||||
@@ -657,11 +667,12 @@
|
|||||||
$("#rl-vault-list").querySelectorAll("[data-delete-vault]").forEach((button) => button.addEventListener("click", async () => {
|
$("#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);
|
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;
|
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)]));
|
const attachmentIds = vault.resources.flatMap((resource) => [resource.attachmentId, resource.thumbnailId]);
|
||||||
state.library = Model.deleteVault(state.library, vault.id);
|
state.library = Model.deleteVault(state.library, vault.id);
|
||||||
state.vaultId = state.library.vaults[0].id;
|
state.vaultId = state.library.vaults[0].id;
|
||||||
state.folderId = "all";
|
state.folderId = "all";
|
||||||
await saveLibrary();
|
if (!await saveLibrary()) return;
|
||||||
|
await Promise.all(attachmentIds.map(deleteAttachment));
|
||||||
renderVaultManager(); render(); toast("Vault deleted.");
|
renderVaultManager(); render(); toast("Vault deleted.");
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -697,11 +708,11 @@
|
|||||||
async function applyImport(mode) {
|
async function applyImport(mode) {
|
||||||
if (!state.importData) return;
|
if (!state.importData) return;
|
||||||
if (mode === "replace" && !confirm("Replace every vault and resource? Retained PDF files will also be cleared.")) 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.library = mode === "merge" ? Model.mergeLibraries(state.library, state.importData) : state.importData;
|
||||||
state.vaultId = state.library.vaults[0] && state.library.vaults[0].id;
|
state.vaultId = state.library.vaults[0] && state.library.vaults[0].id;
|
||||||
state.folderId = "all";
|
state.folderId = "all";
|
||||||
await saveLibrary();
|
if (!await saveLibrary()) return;
|
||||||
|
if (mode === "replace") await request(ATTACHMENT_STORE, "readwrite", (store) => store.clear());
|
||||||
state.importData = null;
|
state.importData = null;
|
||||||
closeDialog(els.importDialog);
|
closeDialog(els.importDialog);
|
||||||
render();
|
render();
|
||||||
@@ -805,7 +816,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearData() {
|
async function clearData() {
|
||||||
if (!confirm("Clear this browser's offline cache and retained PDFs? Server metadata and thumbnails will remain available.")) return;
|
if (!confirm("Remove retained PDFs from this browser? Shared server metadata and thumbnails will remain available.")) return;
|
||||||
closeDialog(els.storageDialog);
|
closeDialog(els.storageDialog);
|
||||||
state.db.close();
|
state.db.close();
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
@@ -851,7 +862,6 @@
|
|||||||
state.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
|
state.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
|
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
});
|
});
|
||||||
window.addEventListener("online", () => saveLibrary());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initialise() {
|
async function initialise() {
|
||||||
@@ -859,14 +869,17 @@
|
|||||||
try {
|
try {
|
||||||
state.db = await openDatabase();
|
state.db = await openDatabase();
|
||||||
state.library = await loadLibrary();
|
state.library = await loadLibrary();
|
||||||
if (!state.library.vaults.length) state.library = Model.createSeed();
|
|
||||||
state.vaultId = state.library.vaults[0].id;
|
state.vaultId = state.library.vaults[0].id;
|
||||||
await saveLibrary();
|
|
||||||
bindEvents();
|
bindEvents();
|
||||||
render();
|
render();
|
||||||
|
uploadLocalThumbnails(state.library).catch((error) => {
|
||||||
|
console.warn("Some generated covers could not be uploaded yet.", error);
|
||||||
|
toast("The library is synced, but a generated cover could not be uploaded.", true);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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>`;
|
setSyncStatus("error", "server unavailable");
|
||||||
|
root.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">!</span><h3>Server library is unavailable</h3><p>${escapeHtml(error.message || "The shared Resource Loader library could not be loaded.")}</p></div>`;
|
||||||
} finally {
|
} finally {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,7 @@
|
|||||||
.rl-summary__privacy span { color: #4a8a5b; font-size: .8rem; }
|
.rl-summary__privacy span { color: #4a8a5b; font-size: .8rem; }
|
||||||
.rl-summary__privacy[data-state="syncing"] span { color: #a87831; }
|
.rl-summary__privacy[data-state="syncing"] span { color: #a87831; }
|
||||||
.rl-summary__privacy[data-state="offline"] span { color: #a13f35; }
|
.rl-summary__privacy[data-state="offline"] span { color: #a13f35; }
|
||||||
|
.rl-summary__privacy[data-state="error"] span { color: #a13f35; }
|
||||||
|
|
||||||
.rl-command-bar {
|
.rl-command-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -944,3 +944,9 @@ at <ScriptBlock>, /home/zaine/master-folder/org-platform/org_web/build-logs/gite
|
|||||||
2026-08-20T11:34:33.3081787+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
|
2026-08-20T11:34:33.3081787+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
|
||||||
2026-08-20T11:34:33.4006115+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
|
2026-08-20T11:34:33.4006115+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
|
||||||
2026-08-20T11:34:33.7249113+01:00 [INFO] Sent authoring server test notification.
|
2026-08-20T11:34:33.7249113+01:00 [INFO] Sent authoring server test notification.
|
||||||
|
2026-08-20T14:04:36.9758174+01:00 [INFO] Prepared current build status for zaine/org_web: severity=Info, status=success
|
||||||
|
2026-08-20T14:04:36.9831625+01:00 [INFO] Fetching recent Gitea Actions runs for zaine/org_web.
|
||||||
|
2026-08-20T14:04:37.8280029+01:00 [INFO] Sent build status notification with 1 embed(s).
|
||||||
|
2026-08-20T14:04:37.8512952+01:00 [INFO] Running authoring server tests with /home/zaine/master-folder/org-platform/authoring-service/.venv/bin/python.
|
||||||
|
2026-08-20T14:04:37.9343738+01:00 [INFO] Authoring server tests passed: ran=0, failures=0, errors=0, skipped=0, exit=0
|
||||||
|
2026-08-20T14:04:38.2713260+01:00 [INFO] Sent authoring server test notification.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
|
||||||
|
|
||||||
* Posts:
|
* Posts:
|
||||||
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">20-08-2026 11:34</span>@@
|
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">20-08-2026 14:04</span>@@
|
||||||
- [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
- [[file:career/cross-site-scripting-xss.org][Cross-Site Scripting (XSS)]] @@html:<span class="post-date">01-06-2026 10:21</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||||
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
- [[file:career/ha-dr.org][High Availability, Disaster Recovery and Business Continuity]] @@html:<span class="post-date">11-03-2026 17:18</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||||
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
- [[file:career/javascript.org][Understands the Javascript language]] @@html:<span class="post-date">11-03-2026 16:52</span>@@ @@html:<a href="/tags/learning.html"><span class="post-tag">learning</span></a>@@ @@html:<a href="/tags/notes.html"><span class="post-tag">notes</span></a>@@
|
||||||
|
|||||||
@@ -165,8 +165,8 @@
|
|||||||
<dialog class="rl-dialog" id="rl-storage-dialog">
|
<dialog class="rl-dialog" id="rl-storage-dialog">
|
||||||
<div class="rl-dialog__shell rl-dialog__shell--small">
|
<div class="rl-dialog__shell rl-dialog__shell--small">
|
||||||
<header class="rl-dialog__head"><div><p class="rl-eyebrow">This browser</p><h2>Local storage</h2></div><button type="button" class="rl-close" data-close-dialog aria-label="Close">×</button></header>
|
<header class="rl-dialog__head"><div><p class="rl-eyebrow">This browser</p><h2>Local storage</h2></div><button type="button" class="rl-close" data-close-dialog aria-label="Close">×</button></header>
|
||||||
<div class="rl-dialog__body"><p id="rl-storage-summary">Calculating storage use…</p><p class="rl-help">Metadata and thumbnails sync to the server. PDFs remain only in the browser where they were imported.</p><button type="button" id="rl-request-persistence">Request persistent storage</button></div>
|
<div class="rl-dialog__body"><p id="rl-storage-summary">Calculating storage use…</p><p class="rl-help">Metadata and thumbnails are read from the shared server library. Only PDF binaries remain in the browser where they were imported.</p><button type="button" id="rl-request-persistence">Request persistent storage</button></div>
|
||||||
<footer class="rl-dialog__foot"><button type="button" class="rl-danger" id="rl-clear-data">Clear this browser's cache & PDFs</button><button type="button" data-close-dialog>Close</button></footer>
|
<footer class="rl-dialog__foot"><button type="button" class="rl-danger" id="rl-clear-data">Clear PDFs from this browser</button><button type="button" data-close-dialog>Close</button></footer>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|||||||
0
rl/resource-loader-2026-08-20.json
Normal file → Executable file
0
rl/resource-loader-2026-08-20.json
Normal file → Executable file
24
sitemap.org
24
sitemap.org
@@ -80,17 +80,17 @@ flowchart TD
|
|||||||
n30 --> n37
|
n30 --> n37
|
||||||
n38["Tag: life"]
|
n38["Tag: life"]
|
||||||
n30 --> n38
|
n30 --> n38
|
||||||
n39["Tag: update"]
|
n39["Tag: education"]
|
||||||
n30 --> n39
|
n30 --> n39
|
||||||
n40["Tag: education"]
|
n40["Tag: update"]
|
||||||
n30 --> n40
|
n30 --> n40
|
||||||
n41["Tag: insights"]
|
n41["Tag: insights"]
|
||||||
n30 --> n41
|
n30 --> n41
|
||||||
n42["Tag: reading"]
|
n42["Tag: emacs"]
|
||||||
n30 --> n42
|
n30 --> n42
|
||||||
n43["Tag: emacs"]
|
n43["Tag: maths"]
|
||||||
n30 --> n43
|
n30 --> n43
|
||||||
n44["Tag: maths"]
|
n44["Tag: reading"]
|
||||||
n30 --> n44
|
n30 --> n44
|
||||||
n45{{"posts"}}
|
n45{{"posts"}}
|
||||||
root --> n45
|
root --> n45
|
||||||
@@ -212,12 +212,12 @@ flowchart TD
|
|||||||
click n36 "tags/review.html" "Tag: review"
|
click n36 "tags/review.html" "Tag: review"
|
||||||
click n37 "tags/website.html" "Tag: website"
|
click n37 "tags/website.html" "Tag: website"
|
||||||
click n38 "tags/life.html" "Tag: life"
|
click n38 "tags/life.html" "Tag: life"
|
||||||
click n39 "tags/update.html" "Tag: update"
|
click n39 "tags/education.html" "Tag: education"
|
||||||
click n40 "tags/education.html" "Tag: education"
|
click n40 "tags/update.html" "Tag: update"
|
||||||
click n41 "tags/insights.html" "Tag: insights"
|
click n41 "tags/insights.html" "Tag: insights"
|
||||||
click n42 "tags/reading.html" "Tag: reading"
|
click n42 "tags/emacs.html" "Tag: emacs"
|
||||||
click n43 "tags/emacs.html" "Tag: emacs"
|
click n43 "tags/maths.html" "Tag: maths"
|
||||||
click n44 "tags/maths.html" "Tag: maths"
|
click n44 "tags/reading.html" "Tag: reading"
|
||||||
click n46 "posts/posts-intro.html" "Posts Introduction"
|
click n46 "posts/posts-intro.html" "Posts Introduction"
|
||||||
click n47 "posts/posts-list.html" "Posts List"
|
click n47 "posts/posts-list.html" "Posts List"
|
||||||
click n49 "posts/career/solid-principles.html" "SOLID Principles"
|
click n49 "posts/career/solid-principles.html" "SOLID Principles"
|
||||||
@@ -302,12 +302,12 @@ flowchart TD
|
|||||||
- [[file:tags/review.org][Tag: review]]
|
- [[file:tags/review.org][Tag: review]]
|
||||||
- [[file:tags/website.org][Tag: website]]
|
- [[file:tags/website.org][Tag: website]]
|
||||||
- [[file:tags/life.org][Tag: life]]
|
- [[file:tags/life.org][Tag: life]]
|
||||||
- [[file:tags/update.org][Tag: update]]
|
|
||||||
- [[file:tags/education.org][Tag: education]]
|
- [[file:tags/education.org][Tag: education]]
|
||||||
|
- [[file:tags/update.org][Tag: update]]
|
||||||
- [[file:tags/insights.org][Tag: insights]]
|
- [[file:tags/insights.org][Tag: insights]]
|
||||||
- [[file:tags/reading.org][Tag: reading]]
|
|
||||||
- [[file:tags/emacs.org][Tag: emacs]]
|
- [[file:tags/emacs.org][Tag: emacs]]
|
||||||
- [[file:tags/maths.org][Tag: maths]]
|
- [[file:tags/maths.org][Tag: maths]]
|
||||||
|
- [[file:tags/reading.org][Tag: reading]]
|
||||||
- posts
|
- posts
|
||||||
- [[file:posts/posts-intro.org][Posts Introduction]]
|
- [[file:posts/posts-intro.org][Posts Introduction]]
|
||||||
- [[file:posts/posts-list.org][Posts List]]
|
- [[file:posts/posts-list.org][Posts List]]
|
||||||
|
|||||||
Reference in New Issue
Block a user