Files
2026-09-04 19:50:29 +02:00

325 lines
10 KiB
JavaScript

/**
* client.js - REST-Client für Paperless-ngx.
*
* Die Eigenheiten der Schnittstelle sind hier festgehalten, weil sie
* nirgends dokumentiert und durch Beobachtung erschlossen sind:
*
* * update_version nimmt NUR "document" und "version_label" entgegen -
* keinen Zeitstempel. Ein "added" je Version lässt sich über die API
* nicht setzen.
* * Der Status einer Aufgabe kommt kleingeschrieben ("success"), in
* älteren Ständen groß. Vergleich deshalb unabhängig von der
* Schreibweise.
* * Die Dokument-ID steht je nach Stand in related_document_ids,
* verschachtelt in result_data.document_id oder direkt in
* related_document.
* * Der Endpunkt für Freigabelinks heißt je nach Version share_links/
* oder sharelinks/.
*/
const TASK_TIMEOUT = 900_000; // 15 Minuten
const TASK_POLL = 1500;
// KEIN eigener Kopfzeileneintrag zur Kennzeichnung.
//
// Ein selbst gesetzter Eintrag wie "X-Client" ist nicht CORS-sicher und
// löst eine Vorabanfrage (OPTIONS) aus. Antwortet Paperless darauf nicht
// wie erwartet, scheitert schon der erste Aufruf mit "NetworkError" -
// einer Meldung, die nichts über die Ursache verrät.
//
// Der Browser sendet ohnehin seine eigene Kennung. Sollte ein Reverse
// Proxy diese abweisen, gehört die Ausnahme dort konfiguriert, nicht hier
// umgangen.
export class PaperlessError extends Error {}
export class PaperlessClient {
constructor(baseUrl, token) {
let parsed;
try {
parsed = new URL(String(baseUrl || "").trim());
} catch {
throw new PaperlessError("badUrl");
}
// Nur http und https. Ohne Prüfung liessen sich hier andere Schemata
// hinterlegen, die fetch teilweise bedient.
if (!["http:", "https:"].includes(parsed.protocol) || !parsed.host) {
throw new PaperlessError("badUrl");
}
this.base = String(baseUrl).trim().replace(/\/+$/, "");
this.token = String(token || "").trim();
}
get origin() {
return new URL(this.base).origin + "/*";
}
async #fetch(path, options = {}) {
const url = path.startsWith("http") ? path
: `${this.base}/api/${String(path).replace(/^\/+/, "")}`;
const headers = {
"Authorization": `Token ${this.token}`,
"Accept": "application/json, */*",
...(options.headers || {})
};
let response;
try {
response = await fetch(url, { ...options, headers, redirect: "follow" });
} catch (err) {
// "NetworkError" ist die Sammelmeldung des Browsers für alles, was
// die Anfrage gar nicht erst erreichen liess: fehlendes
// Zugriffsrecht, unerreichbarer Rechner, abgewiesenes Zertifikat.
// Der Hinweis auf die Berechtigung steht hier, weil das mit Abstand
// der häufigste Fall ist.
throw new PaperlessError(
`Verbindung fehlgeschlagen: ${err.message}\n\n`
+ `Häufigste Ursachen:\n`
+ `• Das Zugriffsrecht für ${new URL(this.base).origin} wurde nicht `
+ `erteilt — in den Einstellungen erneut speichern\n`
+ `• Der Rechner ist nicht erreichbar\n`
+ `• Das TLS-Zertifikat wird abgewiesen (bei selbst ausgestellten `
+ `Zertifikaten in Thunderbird einmal im Browser bestätigen)`);
}
if (!response.ok) {
let body = "";
try {
body = (await response.text()).slice(0, 400);
} catch { /* egal */ }
throw new PaperlessError(`HTTP ${response.status}: ${body}`);
}
return response;
}
async #json(path, options) {
const r = await this.#fetch(path, options);
const text = await r.text();
return text ? JSON.parse(text) : null;
}
// ------------------------------------------------------------ Aufgaben
static docIdFromTask(task) {
const asInt = (v) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : null;
};
for (const key of ["related_document_ids", "document_ids"]) {
const val = task?.[key];
if (Array.isArray(val) && val.length) {
const got = asInt(val[0]);
if (got) return got;
}
}
const nested = task?.result_data;
if (nested && typeof nested === "object") {
for (const key of ["document_id", "related_document", "id"]) {
const got = asInt(nested[key]);
if (got) return got;
}
}
for (const key of ["related_document", "document_id",
"related_document_id"]) {
const got = asInt(task?.[key]);
if (got) return got;
}
const m = /[Dd]ocument\D+(\d+)/.exec(String(task?.result || ""));
return m ? parseInt(m[1], 10) : null;
}
async #findTask(taskId) {
for (const query of [`tasks/?task_id=${encodeURIComponent(taskId)}`,
"tasks/"]) {
let data;
try {
data = await this.#json(query);
} catch {
continue;
}
const rows = Array.isArray(data) ? data : (data?.results || []);
const hit = rows.find((t) => String(t.task_id) === String(taskId));
if (hit) return hit;
}
return null;
}
async waitForTask(taskId, onProgress) {
const deadline = Date.now() + TASK_TIMEOUT;
let waited = 0;
while (Date.now() < deadline) {
const task = await this.#findTask(taskId);
if (task) {
const status = String(task.status || "").toUpperCase();
if (["SUCCESS", "SUCCEEDED"].includes(status)) {
const id = PaperlessClient.docIdFromTask(task);
if (id) return id;
throw new PaperlessError(
`Aufgabe erfolgreich, aber ohne Dokument-ID: ${JSON.stringify(task)}`);
}
if (["FAILURE", "FAILED", "REVOKED"].includes(status)) {
const rd = task.result_data || {};
const msg = task.result || rd.error || rd.exc_message
|| `Aufgabe ${status}`;
throw new PaperlessError(String(msg).slice(0, 400));
}
}
await new Promise((r) => setTimeout(r, TASK_POLL));
waited += TASK_POLL;
onProgress?.(Math.round(waited / 1000));
}
throw new PaperlessError(`Zeitüberschreitung bei Aufgabe ${taskId}`);
}
// ----------------------------------------------------------- Dokumente
async ping() {
await this.#json("documents/?page_size=1");
return true;
}
async search(query, limit = 50) {
const params = new URLSearchParams({
page_size: String(limit),
ordering: "-added",
fields: "id,title,created,added,archive_serial_number,mime_type," +
"original_file_name"
});
if (query) params.set("query", query);
const data = await this.#json(`documents/?${params}`);
return data?.results || [];
}
document(id) {
return this.#json(`documents/${Number(id)}/`);
}
async versions(doc) {
const list = doc?.versions || [];
if (!list.length) {
return [{
id: doc.id, no: 1, added: doc.added, is_root: true,
version_label: null, mime_type: doc.mime_type,
filename: doc.original_file_name || ""
}];
}
// Paperless liefert absteigend, neueste zuerst. Die Versionsnummer
// ergibt sich aus der Position von unten gezählt.
const total = list.length;
const out = [];
for (const [idx, v] of list.entries()) {
const entry = { ...v, no: total - idx };
try {
const full = await this.document(v.id);
entry.mime_type = full.mime_type;
entry.filename = full.original_file_name || "";
} catch {
entry.mime_type = null;
entry.filename = "";
}
out.push(entry);
}
return out;
}
async download(id, original = true) {
const url = `${this.base}/api/documents/${Number(id)}/download/`
+ (original ? "?original=true" : "");
const r = await this.#fetch(url);
const disp = r.headers.get("Content-Disposition") || "";
let name = null;
const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disp);
if (m) {
try {
name = decodeURIComponent(m[1]);
} catch {
name = m[1];
}
}
return { blob: await r.blob(), name };
}
async postDocument(file, filename, fields = {}) {
const form = new FormData();
form.append("document", file, filename);
for (const [k, v] of Object.entries(fields)) {
if (v === null || v === undefined || v === "") continue;
if (Array.isArray(v)) {
v.forEach((x) => form.append(k, String(x)));
} else {
form.append(k, String(v));
}
}
return this.#json("documents/post_document/",
{ method: "POST", body: form });
}
async updateVersion(docId, file, filename, versionLabel) {
const form = new FormData();
form.append("document", file, filename);
if (versionLabel) form.append("version_label", versionLabel);
return this.#json(`documents/${Number(docId)}/update_version/`,
{ method: "POST", body: form });
}
patchDocument(id, payload) {
return this.#json(`documents/${Number(id)}/`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
}
// ----------------------------------------------------------- Stammdaten
async #all(path, fields = "id,name") {
const out = [];
let page = 1;
for (;;) {
const data = await this.#json(
`${path}?page_size=200&page=${page}&fields=${fields}`);
if (!data) break;
out.push(...(data.results || []));
if (!data.next) break;
page += 1;
}
return out;
}
tags() { return this.#all("tags/"); }
correspondents() { return this.#all("correspondents/"); }
documentTypes() { return this.#all("document_types/"); }
create(path, name) {
return this.#json(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name })
});
}
// ------------------------------------------------------------- Freigabe
async shareLink(docId, days = 7, fileVersion = "original") {
const payload = { document: Number(docId), file_version: fileVersion };
if (days) {
const d = new Date(Date.now() + days * 86400_000);
payload.expiration = d.toISOString();
}
let last = null;
// Der Endpunkt heißt je nach Version anders. Beide probieren.
for (const path of ["share_links/", "sharelinks/"]) {
try {
const res = await this.#json(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (res?.slug) return `${this.base}/share/${res.slug}`;
return null;
} catch (err) {
last = err;
}
}
throw last;
}
}