/** * 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, shareBaseUrl = "") { 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(); // Adresse für Freigabelinks. Paperless liefert nur den Slug, die // vollständige Adresse wird hier zusammengesetzt. Führt der interne // Zugang über einen abweichenden Port, wäre ein daraus gebauter Link // für den Empfänger wertlos - deshalb getrennt konfigurierbar. this.shareBase = String(shareBaseUrl || "").trim().replace(/\/+$/, "") || this.base; } 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; } /** * Dokumente suchen. Vier Wege, in dieser Reihenfolge: * * 1. Archiv-Seriennummer, wenn die Eingabe nur aus Ziffern besteht. * Sie ist ein eigenes Feld - die Volltextsuche findet sie NICHT. * * 2. Die GLOBALE SUCHE von Paperless (/api/search/). Dieselbe * Schnittstelle, die die Suchleiste oben in der Weboberfläche * bedient. Sie verbindet Titelsuche und Volltext und liefert * damit die Ergebnisse, die man aus der Oberfläche kennt. * * 3. Teilzeichenkette in Titel und Inhalt. Greift, wenn die globale * Suche nichts liefert oder in der eingesetzten Version fehlt. * * 4. Volltextsuche über den Index, mit UND-Verknüpfung. Sie * beherrscht Wortstämme und findet auch Beugungsformen. * * Zwei Fallstricke, die hier berücksichtigt sind: * * * Bei der Volltextsuche darf KEIN ordering mitgegeben werden. * Paperless sortiert dort nach Relevanz; eine eigene * Sortierangabe führte dazu, dass die Suchanfrage verworfen wurde * und schlicht die zuletzt hinzugefügten Dokumente kamen. * * * Ohne UND-Verknüpfung sucht der Index mit ODER. "Anschreiben * Markus Straub" lieferte dann alles, was irgendeines der drei * Wörter enthält - der gesuchte Treffer ging darin unter. */ async search(query, limit = 50) { const q = String(query || "").trim(); if (!q) return this.#suchlauf({ ordering: "-added" }, limit); // 1. Seriennummer if (/^\d+$/.test(q)) { const asn = await this.#suchlauf({ archive_serial_number: q }, limit); if (asn.length) return asn; } // 2. Globale Suche wie in der Weboberfläche const global = await this.#globaleSuche(q, limit); if (global.length) return global; // 3. Titel und Inhalt const text = await this.#suchlauf( { title_content: q, ordering: "-created" }, limit); if (text.length) return text; // 4. Volltext mit UND, ohne ordering. // // Nach dem Ersetzen der Sonderzeichen ERNEUT zerlegen: Aus // "Müller-Lüdenscheidt" würde sonst ein Wort mit Leerzeichen, und // die UND-Verknüpfung wäre wieder aufgehoben. const worte = q.replace(/[+\-&|!(){}[\]^"~*?:\\/]/g, " ") .split(/\s+/).filter(Boolean); if (!worte.length) return []; return this.#suchlauf({ query: worte.join(" AND ") }, limit); } /** * Die Schnittstelle hinter der Suchleiste der Weboberfläche. * * Sie gibt es erst ab neueren Paperless-Ständen, und die Form der * Antwort ist nicht überall gleich - mal ein Feld "documents", mal * "results". Deshalb wird beides angenommen und bei jedem Fehler * stillschweigend auf den nächsten Weg ausgewichen. */ async #globaleSuche(q, limit) { let antwort; try { const params = new URLSearchParams({ query: q, page_size: String(limit) }); antwort = await this.#json(`search/?${params}`); } catch { return []; } if (!antwort) return []; for (const schluessel of ["documents", "results"]) { const rows = antwort[schluessel]; if (Array.isArray(rows) && rows.length) { // Die globale Suche liefert je nach Version verschiedene // Objektarten. Nur echte Dokumente durchlassen. return rows .filter((r) => r && typeof r === "object" && r.id !== undefined && r.title !== undefined) .slice(0, limit); } } return []; } async #suchlauf(zusatz, limit) { const params = new URLSearchParams({ page_size: String(limit), // Ohne diese Einschränkung liefert Paperless je Treffer auch den // vollständigen Inhalt - bei fünfzig Treffern mehrere Megabyte. fields: "id,title,created,added,archive_serial_number,mime_type," + "original_file_name" }); for (const [k, v] of Object.entries(zusatz)) params.set(k, String(v)); try { const data = await this.#json(`documents/?${params}`); return data?.results || []; } catch { return []; } } 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.shareBase}/share/${res.slug}`; return null; } catch (err) { last = err; } } throw last; } }