thunderbird2docuware/lib/docuware.js
sylyx 7e351a818a Feld-Zuordnung: Dateiname, Datum+Uhrzeit, Benutzer und weitere Werte
MAIL_TOKENS wächst von 11 auf 22 auswählbare Werte:
- Dokument: fileName (je Dokument), attachmentCount, rfcMessageId
- Datum/Zeit: dateTime, time, archiveDate, archiveDateTime
- Wer/Woher: dwUser, dwOrganization, accountEmail, folder

fileName kann nicht wie die übrigen Tokens einmal je Mail aufgelöst werden: die
.eml/PDF trägt den Mailnamen, jeder Anhang seinen eigenen. Darum tragen TOKEN_FN
jetzt ein zweites Argument ctx {fileName, attachmentCount}, und Felder, die auf
ein Token aus PER_FILE_TOKENS gemappt sind, werden über applyPerFileFields() vor
JEDEM Upload neu gesetzt. Im Formular steht nur eine Vorschau (Tooltip erklärt
das), die beim Umschalten .eml <-> PDF nachzieht.

Fix DateTime-Felder: _field prüfte t.includes("date") zuerst, und "DateTime"
enthält "date" — jedes DocuWare-DateTime-Feld wurde damit auf UTC-Mitternacht
normalisiert und verlor die Uhrzeit. Das betraf auch bestehende Zuordnungen.
Jetzt wird datetime zuerst geprüft; der Dialog rendert solche Felder als
datetime-local und liest sie lokal zurück. Date-Felder bleiben unverändert auf
dem lokalen Kalendertag als UTC-Mitternacht.

Offen und im Code vermerkt: ob DocuWare bei DateTime das "Z" auswertet oder die
ISO-Zeichenkette als lokale Wanduhrzeit liest, ist nicht gegen einen Server
verifiziert.

Mail.getMeta liefert zusätzlich folder und accountEmail; _accountForMessage
wurde zu _accountInfo (Name + Identitäts-Adresse).
DEFAULT_FIELD_MAP: DOC_FILE_NAME -> fileName.

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

481 lines
19 KiB
JavaScript
Raw Permalink 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();
// WICHTIG: "datetime" zuerst prüfen – "DateTime".includes("date") ist true und
// würde sonst die Uhrzeit auf Mitternacht normalisieren (Datenverlust).
if (t.includes("datetime")) {
const d = value instanceof Date ? value : new Date(value);
if (isNaN(d)) return { FieldName: name, Item: String(value), ItemElementName: "DateTime" };
// Mit Uhrzeit: echter Zeitpunkt, daher unverändert als ISO (UTC) senden.
//
// UNGEPRÜFT gegen den Server: ob DocuWare das "Z" auswertet (dann stimmt die
// Uhrzeit) oder die ISO-Zeichenkette als lokale Wanduhrzeit übernimmt (dann
// erscheint sie um den UTC-Versatz verschoben, in Wien -2 h). Reine Date-Felder
// sind davon nicht betroffen, die werden auf UTC-Mitternacht normalisiert.
// Falls im Testarchiv verschoben: hier stattdessen die lokale Wanduhrzeit ohne
// Zeitzone senden, also `${toLocalIso(d)}` statt d.toISOString().
return { FieldName: name, Item: d.toISOString(), ItemElementName: "DateTime" };
}
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 };