thunderbird2docuware/lib/docuware.js

360 lines
13 KiB
JavaScript
Raw Permalink Normal View History

// 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 };
},
// --- 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);
return { FieldName: name, Item: d.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 };