// WebDAV-/Nextcloud-Sync. Gemeinsam genutzt von Popup und Einstellungsseite. // Lokale Daten: chrome.storage.sync {links, deleted, modified} // Zugangsdaten: chrome.storage.local {webdav: {url, user, pass}} (nie über Google synchronisiert) const LabSync = (() => { const FILE = "lablinks.json"; const TOMB_TTL = 60 * 24 * 3600 * 1000; const getConfig = async () => (await chrome.storage.local.get("webdav")).webdav || null; const setConfig = (cfg) => cfg ? chrome.storage.local.set({ webdav: cfg }) : chrome.storage.local.remove("webdav"); async function readLocal() { const d = await chrome.storage.sync.get(["links", "deleted", "modified"]); return { links: d.links || [], deleted: d.deleted || [], modified: d.modified || 0 }; } const writeLocal = (s) => chrome.storage.sync.set({ links: s.links, deleted: s.deleted, modified: s.modified }); const baseUrl = (cfg) => cfg.url.trim().replace(/\/+$/, ""); const fileUrl = (cfg) => baseUrl(cfg) + "/" + FILE; function authHeader(cfg) { const bytes = new TextEncoder().encode(`${cfg.user}:${cfg.pass}`); return "Basic " + btoa(String.fromCharCode(...bytes)); } async function dav(cfg, method, url, headers = {}, body) { let res; try { res = await fetch(url, { method, headers: { Authorization: authHeader(cfg), ...headers }, body, credentials: "omit", cache: "no-store", }); } catch { throw new Error("Server nicht erreichbar (URL, Zertifikat oder Berechtigung prüfen)"); } if (res.status === 401 || res.status === 403) throw new Error("Login abgelehnt (Benutzer / App-Passwort prüfen)"); return res; } async function fetchRemote(cfg) { const res = await dav(cfg, "GET", fileUrl(cfg)); if (res.status === 404) return null; if (!res.ok) throw new Error(`Server antwortet mit HTTP ${res.status}`); let data; try { data = await res.json(); } catch { throw new Error("Remote-Datei ist kein gültiges JSON"); } if (!data || !Array.isArray(data.links)) throw new Error("Remote-Datei hat ein unbekanntes Format"); return { data: { links: data.links, deleted: data.deleted || [], modified: data.modified || 0 }, etag: res.headers.get("ETag"), }; } // -> "ok" | "conflict" async function putRemote(cfg, data, etag) { const headers = { "Content-Type": "application/json" }; if (etag) headers["If-Match"] = etag; else headers["If-None-Match"] = "*"; const body = JSON.stringify({ version: 1, ...data }, null, 2); let res = await dav(cfg, "PUT", fileUrl(cfg), headers, body); if (res.status === 404 || res.status === 409) { // Ordner existiert noch nicht -> anlegen und erneut versuchen const mk = await dav(cfg, "MKCOL", baseUrl(cfg)); if (mk.ok || mk.status === 405) res = await dav(cfg, "PUT", fileUrl(cfg), headers, body); } if (res.status === 412) return "conflict"; if (!res.ok) throw new Error(`Schreiben fehlgeschlagen (HTTP ${res.status})`); return "ok"; } // Pro Eintrag gewinnt der neuere `updated`-Zeitstempel; Löschungen sind Tombstones. function merge(a, b) { const tombs = new Map(); for (const t of [...a.deleted, ...b.deleted]) { const cur = tombs.get(t.id); if (!cur || cur.updated < t.updated) tombs.set(t.id, t); } const byId = new Map(); for (const l of [...a.links, ...b.links]) { const cur = byId.get(l.id); if (!cur || (l.updated || 0) > (cur.updated || 0)) byId.set(l.id, l); } for (const [id, t] of tombs) { const l = byId.get(id); if (l && (l.updated || 0) > t.updated) tombs.delete(id); // nach dem Löschen neu bearbeitet else byId.delete(id); } // Reihenfolge der zuletzt geänderten Seite übernehmen, Rest anhängen const [first, second] = a.modified >= b.modified ? [a, b] : [b, a]; const order = [...first.links, ...second.links].map((l) => l.id); const links = [...new Set(order)].filter((id) => byId.has(id)).map((id) => byId.get(id)); const now = Date.now(); const deleted = [...tombs.values()].filter((t) => now - t.updated < TOMB_TTL); return { links, deleted, modified: Math.max(a.modified, b.modified) }; } const sig = (s) => JSON.stringify([s.links, s.deleted]); let running = null; function sync() { return (running ??= doSync().finally(() => (running = null))); } async function doSync() { const cfg = await getConfig(); if (!cfg || !cfg.url) return { skipped: true }; for (let attempt = 0; attempt < 4; attempt++) { const local = await readLocal(); const remote = await fetchRemote(cfg); const merged = merge(local, remote ? remote.data : { links: [], deleted: [], modified: 0 }); // Hat sich lokal währenddessen etwas geändert? Dann nochmal von vorn. if ((await readLocal()).modified !== local.modified) continue; const localChanged = sig(merged) !== sig(local); if (localChanged) await writeLocal(merged); if (!remote || sig(merged) !== sig(remote.data)) { if ((await putRemote(cfg, merged, remote?.etag)) === "conflict") continue; } return { links: merged.links.length, pulled: localChanged }; } throw new Error("Sync-Konflikt, bitte erneut versuchen"); } return { getConfig, setConfig, readLocal, writeLocal, sync }; })();