/* Visitenkarten-Scanner: Routing, Ansichten, API-Aufrufe. */ (() => { "use strict"; const FIELD_LABELS = { first_name: "Vorname", last_name: "Nachname", job_title: "Position", company: "Firma", department: "Abteilung", email: "E-Mail", phone: "Telefon", mobile: "Mobil", fax: "Fax", website: "Web", street: "Straße", postal_code: "PLZ", city: "Ort", country: "Land", occasion: "Anlass", place: "Scan-Ort", }; const FIELD_ORDER = Object.keys(FIELD_LABELS); const state = { route: "scan", cardId: null, query: "", filter: "alle", cards: [], stats: { total: 0, offen: 0, erinnerungen: 0, laeuft: 0 }, editing: false, }; const $ = (id) => document.getElementById(id); const screens = { scan: $("screen-scan"), karten: $("screen-cards"), karte: $("screen-detail"), erinnerungen: $("screen-reminders"), mehr: $("screen-mehr"), }; const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[c]); const store = { get(key) { try { return localStorage.getItem(key) || ""; } catch (_) { return ""; } }, set(key, value) { try { localStorage.setItem(key, value); } catch (_) { /* privater Modus */ } }, }; // ------------------------------------------------------------------ Helfer function toast(message) { const node = $("toast"); node.textContent = message; node.hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => { node.hidden = true; }, 3200); } function busy(on, text) { $("busy-text").textContent = text || "Karten werden freigestellt"; $("busy-overlay").hidden = !on; } async function api(path, options = {}) { const response = await fetch(path, { credentials: "same-origin", ...options }); if (response.status === 401) { $("login-overlay").hidden = false; throw new Error("Nicht angemeldet"); } if (!response.ok) { let detail = `Fehler ${response.status}`; try { detail = (await response.json()).detail || detail; } catch (_) {} throw new Error(detail); } return response.status === 204 ? null : response.json(); } const formatDate = (iso) => { if (!iso) return "–"; const date = new Date(iso); return Number.isNaN(date.getTime()) ? "–" : date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); }; const formatDateTime = (iso) => { if (!iso) return "–"; const date = new Date(iso); return Number.isNaN(date.getTime()) ? "–" : date.toLocaleString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }); }; const displayName = (card) => [card.first_name, card.last_name].filter(Boolean).join(" ") || card.company || "Ohne Namen"; const NAV_APPS = { apple: { label: "Apple Karten", url: (query) => `https://maps.apple.com/?q=${encodeURIComponent(query)}`, }, google: { label: "Google Maps", url: (query) => `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`, }, osm: { label: "OpenStreetMap", url: (query) => `https://www.openstreetmap.org/search?query=${encodeURIComponent(query)}`, }, }; const navApp = () => (NAV_APPS[store.get("navApp")] ? store.get("navApp") : "apple"); const address = (card) => [card.street, [card.postal_code, card.city].filter(Boolean).join(" "), card.country] .filter(Boolean) .join(", "); const link = (href, text) => `${esc(text)}`; const subtitle = (card) => [card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt"; /** Position des Browsers als Rückfallebene. Die EXIF-Daten des Fotos sind * genauer, deshalb blockiert das hier nichts und scheitert leise. */ function browserPosition() { return new Promise((resolve) => { if (!navigator.geolocation) return resolve({ error: "Gerät liefert keinen Standort" }); navigator.geolocation.getCurrentPosition( (position) => resolve({ coords: position.coords }), (error) => resolve({ error: error.code === error.PERMISSION_DENIED ? "Standortfreigabe fehlt" : "Standort nicht ermittelbar", }), { enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 } ); }); } /** Zustand einer Karte als Pillen. Ob sie schon exportiert wurde, steht * bewusst nicht dabei - dafür gibt es den Filter. */ function cardTags(card) { const tags = []; if (card.occasion) tags.push(["pill-solid", card.occasion]); else if (card.place) tags.push(["", card.place]); if (card.extract_status === "offen") tags.push(["", "wird gelesen"]); if (card.extract_status === "fehler") tags.push(["", "Lesefehler"]); return tags .map(([extra, text]) => `${esc(text)}`) .join(""); } function cardRow(card) { return ` `; } // ----------------------------------------------------------------- Routing function go(route, cardId) { location.hash = cardId ? `#/${route}/${cardId}` : `#/${route}`; } function applyRoute() { const parts = location.hash.replace(/^#\/?/, "").split("/"); const route = screens[parts[0]] ? parts[0] : "scan"; state.route = route; state.cardId = route === "karte" ? parts[1] : null; Object.entries(screens).forEach(([name, node]) => { node.hidden = name !== route; }); document.querySelectorAll(".tab").forEach((tab) => { const active = tab.dataset.route === route || (route === "karte" && tab.dataset.route === "karten"); tab.setAttribute("aria-selected", active ? "true" : "false"); }); window.scrollTo(0, 0); if (route === "scan") loadRecent(); if (route === "karten") loadCards(); if (route === "karte") loadCard(state.cardId); if (route === "erinnerungen") loadReminders(); if (route === "mehr") renderSettings(state.session || {}); } // ------------------------------------------------------------------- Scan async function runScan(file) { if (!file) return; busy(true, "Karten werden freigestellt"); const body = new FormData(); body.append("file", file); body.append("occasion", $("occasion-input").value.trim()); const position = await browserPosition(); if (position.coords) { body.append("lat", position.coords.latitude); body.append("lon", position.coords.longitude); } try { const result = await api("/api/scan", { method: "POST", body }); renderScanResult(result); await refreshStats(); loadRecent(); const ohneOrt = result.cards.every((card) => !card.place); toast( `${result.count} Karte${result.count === 1 ? "" : "n"} erfasst` + (ohneOrt && position.error ? ` · ohne Ort (${position.error})` : ", wird gelesen") ); } catch (error) { toast(error.message); } finally { busy(false); } } function renderScanResult(result) { $("scan-result").innerHTML = `
Ergebnis
${result.count} Karte${result.count === 1 ? "" : "n"} gefunden wird gelesen
${result.fallback ? `

Kein Kartenrechteck erkannt – das ganze Foto wurde als eine Karte behandelt. Für einen Stapel hilft ein dunkler, einfarbiger Untergrund mit Abstand zwischen den Karten.

` : ""}
${result.cards.map(cardRow).join("")}
`; } async function loadRecent() { try { const data = await api("/api/cards?filter=alle"); state.stats = data.stats; updateCounters(); const recent = data.cards.slice(0, 3); $("recent-list").innerHTML = recent.length ? `
${recent.map(cardRow).join("")}
${data.cards.length > 3 ? `` : ""}` : `

Noch nichts erfasst

Mach oben ein Foto – der Rest passiert von allein.

`; schedulePoll(); } catch (error) { toast(error.message); } } // ------------------------------------------------------------- Kartenliste async function loadCards() { const params = new URLSearchParams({ q: state.query, filter: state.filter }); try { const data = await api(`/api/cards?${params}`); state.cards = data.cards; state.stats = data.stats; updateCounters(); renderCards(); schedulePoll(); } catch (error) { toast(error.message); } } /** Das Auslesen läuft auf dem Server weiter, nachdem der Scan geantwortet * hat. Solange noch etwas offen ist, holen wir den Stand nach. */ function schedulePoll() { clearTimeout(schedulePoll.timer); if (!state.stats.laeuft) return; schedulePoll.timer = setTimeout(() => { if (state.route === "karten") loadCards(); else if (state.route === "karte") loadCard(state.cardId); else if (state.route === "scan") loadRecent(); else refreshStats(); }, 3000); } function renderCards() { const list = $("cards-list"); if (!state.cards.length) { list.innerHTML = `

Nichts gefunden

${state.query || state.filter !== "alle" ? "Andere Suche oder anderer Filter." : "Noch keine Karten erfasst."}

`; return; } list.innerHTML = `
${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"} ${state.stats.laeuft ? `${state.stats.laeuft} werden gelesen` : ""}
${state.cards.map(cardRow).join("")}
`; } // ----------------------------------------------------------- Kartendetail async function loadCard(cardId) { try { const card = await api(`/api/cards/${cardId}`); renderCard(card); if (card.extract_status === "offen") { state.stats = { ...state.stats, laeuft: Math.max(1, state.stats.laeuft) }; schedulePoll(); } } catch (error) { screens.karte.innerHTML = `

${esc(error.message)}

`; } } /** E-Mail, Web, Nummern und Adresse sind Handlungen, keine Zeichenketten. */ function fieldCell(card, field) { const value = card[field]; if (field === "email") return link(`mailto:${value}`, value); if (field === "website") { return link(/^https?:\/\//i.test(value) ? value : `https://${value}`, value); } if (["phone", "mobile", "fax"].includes(field)) { return link(`tel:${value.replace(/[^\d+]/g, "")}`, value); } return esc(value); } // Straße, PLZ, Ort und Land stehen in der Tabelle als eine Adresszeile. const DISPLAY_ORDER = [ "first_name", "last_name", "job_title", "company", "department", "email", "phone", "mobile", "fax", "website", "address", "occasion", "place", ]; function dataTable(card) { const postal = address(card); const rows = DISPLAY_ORDER.map((field) => { if (field === "address") { if (!postal) return ""; return `Adresse${link(NAV_APPS[navApp()].url(postal), postal)}`; } if (!card[field]) return ""; return `${FIELD_LABELS[field]}${fieldCell(card, field)}`; }).filter(Boolean); return rows.length ? `${rows.join("")}
` : `

Keine Felder erkannt. Über „Bearbeiten“ nachtragen.

`; } function editForm(card) { return `
${FIELD_ORDER.map( (field) => ` ` ).join("")}
`; } function duplicateBlock(card) { if (!card.duplicates || !card.duplicates.length) return ""; return `
Schon einmal erfasst?
${card.duplicates .map( (other) => ` ` ) .join("")}
`; } const GEO_SOURCE = { exif: "aus dem Foto", browser: "vom Gerät" }; const QUICK_DATES = [ ["In 3 Tagen", 3], ["In 1 Woche", 7], ["In 2 Wochen", 14], ["In 1 Monat", 30], ]; function localInputValue(date) { const pad = (n) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + `T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function renderCard(card) { const reminders = card.reminders.length ? `
${card.reminders .map( (reminder) => `
${esc(reminder.text)} ${esc(formatDateTime(reminder.due_at))} ${reminder.sent_at ? " · verschickt" : ""} ${reminder.send_error ? " · Versand fehlgeschlagen" : ""}
` ) .join("")}
` : `

Keine Erinnerung gesetzt.

`; screens.karte.innerHTML = `

${esc(card.occasion || (card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"))}

${esc(displayName(card))}

${esc(subtitle(card))}

Erfasst ${esc(formatDate(card.captured_at || card.created_at))} Ort${card.geo_source ? " · " + GEO_SOURCE[card.geo_source] : ""} ${esc(card.place || "Unbekannt")}
${card.image_url ? `Vorderseite ` : ""} ${card.back_image_url ? `Rückseite ` : ``} In Kontakte speichern → ${card.extract_status === "offen" ? `

Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.

` : ""} ${card.extract_status === "fehler" ? `

Lesefehler: ${esc(card.extract_error || "unbekannt")}

` : ""} ${duplicateBlock(card)}
Daten
${state.editing ? editForm(card) : dataTable(card)}
Notiz
Erinnerung
${reminders}
${QUICK_DATES.map( ([text, days]) => `` ).join("")}
Karte entfernen
`; screens.karte.querySelectorAll("[data-field]").forEach((input) => { input.addEventListener("change", async () => { try { await api(`/api/cards/${card.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ [input.dataset.field]: input.value }), }); toast("Gespeichert"); } catch (error) { toast(error.message); } }); }); const backInput = $("input-back"); if (backInput) { backInput.addEventListener("change", async (event) => { const file = event.target.files[0]; event.target.value = ""; if (!file) return; busy(true, "Rückseite wird gelesen"); try { const body = new FormData(); body.append("file", file); await api(`/api/cards/${card.id}/back`, { method: "POST", body }); toast("Rückseite hinzugefügt"); loadCard(card.id); } catch (error) { toast(error.message); } finally { busy(false); } }); } const form = $("reminder-form"); form.querySelectorAll("[data-quick]").forEach((button) => { button.addEventListener("click", () => { const date = new Date(); date.setDate(date.getDate() + Number(button.dataset.quick)); date.setHours(9, 0, 0, 0); form.elements.due.value = localInputValue(date); }); }); form.addEventListener("submit", async (event) => { event.preventDefault(); const due = form.elements.due.value; if (!due) return; try { await api(`/api/cards/${card.id}/reminders`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: form.elements.text.value, due_at: new Date(due).toISOString(), }), }); toast("Erinnerung angelegt"); loadCard(card.id); refreshStats(); } catch (error) { toast(error.message); } }); } /** Teilen über das System-Teilenblatt. Damit landet die vCard per AirDrop, * Mail oder Nachricht beim Gegenüber, ohne Umweg über den Download. */ async function shareCard(cardId) { try { const response = await fetch(`/vcf/${cardId}`, { credentials: "same-origin" }); if (!response.ok) throw new Error("vCard konnte nicht geladen werden"); const disposition = response.headers.get("content-disposition") || ""; const match = disposition.match(/filename="([^"]+)"/); const blob = await response.blob(); const file = new File([blob], match ? match[1] : "kontakt.vcf", { type: "text/vcard", }); if (navigator.canShare && navigator.canShare({ files: [file] })) { await navigator.share({ files: [file] }); return; } // Kein Teilenblatt (Desktop, ältere Browser): dann eben herunterladen. const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = file.name; link.click(); URL.revokeObjectURL(url); } catch (error) { if (error.name !== "AbortError") toast(error.message); } } // ----------------------------------------------------------- Einstellungen function renderSettings(session) { const current = navApp(); screens.mehr.innerHTML = `
Navigation

Womit Adressen geöffnet werden.

Status
Auslesen${session.extraction_available ? "aktiv" : "kein API-Schlüssel"}
Erinnerungsmails${session.mail_configured ? "aktiv" : "kein SMTP"}
Anmeldung${session.auth_required ? "mit Passwort" : "offen"}
${session.auth_required ? `
Zugang
` : `
Zugang

Ohne Passwort erreichbar – APP_PASSWORD in der .env setzen.

`}`; const logout = $("logout-button"); if (logout) { logout.addEventListener("click", async () => { await api("/api/logout", { method: "POST" }).catch(() => {}); location.reload(); }); } } // ------------------------------------------------------------ Erinnerungen async function loadReminders() { try { const data = await api("/api/reminders"); const list = $("reminders-list"); if (!data.reminders.length) { list.innerHTML = `

Keine Erinnerungen

Erinnerungen legst du auf einer Karte an.

`; return; } list.innerHTML = ` ${data.mail_configured ? "" : `

Kein SMTP konfiguriert – es wird nichts verschickt.

`}
${data.reminders .map( (reminder) => ` ` ) .join("")}
`; } catch (error) { toast(error.message); } } // -------------------------------------------------------------- Zähler function badge(id, value) { const node = $(id); node.textContent = value; node.hidden = !value; } function updateCounters() { badge("tab-count-cards", state.stats.total); badge("tab-count-reminders", state.stats.erinnerungen); } async function refreshStats() { try { const data = await api("/api/cards?filter=alle"); state.stats = data.stats; updateCounters(); schedulePoll(); } catch (_) { /* Zähler sind nicht kritisch */ } } // ------------------------------------------------------- Bildbearbeitung /** Zwei Zuschnitte hintereinander: der zweite bezieht sich auf das Ergebnis * des ersten, der Server rechnet aber immer vom Original. Also verrechnen. */ function composeCrop(previous, next) { if (!previous) return next; return { x: previous.x + next.x * previous.w, y: previous.y + next.y * previous.h, w: next.w * previous.w, h: next.h * previous.h, }; } /** Drehen, ausrichten, zuschneiden. Jede Korrektur wird auf dem Server vom * unbearbeiteten Original gerechnet - deshalb hält der Editor den ganzen * Parametersatz und schickt ihn jedes Mal komplett mit. */ function openEditor(cardId, side) { const overlay = $("editor-overlay"); const source = `/api/cards/${cardId}/image${side === "back" ? "?side=back" : ""}`; const params = { rotate: 0, angle: 0, crop: null }; let box = null; let drag = null; overlay.innerHTML = `

${side === "back" ? "Rückseite" : "Vorderseite"} bearbeiten

Ausrichten 0°

Zum Zuschneiden über dem Bild einen Rahmen aufziehen, Ecken zum Feinjustieren anfassen.

`; overlay.hidden = false; const stage = $("editor-stage"); const image = $("editor-image"); const cropButton = $("editor-crop"); const reload = () => { image.src = `${source}${source.includes("?") ? "&" : "?"}t=${Date.now()}`; }; reload(); function drawBox() { const existing = stage.querySelector(".crop-box"); if (existing) existing.remove(); cropButton.disabled = !box; if (!box) return; const node = document.createElement("div"); node.className = "crop-box"; node.style.left = `${box.x * 100}%`; node.style.top = `${box.y * 100}%`; node.style.width = `${box.w * 100}%`; node.style.height = `${box.h * 100}%`; node.innerHTML = ["nw", "ne", "se", "sw"] .map((corner) => ``) .join(""); stage.appendChild(node); } function relative(event) { const bounds = stage.getBoundingClientRect(); return { x: Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width)), y: Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height)), }; } stage.addEventListener("pointerdown", (event) => { const handle = event.target.closest(".crop-handle"); const point = relative(event); stage.setPointerCapture(event.pointerId); if (handle) { drag = { corner: handle.dataset.corner }; } else { drag = { corner: null, origin: point }; box = { x: point.x, y: point.y, w: 0, h: 0 }; } event.preventDefault(); }); stage.addEventListener("pointermove", (event) => { if (!drag || !box) return; const point = relative(event); if (drag.corner) { const right = box.x + box.w; const bottom = box.y + box.h; if (drag.corner.includes("w")) { box.w = right - point.x; box.x = point.x; } else { box.w = point.x - box.x; } if (drag.corner.includes("n")) { box.h = bottom - point.y; box.y = point.y; } else { box.h = point.y - box.y; } if (box.w < 0) { box.x += box.w; box.w = -box.w; } if (box.h < 0) { box.y += box.h; box.h = -box.h; } } else { box.x = Math.min(drag.origin.x, point.x); box.y = Math.min(drag.origin.y, point.y); box.w = Math.abs(point.x - drag.origin.x); box.h = Math.abs(point.y - drag.origin.y); } drawBox(); }); const endDrag = () => { drag = null; if (box && (box.w < 0.03 || box.h < 0.03)) box = null; drawBox(); }; stage.addEventListener("pointerup", endDrag); stage.addEventListener("pointercancel", endDrag); async function apply(extra = {}) { try { await api(`/api/cards/${cardId}/edit`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ side, ...params, ...extra }), }); reload(); } catch (error) { toast(error.message); } } overlay.querySelectorAll("[data-rotate]").forEach((button) => { button.addEventListener("click", () => { params.rotate = (params.rotate + Number(button.dataset.rotate) + 360) % 360; // Der Zuschnitt bezog sich auf die alte Lage und gilt nicht mehr. params.crop = null; box = null; drawBox(); apply(); }); }); let angleTimer; const angleInput = $("editor-angle"); angleInput.addEventListener("input", () => { params.angle = Number(angleInput.value); $("editor-angle-out").textContent = `${params.angle}°`; clearTimeout(angleTimer); angleTimer = setTimeout(apply, 350); }); cropButton.addEventListener("click", () => { if (!box) return; params.crop = composeCrop(params.crop, box); box = null; drawBox(); apply(); }); $("editor-reset").addEventListener("click", () => { params.rotate = 0; params.angle = 0; params.crop = null; box = null; angleInput.value = 0; $("editor-angle-out").textContent = "0°"; drawBox(); apply({ reset: true }); }); $("editor-done").addEventListener("click", () => { overlay.hidden = true; overlay.innerHTML = ""; loadCard(cardId); }); } // ------------------------------------------------------------- Ereignisse document.addEventListener("click", (event) => { const shareButton = event.target.closest("[data-share]"); if (shareButton) return shareCard(shareButton.dataset.share); const editImage = event.target.closest("[data-edit-image]"); if (editImage) return openEditor(state.cardId, editImage.dataset.editImage); const cardButton = event.target.closest("[data-card]"); if (cardButton) return go("karte", cardButton.dataset.card); const goto = event.target.closest("[data-goto]"); if (goto) return go(goto.dataset.goto); if (event.target.closest("[data-back]")) { state.editing = false; return go("karten"); } if (event.target.closest("[data-toggle-edit]")) { state.editing = !state.editing; return loadCard(state.cardId); } const tab = event.target.closest(".tab"); if (tab) return go(tab.dataset.route); const navChip = event.target.closest("[data-nav-app]"); if (navChip) { store.set("navApp", navChip.dataset.navApp); document.querySelectorAll("[data-nav-app]").forEach((c) => c.setAttribute("aria-pressed", c === navChip ? "true" : "false") ); return toast(`Adressen öffnen in ${NAV_APPS[navChip.dataset.navApp].label}`); } const chip = event.target.closest("#filter-chips .chip"); if (chip) { state.filter = chip.dataset.filter; document.querySelectorAll("#filter-chips .chip").forEach((c) => c.setAttribute("aria-pressed", c === chip ? "true" : "false") ); return loadCards(); } const removeReminder = event.target.closest("[data-delete-reminder]"); if (removeReminder) { event.preventDefault(); return api(`/api/reminders/${removeReminder.dataset.deleteReminder}`, { method: "DELETE" }) .then(() => { loadCard(state.cardId); refreshStats(); }) .catch((error) => toast(error.message)); } const removeCard = event.target.closest("[data-delete-card]"); if (removeCard) { if (!confirm("Karte und Bild endgültig löschen?")) return; return api(`/api/cards/${removeCard.dataset.deleteCard}`, { method: "DELETE" }) .then(() => { toast("Karte gelöscht"); go("karten"); }) .catch((error) => toast(error.message)); } }); let searchTimer; $("search-input").addEventListener("input", (event) => { state.query = event.target.value; clearTimeout(searchTimer); searchTimer = setTimeout(loadCards, 220); }); $("search-clear").addEventListener("click", () => { $("search-input").value = ""; state.query = ""; loadCards(); }); // Der Anlass gilt für eine ganze Messe, nicht für einen Scan. $("occasion-input").value = store.get("occasion"); $("occasion-input").addEventListener("change", (event) => { store.set("occasion", event.target.value.trim()); }); ["input-camera", "input-library"].forEach((id) => { $(id).addEventListener("change", (event) => { const file = event.target.files[0]; event.target.value = ""; runScan(file); }); }); $("login-form").addEventListener("submit", async (event) => { event.preventDefault(); const body = new FormData(); body.append("password", $("login-password").value); try { const response = await fetch("/api/login", { method: "POST", body, credentials: "same-origin", }); if (!response.ok) throw new Error("Passwort stimmt nicht"); $("login-overlay").hidden = true; $("login-error").hidden = true; boot(); } catch (error) { $("login-error").textContent = error.message; $("login-error").hidden = false; } }); window.addEventListener("hashchange", applyRoute); // --------------------------------------------------------------- Start async function boot() { try { const session = await fetch("/api/session", { credentials: "same-origin" }).then((r) => r.json()); if (session.auth_required && !session.authenticated) { $("login-overlay").hidden = false; return; } state.session = session; if (!session.extraction_available) { toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten."); } applyRoute(); } catch (error) { toast("Server nicht erreichbar"); } } if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" }).catch(() => {}); } boot(); })();