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.
7
.gitignore
vendored
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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();
|
||||||
|
After Width: | Height: | Size: 41 KiB |
4892
docs/DocuWare.postman_collection.json
Normal file
284
docs/DocuWare.postman_environment.json
Normal 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
|
After Width: | Height: | Size: 2.5 KiB |
BIN
docs/Sticky.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/ThunderBirdOhneMark.png
Normal file
|
After Width: | Height: | Size: 224 KiB |
BIN
icons/icon-16.png
Normal file
|
After Width: | Height: | Size: 371 B |
BIN
icons/icon-32.png
Normal file
|
After Width: | Height: | Size: 730 B |
BIN
icons/icon-64.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
166
lib/auth.js
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||