From bb7ee831a83c498c91b5b45fe9bf4be234e1145e Mon Sep 17 00:00:00 2001 From: root Date: Sun, 11 Oct 2026 18:15:37 +0000 Subject: [PATCH] WebDAV-/Nextcloud-Sync mit Einstellungsseite (v1.1.0) Co-Authored-By: Claude Sonnet 5.5 --- README.md | 8 +++ manifest.json | 4 +- options.css | 23 +++++++++ options.html | 44 +++++++++++++++++ options.js | 62 ++++++++++++++++++++++++ popup.css | 3 ++ popup.html | 3 ++ popup.js | 56 ++++++++++++++++++--- sync.js | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 options.css create mode 100644 options.html create mode 100644 options.js create mode 100644 sync.js diff --git a/README.md b/README.md index e69de29..e32d377 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,8 @@ +# LabLinks + +Retro-Launcher (Chrome, Manifest V3) für deine HomeLab-WebUIs. + +- Installation: `chrome://extensions` → Entwicklermodus → „Entpackte Erweiterung laden“ → Ordner mit `manifest.json`. +- Shortcut: frei wählbar unter `chrome://extensions/shortcuts` (Vorschlag: Alt+Shift+L). +- Sync: `[⚙]` im Popup → WebDAV-/Nextcloud-Ordner, Benutzer und App-Passwort eintragen. + Die Links liegen dann als `lablinks.json` im Ordner; Änderungen werden pro Eintrag nach Zeitstempel zusammengeführt. diff --git a/manifest.json b/manifest.json index 90cab34..829d252 100644 --- a/manifest.json +++ b/manifest.json @@ -1,9 +1,11 @@ { "manifest_version": 3, "name": "LabLinks", - "version": "1.0.0", + "version": "1.1.0", "description": "Retro-Launcher für deine HomeLab WebUIs.", "permissions": ["storage"], + "optional_host_permissions": ["https://*/*", "http://*/*"], + "options_ui": { "page": "options.html", "open_in_tab": true }, "action": { "default_popup": "popup.html", "default_title": "LabLinks", diff --git a/options.css b/options.css new file mode 100644 index 0000000..5b573ee --- /dev/null +++ b/options.css @@ -0,0 +1,23 @@ +body { width: auto; max-width: 560px; margin: 28px auto; padding: 0 12px; } +.crt { padding: 22px; } +h1 { margin-bottom: 12px; } +h1 small { font-size: 13px; letter-spacing: 1px; color: var(--fg-dim); text-shadow: none; } +.hint { color: var(--fg-dim); margin-bottom: 16px; text-shadow: none; } +.hint.small { font-size: 12px; margin: 14px 0 0; } +code, em { color: var(--fg); font-style: normal; } + +label { display: block; font-size: 12px; letter-spacing: 1px; margin-bottom: 12px; } +label input { display: block; margin: 5px 0 0; } +input[type="password"] { + width: 100%; font: inherit; color: var(--green); background: var(--bg); + border: 2px solid var(--line); padding: 6px 8px; outline: none; +} +input[type="password"]:focus { border-color: var(--fg); } + +.btns { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; } +.btns button { font-size: 13px; } +#off { color: #ff4b3e; } + +#status { min-height: 1.4em; margin-top: 14px; } +#status.ok { color: var(--green); } +#status.err { color: #ff4b3e; } diff --git a/options.html b/options.html new file mode 100644 index 0000000..f1bc924 --- /dev/null +++ b/options.html @@ -0,0 +1,44 @@ + + + + + LabLinks – Sync + + + + +
+

LABLINKS // SYNC

+

+ Synchronisiert deine Links als lablinks.json per WebDAV – z. B. mit Nextcloud.
+ Nextcloud: Einstellungen → Sicherheit → App-Passwort erstellen und nutze dieses statt deines Passworts. +

+ +
+ + + +
+ + + +
+
+ +

+

+ Der Ordner wird bei Bedarf angelegt. Zugangsdaten bleiben lokal in diesem Browser + (nicht über das Google-Konto synchronisiert, aber unverschlüsselt gespeichert). + Konflikte löst der jeweils neuere Eintrag. +

+
+ + + + diff --git a/options.js b/options.js new file mode 100644 index 0000000..103cd30 --- /dev/null +++ b/options.js @@ -0,0 +1,62 @@ +const $ = (id) => document.getElementById(id); + +function status(text, kind = "") { + $("status").textContent = text; + $("status").className = kind; +} + +function showConnected(on) { + $("now").hidden = !on; + $("off").hidden = !on; +} + +async function init() { + const cfg = await LabSync.getConfig(); + if (cfg) { + $("url").value = cfg.url; + $("user").value = cfg.user; + $("pass").value = cfg.pass; + showConnected(true); + } +} + +async function runSync() { + status("> synchronisiere …"); + try { + const r = await LabSync.sync(); + status(`> OK – ${r.links} Links abgeglichen.`, "ok"); + } catch (e) { + status("> FEHLER: " + e.message, "err"); + } +} + +$("cfg").addEventListener("submit", async (e) => { + e.preventDefault(); + const cfg = { url: $("url").value.trim(), user: $("user").value.trim(), pass: $("pass").value }; + + let origin; + try { origin = new URL(cfg.url).origin + "/*"; } catch { return status("> FEHLER: Ungültige URL.", "err"); } + + // Muss direkt aus dem Klick heraus aufgerufen werden (Nutzergeste). + const granted = await chrome.permissions.request({ origins: [origin] }); + if (!granted) return status("> FEHLER: Zugriff auf den Server wurde nicht erlaubt.", "err"); + + await LabSync.setConfig(cfg); + showConnected(true); + await runSync(); +}); + +$("now").addEventListener("click", runSync); + +$("off").addEventListener("click", async () => { + const cfg = await LabSync.getConfig(); + await LabSync.setConfig(null); + if (cfg) { + try { await chrome.permissions.remove({ origins: [new URL(cfg.url).origin + "/*"] }); } catch {} + } + for (const id of ["url", "user", "pass"]) $(id).value = ""; + showConnected(false); + status("> Sync getrennt. Lokale Links bleiben erhalten."); +}); + +init(); diff --git a/popup.css b/popup.css index 012dd31..a7834d0 100644 --- a/popup.css +++ b/popup.css @@ -57,6 +57,9 @@ button { border: 0; cursor: pointer; padding: 2px 4px; text-shadow: inherit; } button:hover, button:focus-visible { background: var(--fg); color: var(--bg); outline: none; text-shadow: none; } +#syncBtn.ok { color: var(--green); } +#syncBtn.err { color: #ff4b3e; } +#syncBtn.busy { animation: blink .8s steps(2, start) infinite; } button.primary { color: var(--green); } button.primary:hover { background: var(--green); color: var(--bg); } diff --git a/popup.html b/popup.html index cdb4a86..f6d7232 100644 --- a/popup.html +++ b/popup.html @@ -10,6 +10,8 @@

LABLINKS

+ +
@@ -39,6 +41,7 @@ + diff --git a/popup.js b/popup.js index c80baf1..dc25fe3 100644 --- a/popup.js +++ b/popup.js @@ -1,9 +1,11 @@ const $ = (id) => document.getElementById(id); -const store = chrome.storage.sync; let links = []; +let deleted = []; let editing = false; let editId = null; +let syncOn = false; +let syncState = ""; // "", "busy", "ok", "err" const uid = () => Math.random().toString(36).slice(2, 10); @@ -14,11 +16,43 @@ function normalizeUrl(raw) { } async function load() { - const data = await store.get("links"); - links = data.links || []; + const s = await LabSync.readLocal(); + links = s.links; + deleted = s.deleted; + // Einträge aus Version 1.0 haben noch keinen Zeitstempel + if (links.some((l) => !l.updated)) { + links = links.map((l) => ({ ...l, updated: l.updated || Date.now() })); + await save(false); + } + render(); + syncOn = !!(await LabSync.getConfig())?.url; + render(); + if (syncOn) runSync(); +} + +async function save(sync = true) { + await LabSync.writeLocal({ links, deleted, modified: Date.now() }); + if (sync && syncOn) runSync(); +} + +async function runSync() { + setSync("busy"); + try { + await LabSync.sync(); + const s = await LabSync.readLocal(); + links = s.links; + deleted = s.deleted; + setSync("ok"); + } catch (e) { + setSync("err", e.message); + } render(); } -const save = () => store.set({ links }); + +function setSync(state, msg = "") { + syncState = state; + $("syncBtn").title = msg || "Mit WebDAV synchronisieren"; +} function render() { const q = $("search").value.trim().toLowerCase(); @@ -57,6 +91,8 @@ function render() { document.body.classList.toggle("editing", editing); for (const id of ["add", "export", "import"]) $(id).hidden = !editing; $("editToggle").textContent = editing ? "[FERTIG]" : "[EDIT]"; + $("syncBtn").hidden = !syncOn; + $("syncBtn").className = syncState; } function btn(label, fn) { @@ -77,6 +113,7 @@ async function move(id, dir) { async function remove(id) { links = links.filter((l) => l.id !== id); + deleted = [...deleted.filter((t) => t.id !== id), { id, updated: Date.now() }]; await save(); render(); } @@ -99,7 +136,7 @@ $("form").addEventListener("submit", async (e) => { e.preventDefault(); const url = normalizeUrl($("fUrl").value); if (!url) { $("fUrl").focus(); return; } - const entry = { name: $("fName").value.trim(), url, icon: $("fIcon").value.trim() }; + const entry = { name: $("fName").value.trim(), url, icon: $("fIcon").value.trim(), updated: Date.now() }; if (editId === "new") links.push({ id: uid(), ...entry }); else Object.assign(links.find((l) => l.id === editId), entry); await save(); @@ -114,6 +151,8 @@ $("editToggle").addEventListener("click", () => { if (!editing) { editId = null; $("form").hidden = true; } render(); }); +$("syncBtn").addEventListener("click", runSync); +$("settings").addEventListener("click", () => chrome.runtime.openOptionsPage()); // Enter im Suchfeld öffnet den ersten Treffer $("search").addEventListener("keydown", (e) => { @@ -132,9 +171,12 @@ $("file").addEventListener("change", async (e) => { if (!file) return; try { const arr = JSON.parse(await file.text()); - links = arr + const now = Date.now(); + const imported = arr .filter((x) => x && x.name && x.url) - .map((x) => ({ id: uid(), name: String(x.name), url: normalizeUrl(String(x.url)) || String(x.url), icon: x.icon || "" })); + .map((x) => ({ id: uid(), name: String(x.name), url: normalizeUrl(String(x.url)) || String(x.url), icon: x.icon || "", updated: now })); + deleted = [...deleted, ...links.map((l) => ({ id: l.id, updated: now }))]; + links = imported; await save(); render(); } catch { alert("Ungültige JSON-Datei."); } diff --git a/sync.js b/sync.js new file mode 100644 index 0000000..a82a8fa --- /dev/null +++ b/sync.js @@ -0,0 +1,132 @@ +// 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 }; +})();