Files
thunderbird-paperless-conne…/lib/client.js

429 lines
14 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, 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;
}
}