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:
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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user