This commit is contained in:
@@ -140,6 +140,37 @@
|
||||
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) => {
|
||||
@@ -218,6 +249,7 @@
|
||||
validateLibrary,
|
||||
normaliseLibrary,
|
||||
mergeLibraries,
|
||||
mergeForSync,
|
||||
progressFor,
|
||||
addVault,
|
||||
renameVault,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
const DB_VERSION = 1;
|
||||
const META_STORE = "metadata";
|
||||
const ATTACHMENT_STORE = "attachments";
|
||||
const API_URL = "/api/resource-loader";
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
||||
const state = {
|
||||
@@ -22,6 +23,10 @@
|
||||
pendingImport: null,
|
||||
pdfUrls: new Map(),
|
||||
thumbnailUrls: new Map(),
|
||||
uploadedThumbnails: new Set(),
|
||||
serverEtag: null,
|
||||
syncQueue: Promise.resolve(),
|
||||
eventsBound: false,
|
||||
};
|
||||
let pdfJsPromise;
|
||||
|
||||
@@ -54,6 +59,7 @@
|
||||
jsonInput: $("#rl-json-input"),
|
||||
loading: $("#rl-loading"),
|
||||
toast: $("#rl-toast"),
|
||||
syncStatus: $("#rl-sync-status"),
|
||||
};
|
||||
|
||||
function openDatabase() {
|
||||
@@ -86,21 +92,93 @@
|
||||
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 setSyncStatus(status, label) {
|
||||
if (!els.syncStatus) return;
|
||||
els.syncStatus.dataset.state = status;
|
||||
els.syncStatus.querySelector("small").textContent = label;
|
||||
}
|
||||
|
||||
async function fetchServerLibrary() {
|
||||
const response = await fetch(API_URL, { cache: "no-store", credentials: "same-origin" });
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`Server library request failed (${response.status}).`);
|
||||
const library = Model.normaliseLibrary(await response.json());
|
||||
state.serverEtag = response.headers.get("ETag");
|
||||
return library;
|
||||
}
|
||||
|
||||
async function uploadLocalThumbnails(library) {
|
||||
const ids = Array.from(new Set(library.vaults.flatMap((vault) => vault.resources.map((resource) => resource.thumbnailId).filter(Boolean))));
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
if (state.uploadedThumbnails.has(id)) return;
|
||||
const attachment = await getAttachment(id);
|
||||
if (!attachment || !attachment.blob || attachment.kind !== "thumbnail") return;
|
||||
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": attachment.type || attachment.blob.type || "image/jpeg" },
|
||||
body: attachment.blob,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Thumbnail upload failed (${response.status}).`);
|
||||
state.uploadedThumbnails.add(id);
|
||||
}));
|
||||
}
|
||||
|
||||
async function pushServerLibrary(library, allowMerge) {
|
||||
setSyncStatus("syncing", "syncing…");
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (state.serverEtag) headers["If-Match"] = state.serverEtag;
|
||||
const response = await fetch(API_URL, {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers,
|
||||
body: JSON.stringify(library),
|
||||
});
|
||||
if (response.status === 412 && allowMerge) {
|
||||
const remote = await fetchServerLibrary();
|
||||
const merged = Model.mergeForSync(remote, library);
|
||||
state.library = merged;
|
||||
await setMetadata("library", merged);
|
||||
if (state.eventsBound) render();
|
||||
toast("Changes from another device were merged.");
|
||||
return pushServerLibrary(merged, false);
|
||||
}
|
||||
if (!response.ok) throw new Error(`Server save failed (${response.status}).`);
|
||||
state.serverEtag = response.headers.get("ETag");
|
||||
await uploadLocalThumbnails(library);
|
||||
setSyncStatus("synced", "synced to server");
|
||||
}
|
||||
|
||||
async function saveLibrary() {
|
||||
await setMetadata("library", state.library);
|
||||
await setMetadata("library", Model.normaliseLibrary(state.library));
|
||||
state.syncQueue = state.syncQueue.catch(() => {}).then(async () => {
|
||||
try {
|
||||
await pushServerLibrary(Model.normaliseLibrary(state.library), true);
|
||||
} catch (error) {
|
||||
console.warn("Resource Loader server sync is unavailable.", error);
|
||||
setSyncStatus("offline", "offline · saved here");
|
||||
}
|
||||
});
|
||||
return state.syncQueue;
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
const saved = await getMetadata("library");
|
||||
if (saved) return Model.normaliseLibrary(saved);
|
||||
try {
|
||||
const response = await fetch("/assets/content/resource-loader.json", { cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Seed library could not be loaded.");
|
||||
return Model.normaliseLibrary(await response.json());
|
||||
const remote = await fetchServerLibrary();
|
||||
if (remote) {
|
||||
setSyncStatus("synced", "synced to server");
|
||||
await setMetadata("library", remote);
|
||||
return remote;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
return Model.createSeed();
|
||||
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() {
|
||||
@@ -216,8 +294,17 @@
|
||||
if (!id) return null;
|
||||
if (state.thumbnailUrls.has(id)) return state.thumbnailUrls.get(id);
|
||||
const attachment = await getAttachment(id);
|
||||
if (!attachment || !attachment.blob) return null;
|
||||
const url = URL.createObjectURL(attachment.blob);
|
||||
let blob = attachment && attachment.blob;
|
||||
if (!blob) {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, { credentials: "same-origin" });
|
||||
if (response.ok) blob = await response.blob();
|
||||
} catch (error) {
|
||||
console.warn(`Thumbnail ${id} is unavailable.`, error);
|
||||
}
|
||||
}
|
||||
if (!blob) return null;
|
||||
const url = URL.createObjectURL(blob);
|
||||
state.thumbnailUrls.set(id, url);
|
||||
return url;
|
||||
}
|
||||
@@ -718,7 +805,7 @@
|
||||
}
|
||||
|
||||
async function clearData() {
|
||||
if (!confirm("Clear every Resource Loader vault, record, session, and retained PDF from this browser?")) return;
|
||||
if (!confirm("Clear this browser's offline cache and retained PDFs? Server metadata and thumbnails will remain available.")) return;
|
||||
closeDialog(els.storageDialog);
|
||||
state.db.close();
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -730,6 +817,7 @@
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
state.eventsBound = true;
|
||||
els.vaultSelect.addEventListener("change", () => { state.vaultId = els.vaultSelect.value; state.folderId = "all"; render(); });
|
||||
[els.search, els.typeFilter, els.statusFilter, els.tagFilter, els.sort].forEach((control) => control.addEventListener(control === els.search ? "input" : "change", renderResources));
|
||||
$("#rl-view-list").addEventListener("click", () => { state.view = "list"; localStorage.setItem("rl-view", state.view); renderResources(); });
|
||||
@@ -763,6 +851,7 @@
|
||||
state.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
});
|
||||
window.addEventListener("online", () => saveLibrary());
|
||||
}
|
||||
|
||||
async function initialise() {
|
||||
|
||||
Reference in New Issue
Block a user