thunderbird2docuware/options/options.js
sylyx 2e6a1a58e5 Feld-Zuordnung: Feldname wieder frei eingebbar
Die Spalte "DocuWare-Feldname" war ein starres <select>, dessen Optionen
ausschliesslich aus availableFields kamen - der Feldliste eines Archivs. Die ist
aber erst gefuellt, wenn Schrankliste gecacht, Passwort dieser Sitzung eingegeben
und ein Archiv gewaehlt ist. Bis dahin enthielt jedes Dropdown nur
"- Feld waehlen -": eine neue Zeile hatte null auswaehlbare Felder, und weil
collectMapping() Zeilen ohne Feldnamen verwirft, verschwand sie beim Speichern
kommentarlos wieder.

Jetzt wieder freie Texteingabe mit Vorschlagsliste (<input list>), wie der
veraltete Kommentar an mapCabinet es schon beschrieb. Die Vorschlaege kommen aus
drei Quellen statt einer: Felder des gewaehlten Archivs, die Namen aus
DEFAULT_FIELD_MAP und alles bereits Eingetragene - damit stehen sofort nach dem
Oeffnen Vorschlaege bereit, auch ohne Anmeldung.

fillFieldSelect entfaellt; refreshFieldSelects baut nur noch die gemeinsame
<datalist> auf und wird zusaetzlich am Ende von loadMapping aufgerufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:07:41 +02:00

391 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Logik der Einstellungsseite.
const $ = (id) => document.getElementById(id);
// Passwort ist bewusst NICHT dabei – es wird nie gespeichert, nur zum Testen/Login
// transient verwendet (siehe "Verbindung testen").
const FIELDS = ["serverUrl", "organization", "username"];
const CHECKS = ["storeAttachments", "tagOnSuccess"];
function setStatus(msg, kind) {
const el = $("status");
el.textContent = msg;
el.className = kind || "";
}
// Warnt, wenn der Server über unverschlüsseltes http:// läuft – dann ginge das
// Passwort im Klartext über die Leitung. Liefert einen anzuhängenden Hinweis.
function insecureWarning(url) {
return /^http:\/\//i.test(String(url || "").trim())
? " ⚠ Achtung: http:// ist unverschlüsselt – bitte https:// verwenden."
: "";
}
// Schnappschuss der auth-relevanten Felder (Server/Org/Benutzer) zum Zeitpunkt
// des letzten Speicherns/Ladens. Ändert sich davon etwas, ist ein evtl. noch im
// Broker liegender Token ungültig (er gehört zu den alten Daten) -> verwerfen.
let authSnapshot = { serverUrl: "", organization: "", username: "" };
const AUTH_FIELDS = ["serverUrl", "organization", "username"];
// Server-URLs nur für den Vergleich vereinheitlichen, damit ein rein kosmetischer
// Edit (Schrägstrich am Ende, Groß-/Kleinschreibung) nicht als Wechsel zählt.
const normUrl = (u) => String(u || "").trim().replace(/\/+$/, "").toLowerCase();
function snapshotAuth(s) {
authSnapshot = { serverUrl: s.serverUrl || "", organization: s.organization || "", username: s.username || "" };
}
function authChanged(s) {
return AUTH_FIELDS.some((k) => (s[k] || "") !== (authSnapshot[k] || ""));
}
// Nur ein echter Server-Wechsel entwertet die Schrank-GUIDs (Schrankliste,
// Standardziel, gemerkte Ablagedialoge). Org-/Benutzer-Wechsel tut das nicht.
function serverChanged(s) {
return normUrl(s.serverUrl) !== normUrl(authSnapshot.serverUrl);
}
/**
* Nach dem Speichern aufräumen, wenn sich Zugangsdaten geändert haben:
* Token immer verwerfen; bei Server-Wechsel zusätzlich alles wegwerfen, was
* Schrank-GUIDs des alten Servers enthält (sonst zeigt der Ablage-Dialog fremde
* Archive bzw. legt gegen eine nicht mehr existierende GUID ab).
* @returns {Promise<object>} die ggf. bereinigten Einstellungen
*/
async function invalidateSession(s) {
if (!authChanged(s)) { snapshotAuth(s); return s; }
const server = serverChanged(s);
await browser.runtime
.sendMessage({ type: "auth:logout", scope: server ? "server" : "auth" })
.catch(() => {});
let next = s;
if (server) {
next = await Settings.set({ defaultCabinetId: "", dialogByCabinet: {} });
fillCabinets([], ""); // Dropdowns leeren – die alte Liste gilt nicht mehr
}
snapshotAuth(next);
return next;
}
async function load() {
const s = await Settings.get();
FIELDS.forEach((f) => ($(f).value = s[f] || ""));
snapshotAuth(s);
CHECKS.forEach((c) => ($(c).checked = !!s[c]));
setMailFormat(s.mailFormat === "pdf" ? "pdf" : "eml");
$("mappingUrl").value = s.mappingUrl || "";
loadMapping(s.fieldMapping && Object.keys(s.fieldMapping).length ? s.fieldMapping : DEFAULT_FIELD_MAP);
// Schrankliste, falls vorher schon getestet (im Storage gecached)
const cached = (await browser.storage.local.get("cabinets")).cabinets || [];
fillCabinets(cached, s.defaultCabinetId);
// Best-effort: Felder des Standard-Archivs gleich laden – nur wenn schon ein
// Sitzungs-Token da ist (sonst wählt der Nutzer das Archiv manuell).
if (s.defaultCabinetId && [...$("mapCabinet").options].some((o) => o.value === s.defaultCabinetId)) {
$("mapCabinet").value = s.defaultCabinetId;
try {
const stt = await browser.runtime.sendMessage({ type: "auth:status" });
if (stt && stt.hasToken && stt.token) {
Auth.setToken(stt.token);
availableFields = (await DocuWare.cabinetFieldNames(s, s.defaultCabinetId))
.filter((n) => n && !/^DW[A-Z]/.test(n)).sort();
refreshFieldSelects();
$("mapFieldStatus").textContent = `${availableFields.length} Felder zur Auswahl geladen.`;
}
} catch (_) { /* egal – Archiv kann manuell gewählt werden */ }
}
}
function setMailFormat(v) {
const el = document.querySelector(`input[name="mailFormat"][value="${v}"]`);
if (el) el.checked = true;
}
function getMailFormat() {
const el = document.querySelector('input[name="mailFormat"]:checked');
return el ? el.value : "eml";
}
function fillCabinets(cabinets, selectedId) {
// Standard-Ziel (Archive + Briefkörbe)
const sel = $("defaultCabinet");
sel.innerHTML = '<option value="">— keiner —</option>';
const addGroup = (label, list) => {
if (!list.length) return;
const g = document.createElement("optgroup");
g.label = label;
list.forEach((c) => {
const o = document.createElement("option");
o.value = c.id;
o.textContent = c.name;
if (String(c.id) === String(selectedId)) o.selected = true;
g.appendChild(o);
});
sel.appendChild(g);
};
addGroup("Archive", cabinets.filter((c) => !c.isBasket));
addGroup("Briefkörbe", cabinets.filter((c) => c.isBasket));
// Mapping-Helfer: nur Archive (Briefkörbe haben keine Indexfelder).
const mc = $("mapCabinet");
const keep = mc.value;
mc.innerHTML = '<option value="">Feldnamen aus Archiv vorschlagen …</option>';
cabinets.filter((c) => !c.isBasket).forEach((c) => {
const o = document.createElement("option");
o.value = c.id;
o.textContent = c.name;
mc.appendChild(o);
});
if (keep) mc.value = keep;
}
// Besorgt einen Sitzungs-Token: vorhandenen vom Broker nutzen, sonst mit dem
// (transienten) Passwortfeld anmelden. Liefert false, wenn kein Passwort da ist.
async function ensureToken() {
const pw = $("password").value;
// Wurde ein Passwort eingegeben, IMMER frisch anmelden: ein evtl. noch im Broker
// liegender Token könnte zu früheren Zugangsdaten/Server gehören und würde sonst
// zu „GET /FileCabinets -> HTTP 401" führen (bis zum Thunderbird-Neustart).
// Der Broker (background.js) ruft bei auth:logon zuerst Auth.reset() und liest die
// gerade gespeicherten Einstellungen neu.
if (pw) {
const res = await browser.runtime.sendMessage({ type: "auth:logon", password: pw });
if (!res || !res.ok) throw new Error(res && res.error ? res.error : "Anmeldung fehlgeschlagen.");
Auth.setToken(res.token);
$("password").value = ""; // Passwort nicht behalten
return true;
}
// Kein Passwort getippt: vorhandenen Sitzungs-Token vom Broker übernehmen.
const st = await browser.runtime.sendMessage({ type: "auth:status" });
if (st && st.hasToken && st.token) { Auth.setToken(st.token); return true; }
return false;
}
// --- Feld-Mapping-Editor ---------------------------------------------------
const CONST_OPT = "__const__"; // Sentinel im Quelle-Dropdown für „Fester Wert"
let availableFields = []; // Feldnamen aus dem zuletzt gewählten Archiv
// Zerlegt einen Mapping-Wert (Token-String ODER { const } / { token }) in den UI-Zustand.
function mapValueState(value) {
if (value && typeof value === "object") {
if (value.const != null) return { sel: CONST_OPT, konst: String(value.const) };
if (value.token) return { sel: value.token, konst: "" };
}
if (typeof value === "string" && value) return { sel: value, konst: "" };
return { sel: "", konst: "" };
}
// Vorschläge für die Feldname-Eingaben: Felder des gewählten Archivs, dazu die
// Standard-Feldnamen und alles schon Eingetragene. Der Archiv-Teil ist erst nach
// Anmeldung + Archivwahl da – ohne die beiden anderen Quellen stünde man vorher
// vor einer leeren Liste.
function fieldSuggestions() {
const used = [...document.querySelectorAll("#mapBody .map-field")].map((i) => i.value);
const all = [...(availableFields || []), ...Object.keys(DEFAULT_FIELD_MAP), ...used];
return [...new Set(all.filter(Boolean))].sort();
}
// Baut die gemeinsame <datalist> neu auf (alle Zeilen hängen daran).
function refreshFieldSelects() {
const dl = $("fieldNameList");
if (!dl) return;
dl.innerHTML = "";
fieldSuggestions().forEach((n) => {
const o = document.createElement("option");
o.value = n;
dl.appendChild(o);
});
}
function addMapRow(field = "", value = "") {
const st = mapValueState(value);
const row = document.createElement("div");
row.className = "map-row" + (st.sel === CONST_OPT ? " has-const" : "");
// Links gestapelt: Feldname (Eingabe mit Vorschlägen) über Mail-Attribut.
const left = document.createElement("div"); left.className = "map-left";
// Freie Eingabe mit Vorschlagsliste – bewusst KEIN <select>: die Archiv-Feldliste
// gibt es erst nach Anmeldung, ein starres Dropdown wäre bis dahin leer und die
// Zeile unbenutzbar (ohne Feldnamen wirft collectMapping sie beim Speichern weg).
const fieldSel = document.createElement("input");
fieldSel.type = "text";
fieldSel.className = "map-field";
fieldSel.setAttribute("list", "fieldNameList");
fieldSel.placeholder = "DocuWare-Feldname, z. B. EML_SUBJECT";
fieldSel.value = field || "";
fieldSel.addEventListener("change", refreshFieldSelects);
const src = document.createElement("select"); src.className = "map-source";
const none = document.createElement("option"); none.value = ""; none.textContent = "— nichts —";
src.appendChild(none);
MAIL_TOKENS.forEach((t) => {
const o = document.createElement("option");
o.value = t.key; o.textContent = t.label;
if (t.key === st.sel) o.selected = true;
src.appendChild(o);
});
const co = document.createElement("option");
co.value = CONST_OPT; co.textContent = "★ Fester Wert";
if (st.sel === CONST_OPT) co.selected = true;
src.appendChild(co);
left.append(fieldSel, src);
// Rechts (breit): fester Wert – nur sichtbar bei „Fester Wert".
const right = document.createElement("div"); right.className = "map-right";
const konst = document.createElement("input");
konst.type = "text"; konst.className = "map-const"; konst.placeholder = "Fester Wert, z. B. NEU";
konst.value = st.konst;
right.appendChild(konst);
src.addEventListener("change", () => {
const isConst = src.value === CONST_OPT;
row.classList.toggle("has-const", isConst);
if (isConst) konst.focus();
});
const del = document.createElement("button");
del.type = "button"; del.className = "icon-btn"; del.textContent = "✕"; del.title = "Zeile entfernen";
del.addEventListener("click", () => row.remove());
row.append(left, right, del);
$("mapBody").appendChild(row);
}
function loadMapping(map) {
$("mapBody").innerHTML = "";
Object.entries(map || {}).forEach(([f, v]) => addMapRow(f, v));
refreshFieldSelects();
}
function collectMapping() {
const map = {};
document.querySelectorAll("#mapBody .map-row").forEach((row) => {
const f = row.querySelector(".map-field").value.trim();
if (!f) return; // ohne Feldname ignorieren
const sel = row.querySelector(".map-source").value;
if (sel === CONST_OPT) {
const c = row.querySelector(".map-const").value;
if (c !== "") map[f] = { const: c }; // fester Wert
} else if (sel) {
map[f] = sel; // Mail-Token (als String, abwärtskompatibel)
}
});
return map;
}
function collect() {
const partial = {};
FIELDS.forEach((f) => (partial[f] = $(f).value.trim()));
CHECKS.forEach((c) => (partial[c] = $(c).checked));
partial.defaultCabinetId = $("defaultCabinet").value;
partial.mailFormat = getMailFormat();
partial.fieldMapping = collectMapping();
partial.mappingUrl = $("mappingUrl").value.trim();
return partial;
}
// --- Events ----------------------------------------------------------------
$("save").addEventListener("click", async () => {
const data = collect();
const saved = await Settings.set(data);
// Server/Org/Benutzer geändert? Dann gehört ein evtl. noch im Broker liegender
// Token zu den ALTEN Daten -> verwerfen, damit Ablage/Test sich neu anmelden.
const wasServerChange = serverChanged(saved); // vor dem Neu-Schnappschuss prüfen
await invalidateSession(saved);
const warn = insecureWarning(data.serverUrl);
const note = wasServerChange ? " Neuer Server – bitte Verbindung testen und Standardziel neu wählen." : "";
setStatus("Gespeichert." + note + warn, warn ? "err" : "ok");
});
$("test").addEventListener("click", async () => {
setStatus("Verbinde …");
try {
// Erst speichern (ohne Passwort), dann testen. Bei geänderten Zugangsdaten
// vorher den alten Broker-Token verwerfen (sonst HTTP 401).
const s = await invalidateSession(await Settings.set(collect()));
const ok = await ensureToken();
if (!ok) { setStatus("Bitte Passwort eingeben.", "err"); return; }
const cabinets = await DocuWare.listCabinets(s);
await browser.storage.local.set({ cabinets });
fillCabinets(cabinets, s.defaultCabinetId);
const arch = cabinets.filter((c) => !c.isBasket).length;
const bk = cabinets.filter((c) => c.isBasket).length;
const warn = insecureWarning(s.serverUrl);
setStatus(`Verbindung ok – ${arch} Archive, ${bk} Briefkörbe gefunden.` + warn, warn ? "err" : "ok");
} catch (e) {
setStatus(`Fehler: ${e.message}`, "err");
}
});
$("mapAdd").addEventListener("click", () => addMapRow());
$("mapReset").addEventListener("click", () => loadMapping(DEFAULT_FIELD_MAP));
// --- Mapping teilen: JSON anzeigen / kopieren / anwenden / von URL laden ----
$("mappingExport").addEventListener("click", () => {
$("mappingJson").value = JSON.stringify(collectMapping(), null, 2);
setStatus("Aktuelles Mapping als JSON angezeigt.", "ok");
});
$("mappingCopy").addEventListener("click", async () => {
let txt = $("mappingJson").value.trim();
if (!txt) { txt = JSON.stringify(collectMapping(), null, 2); $("mappingJson").value = txt; }
try {
await navigator.clipboard.writeText(txt);
setStatus("In Zwischenablage kopiert.", "ok");
} catch (_) {
$("mappingJson").select(); document.execCommand("copy");
setStatus("In Zwischenablage kopiert.", "ok");
}
});
$("mappingApply").addEventListener("click", () => {
try {
const obj = JSON.parse($("mappingJson").value);
if (!obj || typeof obj !== "object" || Array.isArray(obj)) throw new Error("kein JSON-Objekt");
loadMapping(obj);
setStatus("JSON ins Mapping übernommen – mit „Speichern“ sichern.", "ok");
} catch (e) {
setStatus(`Ungültiges JSON: ${e.message}`, "err");
}
});
$("mappingPull").addEventListener("click", async () => {
const url = $("mappingUrl").value.trim();
if (!url) { setStatus("Bitte eine URL eingeben.", "err"); return; }
setStatus("Lade Mapping …");
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const obj = await res.json();
if (!obj || typeof obj !== "object" || Array.isArray(obj)) throw new Error("kein JSON-Objekt");
loadMapping(obj);
await Settings.set({ mappingUrl: url }); // URL gleich merken
setStatus("Mapping von URL geladen – mit „Speichern“ übernehmen.", "ok");
} catch (e) {
setStatus(`Laden fehlgeschlagen: ${e.message}`, "err");
}
});
// Archiv wählen -> dessen Indexfelder als Vorschläge für alle Feldname-Eingaben.
$("mapCabinet").addEventListener("change", async () => {
const id = $("mapCabinet").value;
const st = $("mapFieldStatus");
if (!id) { availableFields = []; refreshFieldSelects(); st.textContent = ""; return; }
st.textContent = "Lade Felder …";
try {
const s = await Settings.get();
const ok = await ensureToken();
if (!ok) { st.textContent = "Erst „Verbindung testen“ (Passwort nötig)."; return; }
availableFields = (await DocuWare.cabinetFieldNames(s, id))
.filter((n) => n && !/^DW[A-Z]/.test(n)) // DW*-Systemfelder ausblenden
.sort();
refreshFieldSelects();
st.textContent = `${availableFields.length} Felder zur Auswahl geladen.`;
} catch (e) {
st.textContent = `Fehler: ${e.message}`;
}
});
load();