- dialog.js: Anhang-Liste per DOM-API statt innerHTML (kein HTML-Inject über angreiferkontrollierte Anhang-Namen); Feldname-Selektoren via CSS.escape, datalist-id bereinigt; Datums-Eingabe lokal statt UTC (kein Off-by-one); bereits abgelegte Mails bei Retry überspringen (keine Dubletten) - docuware.js: Datumsfelder auf lokalen Kalendertag normalisieren (UTC-Mitternacht) - store.js: Settings.set serialisiert (kein read-modify-write Race) - options.js: Warnung bei unverschlüsseltem http://-Server - mail.js: ungenutzten Parameter aus _direction entfernt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
448 lines
17 KiB
JavaScript
448 lines
17 KiB
JavaScript
// DocuWare Platform REST-Client.
|
||
// Alle Pfade relativ zu {serverUrl}/DocuWare/Platform.
|
||
// Auth läuft per Session-Cookie (siehe auth.js); daher überall credentials:"include".
|
||
|
||
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) {
|
||
const t = await res.text().catch(() => "");
|
||
throw new Error(`GET ${path} -> HTTP ${res.status}. ${t}`.trim());
|
||
}
|
||
return res.json();
|
||
},
|
||
|
||
/**
|
||
* 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) {
|
||
const t = await res.text().catch(() => "");
|
||
throw new Error(`Upload fehlgeschlagen (HTTP ${res.status}). ${t}`.trim());
|
||
}
|
||
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 };
|