Feature: „bereits abgelegt"-Erkennung + Dedup über Message-ID (v0.10.0)

Erster C2O-Parität-Schritt. Die RFC822-Message-ID wird normalisiert ins Feld
EML_ID geschrieben und zur Dubletten-Erkennung genutzt.

- mail.js: rfcMessageId aus den Mail-Headern
- docuware.js: normalizeMessageId, cabinetFieldNames, getSearchDialogId,
  findByMessageId (DialogExpression, Format gem. Postman-Collection), documentViewUrl
- background.js: ✓-Badge am Nachrichten-Button beim Öffnen (Suche im Standard-
  schrank, Cache, archive:done-Refresh); lädt dafür lib/docuware.js
- dialog: EML_ID beim Upload mitschreiben; „bereits abgelegt"-Banner mit
  „Dokument öffnen"; Dubletten-Warnung beim Ablegen (warnen, „Trotzdem ablegen")

UX-Entscheidung: Prüfung beim Öffnen (1 Schrank) + Duplikat warnen/erlauben.
Schema bestätigt (EML_ID existiert). TB-Runtime-Test steht noch aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sylyx 2026-06-05 09:03:48 +02:00
parent 1b15973c9e
commit 73693fc381
7 changed files with 296 additions and 2 deletions

View File

@ -35,9 +35,80 @@ browser.runtime.onMessage.addListener((msg) => {
return Promise.resolve({ ok: true });
}
// Dialog meldet eine erfolgreiche Ablage -> Badge-Cache aktualisieren, damit
// die Mail beim nächsten Anzeigen sofort als „abgelegt" erscheint.
if (msg.type === "archive:done") {
const mid = DocuWare.normalizeMessageId(msg.rfcMessageId);
if (mid && msg.cabinetId) {
ARCHIVED_CACHE.set(`${mid}|${msg.cabinetId}`, { found: true, docId: msg.docId || null });
}
return Promise.resolve({ ok: true });
}
return undefined;
});
// --- „Bereits abgelegt"-Badge am Nachrichten-Button ------------------------
// Beim Anzeigen einer Mail im Standard-Schrank nach der Message-ID suchen und das
// Ergebnis als Badge (✓) am messageDisplayAction-Button zeigen. Pro Message-ID +
// Schrank wird das Ergebnis im Speicher gecacht (eine Suche pro Mail).
const ARCHIVED_CACHE = new Map(); // "messageId|cabinetId" -> { found, docId }
async function rfcMessageIdOf(messageId) {
try {
const full = await browser.messages.getFull(messageId);
const h = full && full.headers && full.headers["message-id"];
if (Array.isArray(h) && h.length) return String(h[0]).trim();
} catch (_) {}
return "";
}
function setBadge(tabId, found) {
try {
browser.messageDisplayAction.setBadgeText({ tabId, text: found ? "✓" : "" });
browser.messageDisplayAction.setTitle({
tabId,
title: found
? "Bereits in DocuWare abgelegt klicken zum Prüfen/Ablegen"
: "In DocuWare ablegen",
});
if (found && browser.messageDisplayAction.setBadgeBackgroundColor) {
browser.messageDisplayAction.setBadgeBackgroundColor({ tabId, color: "#0e8a8a" });
}
} catch (_) {}
}
async function updateArchivedBadge(tab, message) {
const tabId = tab.id;
try {
if (!Auth.hasValidToken()) return setBadge(tabId, false); // ohne Login keine Suche
const settings = await Settings.get();
if (!settings.serverUrl || !settings.defaultCabinetId) return setBadge(tabId, false);
const mid = DocuWare.normalizeMessageId(await rfcMessageIdOf(message.id));
if (!mid) return setBadge(tabId, false);
const key = `${mid}|${settings.defaultCabinetId}`;
let info = ARCHIVED_CACHE.get(key);
if (!info) {
const hit = await DocuWare.findByMessageId(settings, settings.defaultCabinetId, mid).catch(
() => null
);
info = { found: !!hit, docId: hit ? hit.id : null };
ARCHIVED_CACHE.set(key, info);
}
setBadge(tabId, info.found);
} catch (_) {
setBadge(tabId, false);
}
}
browser.messageDisplay.onMessagesDisplayed.addListener((tab, displayed) => {
const msgs = Array.isArray(displayed) ? displayed : (displayed && displayed.messages) || [];
if (msgs.length === 1) updateArchivedBadge(tab, msgs[0]);
else setBadge(tab.id, false); // bei Mehrfachauswahl keine eindeutige Aussage
});
function openDialog(messageIds) {
const ids = Array.isArray(messageIds) ? messageIds : [messageIds];
// Pro Nachricht ein eigenes Ablagefenster (leicht versetzt gestapelt).

View File

@ -63,6 +63,16 @@ footer { border-top: 1px solid #e5e7eb; padding: 10px 18px; background: #fafafa;
.err { color: #b91c1c; } .ok { color: #15803d; }
.field.invalid input, .field.invalid textarea, .field.invalid select { border-color: #b91c1c; }
/* „Bereits abgelegt"-Banner */
.banner {
display: flex; align-items: center; gap: 10px;
background: #ecfdf5; border: 1px solid #6ee7b7; color: #065f46;
border-radius: 4px; padding: 7px 10px; margin-bottom: 10px; font-size: 12px;
}
.banner[hidden] { display: none; }
.banner .banner-text { flex: 1; }
.banner .link { color: #065f46; text-decoration: underline; }
/* Passwort-Abfrage (Sitzungs-Login, nicht gespeichert) */
.overlay {
position: fixed; inset: 0; background: rgba(0, 0, 0, .35);

View File

@ -20,6 +20,10 @@
<main>
<div id="multiNote" class="muted" style="margin-bottom:8px;"></div>
<div id="archivedBanner" class="banner" hidden>
<span class="banner-text">✓ Diese E-Mail ist bereits in DocuWare abgelegt.</span>
<button id="openExisting" class="link">Dokument öffnen</button>
</div>
<div class="target">
<label for="cabinet">Ziel</label>
<select id="cabinet"><option value="">Lade …</option></select>
@ -48,6 +52,20 @@
</div>
</div>
<!-- Dubletten-Abfrage beim Ablegen: warnen, aber „Trotzdem ablegen" erlauben. -->
<div id="dupOverlay" class="overlay" hidden>
<div class="overlay-box">
<h2>⚠ Bereits abgelegt</h2>
<div class="muted">Diese E-Mail liegt in DocuWare offenbar schon im gewählten Ziel.</div>
<div class="overlay-actions">
<button id="dupOpen" class="link">Dokument öffnen</button>
<span class="spacer" style="flex:1"></span>
<button id="dupCancel" class="link">Abbrechen</button>
<button id="dupProceed" class="primary">Trotzdem ablegen</button>
</div>
</div>
</div>
<footer>
<section class="options">
<label class="radio"><input type="radio" name="scope" value="both" checked /> E-Mail und Anhänge</label>

View File

@ -13,6 +13,8 @@ let STATE = {
isBasket: false, // gewähltes Ziel ist ein Briefkorb?
dialogId: null,
fields: [], // Felddefinitionen des Store-Dialogs
existingDoc: null, // Treffer der Dedup-Suche im gewählten Schrank (oder null)
cabinetFieldsByCab: {}, // Cache: cabinetId -> [Feldnamen] (für EML_ID-Erkennung)
};
// Mapping DocuWare-Feldname -> Funktion, die einen Vorbefüllwert aus meta liefert.
@ -224,6 +226,8 @@ async function onCabinetChange() {
STATE.cabinetId = $("cabinet").value;
STATE.dialogId = null;
STATE.fields = [];
STATE.existingDoc = null;
hideArchivedBanner();
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";
@ -267,11 +271,76 @@ async function onCabinetChange() {
const pick = cands.find((c) => c.id === remembered) || cands[0];
ds.value = pick.id;
await loadDialogFields(pick);
checkExisting(); // „bereits abgelegt?" im gewählten Schrank prüfen (nicht blockierend)
} catch (e) {
$("fields").innerHTML = `<div class="err">${e.message}</div>`;
}
}
// Sucht im gewählten Schrank nach der Message-ID und zeigt ggf. den Banner.
// Setzt STATE.existingDoc als Grundlage für die Dubletten-Warnung beim Ablegen.
async function checkExisting() {
STATE.existingDoc = null;
hideArchivedBanner();
if (STATE.isBasket || !STATE.cabinetId || !STATE.meta || !STATE.meta.rfcMessageId) return;
try {
const names = await cabinetFieldNames(STATE.cabinetId);
if (!names.includes("EML_ID")) return; // Schrank ohne Message-ID-Feld -> kein Dedup
const hit = await DocuWare.findByMessageId(
STATE.settings, STATE.cabinetId, STATE.meta.rfcMessageId
);
if (hit) {
STATE.existingDoc = hit;
showArchivedBanner();
}
} catch (_) { /* Dedup ist optional Fehler ignorieren */ }
}
// Feldnamen eines Schranks (gecacht), um EML_ID-Unterstützung zu erkennen.
async function cabinetFieldNames(cabinetId) {
if (STATE.cabinetFieldsByCab[cabinetId]) return STATE.cabinetFieldsByCab[cabinetId];
const names = await DocuWare.cabinetFieldNames(STATE.settings, cabinetId).catch(() => []);
STATE.cabinetFieldsByCab[cabinetId] = names;
return names;
}
function showArchivedBanner() {
$("archivedBanner").hidden = false;
}
function hideArchivedBanner() {
const el = $("archivedBanner");
if (el) el.hidden = true;
}
// Öffnet das vorhandene DocuWare-Dokument im Browser/Webclient.
function openExistingDoc() {
if (!STATE.existingDoc) return;
const url = DocuWare.documentViewUrl(STATE.settings, STATE.cabinetId, STATE.existingDoc.id);
browser.tabs.create({ url }).catch(() => {
try { window.open(url, "_blank"); } catch (_) {}
});
}
// Dubletten-Abfrage: resolve(true) = trotzdem ablegen, resolve(false) = abbrechen.
function confirmDuplicate() {
return new Promise((resolve) => {
const ov = $("dupOverlay");
ov.hidden = false;
const cleanup = (val) => {
ov.hidden = true;
$("dupProceed").removeEventListener("click", onProceed);
$("dupCancel").removeEventListener("click", onCancel);
$("dupOpen").removeEventListener("click", openExistingDoc);
resolve(val);
};
const onProceed = () => cleanup(true);
const onCancel = () => cleanup(false);
$("dupProceed").addEventListener("click", onProceed);
$("dupCancel").addEventListener("click", onCancel);
$("dupOpen").addEventListener("click", openExistingDoc); // öffnet Doc, Overlay bleibt offen
});
}
// Lädt und rendert die Felder für einen konkreten Dialog.
async function loadDialogFields(pick) {
STATE.dialogId = pick.id;
@ -486,12 +555,33 @@ async function submit() {
return;
}
// Dubletten-Warnung (nur Einzelmail): liegt die Mail im Ziel schon, warnen
// „Trotzdem ablegen" bleibt aber möglich.
if (STATE.messageIds.length === 1 && !STATE.isBasket && STATE.meta.rfcMessageId) {
let hit = STATE.existingDoc;
if (!hit) {
const names = await cabinetFieldNames(STATE.cabinetId);
if (names.includes("EML_ID")) {
hit = await DocuWare.findByMessageId(
STATE.settings, STATE.cabinetId, STATE.meta.rfcMessageId
).catch(() => null);
}
}
if (hit) {
STATE.existingDoc = hit;
showArchivedBanner();
const proceed = await confirmDuplicate();
if (!proceed) return;
}
}
await saveOptionPrefs(scope, wantPdf); // Sticky: Umfang merken
$("submit").disabled = true;
const multi = STATE.messageIds.length > 1;
let tagFailed = false;
try {
const fieldNames = STATE.isBasket ? [] : await cabinetFieldNames(STATE.cabinetId);
let mi = 0;
for (const mid of STATE.messageIds) {
mi++;
@ -503,6 +593,10 @@ async function submit() {
// Indexfelder: manuelle Werte für alle gleich; gemappte Felder je Mail neu.
const fields = STATE.isBasket ? {} : fieldsForMessage(result, meta, multi);
// Message-ID für Dedup/„bereits abgelegt" mitschreiben (wenn Schrank EML_ID hat).
if (!STATE.isBasket && meta.rfcMessageId && fieldNames.includes("EML_ID")) {
fields.EML_ID = { value: DocuWare.normalizeMessageId(meta.rfcMessageId), type: "Text" };
}
const wantAtt = wantAttScope && atts.length > 0;
if (wantEml) {
@ -532,6 +626,13 @@ async function submit() {
try { await tagMessage(mid); }
catch (te) { console.warn("DocuWare-Markierung fehlgeschlagen:", te); tagFailed = true; }
}
// Background-Badge aktualisieren (Mail gilt jetzt als abgelegt).
if (meta.rfcMessageId) {
browser.runtime
.sendMessage({ type: "archive:done", rfcMessageId: meta.rfcMessageId, cabinetId: STATE.cabinetId })
.catch(() => {});
}
}
const base = multi ? `${STATE.messageIds.length} E-Mails abgelegt.` : "Erfolgreich abgelegt.";
@ -621,6 +722,7 @@ function resetForm() {
$("cabinet").addEventListener("change", onCabinetChange);
$("storeDialog").addEventListener("change", onStoreDialogChange);
$("openExisting").addEventListener("click", openExistingDoc);
$("submit").addEventListener("click", submit);
$("reset").addEventListener("click", resetForm);
$("cancel").addEventListener("click", () => window.close());

View File

@ -312,6 +312,89 @@ const DocuWare = {
return { id: doc.Id, raw: doc };
},
// --- Dedup / „bereits abgelegt" ------------------------------------------
/** Normalisiert eine RFC822-Message-ID (ohne spitze Klammern/Whitespace). */
normalizeMessageId(raw) {
return String(raw || "").trim().replace(/^<|>$/g, "").trim();
},
/** Alle Indexfeld-Namen eines Schranks (zum Prüfen, ob z.B. EML_ID existiert). */
async cabinetFieldNames(settings, cabinetId) {
const map = await this._cabinetFieldMap(settings, cabinetId);
return Object.keys(map);
},
/** Erste Such-Dialog-GUID eines Schranks (für DialogExpression-Suchen). */
async getSearchDialogId(settings, cabinetId) {
const dialogs = await this._dialogList(settings, cabinetId);
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 ? pick.Id : null;
},
/**
* Sucht ein bereits abgelegtes Dokument anhand der Message-ID (Feld EML_ID).
* @returns {Promise<{id:number, raw:object}|null>} erster Treffer oder null
*/
async findByMessageId(settings, cabinetId, messageId, fieldName = "EML_ID") {
const mid = this.normalizeMessageId(messageId);
if (!mid) return null;
const dialogId = await this.getSearchDialogId(settings, cabinetId);
if (!dialogId) return null;
const platform = Settings.platformUrl(settings);
// Format exakt wie in der DocuWare-Postman-Collection: Count/DialogId als
// Query-Parameter (DialogId großgeschrieben), Bedingung im Body.
const url =
`${platform}/FileCabinets/${cabinetId}/Query/DialogExpression` +
`?Count=1&DialogId=${encodeURIComponent(dialogId)}`;
let res;
try {
res = await fetch(url, {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...Auth.authHeaders(),
},
body: JSON.stringify({
Condition: [{ DBName: fieldName, Value: [mid] }],
Operation: "And",
}),
});
} catch (_) {
return null;
}
if (!res.ok) return null;
const data = await res.json().catch(() => ({}));
let items = this._asArray(data.Items);
if (!items.length) items = this._asArray(data.Document);
if (!items.length) {
// Fallback: erstes Array von Dokument-Objekten (mit Id) robust gegen
// unterschiedliche Antwort-Schlüssel je DocuWare-Version.
for (const v of Object.values(data || {})) {
if (Array.isArray(v) && v.length && v[0] && v[0].Id != null) { items = v; break; }
}
}
if (!items.length) return null;
return { id: items[0].Id, raw: items[0] };
},
/** Best-effort-URL, um ein Dokument im DocuWare-Webclient zu öffnen.
* Ggf. je nach DocuWare-Version/Cloud anzupassen. */
documentViewUrl(settings, cabinetId, docId) {
const base = (settings.serverUrl || "").replace(/\/+$/, "");
return `${base}/DocuWare/Platform/WebClient/Client/Document?fc=${encodeURIComponent(
cabinetId
)}&did=${encodeURIComponent(docId)}`;
},
// --- Helfer ---------------------------------------------------------------
/** Wandelt {name:{value,type}} in das DocuWare-Field-Array. */

View File

@ -19,6 +19,7 @@ const Mail = {
const account = await this._accountForMessage(messageId);
return {
rfcMessageId: this._header(full, "message-id"), // RFC822 Message-ID (Dedup-Schlüssel)
subject: header.subject || "",
senderEmail: sender.email,
senderName: sender.name,
@ -53,6 +54,12 @@ const Mail = {
// --- Helfer ---------------------------------------------------------------
/** Erster Wert eines Mail-Headers (getFull liefert Header als Arrays, klein geschrieben). */
_header(full, name) {
const h = full && full.headers && full.headers[name];
return Array.isArray(h) && h.length ? String(h[0]).trim() : "";
},
_extractText(fullPart) {
// Rekursiv den ersten text/plain-Teil suchen, sonst text/html entkernen.
let plain = "";

View File

@ -2,7 +2,7 @@
"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",
"version": "0.10.0",
"author": "l.lingler",
"applications": {
"gecko": {
@ -30,6 +30,7 @@
"scripts": [
"lib/store.js",
"lib/auth.js",
"lib/docuware.js",
"background/background.js"
]
},
@ -42,7 +43,9 @@
},
"commands": {
"open-archive-dialog": {
"suggested_key": { "default": "Ctrl+Alt+I" },
"suggested_key": {
"default": "Ctrl+Alt+I"
},
"description": "Markierte E-Mail in DocuWare ablegen"
}
},