Initial commit

This commit is contained in:
2026-09-04 19:50:29 +02:00
parent 742e5bc6b2
commit 3ae0ab0a88
37 changed files with 3459 additions and 0 deletions

44
ui/classify.html Normal file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="classifyTitle">Klassifizierung bearbeiten</title>
<link rel="stylesheet" href="gemeinsam.css">
</head>
<body>
<h1 data-i18n="classifyTitle">Klassifizierung bearbeiten</h1>
<div id="quelle" class="hinweis"></div>
<label for="titel" data-i18n="fieldTitle">Titel</label>
<input type="text" id="titel">
<label for="erstellt" data-i18n="fieldCreated">Erstellt</label>
<input type="text" id="erstellt" placeholder="JJJJ-MM-TT">
<label for="korr" data-i18n="fieldCorrespondent">Korrespondent</label>
<input type="text" id="korr" list="korrListe" autocomplete="off">
<datalist id="korrListe"></datalist>
<label for="typ" data-i18n="fieldDocType">Dokumenttyp</label>
<input type="text" id="typ" list="typListe" autocomplete="off">
<datalist id="typListe"></datalist>
<div class="wachsen">
<label data-i18n="fieldTags">Tags</label>
<div class="liste"><div id="tags" style="padding:4px 8px"></div></div>
</div>
<div class="hinweis" data-i18n="classifyHintNew">
Unbekannte Korrespondenten und Dokumenttypen werden angelegt.
</div>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="ueberspringen" data-i18n="btnSkip">Überspringen</button>
<button id="speichern" class="haupt" data-i18n="btnSave">Speichern</button>
</div>
<script type="module" src="classify.js"></script>
</body>
</html>

109
ui/classify.js Normal file
View File

@@ -0,0 +1,109 @@
/**
* classify.js - Klassifizierung eines gerade abgelegten Dokuments.
*
* Wird unmittelbar nach dem Ablegen aufgerufen. Ohne diesen Schritt trägt
* ein neues Dokument nur den Titel, und nachträglich macht man es
* erfahrungsgemäß nicht mehr.
*/
import { kurz } from "../lib/settings.js";
import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
from "./gemeinsam.js";
const $ = (id) => document.getElementById(id);
let dokumentId = null;
let alleTags = [];
document.addEventListener("DOMContentLoaded", start);
async function start() {
uebersetzeSeite();
const p = new URLSearchParams(location.search);
dokumentId = Number(p.get("id"));
const weitere = Number(p.get("weiter") || 1);
$("ueberspringen").addEventListener("click", () => window.close());
$("speichern").addEventListener("click", speichern);
try {
const [doc, stamm] = await Promise.all([
ruf("dokument", { id: dokumentId }),
ruf("stammdaten")
]);
alleTags = stamm.tags;
$("quelle").textContent =
`#${dokumentId} · ${kurz(doc.title, 70)}`
+ (weitere > 1
? ` · ${browser.i18n.getMessage("classifyMoreStored",
[String(weitere)]) || `${weitere} Dateien abgelegt`}`
: "");
$("titel").value = doc.title || "";
$("erstellt").value = (doc.created || "").slice(0, 10);
fuelleListe("korrListe", stamm.korr);
fuelleListe("typListe", stamm.typen);
$("korr").value = nameVon(stamm.korr, doc.correspondent);
$("typ").value = nameVon(stamm.typen, doc.document_type);
zeigeTags(doc.tags || []);
} catch (err) {
fehlerAnzeigen(err.message);
}
}
function fuelleListe(id, eintraege) {
const dl = $(id);
dl.replaceChildren();
for (const e of eintraege) {
const o = document.createElement("option");
o.value = e.name;
dl.append(o);
}
}
function nameVon(liste, id) {
return liste.find((x) => x.id === id)?.name || "";
}
function zeigeTags(gesetzt) {
const ziel = $("tags");
ziel.replaceChildren();
for (const tag of alleTags) {
const zeile = document.createElement("div");
zeile.className = "wahl";
const box = document.createElement("input");
box.type = "checkbox";
box.id = `t${tag.id}`;
box.value = String(tag.id);
box.checked = gesetzt.includes(tag.id);
const bez = document.createElement("label");
bez.htmlFor = box.id;
bez.textContent = tag.name;
zeile.append(box, bez);
ziel.append(zeile);
}
}
async function speichern() {
$("speichern").disabled = true;
meldungAnzeigen(browser.i18n.getMessage("msgSaving") || "Wird gespeichert …");
try {
const tags = [...$("tags").querySelectorAll("input:checked")]
.map((b) => Number(b.value));
const payload = { title: $("titel").value.trim(), tags };
const erstellt = $("erstellt").value.trim();
if (erstellt) payload.created = erstellt;
await ruf("klassifizieren", {
id: dokumentId,
payload,
korrName: $("korr").value.trim(),
typName: $("typ").value.trim()
});
meldungAnzeigen(browser.i18n.getMessage("msgSaved") || "Gespeichert.");
setTimeout(() => window.close(), 900);
} catch (err) {
fehlerAnzeigen(err.message);
$("speichern").disabled = false;
}
}

74
ui/gemeinsam.css Normal file
View File

@@ -0,0 +1,74 @@
/* Gemeinsames Aussehen der Dialoge. Bewusst zurückhaltend, damit sie sich
in die Oberfläche von Thunderbird einfügen statt sich abzuheben. */
:root {
--gruen: #175420;
--rand: #c8ccd0;
--hell: #f6f6f4;
color-scheme: light dark;
}
* { box-sizing: border-box; }
body {
font: message-box;
font-size: 13px;
margin: 0;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
height: 100vh;
}
h1 { font-size: 14px; margin: 0 0 2px; font-weight: 600; }
fieldset {
border: 1px solid var(--rand);
border-radius: 4px;
margin: 0;
padding: 8px 10px 10px;
min-width: 0;
}
legend { padding: 0 4px; font-weight: 600; }
label { display: block; margin: 4px 0 2px; }
input[type=text], input[type=password], input[type=number], select {
width: 100%;
padding: 4px 6px;
border: 1px solid var(--rand);
border-radius: 3px;
background: Field;
color: FieldText;
}
.zeile { display: flex; gap: 8px; align-items: center; }
.zeile > input[type=text] { flex: 1; }
.wahl { display: flex; gap: 6px; align-items: center; margin: 3px 0; }
.wahl input { width: auto; margin: 0; }
button {
padding: 5px 14px;
border: 1px solid var(--rand);
border-radius: 3px;
background: ButtonFace;
color: ButtonText;
cursor: pointer;
}
button.haupt { background: var(--gruen); color: #fff; border-color: var(--gruen); }
button:disabled { opacity: .5; cursor: default; }
.fuss { display: flex; justify-content: flex-end; gap: 8px; margin-top: auto; }
.liste {
border: 1px solid var(--rand);
border-radius: 3px;
overflow: auto;
background: Field;
flex: 1;
min-height: 90px;
}
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { padding: 3px 8px; text-align: left; white-space: nowrap; }
th {
position: sticky; top: 0;
background: var(--hell);
border-bottom: 1px solid var(--rand);
font-weight: 600;
}
td.zahl, th.zahl { text-align: right; }
tr.gewaehlt { background: SelectedItem; color: SelectedItemText; }
tbody tr { cursor: default; }
.hinweis { color: GrayText; font-size: 12px; }
.fehler { color: #b32; }
.wachsen { flex: 1; display: flex; flex-direction: column; gap: 6px; min-height: 0; }

48
ui/gemeinsam.js Normal file
View File

@@ -0,0 +1,48 @@
/**
* gemeinsam.js - Hilfsmittel für alle Dialogseiten.
*/
/** Ruft die Ereignisseite und wirft bei Fehlern eine Ausnahme. */
export async function ruf(befehl, daten = {}) {
const antwort = await browser.runtime.sendMessage({ befehl, ...daten });
if (!antwort?.ok) {
throw new Error(antwort?.fehler || `Aufruf ${befehl} fehlgeschlagen`);
}
return antwort.daten;
}
/**
* Beschriftungen aus _locales einsetzen.
*
* Elemente mit data-i18n bekommen ihren Text, solche mit
* data-i18n-placeholder ihren Platzhalter. Fehlt ein Eintrag, bleibt der
* im HTML hinterlegte deutsche Text stehen.
*/
export function uebersetzeSeite() {
for (const el of document.querySelectorAll("[data-i18n]")) {
const s = browser.i18n.getMessage(el.dataset.i18n);
if (s) el.textContent = s;
}
for (const el of document.querySelectorAll("[data-i18n-placeholder]")) {
const s = browser.i18n.getMessage(el.dataset.i18nPlaceholder);
if (s) el.placeholder = s;
}
for (const el of document.querySelectorAll("[data-i18n-title]")) {
const s = browser.i18n.getMessage(el.dataset.i18nTitle);
if (s) el.title = s;
}
}
export function fehlerAnzeigen(text) {
const ziel = document.getElementById("meldung");
if (!ziel) return;
ziel.className = "fehler";
ziel.textContent = String(text || "").slice(0, 300);
}
export function meldungAnzeigen(text) {
const ziel = document.getElementById("meldung");
if (!ziel) return;
ziel.className = "hinweis";
ziel.textContent = String(text || "");
}

83
ui/insert.html Normal file
View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="insertTitle">Aus Paperless einfügen</title>
<link rel="stylesheet" href="gemeinsam.css">
</head>
<body>
<h1 data-i18n="insertTitle">Aus Paperless einfügen</h1>
<div class="zeile">
<input type="text" id="suche">
<button id="suchen" data-i18n="btnSearch">Suchen</button>
</div>
<div class="wachsen">
<label data-i18n="colDocuments">Dokumente</label>
<div class="liste">
<table>
<thead><tr>
<th class="zahl" data-i18n="colAsn">ASN</th>
<th data-i18n="colCreated">Erstellt</th>
<th data-i18n="colTitle">Titel</th>
</tr></thead>
<tbody id="treffer"></tbody>
</table>
</div>
</div>
<div class="wachsen">
<label data-i18n="colVersions">Versionen</label>
<div class="liste" style="min-height:70px">
<table>
<thead><tr>
<th class="zahl" data-i18n="colNo">Nr</th>
<th data-i18n="colCreated">Erstellt</th>
<th data-i18n="colFormat">Format</th>
<th data-i18n="colLabel">Bezeichnung</th>
</tr></thead>
<tbody id="versionen"></tbody>
</table>
</div>
</div>
<fieldset>
<legend data-i18n="insertHow">Einfügen als</legend>
<div class="wahl">
<input type="radio" name="art" id="artAnhang" value="anhang" checked>
<label for="artAnhang" data-i18n="insertAsAttachment">
Anhang der Nachricht
</label>
</div>
<div class="wahl">
<input type="radio" name="art" id="artLink" value="link">
<label for="artLink" data-i18n="insertAsLink">
Freigabelink im Nachrichtentext
</label>
</div>
<div id="linkOptionen" class="zeile" style="margin-top:6px">
<label for="tage" style="margin:0" data-i18n="fieldShareDays">
Gültigkeit in Tagen
</label>
<input type="number" id="tage" min="0" max="3650" style="width:80px">
<label for="tagName" style="margin:0 0 0 10px" data-i18n="fieldShareTag">
Tag
</label>
<input type="text" id="tagName">
</div>
<div class="hinweis" data-i18n="insertHintLink">
Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt.
</div>
</fieldset>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="abbrechen" data-i18n="btnCancel">Abbrechen</button>
<button id="einfuegen" class="haupt" data-i18n="btnInsert">Einfügen</button>
</div>
<script type="module" src="insert.js"></script>
</body>
</html>

211
ui/insert.js Normal file
View File

@@ -0,0 +1,211 @@
/**
* insert.js - Dokument aus Paperless in einen Entwurf einfügen.
*
* Zwei Wege: als Anhang oder als Freigabelink. Der Link ist die bessere
* Wahl für grosse Dateien und für Empfänger, die ohnehin Zugriff auf das
* Archiv haben sollen - der Anhang für alles, was auch in fünf Jahren noch
* lesbar sein muss, wenn der Link längst abgelaufen ist.
*/
import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
from "./gemeinsam.js";
const $ = (id) => document.getElementById(id);
let auftrag = null;
let einstellungen = null;
let treffer = [];
let versionen = [];
let gewaehltesDokument = null;
let gewaehlteVersion = null;
// Formate, die LibreOffice und Thunderbird sinnvoll darstellen. Nur für
// die Anzeige - eingefügt werden kann jedes.
const FORMATE = {
"application/pdf": "PDF",
"application/vnd.oasis.opendocument.text": "ODF Text",
"application/vnd.oasis.opendocument.spreadsheet": "ODF Tabelle",
"application/vnd.oasis.opendocument.presentation": "ODF Präsentation",
"application/msword": "Word 97",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "Word",
"application/vnd.ms-excel": "Excel 97",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "Excel",
"message/rfc822": "E-Mail",
"image/png": "Bild",
"image/jpeg": "Bild",
"text/plain": "Text"
};
document.addEventListener("DOMContentLoaded", start);
async function start() {
uebersetzeSeite();
const nummer = Number(new URLSearchParams(location.search).get("auftrag"));
einstellungen = await ruf("einstellungen");
auftrag = await ruf("auftrag", { nummer });
if (!auftrag) {
fehlerAnzeigen("Der Auftrag ist nicht mehr vorhanden.");
return;
}
$("tage").value = String(einstellungen.shareDays ?? 7);
// ?? statt ||: Ein bewusst geleertes Feld in den Einstellungen soll
// leer bleiben. Mit || käme die Vorgabe zurück, und die Kennzeichnung
// liesse sich nicht dauerhaft abschalten.
$("tagName").value = einstellungen.shareTag ?? "Sharelink";
$("suchen").addEventListener("click", suchen);
$("suche").addEventListener("keydown", (e) => {
if (e.key === "Enter") suchen();
});
$("abbrechen").addEventListener("click", () => window.close());
$("einfuegen").addEventListener("click", einfuegen);
for (const r of document.querySelectorAll('input[name=art]')) {
r.addEventListener("change", artGewechselt);
}
artGewechselt();
suchen();
}
function artGewechselt() {
const link = $("artLink").checked;
for (const id of ["tage", "tagName"]) $(id).disabled = !link;
// Bei einem Freigabelink zählt das Dokument, nicht die einzelne Version -
// der Link zeigt immer auf die Kopfversion.
$("versionen").closest(".wachsen").style.opacity = link ? ".5" : "1";
knopfPruefen();
}
async function suchen() {
$("treffer").replaceChildren();
$("versionen").replaceChildren();
gewaehltesDokument = null;
gewaehlteVersion = null;
try {
treffer = await ruf("suchen", { query: $("suche").value.trim() });
} catch (err) {
fehlerAnzeigen(err.message);
return;
}
for (const [i, r] of treffer.entries()) {
const tr = document.createElement("tr");
const a = document.createElement("td");
a.className = "zahl";
a.textContent = r.archive_serial_number ?? "—";
const b = document.createElement("td");
b.textContent = (r.created || "").slice(0, 10);
const c = document.createElement("td");
c.textContent = r.title || "";
c.title = r.title || "";
tr.append(a, b, c);
tr.addEventListener("click", () => waehleDokument(i));
$("treffer").append(tr);
}
if (treffer.length) waehleDokument(0);
else meldungAnzeigen(browser.i18n.getMessage("msgNoHits") || "Keine Treffer.");
}
async function waehleDokument(i) {
gewaehltesDokument = treffer[i] || null;
for (const [j, tr] of [...$("treffer").children].entries()) {
tr.classList.toggle("gewaehlt", j === i);
}
$("versionen").replaceChildren();
gewaehlteVersion = null;
knopfPruefen();
if (!gewaehltesDokument) return;
meldungAnzeigen(browser.i18n.getMessage("msgLoadingVersions")
|| "Versionen werden geladen …");
try {
versionen = await ruf("versionen", { id: gewaehltesDokument.id });
} catch (err) {
fehlerAnzeigen(err.message);
return;
}
meldungAnzeigen("");
for (const [k, v] of versionen.entries()) {
const tr = document.createElement("tr");
const a = document.createElement("td");
a.className = "zahl";
a.textContent = v.no;
const b = document.createElement("td");
b.textContent = (v.added || "").slice(0, 10);
const c = document.createElement("td");
c.textContent = FORMATE[v.mime_type] || (v.mime_type || "?");
const d = document.createElement("td");
d.textContent = v.version_label || "";
tr.append(a, b, c, d);
tr.addEventListener("click", () => waehleVersion(k));
$("versionen").append(tr);
}
if (versionen.length) waehleVersion(0);
}
function waehleVersion(k) {
gewaehlteVersion = versionen[k] || null;
for (const [j, tr] of [...$("versionen").children].entries()) {
tr.classList.toggle("gewaehlt", j === k);
}
knopfPruefen();
}
function knopfPruefen() {
const ok = $("artLink").checked
? Boolean(gewaehltesDokument)
: Boolean(gewaehlteVersion);
$("einfuegen").disabled = !ok;
}
async function einfuegen() {
$("einfuegen").disabled = true;
try {
if ($("artLink").checked) {
meldungAnzeigen(browser.i18n.getMessage("msgCreatingLink")
|| "Link wird erzeugt …");
// ACHTUNG: linktext darf NICHT auf url zurückgreifen - die Variable
// entsteht erst durch dieselbe Zuweisung. Bei einem Dokument ohne
// Titel gäbe das eine Ausnahme.
const { url, tagStatus, gueltigBis } = await ruf("freigabelink", {
id: gewaehltesDokument.id,
tage: Number($("tage").value || 0),
variante: "original",
tagName: $("tagName").value.trim() || null,
tabId: auftrag.tabId,
einfuegen: true,
linktext: gewaehltesDokument.title
|| `Dokument ${gewaehltesDokument.id}`
});
// Ein fehlgeschlagener Tag darf nicht untergehen. Er ist der Grund,
// warum sich geteilte Dokumente später wiederfinden lassen.
if (tagStatus && !tagStatus.ok) {
fehlerAnzeigen(`Link erzeugt, aber der Tag konnte nicht gesetzt `
+ `werden:\n${tagStatus.fehler}`);
return;
}
const teile = [url];
if (gueltigBis) {
const d = new Date(gueltigBis);
const z = (n) => String(n).padStart(2, "0");
teile.push(`gültig bis ${z(d.getDate())}.${z(d.getMonth() + 1)}.`
+ `${d.getFullYear()}`);
} else {
teile.push("ohne zeitliche Begrenzung");
}
if (tagStatus?.ok) teile.push(`Tag „${tagStatus.name}" gesetzt`);
meldungAnzeigen(teile.join(" · "));
} else {
meldungAnzeigen(browser.i18n.getMessage("msgAttaching")
|| "Wird angehängt …");
await ruf("anhaengen", {
versionId: gewaehlteVersion.id,
tabId: auftrag.tabId
});
}
setTimeout(() => window.close(), 1000);
} catch (err) {
fehlerAnzeigen(err.message);
$("einfuegen").disabled = false;
}
}

76
ui/options.html Normal file
View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="optionsTitle">Paperless — Verbindung</title>
<link rel="stylesheet" href="gemeinsam.css">
<style>body { max-width: 620px; height: auto; }</style>
</head>
<body>
<h1 data-i18n="optionsTitle">Paperless — Verbindung</h1>
<fieldset>
<legend data-i18n="optionsConnection">Zugang</legend>
<label for="baseUrl" data-i18n="fieldBaseUrl">Basisadresse</label>
<input type="text" id="baseUrl" placeholder="https://dms.example.org">
<div class="hinweis" data-i18n="optionsHintUrl">
Ohne abschließenden Schrägstrich. Nur http und https.
</div>
<label for="token" data-i18n="fieldToken">API-Token</label>
<input type="password" id="token">
<div class="hinweis" data-i18n="optionsHintToken">
Im Benutzerprofil von Paperless erzeugen. Ein eigener Token für diese
Erweiterung lässt sich einzeln widerrufen.
</div>
<div style="margin-top:10px" class="zeile">
<button id="pruefen" data-i18n="btnTest">Verbindung prüfen</button>
<span id="status" class="hinweis"></span>
</div>
<div id="recht" class="hinweis" style="margin-top:6px"></div>
</fieldset>
<fieldset>
<legend data-i18n="optionsBehaviour">Verhalten</legend>
<label for="versionLabel" data-i18n="fieldVersionLabel">
Bezeichnung neuer Versionen
</label>
<input type="text" id="versionLabel">
<div class="hinweis" data-i18n="optionsHintLabel">
{datum} wird durch das heutige Datum ersetzt.
</div>
<div class="zeile" style="margin-top:8px">
<label for="shareDays" style="margin:0" data-i18n="fieldShareDays">
Gültigkeit in Tagen
</label>
<input type="number" id="shareDays" min="0" max="3650" style="width:90px">
<label for="shareTag" style="margin:0 0 0 12px" data-i18n="fieldShareTag">
Tag beim Teilen
</label>
<input type="text" id="shareTag" style="width:160px">
</div>
<label for="ignorierteKleiner" style="margin-top:8px"
data-i18n="fieldIgnoreSmaller">
Anhänge vorab abwählen unter … Bytes
</label>
<input type="number" id="ignorierteKleiner" min="0" style="width:120px">
<div class="hinweis" data-i18n="optionsHintIgnore">
Signaturbilder und Logos sind meist kleiner als 8 KB. Sie lassen sich
im Dialog jederzeit anhaken.
</div>
</fieldset>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="speichern" class="haupt" data-i18n="btnSave">Speichern</button>
</div>
<script type="module" src="options.js"></script>
</body>
</html>

125
ui/options.js Normal file
View File

@@ -0,0 +1,125 @@
/**
* options.js - Einstellungsseite.
*
* Beim Speichern wird zusätzlich das Zugriffsrecht für genau die
* eingetragene Adresse angefordert. Es steht bewusst nicht pauschal im
* Manifest: Eine Erweiterung, die von Haus aus jede Adresse erreichen
* darf, ist eine unnötig grosse Angriffsfläche.
*/
import { ladeEinstellungen, speichereEinstellungen } from "../lib/settings.js";
import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
from "./gemeinsam.js";
const $ = (id) => document.getElementById(id);
const FELDER = ["baseUrl", "token", "versionLabel", "shareDays", "shareTag",
"ignorierteKleiner"];
document.addEventListener("DOMContentLoaded", async () => {
uebersetzeSeite();
const s = await ladeEinstellungen();
for (const f of FELDER) if ($(f)) $(f).value = s[f] ?? "";
$("speichern").addEventListener("click", speichern);
$("pruefen").addEventListener("click", pruefen);
await rechtAnzeigen();
});
/**
* Zustand des Zugriffsrechts anzeigen.
*
* Es ist die häufigste Ursache für ein scheiterndes fetch, und ohne
* Anzeige merkt man erst beim ersten Ablegen, dass es fehlt.
*/
async function rechtAnzeigen() {
// Diese Anzeige ist Beiwerk. Fehlt das Element - etwa weil eine ältere
// options.html geladen ist -, darf das nicht das Speichern verhindern.
const ziel = $("recht");
if (!ziel) return;
const url = $("baseUrl").value.trim();
if (!url) {
ziel.textContent = "";
return;
}
let origin;
try {
origin = new URL(url).origin + "/*";
} catch {
ziel.className = "fehler";
ziel.textContent = browser.i18n.getMessage("errBadUrl") || "Ungültige Adresse.";
return;
}
const erteilt = await browser.permissions.contains({ origins: [origin] });
ziel.className = erteilt ? "hinweis" : "fehler";
ziel.textContent = erteilt
? `Zugriffsrecht erteilt für ${origin}`
: `Zugriffsrecht fehlt für ${origin} — auf Speichern klicken.`;
}
function gelesen() {
return {
baseUrl: $("baseUrl").value.trim().replace(/\/+$/, ""),
token: $("token").value.trim(),
versionLabel: $("versionLabel").value.trim() || "Thunderbird {datum}",
shareDays: Number($("shareDays").value || 0),
shareTag: $("shareTag").value.trim(),
ignorierteKleiner: Number($("ignorierteKleiner").value || 0)
};
}
/**
* Einstellungen speichern und das Zugriffsrecht anfordern.
*
* WICHTIG: permissions.request MUSS als erstes stehen, noch vor jedem
* await. Der Aufruf ist nur aus einer Benutzeraktion heraus zulässig, und
* diese Eigenschaft geht beim ersten await verloren - die Folge wäre
* "permissions.request may only be called from a user input handler".
*
* Ein vorgeschaltetes permissions.contains() ist deshalb ausgeschlossen.
* Das ist auch nicht nötig: request() löst keine Abfrage aus, wenn das
* Recht bereits erteilt ist, sondern liefert sofort true.
*/
function speichern() {
const werte = gelesen();
let origin;
try {
origin = new URL(werte.baseUrl).origin + "/*";
} catch {
fehlerAnzeigen(browser.i18n.getMessage("errBadUrl")
|| "Ungültige Adresse. Erwartet wird https://rechner oder http://rechner.");
return;
}
// Kein await davor. Die Auswertung läuft in der Zusage.
browser.permissions.request({ origins: [origin] })
.then(async (erteilt) => {
if (!erteilt) {
fehlerAnzeigen(browser.i18n.getMessage("errNoPermission")
|| "Ohne Zugriffsrecht auf die Adresse kann die Erweiterung nicht arbeiten.");
await rechtAnzeigen();
return;
}
await speichereEinstellungen(werte);
meldungAnzeigen(browser.i18n.getMessage("msgSaved") || "Gespeichert.");
// Anzeige und Prüfung sind Beiwerk; ein Fehler darin soll nicht
// aussehen, als wäre das Speichern gescheitert.
try { await rechtAnzeigen(); } catch { /* egal */ }
try { await pruefen(); } catch { /* egal */ }
})
.catch((err) => fehlerAnzeigen(err.message));
}
async function pruefen() {
$("status").className = "hinweis";
$("status").textContent = "…";
try {
await speichereEinstellungen(gelesen());
const treffer = await ruf("suchen", { query: "" });
$("status").textContent =
browser.i18n.getMessage("msgConnectionOk", [String(treffer.length)])
|| `Verbindung steht (${treffer.length} Dokumente sichtbar).`;
} catch (err) {
$("status").className = "fehler";
$("status").textContent = String(err.message).slice(0, 200);
}
}

85
ui/store.html Normal file
View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="storeTitle">In Paperless ablegen</title>
<link rel="stylesheet" href="gemeinsam.css">
</head>
<body>
<h1 data-i18n="storeTitle">In Paperless ablegen</h1>
<div id="quelle" class="hinweis"></div>
<fieldset>
<legend data-i18n="storeWhat">Was soll abgelegt werden?</legend>
<div id="dateien"></div>
<div class="hinweis" data-i18n="storeHintPreselect">
Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist
Teil der Nachricht, keine eigenen Dokumente.
</div>
</fieldset>
<fieldset class="wachsen">
<legend data-i18n="storeTarget">Ziel</legend>
<div class="wahl">
<input type="radio" name="ziel" id="zielNeu" value="neu" checked>
<label for="zielNeu" data-i18n="storeTargetNew">
Neues Dokument anlegen
</label>
</div>
<div id="bereichNeu">
<label for="titel" data-i18n="fieldTitle">Titel</label>
<input type="text" id="titel">
<div class="hinweis" data-i18n="storeHintTitleMulti">
Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt.
</div>
<label for="erstellt" data-i18n="fieldCreated">Erstellt</label>
<input type="text" id="erstellt" placeholder="JJJJ-MM-TT">
</div>
<div class="wahl">
<input type="radio" name="ziel" id="zielVorhanden" value="vorhanden">
<label for="zielVorhanden" data-i18n="storeTargetExisting">
An bestehendes Dokument anhängen
</label>
</div>
<div id="bereichVorhanden" class="wachsen">
<div class="zeile">
<input type="text" id="suche" disabled>
<button id="suchen" disabled data-i18n="btnSearch">Suchen</button>
</div>
<div class="liste">
<table>
<thead>
<tr>
<th class="zahl" data-i18n="colAsn">ASN</th>
<th data-i18n="colCreated">Erstellt</th>
<th data-i18n="colTitle">Titel</th>
</tr>
</thead>
<tbody id="treffer"></tbody>
</table>
</div>
</div>
</fieldset>
<div class="wahl">
<input type="checkbox" id="klassifizieren" checked>
<label for="klassifizieren" data-i18n="storeClassifyAfter">
Nach dem Ablegen die Klassifizierung bearbeiten
</label>
</div>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="abbrechen" data-i18n="btnCancel">Abbrechen</button>
<button id="ablegen" class="haupt" data-i18n="btnStore">Ablegen</button>
</div>
<script type="module" src="store.js"></script>
</body>
</html>

212
ui/store.js Normal file
View File

@@ -0,0 +1,212 @@
/**
* store.js - Dialog zum Ablegen von Anhängen und ganzen Nachrichten.
*
* Der Dialog spricht nicht selbst mit Paperless, sondern lässt die
* Ereignisseite arbeiten. Dort hängt die zur Laufzeit erteilte
* Berechtigung für die Zieladresse.
*/
import { groesse, kurz, titelAusBetreff } from "../lib/settings.js";
import { uebersetzeSeite, ruf, fehlerAnzeigen } from "./gemeinsam.js";
const $ = (id) => document.getElementById(id);
let auftrag = null;
let einstellungen = null;
let treffer = [];
let gewaehlterTreffer = null;
document.addEventListener("DOMContentLoaded", start);
async function start() {
uebersetzeSeite();
const nummer = Number(new URLSearchParams(location.search).get("auftrag"));
einstellungen = await ruf("einstellungen");
auftrag = await ruf("auftrag", { nummer });
if (!auftrag) {
fehlerAnzeigen("Der Auftrag ist nicht mehr vorhanden.");
return;
}
quelleAnzeigen();
dateienAnzeigen();
vorbelegen();
verdrahten();
}
function quelleAnzeigen() {
const teile = [];
if (auftrag.betreff) teile.push(kurz(auftrag.betreff, 80));
if (auftrag.absender) teile.push(kurz(auftrag.absender, 40));
if (auftrag.datum) teile.push(auftrag.datum);
$("quelle").textContent = teile.join(" · ");
}
/**
* Anhänge zur Auswahl anbieten.
*
* Vorab abgewählt werden kleine Bilder und Signaturen. Sie gehören zur
* Darstellung der Nachricht, nicht zum Inhalt - wer sie alle mit
* archiviert, hat nach einem Jahr hunderte Logos im Bestand.
*/
function dateienAnzeigen() {
const ziel = $("dateien");
ziel.replaceChildren();
if (auftrag.quelle === "nachricht") {
// Aufbau über createElement statt innerHTML. Hier stünde zwar nur
// festes Markup, aber die Zusicherung "kein innerHTML" soll ohne
// Ausnahme gelten - sonst muss man bei jeder Änderung neu prüfen, ob
// sich inzwischen ein Fremdwert eingeschlichen hat.
const zeile = document.createElement("div");
zeile.className = "wahl";
const box = document.createElement("input");
box.type = "checkbox";
box.id = "ganz";
box.checked = true;
box.disabled = true;
const bez = document.createElement("label");
bez.htmlFor = "ganz";
bez.textContent = browser.i18n.getMessage("storeWholeMessage")
|| "Die vollständige Nachricht als .eml";
zeile.append(box, bez);
ziel.append(zeile);
if (auftrag.dateien?.length) {
const h = document.createElement("div");
h.className = "hinweis";
h.textContent = browser.i18n.getMessage("storeAttachmentsInside",
[String(auftrag.dateien.length)])
|| `Die ${auftrag.dateien.length} Anhänge bleiben darin enthalten.`;
ziel.append(h);
}
return;
}
for (const [i, d] of (auftrag.dateien || []).entries()) {
const uninteressant =
einstellungen.ignorierteTypen.includes(d.contentType)
|| (d.size || 0) < einstellungen.ignorierteKleiner;
d.ausgewaehlt = !uninteressant;
const zeile = document.createElement("div");
zeile.className = "wahl";
const box = document.createElement("input");
box.type = "checkbox";
box.id = `d${i}`;
box.checked = d.ausgewaehlt;
box.addEventListener("change", () => {
d.ausgewaehlt = box.checked;
knopfPruefen();
});
const bez = document.createElement("label");
bez.htmlFor = box.id;
bez.textContent = `${d.name} (${groesse(d.size)}, ${d.contentType})`;
zeile.append(box, bez);
ziel.append(zeile);
}
}
function vorbelegen() {
$("titel").value = titelAusBetreff(auftrag.betreff);
if (auftrag.datum) $("erstellt").value = auftrag.datum;
}
function verdrahten() {
for (const r of document.querySelectorAll('input[name=ziel]')) {
r.addEventListener("change", zielGewechselt);
}
$("suchen").addEventListener("click", suchen);
$("suche").addEventListener("keydown", (e) => {
if (e.key === "Enter") suchen();
});
$("abbrechen").addEventListener("click", () => window.close());
$("ablegen").addEventListener("click", ablegen);
zielGewechselt();
}
function zielGewechselt() {
const neu = $("zielNeu").checked;
for (const id of ["titel", "erstellt"]) $(id).disabled = !neu;
for (const id of ["suche", "suchen"]) $(id).disabled = neu;
if (!neu && !treffer.length) suchen();
knopfPruefen();
}
async function suchen() {
const liste = $("treffer");
liste.replaceChildren();
try {
treffer = await ruf("suchen", { query: $("suche").value.trim() });
} catch (err) {
fehlerAnzeigen(err.message);
return;
}
for (const [i, r] of treffer.entries()) {
const tr = document.createElement("tr");
const asn = document.createElement("td");
asn.className = "zahl";
asn.textContent = r.archive_serial_number ?? "—";
const erstellt = document.createElement("td");
erstellt.textContent = (r.created || "").slice(0, 10);
const titel = document.createElement("td");
titel.textContent = r.title || "";
titel.title = r.title || "";
tr.append(asn, erstellt, titel);
tr.addEventListener("click", () => waehle(i));
liste.append(tr);
}
if (treffer.length) waehle(0);
else fehlerAnzeigen(browser.i18n.getMessage("msgNoHits") || "Keine Treffer.");
}
function waehle(i) {
gewaehlterTreffer = treffer[i] || null;
for (const [j, tr] of [...$("treffer").children].entries()) {
tr.classList.toggle("gewaehlt", j === i);
}
knopfPruefen();
}
function knopfPruefen() {
const etwasGewaehlt = auftrag.quelle === "nachricht"
|| (auftrag.dateien || []).some((d) => d.ausgewaehlt);
const zielOk = $("zielNeu").checked || Boolean(gewaehlterTreffer);
$("ablegen").disabled = !(etwasGewaehlt && zielOk);
}
async function ablegen() {
$("ablegen").disabled = true;
$("meldung").className = "hinweis";
$("meldung").textContent =
browser.i18n.getMessage("msgUploading") || "Wird abgelegt …";
try {
const ergebnisse = await ruf("ablegen", {
auftrag,
ziel: $("zielNeu").checked ? "neu" : "vorhanden",
zielId: gewaehlterTreffer?.id,
titel: $("titel").value.trim(),
erstellt: $("erstellt").value.trim()
});
if ($("klassifizieren").checked && ergebnisse.length) {
// Beim Anhängen an ein vorhandenes Dokument gilt dessen
// Klassifizierung - dann ist die Wurzel gemeint, nicht die neue
// Version.
const erstes = ergebnisse[0];
const id = erstes.art === "version" ? erstes.zielId : erstes.id;
location.href = `classify.html?id=${id}&weiter=${ergebnisse.length}`;
return;
}
$("meldung").textContent =
browser.i18n.getMessage("msgStored", [String(ergebnisse.length)])
|| `${ergebnisse.length} abgelegt.`;
setTimeout(() => window.close(), 1200);
} catch (err) {
fehlerAnzeigen(err.message);
$("ablegen").disabled = false;
}
}