Make Resource Loader server-backed and drop local metadata cache
All checks were successful
Build Org Website / build (push) Successful in 50s

This commit is contained in:
gitea-actions
2026-08-20 14:19:00 +01:00
parent 7a60d33610
commit 4653a2caae
8 changed files with 78 additions and 58 deletions

View File

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

View File

@@ -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,35 +160,34 @@
} }
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"); const remote = await fetchServerLibrary();
try { if (!remote) throw new Error("The server Resource Loader library has not been initialised.");
const remote = await fetchServerLibrary(); setSyncStatus("synced", "synced to server");
if (remote) { return remote;
setSyncStatus("synced", "synced to server");
await setMetadata("library", 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() {
@@ -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();
} }

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
See the categories: @@html:<a href="../home/categories.html">Categories</a>@@ See the categories: @@html:<a href="../home/categories.html">Categories</a>@@
* Posts: * Posts:
- [[file:career/career-list.org][Career List]] @@html:<span class="post-date">20-08-2026 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>@@

View File

@@ -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 &amp; 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
View File

View File

@@ -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]]