325 lines
10 KiB
JavaScript
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;
|
|
}
|
|
}
|