DocuWare Ablage – Thunderbird-Extension v0.8.0

Thunderbird-MailExtension zum Ablegen von E-Mails in DocuWare
  (.eml + PDF + Anhänge), dynamische Indexfelder aus dem Store-Dialog,
  Auswahllisten, Identity-Service-Login. Self-distribution-Updates
  über updates.json auf eigenem Gitea.
This commit is contained in:
sylyx 2026-06-03 13:15:59 +02:00
commit 2befbb042b
24 changed files with 7145 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Lokale Einstellungs-Exporte enthalten das DocuWare-Passwort im Klartext NIE committen!
docuware-ablage-einstellungen.json
*einstellung*.json
settings*.json
# Build-Artefakte (das signierte XPI wird als Gitea-Release verteilt, nicht im Repo)
*.xpi

108
README.md Normal file
View File

@ -0,0 +1,108 @@
# DocuWare Ablage Thunderbird-Extension
Legt eine markierte E-Mail aus Thunderbird in DocuWare ab. Nach Klick auf den Button
erscheint ein **Ablage-Dialog**: Aktenschrank wählen, Indexfelder ausfüllen (aus der
Mail vorbefüllt), ablegen. Hochgeladen werden je nach Auswahl die Mail als `.eml`,
die Mail als PDF und die Anhänge als separate Dokumente.
## Funktionsweise
- **Direkte Anbindung** an die DocuWare **Platform REST-API** (kein eigener Server).
Auth v1 per **Cookie-Logon** (`/Account/Logon`).
- **Dynamische Felder**: Die Eingabemaske wird aus dem *Store-Dialog* des gewählten
Schranks erzeugt (Felder, Pflichtfelder, Auswahllisten).
- **Vorbefüllung** der `EML_*`-Felder aus der Mail (Absender, Empfänger, Betreff,
Datum, Richtung, Größe, Body …).
## Projektstruktur
```
manifest.json MailExtension-Manifest (Manifest v2)
background/background.js Button + Kontextmenü, öffnet den Dialog
dialog/ Ablage-Dialog (HTML/CSS/JS)
options/ Einstellungen (Server, Login, Defaults)
lib/
store.js Einstellungen (browser.storage.local)
auth.js Cookie-Logon (Auth-Abstraktion)
docuware.js Platform-REST-Client (Schränke, Dialoge, Felder, Upload)
mail.js Mail-Extraktion (eml, Metadaten, Anhänge)
pdf.js abhängigkeitsfreier Text-PDF-Generator
icons/
```
## Installation (Entwicklung)
1. Thunderbird → Menü → **Add-ons und Themes** → Zahnrad → **Add-on aus Datei
installieren …** *oder* zum Testen:
2. Adresszeile: `about:debugging`**Dieses Thunderbird** → **Temporäres Add-on
laden …** → `manifest.json` in diesem Ordner wählen.
> Hinweis: Bei einem Self-Signed-Zertifikat des DocuWare-Servers muss dieses einmalig
> in Thunderbird akzeptiert werden, sonst schlagen die `fetch`-Aufrufe fehl.
## Einrichtung
Add-on-Einstellungen öffnen und ausfüllen:
- **Server-URL** Basis ohne `/DocuWare/Platform`, z.B. `https://docuware.firma.de`
- **Organisation**, **Benutzername**, **Passwort**
- **Verbindung testen** → lädt die Aktenschränke (Bestätigung, dass Login & URL passen)
- optional **Standard-Aktenschrank** und Default-Optionen (eml/pdf/Anhänge)
> ⚠️ Das Passwort wird unverschlüsselt in `storage.local` gespeichert (v1). Für den
> Produktiveinsatz sollte auf den DocuWare Identity Service / OAuth umgestellt werden
> (`lib/auth.js` ist dafür gekapselt).
## Benutzung
1. E-Mail öffnen → Button **„In DocuWare ablegen"** (oder Rechtsklick in der
Nachrichtenliste → *In DocuWare ablegen*).
2. Aktenschrank wählen → Felder prüfen/ergänzen (Pflichtfelder mit `*`).
3. Auswählen, was abgelegt wird (eml / PDF / Anhänge).
4. **Ablegen**. Bei Erfolg wird die Mail optional mit dem Tag *DocuWare* markiert.
## Verifikation (End-to-End)
Gegen den **Test-Schrank `LL_TEST_BELEGE`** ablegen, dann per DocuWare-MCP gegenprüfen:
- `docuware_search` (file_cabinet_name `LL_TEST_BELEGE`, query = Betreff) → Treffer?
- `docuware_get_document` → Indexfelder (`EML_SENDER`, `EML_SUBJECT`, …) korrekt?
- `docuware_download_document``.eml`/PDF herunterladen & prüfen
- Anhänge erscheinen als eigene Dokumente
- Aufräumen: `docuware_delete_document` (nur im Test-Schrank!)
## Verteilung & automatische Updates (eigenes Gitea, nicht öffentlich)
Das Add-on wird **nicht** öffentlich auf addons.thunderbird.net gelistet, sondern
über AMO als **„Selbstständig" (self-distribution)** signiert und über dein eigenes
Gitea verteilt + aktualisiert.
> Im XPI und in `updates.json` stehen **keine** Zugangsdaten. Das DocuWare-Passwort
> liegt ausschließlich lokal in `storage.local` und wird nie mitverteilt.
**Einmalige Einrichtung:**
1. In `manifest.json``applications.gecko.update_url` die Domain `GITEA.EXAMPLE.DE`
durch deine (HTTPS-)Gitea-Adresse ersetzen. **HTTPS ist Pflicht** (TB lehnt HTTP ab).
2. `updates.json` ebenfalls auf deine Gitea-URLs anpassen und in dein Repo committen
(erreichbar unter der `update_url`, z.B. via *raw*-Link auf `main`).
**Bei jedem neuen Release:**
1. `version` in `manifest.json` erhöhen (z.B. `0.8.0``0.8.1`).
2. XPI bauen (`manifest.json` muss im Archiv-Wurzelverzeichnis liegen).
3. XPI bei AMO hochladen → **„Selbstständig"** wählen → **signiertes** XPI herunterladen.
4. Signiertes XPI als Release-Anhang in Gitea hochladen (passend zum `update_link`).
5. In `updates.json` einen neuen Eintrag mit `version` + `update_link` ergänzen,
committen/pushen.
Thunderbird prüft die `update_url` periodisch und aktualisiert automatisch auf die
neueste dort gelistete Version.
## Bekannte Grenzen / nächste Schritte
- **PDF** ist v1 ein einfaches Text-PDF (Kopf + Klartext-Body) ohne HTML-Layout/Bilder.
- **Auth** nur Cookie-Logon; OAuth/Identity Service noch nicht implementiert.
- **Upload-Multipart-Format** ggf. je nach DocuWare-Version anpassen
(`docuware.js → uploadDocument`).
- Mehrfachauswahl von Mails: aktuell wird die erste markierte Mail abgelegt.

62
background/background.js Normal file
View File

@ -0,0 +1,62 @@
// Hintergrund-Skript: Button + Kontextmenü, öffnet den Ablage-Dialog.
const DIALOG_URL = "dialog/dialog.html";
function openDialog(messageIds) {
const ids = Array.isArray(messageIds) ? messageIds : [messageIds];
// Pro Nachricht ein eigenes Ablagefenster (leicht versetzt gestapelt).
ids.forEach((id, i) => {
const url = browser.runtime.getURL(`${DIALOG_URL}?messageId=${id}`);
browser.windows.create({
url,
type: "popup",
width: 720,
height: 820,
left: 100 + i * 28,
top: 80 + i * 28,
});
});
}
// Button in der geöffneten Nachricht
browser.messageDisplayAction.onClicked.addListener(async (tab) => {
const msg = await browser.messageDisplay.getDisplayedMessage(tab.id);
if (msg) openDialog([msg.id]);
});
// Kontextmenü-Eintrag in der Nachrichtenliste
browser.menus.create({
id: "docuware-archive",
title: "In DocuWare ablegen",
contexts: ["message_list"],
});
browser.menus.onClicked.addListener((info) => {
if (info.menuItemId !== "docuware-archive") return;
const msgs = info.selectedMessages && info.selectedMessages.messages;
if (msgs && msgs.length > 0) openDialog(msgs.map((m) => m.id));
});
// Tastenkürzel (Strg+Alt+I): angezeigte bzw. alle markierten Nachrichten ablegen.
browser.commands.onCommand.addListener(async (command) => {
if (command !== "open-archive-dialog") return;
const ids = await currentMessageIds();
if (ids.length) openDialog(ids);
});
// Ermittelt die "aktiven" Nachrichten: erst die im Reader angezeigte, sonst
// alle im Nachrichtenlisten-Tab markierten.
async function currentMessageIds() {
try {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
if (tab) {
const shown = await browser.messageDisplay
.getDisplayedMessage(tab.id)
.catch(() => null);
if (shown) return [shown.id];
}
const sel = await browser.mailTabs.getSelectedMessages().catch(() => null);
if (sel && sel.messages && sel.messages.length) return sel.messages.map((m) => m.id);
} catch (_) {}
return [];
}

64
dialog/dialog.css Normal file
View File

@ -0,0 +1,64 @@
* { box-sizing: border-box; }
body {
font: 13px/1.45 "Segoe UI", system-ui, sans-serif;
margin: 0; color: #1a1a1a; background: #fff;
display: flex; flex-direction: column; height: 100vh;
}
/* Titel-Leiste wie im Connect-to-Outlook-Dialog */
.titlebar {
background: #2b3a44; color: #fff;
padding: 8px 14px; font-weight: 600; font-size: 13px;
flex-shrink: 0;
}
header { padding: 12px 18px 6px; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; }
header h1 { font-size: 16px; font-weight: 600; margin: 0 0 10px; }
.toolbar { display: flex; align-items: center; gap: 14px; }
.toolbar .spacer { flex: 1; }
button.link {
background: none; border: 0; color: #1f2937; cursor: pointer;
font: inherit; padding: 4px 2px; display: inline-flex; align-items: center; gap: 5px;
}
button.link:hover { color: #2563eb; }
button.primary {
background: #f4b400; color: #1a1a1a; border: 0; border-radius: 3px;
padding: 7px 22px; font-weight: 600; cursor: pointer;
}
button.primary:hover { background: #e0a500; }
button:disabled { opacity: .5; cursor: default; }
main { flex: 1; overflow-y: auto; padding: 14px 18px; }
.target { display: grid; grid-template-columns: 150px 1fr; align-items: center; gap: 10px; margin-bottom: 12px; }
.target > label { text-align: right; color: #374151; font-weight: 500; }
/* Indexfelder: rechtsbündiges Label links, Eingabe rechts */
.fields { display: flex; flex-direction: column; gap: 8px; }
.field { display: grid; grid-template-columns: 150px 1fr; align-items: center; gap: 10px; }
.field > label { text-align: right; color: #374151; font-weight: 500; padding-top: 2px; }
.field.full { grid-template-columns: 150px 1fr; align-items: start; }
.field .req { color: #b91c1c; }
select, input[type="text"], input[type="number"], input[type="date"], textarea {
width: 100%; padding: 6px 9px; border: 1px solid #b9c0c8; border-radius: 3px; font: inherit; background: #fff;
}
select:focus, input:focus, textarea:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px #2563eb33; }
textarea { resize: vertical; min-height: 56px; }
/* Vorbefüllte (aus der Mail übernommene) Felder dezent kennzeichnen */
.field.prefilled input, .field.prefilled textarea { background: #f3f4f6; color: #4b5563; }
.options { margin: 0; }
.radio, .check { display: flex; align-items: center; gap: 8px; margin: 5px 0; font-weight: 400; }
.radio input, .check input { accent-color: #2563eb; }
.att-list { list-style: none; margin: 6px 0 0 26px; padding: 0; max-height: 110px; overflow-y: auto; }
.att-list li { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 2px 0; color: #374151; }
/* Footer bleibt fix sichtbar, nur der Felderbereich (main) scrollt. */
footer { border-top: 1px solid #e5e7eb; padding: 10px 18px; background: #fafafa; flex-shrink: 0; }
.muted { color: #6b7280; font-size: 12px; }
.err { color: #b91c1c; } .ok { color: #15803d; }
.field.invalid input, .field.invalid textarea, .field.invalid select { border-color: #b91c1c; }

54
dialog/dialog.html Normal file
View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>In DocuWare ablegen</title>
<link rel="stylesheet" href="dialog.css" />
</head>
<body>
<div class="titlebar">In DocuWare ablegen</div>
<header>
<h1 id="cabHeading">Ablage</h1>
<div class="toolbar">
<button id="cancel" class="link"> Abbrechen</button>
<button id="reset" class="link">↺ Zurücksetzen</button>
<span class="spacer"></span>
<button id="submit" class="primary">Ablegen</button>
</div>
</header>
<main>
<div id="multiNote" class="muted" style="margin-bottom:8px;"></div>
<div class="target">
<label for="cabinet">Ziel</label>
<select id="cabinet"><option value="">Lade …</option></select>
</div>
<div class="target" id="dialogRow" style="display:none;">
<label for="storeDialog">Ablagedialog</label>
<select id="storeDialog"></select>
</div>
<div id="fields" class="fields"><div class="muted">Ziel wählen …</div></div>
</main>
<footer>
<section class="options">
<label class="radio"><input type="radio" name="scope" value="both" checked /> E-Mail und Anhänge</label>
<label class="radio"><input type="radio" name="scope" value="att" /> Nur Anhänge</label>
<label class="radio"><input type="radio" name="scope" value="eml" /> Nur E-Mail</label>
<label class="check"><input type="checkbox" id="optPdf" /> Zusätzlich als PDF ablegen</label>
<ul id="attList" class="att-list"></ul>
</section>
<div id="status" class="muted"></div>
</footer>
<script src="../lib/store.js"></script>
<script src="../lib/auth.js"></script>
<script src="../lib/docuware.js"></script>
<script src="../lib/mail.js"></script>
<script src="../lib/pdf.js"></script>
<script src="dialog.js"></script>
</body>
</html>

581
dialog/dialog.js Normal file
View File

@ -0,0 +1,581 @@
// Ablage-Dialog: Schrankwahl, dynamische Felder, Upload.
const $ = (id) => document.getElementById(id);
let STATE = {
messageId: null,
messageIds: [], // alle ausgewählten Nachrichten
settings: null,
meta: null,
attachments: [],
cabinets: [], // alle Ziele (Archive + Briefkörbe)
cabinetId: "",
isBasket: false, // gewähltes Ziel ist ein Briefkorb?
dialogId: null,
fields: [], // Felddefinitionen des Store-Dialogs
};
// Mapping DocuWare-Feldname -> Funktion, die einen Vorbefüllwert aus meta liefert.
const PREFILL = {
EML_SENDER: (m) => m.senderEmail,
EML_SENDER_DISPLAYNAME: (m) => m.senderName,
EML_SENDER_NAME: (m) => m.senderName,
EML_RECEIVER: (m) => m.to,
EML_CC: (m) => m.cc,
EML_BCC: (m) => m.bcc,
EML_SUBJECT: (m) => m.subject,
DOC_SUBJECT: (m) => m.subject,
EML_BODY: (m) => m.bodyText,
EML_SENDINGDATE: (m) => m.date,
EML_DISPLAYDATE: (m) => m.date,
EML_RECEIVINGDATE: (m) => m.date,
EML_DIRECTION: (m) => m.direction,
EML_SIZE: (m) => m.sizeBytes,
EML_ACCOUNT: (m) => m.account,
DOC_DATE: (m) => m.date,
// Naheliegende generische Feldnamen (Schränke ohne EML_*-Schema)
SUBJECT: (m) => m.subject,
BETREFF: (m) => m.subject,
EMAIL: (m) => m.senderEmail,
E_MAIL: (m) => m.senderEmail,
DATE: (m) => m.date,
DATUM: (m) => m.date,
};
function status(msg, kind) {
const el = $("status");
el.textContent = msg || "";
el.className = kind || "muted";
}
function qs(name) {
return new URLSearchParams(location.search).get(name);
}
async function init() {
// messageIds (Mehrfachauswahl) bevorzugt, sonst einzelnes messageId.
const idsParam = qs("messageIds");
STATE.messageIds = (idsParam ? idsParam.split(",") : [qs("messageId")])
.map((x) => parseInt(x, 10))
.filter((n) => !isNaN(n));
STATE.messageId = STATE.messageIds[0];
STATE.settings = await Settings.get();
if (!STATE.settings.serverUrl) {
status("Bitte zuerst in den Einstellungen Server & Login hinterlegen.", "err");
$("submit").disabled = true;
return;
}
try {
// Anzeige/Vorbefüllung anhand der ersten Nachricht.
STATE.meta = await Mail.getMeta(STATE.messageId);
STATE.attachments = await Mail.listAttachments(STATE.messageId);
} catch (e) {
status(`Mail konnte nicht gelesen werden: ${e.message}`, "err");
return;
}
if (STATE.messageIds.length > 1) {
$("multiNote").textContent =
`${STATE.messageIds.length} E-Mails werden abgelegt. ` +
"Eingetragene Werte gelten für alle; Absender/Betreff/Datum werden je E-Mail einzeln übernommen.";
}
renderAttachments();
applyDefaults();
try {
status("Verbinde mit DocuWare …");
await Auth.logon(STATE.settings);
await loadCabinets();
status("");
} catch (e) {
status(`Verbindung fehlgeschlagen: ${e.message}`, "err");
}
}
function applyDefaults() {
$("optPdf").checked = STATE.settings.storePdf;
const hasAtt = STATE.attachments.length > 0;
// Standard-Umfang aus den Einstellungen ableiten.
let scope = "both";
if (STATE.settings.storeEml && !STATE.settings.storeAttachments) scope = "eml";
else if (!STATE.settings.storeEml && STATE.settings.storeAttachments) scope = "att";
if (!hasAtt) scope = "eml"; // ohne Anhänge nur E-Mail sinnvoll
setScope(scope);
// Optionen, die Anhänge erfordern, deaktivieren wenn keine vorhanden.
document.querySelectorAll('input[name="scope"]').forEach((r) => {
if ((r.value === "att" || r.value === "both") && !hasAtt) r.disabled = true;
});
}
function setScope(value) {
const el = document.querySelector(`input[name="scope"][value="${value}"]`);
if (el) el.checked = true;
}
function getScope() {
const el = document.querySelector('input[name="scope"]:checked');
return el ? el.value : "both";
}
function renderAttachments() {
const ul = $("attList");
ul.innerHTML = "";
STATE.attachments.forEach((a, i) => {
const li = document.createElement("li");
li.innerHTML =
`<input type="checkbox" id="att-${i}" checked /> ` +
`<label for="att-${i}" style="margin:0;font-weight:400">${a.name} ` +
`<span class="muted">(${formatSize(a.size)})</span></label>`;
ul.appendChild(li);
});
}
async function loadCabinets() {
let cabinets = (await browser.storage.local.get("cabinets")).cabinets;
if (!cabinets || cabinets.length === 0) {
cabinets = await DocuWare.listCabinets(STATE.settings);
await browser.storage.local.set({ cabinets });
}
STATE.cabinets = cabinets;
const sel = $("cabinet");
sel.innerHTML = '<option value="">— bitte wählen —</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;
g.appendChild(o);
});
sel.appendChild(g);
};
addGroup("Archive", cabinets.filter((c) => !c.isBasket));
addGroup("Briefkörbe", cabinets.filter((c) => c.isBasket));
if (STATE.settings.defaultCabinetId) {
sel.value = STATE.settings.defaultCabinetId;
if (sel.value) await onCabinetChange();
}
}
async function onCabinetChange() {
STATE.cabinetId = $("cabinet").value;
STATE.dialogId = null;
STATE.fields = [];
const target = STATE.cabinets.find((c) => String(c.id) === String(STATE.cabinetId));
STATE.isBasket = !!(target && target.isBasket);
$("cabHeading").textContent = target ? `Ablegen in „${target.name}"` : "Ablage";
// Sticky: zuletzt gewählten Schrank als Standard merken.
if (STATE.cabinetId) {
Settings.set({ defaultCabinetId: STATE.cabinetId }).then((s) => (STATE.settings = s));
}
if (!STATE.cabinetId) {
$("fields").innerHTML = '<div class="muted">Ziel wählen …</div>';
return;
}
// Briefkörbe haben keinen Store-Dialog -> keine Indexfelder.
$("dialogRow").style.display = "none";
if (STATE.isBasket) {
$("fields").innerHTML =
'<div class="muted">Briefkorb: keine Indexfelder. Die Dokumente werden ' +
"unindiziert im Briefkorb abgelegt und können dort später archiviert werden.</div>";
return;
}
$("fields").innerHTML = '<div class="muted">Lade Ablagedialog …</div>';
try {
const cands = await DocuWare.listFieldsDialogs(STATE.settings, STATE.cabinetId);
STATE.dialogCands = cands;
if (!cands.length) throw new Error("Kein Store- oder Suchdialog für diesen Schrank gefunden.");
// Auswahl-Dropdown nur zeigen, wenn es mehrere Dialoge gibt.
const ds = $("storeDialog");
ds.innerHTML = "";
cands.forEach((c) => {
const o = document.createElement("option");
o.value = c.id;
o.textContent = c.type === "search" ? `${c.name} (Suchdialog)` : c.name;
ds.appendChild(o);
});
$("dialogRow").style.display = cands.length > 1 ? "grid" : "none";
// Gemerkte Wahl pro Schrank, sonst erster Dialog.
const remembered = (STATE.settings.dialogByCabinet || {})[STATE.cabinetId];
const pick = cands.find((c) => c.id === remembered) || cands[0];
ds.value = pick.id;
await loadDialogFields(pick);
} catch (e) {
$("fields").innerHTML = `<div class="err">${e.message}</div>`;
}
}
// Lädt und rendert die Felder für einen konkreten Dialog.
async function loadDialogFields(pick) {
STATE.dialogId = pick.id;
STATE.dialogType = pick.type;
STATE.fields = [];
$("fields").innerHTML = '<div class="muted">Lade Felder …</div>';
STATE.fields = await DocuWare.getStoreFields(STATE.settings, STATE.cabinetId, STATE.dialogId);
renderFields();
if (pick.type === "search") {
const note = document.createElement("div");
note.className = "muted";
note.style.marginTop = "6px";
note.textContent =
"Hinweis: Kein Store-Dialog verfügbar Felder stammen aus dem Suchdialog.";
$("fields").appendChild(note);
}
}
// Nutzer wählt einen anderen Ablagedialog -> merken (pro Schrank) und neu laden.
async function onStoreDialogChange() {
const id = $("storeDialog").value;
const pick = (STATE.dialogCands || []).find((c) => c.id === id);
if (!pick) return;
const map = { ...(STATE.settings.dialogByCabinet || {}), [STATE.cabinetId]: id };
STATE.settings = await Settings.set({ dialogByCabinet: map });
try {
await loadDialogFields(pick);
} catch (e) {
$("fields").innerHTML = `<div class="err">${e.message}</div>`;
}
}
function renderFields() {
const container = $("fields");
container.innerHTML = "";
for (const f of STATE.fields) {
const wrap = document.createElement("div");
wrap.className = "field" + (isMemo(f) ? " full" : "");
wrap.dataset.name = f.name;
wrap.dataset.type = f.type;
wrap.dataset.required = f.required ? "1" : "";
const label = document.createElement("label");
label.textContent = f.label;
if (f.required) {
const r = document.createElement("span");
r.className = "req"; r.textContent = " *";
label.appendChild(r);
}
wrap.appendChild(label);
const input = buildInput(f);
wrap.appendChild(input);
if (input._datalist) wrap.appendChild(input._datalist);
container.appendChild(wrap);
}
applyFieldDefaults(); // vordefinierte Werte (PrefillValue) zuerst
prefillFields(); // Mail-Vorbefüllung überschreibt gemappte Felder
populateSelectLists(); // Auswahllisten/Vorschläge asynchron nachladen
}
// Baut das Eingabe-Element (synchron). Auswahllisten werden später befüllt.
function buildInput(f) {
const t = (f.type || "").toLowerCase();
let el;
if (isMemo(f)) {
el = document.createElement("textarea");
} else if (f.isSelectList && f.selectListOnly) {
// Nur Listenwerte erlaubt -> starres Dropdown.
el = document.createElement("select");
el.innerHTML = '<option value=""></option>';
} else if (t.includes("date")) {
el = document.createElement("input");
el.type = "date";
} else if (t.includes("decimal") || t.includes("numeric") || t.includes("int")) {
el = document.createElement("input");
el.type = "number"; el.step = "any";
} else {
el = document.createElement("input");
el.type = "text";
if (f.isSelectList) {
// Combobox: frei tippbar mit Vorschlagsliste (wie in Connect-to-Outlook).
const dl = document.createElement("datalist");
dl.id = "dl-" + f.name;
el.setAttribute("list", dl.id);
el._datalist = dl;
}
}
el.dataset.role = "value";
if (f.readOnly && el.tagName !== "SELECT") el.readOnly = true;
return el;
}
// Vordefinierte Werte (PrefillValue aus dem Dialog) eintragen.
function applyFieldDefaults() {
STATE.fields.forEach((f) => {
if (f.prefill === undefined || f.prefill === null || f.prefill === "") return;
setFieldValue(f.name, f, f.prefill);
});
}
function prefillFields() {
STATE.fields.forEach((f) => {
const fn = PREFILL[f.name];
if (!fn) return;
const val = fn(STATE.meta);
if (val === undefined || val === null || val === "") return;
setFieldValue(f.name, f, val);
});
}
// Setzt einen Wert auf das Control eines Feldes (kümmert sich um Datum/Select).
function setFieldValue(name, f, val) {
const wrap = document.querySelector(`.field[data-name="${name}"]`);
const el = wrap && wrap.querySelector("[data-role='value']");
if (!el) return;
if (el.type === "date") {
const d = val instanceof Date ? val : new Date(val);
if (!isNaN(d)) el.value = toDateInput(d);
} else if (el.tagName === "SELECT") {
let opt = Array.from(el.options).find((o) => o.value === String(val));
if (!opt) {
opt = document.createElement("option");
opt.value = String(val); opt.textContent = String(val);
el.appendChild(opt);
}
el.value = String(val);
} else {
el.value = val instanceof Date ? toDateInput(val) : String(val);
}
wrap.classList.add("prefilled");
}
// Lädt die Auswahllisten-Werte und füllt Dropdowns/Vorschlagslisten.
async function populateSelectLists() {
for (const f of STATE.fields) {
if (!f.isSelectList) continue;
let vals = [];
try {
vals = await DocuWare.getSelectList(
STATE.settings, STATE.cabinetId, STATE.dialogId, f.name
);
} catch (_) { /* leere Liste ist ok */ }
if (!vals.length) continue;
const wrap = document.querySelector(`.field[data-name="${f.name}"]`);
const el = wrap && wrap.querySelector("[data-role='value']");
if (!el) continue;
if (el.tagName === "SELECT") {
const current = el.value;
vals.forEach((v) => {
if (!Array.from(el.options).some((o) => o.value === v)) {
const o = document.createElement("option");
o.value = v; o.textContent = v;
el.appendChild(o);
}
});
if (current) el.value = current;
} else {
const dl = el._datalist || document.getElementById(el.getAttribute("list"));
if (dl) {
dl.innerHTML = "";
vals.forEach((v) => {
const o = document.createElement("option");
o.value = v;
dl.appendChild(o);
});
}
}
}
}
function collectFieldValues() {
const result = {};
let firstInvalid = null;
document.querySelectorAll(".field").forEach((wrap) => {
wrap.classList.remove("invalid");
const el = wrap.querySelector("[data-role='value']");
const name = wrap.dataset.name;
const type = wrap.dataset.type;
const required = wrap.dataset.required === "1";
const raw = el ? el.value : "";
if (required && !String(raw).trim()) {
wrap.classList.add("invalid");
if (!firstInvalid) firstInvalid = name;
}
if (String(raw).trim() !== "") {
result[name] = { value: el.type === "date" ? new Date(raw) : raw, type };
}
});
return { result, firstInvalid };
}
async function submit() {
if (!STATE.cabinetId) { status("Bitte ein Ziel wählen.", "err"); return; }
let result = {};
if (!STATE.isBasket) {
const collected = collectFieldValues();
if (collected.firstInvalid) {
status(`Pflichtfeld fehlt: ${collected.firstInvalid}`, "err");
return;
}
result = collected.result;
}
const scope = getScope();
const wantEml = scope !== "att";
const wantPdf = $("optPdf").checked;
const wantAttScope = scope !== "eml";
if (!wantEml && !wantAttScope) {
status("Nichts ausgewählt zum Ablegen.", "err");
return;
}
await saveOptionPrefs(scope, wantPdf); // Sticky: Umfang merken
$("submit").disabled = true;
const multi = STATE.messageIds.length > 1;
let tagFailed = false;
try {
let mi = 0;
for (const mid of STATE.messageIds) {
mi++;
const prefix = multi ? `Mail ${mi}/${STATE.messageIds.length}: ` : "";
// Meta/Anhänge der jeweiligen Nachricht.
const meta = mid === STATE.messageId ? STATE.meta : await Mail.getMeta(mid);
const atts =
mid === STATE.messageId ? STATE.attachments : await Mail.listAttachments(mid);
// Indexfelder: manuelle Werte für alle gleich; gemappte Felder je Mail neu.
const fields = STATE.isBasket ? {} : fieldsForMessage(result, meta, multi);
const wantAtt = wantAttScope && atts.length > 0;
if (wantEml) {
status(`${prefix}E-Mail …`);
const eml = await Mail.getEmlFile(mid, meta.subject);
await DocuWare.uploadDocument(STATE.settings, STATE.cabinetId, eml, eml.name, fields);
}
if (wantPdf) {
status(`${prefix}PDF …`);
const pdf = Pdf.fromMail(meta);
await DocuWare.uploadDocument(STATE.settings, STATE.cabinetId, pdf, pdf.name, fields);
}
if (wantAtt) {
// Bei Einzelmail: angehakte Anhänge. Bei Mehrfach: alle Anhänge je Mail.
const indices = multi ? atts.map((_, i) => i) : selectedAttachmentIndices();
for (const i of indices) {
const a = atts[i];
if (!a) continue;
status(`${prefix}Anhang ${a.name}`);
const file = await Mail.getAttachmentFile(mid, a.partName);
const af = STATE.isBasket ? {} : { ...fields, DOC_FILE_NAME: { value: a.name, type: "Text" } };
await DocuWare.uploadDocument(STATE.settings, STATE.cabinetId, file, a.name, af);
}
}
if (STATE.settings.tagOnSuccess) {
try { await tagMessage(mid); }
catch (te) { console.warn("DocuWare-Markierung fehlgeschlagen:", te); tagFailed = true; }
}
}
const base = multi ? `${STATE.messageIds.length} E-Mails abgelegt.` : "Erfolgreich abgelegt.";
status(tagFailed ? `${base} (Markierung nicht möglich)` : base, "ok");
setTimeout(() => window.close(), tagFailed ? 2500 : 1200);
} catch (e) {
status(`Fehler beim Ablegen: ${e.message}`, "err");
$("submit").disabled = false;
}
}
// Indexfelder für eine konkrete Nachricht: gemappte Felder (EML_*/generisch)
// werden je Mail aus deren Metadaten neu berechnet, manuelle Werte bleiben.
function fieldsForMessage(baseResult, meta, multi) {
if (!multi) return baseResult; // Einzelmail: manuelle Edits respektieren
const result = { ...baseResult };
STATE.fields.forEach((f) => {
const fn = PREFILL[f.name];
if (!fn) return;
const val = fn(meta);
if (val === undefined || val === null || val === "") return;
result[f.name] = { value: val, type: f.type };
});
return result;
}
// Sticky: zuletzt genutzten Ablage-Umfang in den Einstellungen merken.
async function saveOptionPrefs(scope, wantPdf) {
const storeEml = scope !== "att";
const storeAttachments = scope !== "eml";
try {
STATE.settings = await Settings.set({ storeEml, storeAttachments, storePdf: wantPdf });
} catch (_) { /* nicht kritisch */ }
}
function selectedAttachmentIndices() {
return STATE.attachments
.map((_, i) => i)
.filter((i) => { const c = $(`att-${i}`); return c && c.checked; });
}
// Markiert die Mail mit dem Tag "DocuWare". Wirft bei Fehler (Aufrufer fängt).
async function tagMessage(messageId) {
const KEY = "docuware";
const NAME = "DocuWare";
const COLOR = "#0e8a8a";
// Tags-API unterscheidet sich je nach Thunderbird-Version.
const api = browser.messages.tags;
let list = [];
if (api && api.list) list = await api.list();
else if (browser.messages.listTags) list = await browser.messages.listTags();
let tag = list.find((t) => t.key === KEY || t.tag === NAME);
if (!tag) {
if (api && api.create) await api.create(KEY, NAME, COLOR);
else if (browser.messages.createTag) await browser.messages.createTag(KEY, NAME, COLOR);
tag = { key: KEY };
}
const msg = await browser.messages.get(messageId);
const current = msg.tags || [];
if (!current.includes(tag.key)) {
await browser.messages.update(messageId, { tags: [...current, tag.key] });
}
}
// --- Helfer ---
const isMemo = (f) => /memo/i.test(f.type) || f.name === "EML_BODY";
const toDateInput = (d) => d.toISOString().slice(0, 10);
function formatSize(b) {
if (!b) return "";
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB`;
return `${(b / 1024 / 1024).toFixed(1)} MB`;
}
function resetForm() {
document.querySelectorAll(".field").forEach((wrap) => {
const el = wrap.querySelector("[data-role='value']");
if (el) el.value = "";
wrap.classList.remove("prefilled", "invalid");
});
applyFieldDefaults();
prefillFields();
applyDefaults();
status("");
}
$("cabinet").addEventListener("change", onCabinetChange);
$("storeDialog").addEventListener("change", onStoreDialogChange);
$("submit").addEventListener("click", submit);
$("reset").addEventListener("click", resetForm);
$("cancel").addEventListener("click", () => window.close());
// Sticky: Optionen sofort bei Änderung merken (nicht erst beim Ablegen).
document.querySelectorAll('input[name="scope"]').forEach((r) =>
r.addEventListener("change", () => saveOptionPrefs(getScope(), $("optPdf").checked))
);
$("optPdf").addEventListener("change", () =>
saveOptionPrefs(getScope(), $("optPdf").checked)
);
init();

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,284 @@
{
"id": "0dcdfed3-9e3d-490d-819d-e7c811d33d71",
"name": "DocuWare",
"values": [
{
"key": "ServerUrl",
"value": "",
"enabled": true
},
{
"key": "OrgId",
"value": "",
"enabled": true
},
{
"key": "FileCabinetId",
"value": "",
"enabled": true
},
{
"key": "SearchDialogId",
"value": "",
"enabled": true
},
{
"key": "StoreDialogId",
"value": "",
"enabled": true
},
{
"key": "DocumentId",
"value": "",
"enabled": true
},
{
"key": "Page",
"value": "0",
"enabled": true
},
{
"key": "Layer",
"value": "1",
"type": "default",
"enabled": true
},
{
"key": "StampId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "Platform",
"value": "DocuWare/Platform",
"enabled": true
},
{
"key": "Organization",
"value": "",
"enabled": true
},
{
"key": "Username",
"value": "",
"enabled": true
},
{
"key": "Password",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "TrustedUserName",
"value": "",
"enabled": true
},
{
"key": "TrustedUserPassword",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "DestinationFileCabinetId",
"value": "",
"enabled": true
},
{
"key": "ClientID",
"value": "",
"enabled": true
},
{
"key": "ClientSecret",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "IdentityServiceUrl",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "TokenEndpoint",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "SectionId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "UserId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "CreatedUserId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "CreatedUserNetworkId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "FileCabinetName",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "DataRecordId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "CurrentDate",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "IndexDialogId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "Filename",
"value": "TestDocument.pdf",
"type": "default",
"enabled": true
},
{
"key": "CheckoutName",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "DocumentTrayName",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "DocumentTrayId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "MergedDocumentId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "SectionNumber",
"value": "0",
"type": "default",
"enabled": true
},
{
"key": "GroupId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "RoleId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "DocumentTypeField",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "DocumentTypeValue",
"value": "API Test Document",
"type": "default",
"enabled": true
},
{
"key": "DocumentTypeValueUpdated",
"value": "API Test Document Updated",
"type": "default",
"enabled": true
},
{
"key": "DocumentDateField",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "DeletedDocumentId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "AccessToken",
"value": "",
"type": "any",
"enabled": true
},
{
"key": "LoginToken",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "WorkflowId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "WorkflowInstanceId",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "WindowsUsername",
"value": "",
"type": "default",
"enabled": true
},
{
"key": "WindowsUserPassword",
"value": "",
"type": "secret",
"enabled": true
},
{
"key": "WindowsAuthEndpoint",
"value": "",
"type": "default",
"enabled": true
}
],
"_postman_variable_scope": "environment",
"_postman_exported_at": "2024-09-06T17:56:02.482Z",
"_postman_exported_using": "Postman/11.9.2"
}

BIN
docs/DocuWareMark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
docs/Sticky.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
icons/icon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

BIN
icons/icon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

BIN
icons/icon-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

166
lib/auth.js Normal file
View File

@ -0,0 +1,166 @@
// DocuWare-Authentifizierung.
//
// Primär: DocuWare Identity Service (OpenID Connect).
// Neuere DocuWare-Versionen / DocuWare Cloud haben das alte Cookie-Logon
// (/Account/Logon) abgeschaltet (HTTP 410) und verlangen ein OAuth-Token.
// Wir nutzen den Resource-Owner-Password-Grant (ROPC) mit dem von DocuWare
// vorinstallierten ÖFFENTLICHEN Client "docuware.platform.net.client" --
// d.h. KEINE eigene App-Registrierung und KEIN Client-Secret nötig.
//
// Ablauf:
// 1. GET {platform}/Home/IdentityServiceInfo -> Identity-Service-URL
// 2. GET {identity}/.well-known/openid-configuration -> token_endpoint
// 3. POST {token_endpoint} grant_type=password ... -> access_token
// 4. Folgeaufrufe der Platform-API mit Authorization: Bearer <token>
//
// Fallback: Findet sich kein Identity Service (alte On-Prem-Server), wird das
// klassische Cookie-Logon versucht.
//
// Token wird im Speicher gehalten (TOKEN) und von authHeaders() ausgegeben.
const DW_PUBLIC_CLIENT_ID = "docuware.platform.net.client";
const DW_SCOPE = "docuware.platform";
let TOKEN = null; // { accessToken, refreshToken, expiresAt, tokenEndpoint }
const Auth = {
/**
* Meldet sich an DocuWare an. Wirft bei Fehlschlag.
* @returns {Promise<{mode:string}>}
*/
async logon(settings) {
const identityUrl = await this._discoverIdentityService(settings);
if (identityUrl) {
await this._logonIdentity(settings, identityUrl);
return { mode: "identity" };
}
// Fallback: altes Cookie-Logon (nur ältere On-Prem-Server).
await this._logonCookie(settings);
return { mode: "cookie" };
},
/** Zusätzliche Header für Requests. Bearer-Token, falls vorhanden. */
authHeaders() {
if (TOKEN && TOKEN.accessToken) {
return { Authorization: `Bearer ${TOKEN.accessToken}` };
}
return {};
},
/** Aktuell ein Token vorhanden und (noch) gültig? */
hasValidToken() {
return !!(TOKEN && TOKEN.accessToken && TOKEN.expiresAt > Date.now() + 5000);
},
/** Token verwerfen (z.B. beim Wechsel der Zugangsdaten). */
reset() {
TOKEN = null;
},
// --- Identity Service -----------------------------------------------------
/**
* Ermittelt die Identity-Service-URL. Liefert null, wenn der Server keinen
* Identity Service meldet (dann Cookie-Fallback).
*/
async _discoverIdentityService(settings) {
const platform = Settings.platformUrl(settings);
let res;
try {
res = await fetch(`${platform}/Home/IdentityServiceInfo`, {
method: "GET",
headers: { Accept: "application/json" },
});
} catch (_) {
return null;
}
if (!res.ok) return null;
const info = await res.json().catch(() => ({}));
const url =
info.IdentityServiceUrl ||
info.identityServiceUrl ||
info.Url ||
info.url ||
null;
return url ? url.replace(/\/+$/, "") : null;
},
async _logonIdentity(settings, identityUrl) {
// OpenID-Discovery -> token_endpoint.
const discoRes = await fetch(`${identityUrl}/.well-known/openid-configuration`, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!discoRes.ok) {
throw new Error(
`Identity Service nicht erreichbar (HTTP ${discoRes.status} bei ${identityUrl}).`
);
}
const disco = await discoRes.json();
const tokenEndpoint = disco.token_endpoint;
if (!tokenEndpoint) throw new Error("token_endpoint im Identity Service nicht gefunden.");
const body = new URLSearchParams({
grant_type: "password",
scope: DW_SCOPE,
client_id: DW_PUBLIC_CLIENT_ID,
username: settings.username,
password: settings.password,
});
const res = await fetch(tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.access_token) {
const detail =
data.error_description || data.error || (await this._safeText(res));
throw new Error(`Identity-Logon fehlgeschlagen (HTTP ${res.status}). ${detail || ""}`.trim());
}
TOKEN = {
accessToken: data.access_token,
refreshToken: data.refresh_token || null,
expiresAt: Date.now() + (Number(data.expires_in) || 3600) * 1000,
tokenEndpoint,
};
},
// --- Cookie-Fallback (alte Server) ----------------------------------------
async _logonCookie(settings) {
const platform = Settings.platformUrl(settings);
const body = new URLSearchParams({
UserName: settings.username,
Password: settings.password,
Organization: settings.organization,
RedirectToMyselfInCaseOfError: "false",
RememberMe: "false",
});
const res = await fetch(`${platform}/Account/Logon`, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!res.ok) {
const text = await this._safeText(res);
throw new Error(`Logon fehlgeschlagen (HTTP ${res.status}). ${text}`.trim());
}
},
async _safeText(res) {
return res.text().catch(() => "");
},
};
if (typeof module !== "undefined") module.exports = { Auth };

359
lib/docuware.js Normal file
View File

@ -0,0 +1,359 @@
// DocuWare Platform REST-Client.
// Alle Pfade relativ zu {serverUrl}/DocuWare/Platform.
// Auth läuft per Session-Cookie (siehe auth.js); daher überall credentials:"include".
const DocuWare = {
async _get(settings, path) {
const platform = Settings.platformUrl(settings);
const res = await fetch(`${platform}${path}`, {
method: "GET",
credentials: "include",
headers: { Accept: "application/json", ...Auth.authHeaders() },
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`GET ${path} -> HTTP ${res.status}. ${t}`.trim());
}
return res.json();
},
/**
* Liste der Ablageziele: Archive UND Briefkörbe.
* @returns {Promise<Array<{id, name, color, isBasket}>>}
*/
async listCabinets(settings) {
const data = await this._get(settings, "/FileCabinets");
const items = data.FileCabinet || [];
return items.map((c) => ({
id: c.Id,
name: c.Name,
color: c.Color,
isBasket: !!c.IsBasket,
}));
},
/** Dialoge eines Schranks. Liefert den ersten Store-Dialog (oder null). */
async getStoreDialogId(settings, cabinetId) {
const dialogs = await this._dialogList(settings, cabinetId);
const store = dialogs.find((d) => this._isStore(d));
return store ? store.Id : null;
},
/**
* Wählt den Dialog, aus dem die Indexfelder gelesen werden:
* bevorzugt der Store-Dialog, sonst ein Suchdialog (mit echter GUID).
* Gespeichert wird per API auch ohne Store-Dialog (Fields-only Upload).
* @returns {Promise<{id:string, type:"store"|"search"}|null>}
*/
async getFieldsDialog(settings, cabinetId) {
const dialogs = await this._dialogList(settings, cabinetId);
const store = dialogs.find((d) => this._isStore(d));
if (store) return { id: store.Id, type: "store" };
const searches = dialogs.filter(
(d) => (d.Type || d.DialogType || "").toLowerCase() === "search"
);
const real = searches.find(
(d) => d.Id && d.Id !== "00000000-0000-0000-0000-000000000000"
);
const pick = real || searches[0];
return pick ? { id: pick.Id, type: "search" } : null;
},
/**
* Alle Dialoge, aus denen Indexfelder gelesen werden können:
* bevorzugt ALLE Store-Dialoge; gibt es keinen, die Suchdialoge.
* @returns {Promise<Array<{id, name, type:"store"|"search"}>>}
*/
async listFieldsDialogs(settings, cabinetId) {
const dialogs = await this._dialogList(settings, cabinetId);
const stores = dialogs
.filter((d) => this._isStore(d))
.map((d) => ({ id: d.Id, name: d.DisplayName || d.Name || "Ablagedialog", type: "store" }));
if (stores.length) return stores;
const searches = dialogs
.filter((d) => (d.Type || d.DialogType || "").toLowerCase() === "search")
.map((d) => ({ id: d.Id, name: d.DisplayName || d.Name || "Suchdialog", type: "search" }));
const real = searches.filter((d) => d.id && d.id !== "00000000-0000-0000-0000-000000000000");
return real.length ? real : searches;
},
async _dialogList(settings, cabinetId) {
const data = await this._get(settings, `/FileCabinets/${cabinetId}/Dialogs`);
return this._asArray(data.Dialog).length
? this._asArray(data.Dialog)
: this._asArray(data.Dialogs);
},
_isStore(d) {
return (d.Type || d.DialogType || "").toLowerCase() === "store" || d.IsForStoring;
},
/**
* Felddefinitionen eines Store-Dialogs.
* Robust gegen die verschiedenen DocuWare-JSON-Formen:
* Fields (flaches Array) | Fields.Field | Field | Items.
* Feldtypen werden zusätzlich aus den Schrank-Stammdaten ergänzt, falls der
* Dialog sie nicht mitliefert (wichtig für korrekte Datums-/Zahlenformate).
* @returns {Promise<Array<{name, label, type, required, isSelectList}>>}
*/
async getStoreFields(settings, cabinetId, dialogId) {
const data = await this._get(
settings,
`/FileCabinets/${cabinetId}/Dialogs/${dialogId}`
);
let raw = this._fieldArray(data);
if (!raw.length) {
// Diagnose: zeigt die tatsächliche Struktur, damit wir das Format sehen.
throw new Error(
`Dialog lieferte keine Felder. Top-Level-Keys: [${Object.keys(data).join(", ")}].`
);
}
return raw
.map((f) => {
const name = f.DBFieldName || f.DBName || f.FieldName || f.Name;
const hasList =
f.SelectListsAssigned === true ||
f.AssignedInternalSelectList === true ||
(Array.isArray(f.Links) && f.Links.some((l) => l.rel === "simpleSelectList"));
return {
name,
label: f.DlgLabel || f.FieldLabel || f.Label || f.DisplayName || name,
type: f.DWFieldType || f.FieldType || f.Type || "Text",
required: f.NotEmpty === true || f.Required === true || f.Mandatory === true,
readOnly: f.ReadOnly === true || f.Locked === true,
visible: f.Visible !== false,
isSelectList: hasList,
selectListOnly: f.SelectListOnly === true,
prefill: this._prefillValue(f),
};
})
.filter(
(f) =>
f.name &&
f.visible &&
!this._isSystemField(f.name) &&
!/table/i.test(f.type) // Tabellenfelder unterstützt v1 nicht
);
},
/** Liest den vordefinierten Default-Wert (PrefillValue) eines Feldes. */
_prefillValue(f) {
const pv = f.PrefillValue || f.DefaultItem || f.Default;
if (pv === undefined || pv === null) return "";
if (Array.isArray(pv)) {
const items = pv
.map((p) => (p && typeof p === "object" ? p.Item : p))
.filter((x) => x !== null && x !== undefined && x !== "");
return items.join("; ");
}
if (typeof pv === "object") return pv.Item != null ? String(pv.Item) : "";
return String(pv);
},
/** Map DBFieldName -> {type, label, isSelectList} aus /FileCabinets/{id}. */
async _cabinetFieldMap(settings, cabinetId) {
const data = await this._get(settings, `/FileCabinets/${cabinetId}`);
const raw = this._fieldArray(data);
const map = {};
raw.forEach((f) => {
const name = f.DBFieldName || f.DBName || f.Name;
if (!name) return;
map[name] = {
type: f.DWFieldType || f.FieldType || f.Type || "Text",
label: f.FieldLabel || f.DlgLabel || f.Label || f.DisplayName || name,
isSelectList: !!f.Keywords || !!f.HasSelectList || !!f.SelectList,
};
});
return map;
},
/** Holt die Feldliste aus den gängigen Container-Formen. */
_fieldArray(data) {
if (!data) return [];
if (this._asArray(data.Fields).length) return this._asArray(data.Fields);
if (data.Fields && this._asArray(data.Fields.Field).length)
return this._asArray(data.Fields.Field);
if (this._asArray(data.Field).length) return this._asArray(data.Field);
if (this._asArray(data.Items).length) return this._asArray(data.Items);
return [];
},
_asArray(x) {
return Array.isArray(x) ? x : [];
},
/**
* DIAGNOSE: liefert die echten Rohstrukturen, damit Auswahllisten- und
* Default-Parsing exakt (statt geraten) implementiert werden kann.
*/
async diagnoseStoreDialog(settings, cabinetId) {
const out = {};
const dialogs = await this._dialogList(settings, cabinetId);
out.dialogs = dialogs.map((d) => ({
Id: d.Id,
Type: d.Type || d.DialogType,
Name: d.DisplayName || d.Name,
}));
const pick = await this.getFieldsDialog(settings, cabinetId);
out.usedDialog = pick; // welcher Dialog für die Felder genutzt wird
if (!pick) {
out.note = "Weder Store- noch Suchdialog gefunden.";
return out;
}
const detail = await this._get(
settings,
`/FileCabinets/${cabinetId}/Dialogs/${pick.id}`
);
const arr = this._fieldArray(detail);
out.detailTopLevelKeys = Object.keys(detail);
out.fieldCount = arr.length;
out.sampleFields = arr.slice(0, 10); // genug, um das Schema zu erkennen
// Roh-Antwort einer Auswahlliste am ersten Feld, um deren Form zu sehen.
if (arr.length) {
const fname = arr[0].DBFieldName || arr[0].DBName || arr[0].FieldName || arr[0].Name;
out.sampleSelectListField = fname;
try {
const platform = Settings.platformUrl(settings);
const url =
`${platform}/FileCabinets/${cabinetId}/Query/SelectListExpression` +
`?DialogId=${encodeURIComponent(pick.id)}&FieldName=${encodeURIComponent(fname)}`;
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...Auth.authHeaders(),
},
body: JSON.stringify({ ValuePrefix: "", Limit: 25, Typed: false, ExcludeExternal: true }),
});
out.sampleSelectListStatus = res.status;
out.sampleSelectListRaw = await res.json().catch(() => "(kein JSON)");
} catch (e) {
out.sampleSelectListError = String(e);
}
}
return out;
},
/**
* Werte einer Auswahlliste für ein Feld.
* POST /FileCabinets/{id}/Query/SelectListExpression?DialogId=..&FieldName=..
*/
async getSelectList(settings, cabinetId, dialogId, fieldName, prefix = "") {
const platform = Settings.platformUrl(settings);
const url =
`${platform}/FileCabinets/${cabinetId}/Query/SelectListExpression` +
`?DialogId=${encodeURIComponent(dialogId)}&FieldName=${encodeURIComponent(fieldName)}`;
const res = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...Auth.authHeaders(),
},
body: JSON.stringify({
ValuePrefix: prefix,
Limit: 1000,
Typed: false,
ExcludeExternal: false, // auch fest hinterlegte Listen liefern
}),
});
if (!res.ok) return [];
const data = await res.json().catch(() => ({}));
// Antwort-Form variiert: Value/Values/List(.Value) mit Strings oder Objekten.
const raw =
data.Value || data.Values ||
(data.List && (data.List.Value || data.List.Values)) || [];
return raw
.map((v) => (v && typeof v === "object" ? v.Item ?? v.Value ?? v.Name ?? "" : v))
.filter((v) => v !== "" && v !== null && v !== undefined)
.map(String);
},
/**
* Lädt eine Datei mit Indexfeldern in einen Schrank hoch.
* @param {File|Blob} file Inhalt (eml/pdf/Anhang)
* @param {string} fileName Dateiname inkl. Endung
* @param {Object<string,{value:any,type:string}>} fields Indexfelder
* @returns {Promise<{id:number}>}
*/
async uploadDocument(settings, cabinetId, file, fileName, fields) {
const platform = Settings.platformUrl(settings);
const indexJson = JSON.stringify({ Fields: this._buildFields(fields) });
const form = new FormData();
// Reihenfolge wichtig: erst Index-Teil ("Document"), dann Datei ("File[]").
// Part-Namen exakt wie in der DocuWare-API-Collection.
form.append(
"Document",
new Blob([indexJson], { type: "application/json" }),
"Index.json"
);
form.append("File[]", file, fileName);
const res = await fetch(`${platform}/FileCabinets/${cabinetId}/Documents`, {
method: "POST",
credentials: "include",
headers: { Accept: "application/json", ...Auth.authHeaders() },
body: form,
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(`Upload fehlgeschlagen (HTTP ${res.status}). ${t}`.trim());
}
const doc = await res.json().catch(() => ({}));
return { id: doc.Id, raw: doc };
},
// --- Helfer ---------------------------------------------------------------
/** Wandelt {name:{value,type}} in das DocuWare-Field-Array. */
_buildFields(fields) {
const out = [];
for (const [name, def] of Object.entries(fields)) {
const value = def && typeof def === "object" ? def.value : def;
const type = def && typeof def === "object" ? def.type : "Text";
if (value === undefined || value === null || value === "") continue;
out.push(this._field(name, value, type));
}
return out;
},
_field(name, value, type) {
const t = (type || "Text").toLowerCase();
if (t.includes("date")) {
// DocuWare erwartet /Date(ms)/ oder ISO; ISO funktioniert bei Platform.
const d = value instanceof Date ? value : new Date(value);
return { FieldName: name, Item: d.toISOString(), ItemElementName: "Date" };
}
if (t.includes("decimal") || t.includes("currency")) {
return { FieldName: name, Item: Number(value), ItemElementName: "Decimal" };
}
if (t.includes("numeric") || t.includes("int")) {
return { FieldName: name, Item: Number(value), ItemElementName: "Int" };
}
if (t.includes("memo")) {
return { FieldName: name, Item: String(value), ItemElementName: "Memo" };
}
if (t.includes("keyword")) {
const arr = Array.isArray(value) ? value : [String(value)];
return { FieldName: name, Item: arr, ItemElementName: "Keywords" };
}
return { FieldName: name, Item: String(value), ItemElementName: "String" };
},
_isSystemField(dbName) {
if (!dbName) return true;
// DW-interne Systemfelder (DWxxx) und Volltext nicht im Dialog anzeigen.
return /^DW[A-Z]/.test(dbName) || dbName === "DocuWareFulltext";
},
};
if (typeof module !== "undefined") module.exports = { DocuWare };

104
lib/mail.js Normal file
View File

@ -0,0 +1,104 @@
// Extraktion von Mail-Inhalten über die Thunderbird messages.* API.
const Mail = {
/** Rohe RFC822-Nachricht als .eml-File. */
async getEmlFile(messageId, subject) {
const raw = await browser.messages.getRaw(messageId); // String (RFC822)
const blob = new Blob([raw], { type: "message/rfc822" });
const name = `${this._safeName(subject) || "email"}.eml`;
return new File([blob], name, { type: "message/rfc822" });
},
/** Kopf + Body als strukturierte Metadaten zum Vorbefüllen. */
async getMeta(messageId) {
const header = await browser.messages.get(messageId);
const full = await browser.messages.getFull(messageId);
const bodyText = this._extractText(full);
const sender = this._parseAddress(header.author);
const account = await this._accountForMessage(messageId);
return {
subject: header.subject || "",
senderEmail: sender.email,
senderName: sender.name,
to: (header.recipients || []).join(", "),
cc: (header.ccList || []).join(", "),
bcc: (header.bccList || []).join(", "),
date: header.date instanceof Date ? header.date : new Date(header.date),
bodyText,
account,
direction: this._direction(header, account),
sizeBytes: header.size || 0,
};
},
/** Liste der Anhänge: [{partName, name, contentType, size}]. */
async listAttachments(messageId) {
const atts = await browser.messages.listAttachments(messageId);
return (atts || [])
.filter((a) => a.partName && a.name) // echte Datei-Anhänge
.map((a) => ({
partName: a.partName,
name: a.name,
contentType: a.contentType,
size: a.size || 0,
}));
},
/** Anhang als File holen. */
async getAttachmentFile(messageId, partName) {
return browser.messages.getAttachmentFile(messageId, partName); // File
},
// --- Helfer ---------------------------------------------------------------
_extractText(fullPart) {
// Rekursiv den ersten text/plain-Teil suchen, sonst text/html entkernen.
let plain = "";
let html = "";
const walk = (part) => {
if (!part) return;
if (part.contentType === "text/plain" && part.body) plain += part.body;
else if (part.contentType === "text/html" && part.body) html += part.body;
(part.parts || []).forEach(walk);
};
walk(fullPart);
if (plain.trim()) return plain;
if (html.trim()) return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
return "";
},
_parseAddress(str) {
if (!str) return { name: "", email: "" };
const m = str.match(/^\s*"?([^"<]*)"?\s*<([^>]+)>\s*$/);
if (m) return { name: m[1].trim(), email: m[2].trim() };
return { name: "", email: str.trim() };
},
async _accountForMessage(messageId) {
try {
const msg = await browser.messages.get(messageId);
const folder = msg.folder;
if (folder && folder.accountId) {
const acc = await browser.accounts.get(folder.accountId);
return acc ? acc.name : "";
}
} catch (_) {}
return "";
},
_direction(header, accountName) {
// Heuristik: ist der Account-Name/eine Identität Absender -> Ausgang.
// Robustere Erkennung erfolgt später über Identitäten; v1 simpel:
const folderType = header.folder && header.folder.type;
if (folderType === "sent" || folderType === "outbox") return "Ausgang";
return "Eingang";
},
_safeName(s) {
return (s || "").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 80).trim();
},
};
if (typeof module !== "undefined") module.exports = { Mail };

154
lib/pdf.js Normal file
View File

@ -0,0 +1,154 @@
// Minimaler, abhängigkeitsfreier PDF-Generator.
// Rendert die E-Mail (Kopf + Klartext-Body) als einfaches Text-PDF.
// Bewusst schlicht (v1): kein HTML-Rendering, keine Bilder — dafür keine
// externen Libraries. Reicht für eine lesbare Aktenkopie der Mail.
const Pdf = {
/**
* Erzeugt ein PDF-File aus den Mail-Metadaten.
* @param {object} meta Ergebnis von Mail.getMeta()
* @returns {File}
*/
fromMail(meta) {
const header = [
`Von: ${meta.senderName ? meta.senderName + " <" + meta.senderEmail + ">" : meta.senderEmail}`,
`An: ${meta.to}`,
meta.cc ? `CC: ${meta.cc}` : null,
`Datum: ${this._fmtDate(meta.date)}`,
`Betreff: ${meta.subject}`,
"",
"----------------------------------------------------------------------",
"",
].filter((l) => l !== null);
const lines = header.concat(this._wrap(meta.bodyText || "", 95));
const bytes = this._build(lines);
const name = `${(meta.subject || "email").replace(/[\\/:*?"<>|]+/g, "_").slice(0, 80) || "email"}.pdf`;
return new File([bytes], name, { type: "application/pdf" });
},
// --- PDF-Aufbau -----------------------------------------------------------
_build(allLines) {
const PAGE_W = 595, PAGE_H = 842; // A4 in pt
const MARGIN = 50, FONT = 10, LEADING = 14;
const linesPerPage = Math.floor((PAGE_H - 2 * MARGIN) / LEADING);
// Seiten aufteilen
const pages = [];
for (let i = 0; i < allLines.length; i += linesPerPage) {
pages.push(allLines.slice(i, i + linesPerPage));
}
if (pages.length === 0) pages.push([""]);
const objects = []; // {id, body} body als latin1-String
const addObj = (body) => { objects.push(body); return objects.length; };
// Platzhalter-IDs vorab festlegen
// 1 Catalog, 2 Pages, 3 Font, dann je Seite: PageObj + ContentObj
const catalogId = 1, pagesId = 2, fontId = 3;
objects.length = 0;
objects.push("", "", ""); // reserviere 1..3
const pageIds = [];
const contentIds = [];
pages.forEach((pageLines) => {
const stream = this._contentStream(pageLines, MARGIN, PAGE_H - MARGIN, FONT, LEADING);
const contentId = addObj(
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`
);
const pageId = addObj(
`<< /Type /Page /Parent ${pagesId} 0 R ` +
`/MediaBox [0 0 ${PAGE_W} ${PAGE_H}] ` +
`/Resources << /Font << /F1 ${fontId} 0 R >> >> ` +
`/Contents ${contentId} 0 R >>`
);
pageIds.push(pageId);
contentIds.push(contentId);
});
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
objects[pagesId - 1] =
`<< /Type /Pages /Count ${pageIds.length} ` +
`/Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] >>`;
objects[fontId - 1] =
`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>`;
// Serialisieren mit xref
let pdf = "%PDF-1.4\n";
const offsets = [];
objects.forEach((body, idx) => {
offsets[idx] = pdf.length;
pdf += `${idx + 1} 0 obj\n${body}\nendobj\n`;
});
const xrefOffset = pdf.length;
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
offsets.forEach((off) => {
pdf += `${String(off).padStart(10, "0")} 00000 n \n`;
});
pdf +=
`trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\n` +
`startxref\n${xrefOffset}\n%%EOF`;
return this._latin1Bytes(pdf);
},
_contentStream(lines, x, yTop, font, leading) {
let s = `BT\n/F1 ${font} Tf\n${leading} TL\n${x} ${yTop} Td\n`;
lines.forEach((line, i) => {
if (i > 0) s += "T*\n";
s += `(${this._escape(line)}) Tj\n`;
});
s += "ET";
return s;
},
_wrap(text, width) {
const out = [];
(text || "").split(/\r?\n/).forEach((para) => {
if (para.length <= width) { out.push(para); return; }
let line = "";
para.split(/\s+/).forEach((word) => {
if ((line + " " + word).trim().length > width) {
if (line) out.push(line);
// sehr lange Wörter hart umbrechen
while (word.length > width) { out.push(word.slice(0, width)); word = word.slice(width); }
line = word;
} else {
line = line ? `${line} ${word}` : word;
}
});
if (line) out.push(line);
});
return out;
},
_escape(s) {
return this._toLatin1(s).replace(/[\\()]/g, "\\$&");
},
_toLatin1(s) {
// Zeichen außerhalb Latin-1 durch '?' ersetzen.
return (s || "").replace(/[^\x00-\xFF]/g, "?");
},
_latin1Bytes(str) {
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i) & 0xff;
return bytes;
},
_fmtDate(d) {
try {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium", timeStyle: "short",
}).format(d);
} catch (_) {
return String(d);
}
},
};
if (typeof module !== "undefined") module.exports = { Pdf };

38
lib/store.js Normal file
View File

@ -0,0 +1,38 @@
// Einstellungen lesen/schreiben (browser.storage.local).
// Hinweis: Credentials werden v1 im Klartext gespeichert (siehe README / Risiken).
const DEFAULTS = {
serverUrl: "", // z.B. https://docuware.example.com (ohne /DocuWare/Platform)
organization: "",
username: "",
password: "",
defaultCabinetId: "",
dialogByCabinet: {}, // gemerkter Ablagedialog je Schrank: { [cabinetId]: dialogId }
storeEml: true,
storePdf: true,
storeAttachments: true,
tagOnSuccess: true,
};
const Settings = {
async get() {
const stored = await browser.storage.local.get("settings");
return { ...DEFAULTS, ...(stored.settings || {}) };
},
async set(partial) {
const current = await this.get();
const next = { ...current, ...partial };
await browser.storage.local.set({ settings: next });
return next;
},
/** Basis-URL der Platform-API ohne abschließenden Slash. */
platformUrl(settings) {
const base = (settings.serverUrl || "").replace(/\/+$/, "");
if (!base) throw new Error("Keine DocuWare-Server-URL konfiguriert.");
return `${base}/DocuWare/Platform`;
},
};
if (typeof module !== "undefined") module.exports = { Settings, DEFAULTS };

49
manifest.json Normal file
View File

@ -0,0 +1,49 @@
{
"manifest_version": 2,
"name": "DocuWare Ablage",
"description": "Legt markierte E-Mails (als .eml, PDF und mit Anhängen) in DocuWare ab.",
"version": "0.8.0",
"author": "l.lingler",
"applications": {
"gecko": {
"id": "docuware-archive@thunderbird",
"strict_min_version": "115.0",
"update_url": "https://sylyx.xyz/sylyx/thunderbird2docuware/raw/branch/main/updates.json"
}
},
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"64": "icons/icon-64.png"
},
"permissions": [
"messagesRead",
"messagesUpdate",
"messagesTags",
"accountsRead",
"menus",
"storage",
"tabs",
"<all_urls>"
],
"background": {
"scripts": ["background/background.js"]
},
"message_display_action": {
"default_title": "In DocuWare ablegen",
"default_icon": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png"
}
},
"commands": {
"open-archive-dialog": {
"suggested_key": { "default": "Ctrl+Alt+I" },
"description": "Markierte E-Mail in DocuWare ablegen"
}
},
"options_ui": {
"page": "options/options.html",
"open_in_tab": true
}
}

89
options/options.html Normal file
View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>DocuWare Ablage Einstellungen</title>
<style>
body { font: 14px/1.5 system-ui, sans-serif; max-width: 560px; margin: 24px auto; padding: 0 16px; color: #1a1a1a; }
h1 { font-size: 20px; }
fieldset { border: 1px solid #ddd; border-radius: 8px; margin: 16px 0; padding: 12px 16px; }
legend { font-weight: 600; padding: 0 6px; }
label { display: block; margin: 10px 0 4px; font-weight: 500; }
input[type="text"], input[type="password"], input[type="url"] { width: 100%; padding: 7px 9px; border: 1px solid #ccc; border-radius: 6px; box-sizing: border-box; }
.check { display: flex; align-items: center; gap: 8px; font-weight: 400; margin: 8px 0; }
.row { display: flex; gap: 12px; align-items: center; margin-top: 16px; }
button { padding: 8px 16px; border: 0; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 600; cursor: pointer; }
button.secondary { background: #e5e7eb; color: #111; }
#status { font-size: 13px; min-height: 18px; }
.ok { color: #15803d; } .err { color: #b91c1c; }
.hint { font-size: 12px; color: #666; margin-top: 2px; }
</style>
</head>
<body>
<h1>DocuWare Ablage Einstellungen</h1>
<fieldset>
<legend>Verbindung</legend>
<label for="serverUrl">Server-URL</label>
<input type="url" id="serverUrl" placeholder="https://docuware.example.com" />
<div class="hint">Basis-URL ohne <code>/DocuWare/Platform</code>.</div>
<label for="organization">Organisation</label>
<input type="text" id="organization" />
<label for="username">Benutzername</label>
<input type="text" id="username" autocomplete="username" />
<label for="password">Passwort</label>
<input type="password" id="password" autocomplete="current-password" />
<div class="hint">⚠️ Wird unverschlüsselt lokal gespeichert.</div>
</fieldset>
<fieldset>
<legend>Standardwerte</legend>
<label for="defaultCabinet">Standard-Ziel (Archiv oder Briefkorb)</label>
<select id="defaultCabinet" style="width:100%;padding:7px;border:1px solid #ccc;border-radius:6px;">
<option value="">— keiner —</option>
</select>
<div class="hint">Liste wird nach erfolgreichem Verbindungstest gefüllt.</div>
<div class="check"><input type="checkbox" id="storeEml" /> <label for="storeEml" style="margin:0">E-Mail als .eml ablegen</label></div>
<div class="check"><input type="checkbox" id="storePdf" /> <label for="storePdf" style="margin:0">E-Mail als PDF ablegen</label></div>
<div class="check"><input type="checkbox" id="storeAttachments" /> <label for="storeAttachments" style="margin:0">Anhänge separat ablegen</label></div>
<div class="check"><input type="checkbox" id="tagOnSuccess" /> <label for="tagOnSuccess" style="margin:0">Mail nach Ablage als „DocuWare" markieren</label></div>
</fieldset>
<div class="row">
<button id="save">Speichern</button>
<button id="test" class="secondary">Verbindung testen</button>
<span id="status"></span>
</div>
<fieldset>
<legend>Einstellungen sichern</legend>
<div class="hint">Exportiert/importiert alle Einstellungen (inkl. Login) als Datei
so musst du nach einer Neuinstallation nichts neu eintippen.</div>
<div class="row">
<button id="exportBtn" class="secondary">Exportieren</button>
<button id="importBtn" class="secondary">Importieren</button>
<input type="file" id="importFile" accept="application/json,.json" style="display:none" />
</div>
</fieldset>
<fieldset>
<legend>Diagnose (für Entwicklung)</legend>
<div class="hint">Analysiert den Store-Dialog des oben gewählten Standard-Ziels und
zeigt die Rohstruktur (Felder, Auswahllisten, Defaults). Inhalt bitte kopieren und
an den Entwickler geben.</div>
<div class="row">
<button id="diagBtn" class="secondary">Store-Dialog analysieren</button>
</div>
<textarea id="diag" readonly style="width:100%;height:240px;margin-top:10px;font:12px/1.4 monospace;border:1px solid #ccc;border-radius:6px;padding:8px;display:none;"></textarea>
</fieldset>
<script src="../lib/store.js"></script>
<script src="../lib/auth.js"></script>
<script src="../lib/docuware.js"></script>
<script src="options.js"></script>
</body>
</html>

122
options/options.js Normal file
View File

@ -0,0 +1,122 @@
// Logik der Einstellungsseite.
const $ = (id) => document.getElementById(id);
const FIELDS = ["serverUrl", "organization", "username", "password"];
const CHECKS = ["storeEml", "storePdf", "storeAttachments", "tagOnSuccess"];
function setStatus(msg, kind) {
const el = $("status");
el.textContent = msg;
el.className = kind || "";
}
async function load() {
const s = await Settings.get();
FIELDS.forEach((f) => ($(f).value = s[f] || ""));
CHECKS.forEach((c) => ($(c).checked = !!s[c]));
// Schrankliste, falls vorher schon getestet (im Storage gecached)
const cached = (await browser.storage.local.get("cabinets")).cabinets || [];
fillCabinets(cached, s.defaultCabinetId);
}
function fillCabinets(cabinets, selectedId) {
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));
}
function collect() {
const partial = {};
FIELDS.forEach((f) => (partial[f] = $(f).value.trim()));
CHECKS.forEach((c) => (partial[c] = $(c).checked));
partial.defaultCabinetId = $("defaultCabinet").value;
return partial;
}
$("save").addEventListener("click", async () => {
await Settings.set(collect());
setStatus("Gespeichert.", "ok");
});
$("test").addEventListener("click", async () => {
setStatus("Verbinde …");
try {
const s = await Settings.set(collect()); // erst speichern, dann testen
await Auth.logon(s);
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;
setStatus(`Verbindung ok ${arch} Archive, ${bk} Briefkörbe gefunden.`, "ok");
} catch (e) {
setStatus(`Fehler: ${e.message}`, "err");
}
});
$("exportBtn").addEventListener("click", async () => {
const s = await Settings.get();
const blob = new Blob([JSON.stringify(s, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "docuware-ablage-einstellungen.json";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setStatus("Einstellungen exportiert.", "ok");
});
$("importBtn").addEventListener("click", () => $("importFile").click());
$("importFile").addEventListener("change", async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
await Settings.set(data);
await load();
setStatus("Einstellungen importiert.", "ok");
} catch (err) {
setStatus(`Import fehlgeschlagen: ${err.message}`, "err");
} finally {
e.target.value = "";
}
});
$("diagBtn").addEventListener("click", async () => {
const cabinetId = $("defaultCabinet").value;
const out = $("diag");
out.style.display = "block";
if (!cabinetId) {
out.value = "Bitte oben zuerst ein Standard-Ziel (Archiv) wählen.";
return;
}
out.value = "Analysiere …";
try {
const s = await Settings.set(collect());
await Auth.logon(s);
const dump = await DocuWare.diagnoseStoreDialog(s, cabinetId);
out.value = JSON.stringify(dump, null, 2);
} catch (e) {
out.value = `Fehler: ${e.message}`;
}
});
load();

12
updates.json Normal file
View File

@ -0,0 +1,12 @@
{
"addons": {
"docuware-archive@thunderbird": {
"updates": [
{
"version": "0.8.0",
"update_link": "https://sylyx.xyz/sylyx/thunderbird2docuware/releases/download/v0.8.0/docuware-ablage-0.8.0.xpi"
}
]
}
}
}