thunderbird2docuware/lib/docuware.js
sylyx 03ff3795b2 Datum + Uhrzeit: Uhrzeit ging in Text- und DateTime-Feldern verloren
Das Token "Datum + Uhrzeit der E-Mail" lieferte nur das Datum. Drei Ursachen:

1. setFieldValue schrieb ein Date in ein TEXT-Feld ueber toDateInput, also nur
   "2026-08-04" - die Uhrzeit war weg, obwohl ein Textfeld sie problemlos traegt.
   Beim Upload wurde daraus String(dateObjekt), also die JS-Standardausgabe
   "Mon Aug 04 2026 16:28:51 GMT+0200 (...)". Neu: DocuWare.dateToText() liefert
   "04.08.2026 16:28:51", bei exakter Mitternacht nur "04.08.2026". Gilt fuer
   Text-, Memo- und Keyword-Felder und fuer Anzeige wie Upload gleichermassen.

2. TOKEN_FN.date war identisch mit dateTime (beide m.date). In einem Datums- oder
   DateTime-Feld fiel das nicht auf, in einem Textfeld schon: "Datum" haette dort
   ebenfalls eine Uhrzeit getragen. date liefert jetzt via dayOnly() den
   Kalendertag (lokale Mitternacht), wie archiveDate es schon tat.

3. Das datetime-local-Control kannte ohne step="1" nur Minuten und schnitt die
   Sekunden ab; fmtTime liefert sie jetzt mit.

Ausserdem geklaert: DateTime geht als LOKALE Wanduhrzeit ohne Zeitzonen-Suffix
raus ("2026-08-04T16:28:51") statt als toISOString() mit "Z". Ob der Server das
"Z" zurueckrechnet, haengt von dessen Zeitzone ab (Cloud meist UTC) - Zeiten
erschienen verschoben, und lokale Mitternacht kippte als UTC auf den VORTAG
22:00. Ohne Suffix ist der Wert eindeutig. Date-Felder bleiben unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:54:22 +02:00

506 lines
20 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;
},
/**
* Date-Objekt als deutscher Text: "04.08.2026 16:28:51". Ohne Uhrzeit (exakt
* Mitternacht) nur "04.08.2026" – so tragen reine Datums-Tokens kein
* künstliches " 00:00:00". Gebraucht, sobald ein Datum in einem TEXT-Feld
* landet: String(dateObjekt) lieferte sonst die JS-Standardausgabe
* ("Mon Aug 04 2026 16:28:51 GMT+0200 (Mitteleuropäische Sommerzeit)").
* Bewusst lokale Zeit ohne UTC-Umrechnung – der Text soll zeigen, was der
* Nutzer in Thunderbird sieht.
*/
dateToText(value) {
const d = value instanceof Date ? value : new Date(value);
if (isNaN(d)) return String(value);
const p = (n) => String(n).padStart(2, "0");
const day = `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()}`;
if (!d.getHours() && !d.getMinutes() && !d.getSeconds()) return day;
return `${day} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
},
_field(name, value, type) {
const t = (type || "Text").toLowerCase();
// Datum in einem Text-/Memo-Feld lesbar machen (s. dateToText).
const asText = (v) => (v instanceof Date ? this.dateToText(v) : String(v));
// 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" };
// LOKALE Wanduhrzeit ohne Zeitzonen-Suffix ("2026-08-04T16:28:51").
//
// Vorher ging hier d.toISOString() raus, also UTC mit "Z". Ob DocuWare das
// "Z" in Serverzeit zurückrechnet, hängt vom Server ab (Cloud-Instanzen
// laufen meist auf UTC) – die Uhrzeit erschien dann um den Versatz
// verschoben, in Wien -2 h. Schlimmer beim Kalendertag: lokale Mitternacht
// wird als UTC zum VORTAG 22:00.
// Ohne Suffix ist der Wert eindeutig: der Server übernimmt ihn so, wie der
// Nutzer die Zeit in Thunderbird sieht. Reine Date-Felder bleiben unberührt.
const p = (n) => String(n).padStart(2, "0");
const iso =
`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` +
`T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
return { FieldName: name, Item: iso, 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: asText(value), ItemElementName: "Memo" };
}
if (t.includes("keyword")) {
const arr = Array.isArray(value) ? value : [asText(value)];
return { FieldName: name, Item: arr, ItemElementName: "Keywords" };
}
return { FieldName: name, Item: asText(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 };