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:
parent
1b15973c9e
commit
73693fc381
@ -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).
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
102
dialog/dialog.js
102
dialog/dialog.js
@ -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());
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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 = "";
|
||||
|
||||
@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user