WebDAV-/Nextcloud-Sync mit Einstellungsseite (v1.1.0)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
bec85f7e50
commit
bb7ee831a8
@@ -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.
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+23
@@ -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; }
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>LabLinks – Sync</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
<link rel="stylesheet" href="options.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="crt">
|
||||
<h1>LAB<span>LINKS</span> <small>// SYNC</small></h1>
|
||||
<p class="hint">
|
||||
Synchronisiert deine Links als <code>lablinks.json</code> per WebDAV – z. B. mit Nextcloud.<br>
|
||||
Nextcloud: <em>Einstellungen → Sicherheit → App-Passwort erstellen</em> und nutze dieses statt deines Passworts.
|
||||
</p>
|
||||
|
||||
<form id="cfg" autocomplete="off">
|
||||
<label>WEBDAV-ORDNER (URL)
|
||||
<input id="url" type="text" placeholder="https://cloud.example.com/remote.php/dav/files/NUTZER/LabLinks" required>
|
||||
</label>
|
||||
<label>BENUTZER
|
||||
<input id="user" type="text" required>
|
||||
</label>
|
||||
<label>APP-PASSWORT
|
||||
<input id="pass" type="password" required>
|
||||
</label>
|
||||
<div class="btns">
|
||||
<button type="submit" class="primary">[SPEICHERN & TESTEN]</button>
|
||||
<button type="button" id="now" hidden>[JETZT SYNCEN]</button>
|
||||
<button type="button" id="off" hidden>[TRENNEN]</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p id="status" role="status"></p>
|
||||
<p class="hint small">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<script src="sync.js"></script>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+62
@@ -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();
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<header>
|
||||
<h1>LAB<span>LINKS</span><i class="cursor"></i></h1>
|
||||
<div class="actions">
|
||||
<button id="syncBtn" hidden title="Mit WebDAV synchronisieren">[⟳]</button>
|
||||
<button id="settings" title="Sync-Einstellungen">[⚙]</button>
|
||||
<button id="editToggle" title="Bearbeiten">[EDIT]</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -39,6 +41,7 @@
|
||||
<span id="count"></span>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="sync.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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."); }
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user