/** * 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; } }