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