230 lines
8.4 KiB
JavaScript
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,
|
|
};
|
|
});
|