thunderbird2docuware/lib/docuware.js
sylyx e201e6c654 Ein Ablagefenster für Mehrfachauswahl + Sitzungs-/Retry-Fixes
Mehrfachauswahl öffnet jetzt EIN Ablagefenster (?messageIds=1,2,3) statt eines
Fensters je Nachricht. Der dafür schon vorhandene, bisher unerreichbare Multi-Pfad
in dialog.js hatte drei Fehler, die dabei aufgeschlagen wären:
- Der Anhang-Haken wurde aus der ERSTEN Mail abgeleitet: hatte die keine Anhänge,
  fielen die Anhänge aller übrigen Mails still weg.
- Die Anhangsliste zeigte die Anhänge von Mail 1 mit wirkungslosen Haken.
- Der „bereits abgelegt"-Banner galt nur für Mail 1, sah aber nach einer Aussage
  über die ganze Auswahl aus.

Weiter:
- Server-/Zugangsdaten-Wechsel verwirft jetzt auch die serverspezifischen Caches.
  auth:logout leert zusätzlich ARCHIVED_CACHE; mit scope:"server" fliegen die
  gecachte Schrankliste sowie defaultCabinetId/dialogByCabinet raus, weil deren
  GUIDs nur auf dem alten Server existieren.
- Retry nach einem Teilfehler lud bereits übertragene Teile erneut hoch und
  erzeugte Dubletten. Jeder Teil (eml/pdf/Anhang) wird nun je Ziel einmal
  hochgeladen; dieselbe Mail lässt sich dadurch auch bewusst in ein zweites
  Archiv ablegen.
- ARCHIVED_CACHE wächst nicht mehr unbegrenzt (500 Einträge, ältester fliegt).
- HTTP 401/403 liefern statt des nackten Statuscodes eine verständliche Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:16:35 +02:00

466 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// DocuWare Platform REST-Client.
// Alle Pfade relativ zu {serverUrl}/DocuWare/Platform.
// Auth primär per Bearer-Token (Identity Service) über Auth.authHeaders(); das
// credentials:"include" überall ist für den Cookie-Fallback alter On-Prem-Server
// nötig (siehe auth.js) und stört im Token-Modus nicht.
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) throw await this._httpError(res, `GET ${path}`);
return res.json();
},
/**
* Baut aus einer fehlgeschlagenen Antwort einen sprechenden Fehler.
* 401/403 sind der häufigste Fall (Token abgelaufen oder Zugangsdaten/Server
* gewechselt) – der nackte Statuscode hilft dem Anwender dabei nicht weiter.
*/
async _httpError(res, what) {
const body = (await res.text().catch(() => "")).slice(0, 300);
if (res.status === 401) {
return new Error(
"Nicht angemeldet (HTTP 401). Die DocuWare-Sitzung ist abgelaufen oder die " +
"Zugangsdaten stimmen nicht. Bitte das Ablagefenster neu öffnen bzw. in den " +
"Einstellungen „Verbindung testen“."
);
}
if (res.status === 403) {
return new Error(
"Keine Berechtigung (HTTP 403). Der Benutzer darf diese Aktion in DocuWare nicht ausführen."
);
}
return new Error(`${what} -> HTTP ${res.status}. ${body}`.trim());
},
/**
* 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) throw await this._httpError(res, "Upload fehlgeschlagen");
const doc = await res.json().catch(() => ({}));
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. */
_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);
if (isNaN(d)) return { FieldName: name, Item: String(value), ItemElementName: "Date" };
// Auf den LOKALEN Kalendertag als UTC-Mitternacht normalisieren. Sonst würde
// toISOString eine Uhrzeit nahe Mitternacht in eine andere Zeitzone und damit
// auf den Vor-/Folgetag kippen (Datumsfelder tragen keine Uhrzeit).
const utcDay = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
return { FieldName: name, Item: utcDay.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 };