Files
org_web/assets/scripts/pages/resource-loader-model.js
gitea-actions b8bb1944dd
All checks were successful
Build Org Website / build (push) Successful in 38s
Add Resource Loader SPA and JS test harness
2026-08-19 15:03:41 +01:00

230 lines
8.4 KiB
JavaScript

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