(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 newestByTimestamp(remote, local) { const remoteStamp = String(remote.updatedAt || remote.createdAt || ""); const localStamp = String(local.updatedAt || local.createdAt || ""); return clone(localStamp >= remoteStamp ? local : remote); } function mergeSyncItems(remoteItems, localItems, mergeMatch) { const items = new Map(remoteItems.map((item) => [item.id, clone(item)])); localItems.forEach((local) => { const remote = items.get(local.id); items.set(local.id, remote ? mergeMatch(remote, local) : clone(local)); }); return Array.from(items.values()); } function mergeForSync(remoteLibrary, localLibrary) { const remote = normaliseLibrary(remoteLibrary); const local = normaliseLibrary(localLibrary); const vaults = mergeSyncItems(remote.vaults, local.vaults, (remoteVault, localVault) => { const vault = newestByTimestamp(remoteVault, localVault); vault.folders = mergeSyncItems(remoteVault.folders, localVault.folders, newestByTimestamp); vault.resources = mergeSyncItems(remoteVault.resources, localVault.resources, (remoteResource, localResource) => { const resource = newestByTimestamp(remoteResource, localResource); resource.sessions = mergeSyncItems(remoteResource.sessions || [], localResource.sessions || [], newestByTimestamp); return resource; }); return vault; }); return normaliseLibrary({ schemaVersion: SCHEMA_VERSION, vaults }); } 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, mergeForSync, progressFor, addVault, renameVault, deleteVault, addFolder, deleteFolder, serialise, }; });