Initial commit
This commit is contained in:
324
lib/client.js
Normal file
324
lib/client.js
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
97
lib/settings.js
Normal file
97
lib/settings.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* settings.js - Einstellungen und gemeinsam genutzte Hilfsmittel.
|
||||
*
|
||||
* Die Einstellungen liegen in browser.storage.local, also im Profil von
|
||||
* Thunderbird. Der Token steht dort im Klartext - das entspricht dem, was
|
||||
* Erweiterungen üblicherweise tun, ist aber kein Geheimnisspeicher.
|
||||
* Einzelheiten in SICHERHEIT.md.
|
||||
*/
|
||||
|
||||
const VORGABEN = {
|
||||
baseUrl: "",
|
||||
token: "",
|
||||
versionLabel: "Thunderbird {datum}",
|
||||
shareDays: 7,
|
||||
shareTag: "Sharelink",
|
||||
// Anhänge, die üblicherweise keine Dokumente sind. Sie werden im
|
||||
// Auswahldialog vorab abgewählt, lassen sich aber anhaken.
|
||||
ignorierteTypen: ["image/gif", "image/png", "image/jpeg", "text/vcard",
|
||||
"text/calendar", "application/pkcs7-signature",
|
||||
"application/pgp-signature"],
|
||||
ignorierteKleiner: 8192 // Bytes; darunter meist Signaturbilder
|
||||
};
|
||||
|
||||
export async function ladeEinstellungen() {
|
||||
const gespeichert = await browser.storage.local.get(Object.keys(VORGABEN));
|
||||
return { ...VORGABEN, ...gespeichert };
|
||||
}
|
||||
|
||||
export async function speichereEinstellungen(werte) {
|
||||
await browser.storage.local.set(werte);
|
||||
}
|
||||
|
||||
export function vollstaendig(s) {
|
||||
return Boolean(s?.baseUrl) && Boolean(s?.token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Beliebige Zeichenkette in einen unbedenklichen Dateinamen überführen.
|
||||
*
|
||||
* Der Name eines E-Mail-Anhangs stammt vom Absender und ist damit nicht
|
||||
* vertrauenswürdig. Er wird zwar nur an Paperless weitergereicht und nicht
|
||||
* ins Dateisystem geschrieben, aber ein Wert wie "../../x" hat in einem
|
||||
* Dateinamen nichts zu suchen.
|
||||
*/
|
||||
export function sichererName(name, vorgabe = "Dokument", maxlen = 120) {
|
||||
let n = String(name || "");
|
||||
n = n.replace(/\\/g, "/").split("/").pop();
|
||||
n = n.replace(/[<>:"|?*\u0000-\u001f]/g, "").trim();
|
||||
n = n.replace(/\s+/g, " ").replace(/^[. ]+|[. ]+$/g, "");
|
||||
if (!n) return vorgabe;
|
||||
const reserviert = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
|
||||
if (reserviert.test(n)) n = "_" + n;
|
||||
if (n.length > maxlen) {
|
||||
const punkt = n.lastIndexOf(".");
|
||||
const endung = punkt > 0 && n.length - punkt <= 9 ? n.slice(punkt) : "";
|
||||
n = n.slice(0, maxlen - endung.length) + endung;
|
||||
}
|
||||
return n || vorgabe;
|
||||
}
|
||||
|
||||
export function versionslabel(vorlage) {
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
return String(vorlage || "Thunderbird {datum}").replace("{datum}", heute);
|
||||
}
|
||||
|
||||
/** Kürzt einen Text für die Anzeige. */
|
||||
export function kurz(text, laenge = 70) {
|
||||
const t = String(text || "");
|
||||
return t.length > laenge ? t.slice(0, laenge - 1) + "…" : t;
|
||||
}
|
||||
|
||||
/** Menschenlesbare Größenangabe. */
|
||||
export function groesse(bytes) {
|
||||
const b = Number(bytes) || 0;
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB`;
|
||||
return `${(b / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Betreff einer Nachricht in einen Dokumenttitel überführen.
|
||||
*
|
||||
* Präfixe wie "AW:" oder "Fwd:" tragen im Archiv nichts bei und stören
|
||||
* beim Sortieren.
|
||||
*/
|
||||
export function titelAusBetreff(betreff) {
|
||||
let t = String(betreff || "").trim();
|
||||
const praefixe = /^((AW|RE|WG|FWD|FW|TR|RÉF|REF)\s*(\[\d+\])?\s*:\s*)+/i;
|
||||
t = t.replace(praefixe, "").trim();
|
||||
return t || "E-Mail ohne Betreff";
|
||||
}
|
||||
|
||||
/** Übersetzung über die eingebaute Verwaltung von Thunderbird. */
|
||||
export function t(schluessel, ersetzungen) {
|
||||
const s = browser.i18n.getMessage(schluessel, ersetzungen);
|
||||
return s || schluessel;
|
||||
}
|
||||
Reference in New Issue
Block a user