diff --git a/_locales/de/messages.json b/_locales/de/messages.json
index edfbea2..926ee2a 100644
--- a/_locales/de/messages.json
+++ b/_locales/de/messages.json
@@ -44,7 +44,7 @@
"message": "Neues Dokument anlegen"
},
"storeTargetExisting": {
- "message": "An bestehendes Dokument anhängen"
+ "message": "Als neue Version an ein bestehendes anhängen"
},
"storeHintTitleMulti": {
"message": "Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt."
@@ -320,5 +320,48 @@
},
"artPdfUnavailable": {
"message": "Für dieses Format nicht möglich - nur Textdateien lassen sich aufbereiten."
+ },
+ "btnSkipAll": {
+ "message": "Alle überspringen"
+ },
+ "classifyProgress": {
+ "message": "$1 von $2",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ },
+ "2": {
+ "content": "$2"
+ }
+ }
+ },
+ "msgUploadingOf": {
+ "message": "Wird abgelegt … $1 von $2",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ },
+ "2": {
+ "content": "$2"
+ }
+ }
+ },
+ "stepTitle": {
+ "message": "In Paperless ablegen"
+ },
+ "btnNext": {
+ "message": "Weiter"
+ },
+ "stepWholeMessage": {
+ "message": "Die ganze Nachricht"
+ },
+ "stepHintExisting": {
+ "message": "Die Klassifizierung des Ziels wird übernommen und kann angepasst werden."
+ },
+ "msgLoadingTarget": {
+ "message": "Klassifizierung des Ziels wird geladen …"
+ },
+ "msgOpeningStep": {
+ "message": "Wird vorbereitet …"
}
}
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 4e90796..d9aaa1f 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -44,7 +44,7 @@
"message": "Create a new document"
},
"storeTargetExisting": {
- "message": "Append to an existing document"
+ "message": "Append as a new version to an existing document"
},
"storeHintTitleMulti": {
"message": "With several files the title is extended by the file name."
@@ -320,5 +320,48 @@
},
"artPdfUnavailable": {
"message": "Not possible for this format - only text files can be prepared."
+ },
+ "btnSkipAll": {
+ "message": "Skip all"
+ },
+ "classifyProgress": {
+ "message": "$1 of $2",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ },
+ "2": {
+ "content": "$2"
+ }
+ }
+ },
+ "msgUploadingOf": {
+ "message": "Storing … $1 of $2",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ },
+ "2": {
+ "content": "$2"
+ }
+ }
+ },
+ "stepTitle": {
+ "message": "Store in Paperless"
+ },
+ "btnNext": {
+ "message": "Next"
+ },
+ "stepWholeMessage": {
+ "message": "The whole message"
+ },
+ "stepHintExisting": {
+ "message": "The target's classification is copied and can be adjusted."
+ },
+ "msgLoadingTarget": {
+ "message": "Loading the target's classification …"
+ },
+ "msgOpeningStep": {
+ "message": "Preparing …"
}
}
diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json
index 97d1515..c042878 100644
--- a/_locales/fr/messages.json
+++ b/_locales/fr/messages.json
@@ -44,7 +44,7 @@
"message": "Créer un nouveau document"
},
"storeTargetExisting": {
- "message": "Ajouter à un document existant"
+ "message": "Ajouter comme nouvelle version à un document existant"
},
"storeHintTitleMulti": {
"message": "Avec plusieurs fichiers, le titre est complété par le nom du fichier."
@@ -320,5 +320,48 @@
},
"artPdfUnavailable": {
"message": "Impossible pour ce format - seuls les fichiers texte peuvent être préparés."
+ },
+ "btnSkipAll": {
+ "message": "Tout ignorer"
+ },
+ "classifyProgress": {
+ "message": "$1 sur $2",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ },
+ "2": {
+ "content": "$2"
+ }
+ }
+ },
+ "msgUploadingOf": {
+ "message": "Enregistrement … $1 sur $2",
+ "placeholders": {
+ "1": {
+ "content": "$1"
+ },
+ "2": {
+ "content": "$2"
+ }
+ }
+ },
+ "stepTitle": {
+ "message": "Enregistrer dans Paperless"
+ },
+ "btnNext": {
+ "message": "Suivant"
+ },
+ "stepWholeMessage": {
+ "message": "Le message entier"
+ },
+ "stepHintExisting": {
+ "message": "Le classement de la cible est repris et peut être ajusté."
+ },
+ "msgLoadingTarget": {
+ "message": "Chargement du classement de la cible …"
+ },
+ "msgOpeningStep": {
+ "message": "Préparation …"
}
}
diff --git a/background.js b/background.js
index 324ba2c..2d7e24c 100644
--- a/background.js
+++ b/background.js
@@ -256,7 +256,10 @@ async function dialogOeffnen(art, daten) {
// Absender und Dateinamen; das muss nicht bis zum Neustart im Speicher
// liegen, nur weil jemand den Dialog offen stehen liess.
setTimeout(() => auftraege.delete(nummer), 30 * 60 * 1000);
- const seite = art === "einfuegen" ? "ui/insert.html" : "ui/store.html";
+ const seite = {
+ einfuegen: "ui/insert.html",
+ schritt: "ui/schritt.html"
+ }[art] || "ui/store.html";
// Die Adresse MUSS über runtime.getURL aufgelöst werden. Ein relativer
// Pfad wird in Manifest V3 nicht gegen das Erweiterungsverzeichnis
@@ -267,8 +270,12 @@ async function dialogOeffnen(art, daten) {
await browser.windows.create({
url,
type: "popup",
- width: art === "einfuegen" ? 760 : 820,
- height: art === "einfuegen" ? 640 : 720
+ // Der Schrittdialog steht zweispaltig. Die Höhe ist auf den Inhalt
+ // gerechnet: rund 540 px für die Spalten plus Kopf, Meldung und
+ // Fusszeile, dazu etwas Spielraum - die Systemschrift fällt je
+ // nach Umgebung anders aus.
+ width: { schritt: 980, einfuegen: 760 }[art] || 820,
+ height: { schritt: 760, einfuegen: 640 }[art] || 520
});
} catch (err) {
// Manche Stände lehnen type "popup" ab. Dann ein gewöhnliches Fenster.
@@ -297,6 +304,11 @@ const verbindungen = new Set();
browser.runtime.onConnect.addListener((port) => {
verbindungen.add(port);
console.debug("[paperless] Dialog verbunden, offen:", verbindungen.size);
+
+ // Die Lebenszeichen werden nicht ausgewertet - allein ihr Eintreffen
+ // hält die Ereignisseite wach.
+ port.onMessage.addListener(() => {});
+
port.onDisconnect.addListener(() => {
verbindungen.delete(port);
console.debug("[paperless] Dialog getrennt, offen:", verbindungen.size);
@@ -345,8 +357,15 @@ browser.runtime.onMessage.addListener(async (nachricht, absender) => {
return { ok: true, daten: { tags, korr, typen } };
}
- case "ablegen":
- return { ok: true, daten: await ablegen(nachricht) };
+ case "schrittOeffnen":
+ await dialogOeffnen("schritt", nachricht.auftrag);
+ return { ok: true };
+
+ case "dateiListe":
+ return { ok: true, daten: dateiListe(nachricht.auftrag) };
+
+ case "ablegenEine":
+ return { ok: true, daten: await ablegenEine(nachricht) };
case "klassifizieren": {
const c = await client();
@@ -754,38 +773,91 @@ async function anhangAlsPdf(datei, beschreibung, s) {
};
}
-async function ablegen(nachricht) {
+/**
+ * Die Dateien eines Auftrags aufzählen, ohne sie zu laden.
+ *
+ * Der Dialog fragt damit ab, wie viele Schritte anstehen, und ruft dann
+ * jeden einzeln auf.
+ */
+function dateiListe(auftrag) {
+ if (auftrag.quelle === "nachricht") {
+ return [{ art: "nachricht" }];
+ }
+ return (auftrag.dateien || [])
+ .map((d, i) => ({ art: "anhang", index: i, name: d.name }))
+ .filter((_x, i) => auftrag.dateien[i].ausgewaehlt);
+}
+
+/**
+ * EINE Datei ablegen.
+ *
+ * Bewusst je Datei ein eigener Aufruf statt einer Schleife über alle:
+ *
+ * * Die Ereignisseite wird in Manifest V3 beendet, sobald sie untätig
+ * scheint. Ein Aufruf über zehn Anhänge lief regelmäßig in
+ * "Receiving end does not exist".
+ * * Der Dialog kann den Fortschritt anzeigen.
+ * * Scheitert eine Datei, sind die übrigen trotzdem abgelegt.
+ */
+async function ablegenEine(nachricht) {
const c = await client();
const s = await ladeEinstellungen();
- const dateien = await datenHolen(nachricht.auftrag, s);
- if (!dateien.length) throw new PaperlessError(t("errNothingSelected"));
+ const auftrag = nachricht.auftrag;
+ const eintrag = nachricht.eintrag;
- const ergebnisse = [];
- for (const datei of dateien) {
- if (nachricht.ziel === "vorhanden") {
- const antwort = await c.updateVersion(
- nachricht.zielId, datei.blob, datei.name,
- versionslabel(s.versionLabel));
- const id = await c.waitForTask(antwort, (sek) => {
- if (sek % 15 === 0) {
- console.debug("[paperless] warte auf Aufgabe:", sek, "s");
- }
- });
- ergebnisse.push({ id, name: datei.name, art: "version",
- zielId: nachricht.zielId });
+ let datei;
+ if (eintrag.art === "nachricht") {
+ const alle = await datenHolen(auftrag, s);
+ datei = alle[0];
+ } else {
+ const beschreibung = auftrag.dateien[eintrag.index];
+ const roh = await browser.messages.getAttachmentFile(
+ beschreibung.messageId, beschreibung.partName);
+
+ if (beschreibung.alsPdf
+ && umwandelbar(beschreibung.contentType, beschreibung.name)) {
+ try {
+ datei = await anhangAlsPdf(roh, beschreibung, s);
+ } catch (err) {
+ console.error("[paperless] Aufbereitung gescheitert",
+ beschreibung.name, err);
+ datei = { blob: roh, name: sichererName(beschreibung.name) };
+ }
} else {
- const felder = { title: nachricht.titel || datei.name };
- if (nachricht.erstellt) felder.created = nachricht.erstellt;
- const antwort = await c.postDocument(datei.blob, datei.name, felder);
- const id = await c.waitForTask(antwort, (sek) => {
- if (sek % 15 === 0) {
- console.debug("[paperless] warte auf Aufgabe:", sek, "s");
- }
- });
- ergebnisse.push({ id, name: datei.name, art: "neu" });
+ datei = { blob: roh, name: sichererName(beschreibung.name) };
}
}
- return ergebnisse;
+
+ if (!datei) throw new PaperlessError(t("errNothingSelected"));
+
+ const melden = (sek) => {
+ if (sek % 15 === 0) {
+ console.debug("[paperless] warte auf Aufgabe:", sek, "s");
+ }
+ };
+
+ if (nachricht.ziel === "vorhanden") {
+ const antwort = await c.updateVersion(
+ nachricht.zielId, datei.blob, datei.name,
+ versionslabel(s.versionLabel));
+ const id = await c.waitForTask(antwort, melden);
+ return { id, name: datei.name, art: "version", zielId: nachricht.zielId };
+ }
+
+ // Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt -
+ // sonst hiessen alle gleich und wären im Archiv nicht auseinander-
+ // zuhalten.
+ const grundtitel = nachricht.titel || datei.name;
+ const titel = nachricht.mehrere
+ ? `${grundtitel} - ${datei.name.replace(/\.[^.]{1,8}$/, "")}`
+ : grundtitel;
+
+ const felder = { title: titel.slice(0, 127) };
+ if (nachricht.erstellt) felder.created = nachricht.erstellt;
+
+ const antwort = await c.postDocument(datei.blob, datei.name, felder);
+ const id = await c.waitForTask(antwort, melden);
+ return { id, name: datei.name, art: "neu" };
}
async function aufloesen(c, pfad, name) {
diff --git a/build.sh b/build.sh
index 1bf9b84..4bb52d6 100644
--- a/build.sh
+++ b/build.sh
@@ -16,7 +16,7 @@ OUT="${NAME}-${VERSION}.xpi"
FEHLT=""
for f in manifest.json background.js \
lib/client.js lib/settings.js lib/pdf.js lib/mailpdf.js \
- ui/store.html ui/store.js ui/classify.html ui/classify.js \
+ ui/store.html ui/store.js ui/schritt.html ui/schritt.js \
ui/insert.html ui/insert.js ui/options.html ui/options.js \
ui/gemeinsam.js ui/gemeinsam.css \
_locales/de/messages.json _locales/en/messages.json \
diff --git a/manifest.json b/manifest.json
index e510475..d0545d0 100644
--- a/manifest.json
+++ b/manifest.json
@@ -2,13 +2,13 @@
"manifest_version": 3,
"name": "__MSG_extName__",
"description": "__MSG_extDescription__",
- "version": "1.12.0",
- "author": "Name des Herausgebers",
- "homepage_url": "https://example.org/paperless-thunderbird",
+ "version": "1.14.3",
+ "author": "Marco Morath",
+ "homepage_url": "https://gitea.cloud.mmorath.de/marco.morath/thunderbird-paperless-connector",
"default_locale": "de",
"browser_specific_settings": {
"gecko": {
- "id": "paperless@example.org",
+ "id": "webmaster@mmorath.de",
"strict_min_version": "128.0"
}
},
@@ -16,7 +16,6 @@
"storage",
"menus",
"messagesRead",
- "accountsRead",
"compose",
"notifications"
],
diff --git a/ui/classify.html b/ui/classify.html
index 831cbee..dcad507 100644
--- a/ui/classify.html
+++ b/ui/classify.html
@@ -44,6 +44,7 @@
+
diff --git a/ui/classify.js b/ui/classify.js
index 5bbba32..e712a1c 100644
--- a/ui/classify.js
+++ b/ui/classify.js
@@ -14,6 +14,12 @@ const $ = (id) => document.getElementById(id);
let dokumentId = null;
let alleTags = [];
+// Bei mehreren abgelegten Dateien wird jede einzeln klassifiziert.
+// Andernfalls trüge nur die erste eine sinnvolle Zuordnung, und die
+// übrigen fielen später als unklassifiziert auf - wenn überhaupt.
+let liste = [];
+let stelle = 0;
+
// Gewählte Tags als Menge. Bewusst getrennt vom angezeigten Zustand der
// Kontrollkästchen: Der Filter blendet Zeilen aus, die Auswahl darf davon
// nicht berührt werden.
@@ -24,38 +30,82 @@ 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());
+ // "ids" für mehrere, "id" für den Einzelfall - letzteres bleibt für
+ // Aufrufe aus anderen Stellen erhalten.
+ const roh = p.get("ids") || p.get("id") || "";
+ liste = roh.split(",").map((x) => Number(x)).filter(Boolean);
+ stelle = 0;
+
+ $("ueberspringen").addEventListener("click", weiter);
+ $("abbrechenAlle").addEventListener("click", () => window.close());
$("speichern").addEventListener("click", speichern);
- try {
- const [doc, stamm] = await Promise.all([
- ruf("dokument", { id: dokumentId }),
- ruf("stammdaten")
- ]);
- alleTags = stamm.tags;
+ if (!liste.length) {
+ fehlerAnzeigen("Kein Dokument angegeben.");
+ return;
+ }
- $("quelle").textContent =
- `#${dokumentId} · ${kurz(doc.title, 70)}`
- + (weitere > 1
- ? ` · ${browser.i18n.getMessage("classifyMoreStored",
- [String(weitere)]) || `${weitere} Dateien abgelegt`}`
- : "");
+ try {
+ const stamm = await ruf("stammdaten");
+ alleTags = stamm.tags;
+ fuelleListe("korrListe", stamm.korr);
+ fuelleListe("typListe", stamm.typen);
+ zustand.korr = stamm.korr;
+ zustand.typen = stamm.typen;
+ } catch (err) {
+ fehlerAnzeigen(err.message);
+ return;
+ }
+
+ await anzeigen();
+}
+
+const zustand = { korr: [], typen: [] };
+
+/** Das Dokument an der aktuellen Stelle laden und darstellen. */
+async function anzeigen() {
+ dokumentId = liste[stelle];
+ $("speichern").disabled = false;
+ $("ueberspringen").disabled = false;
+ meldungAnzeigen("");
+
+ // Der Knopf zum Abbrechen aller weiteren ist nur sinnvoll, wenn noch
+ // welche folgen.
+ $("abbrechenAlle").hidden = liste.length < 2;
+
+ try {
+ const doc = await ruf("dokument", { id: dokumentId });
+
+ const zaehler = liste.length > 1
+ ? ` · ${browser.i18n.getMessage("classifyProgress",
+ [String(stelle + 1), String(liste.length)])
+ || `${stelle + 1} von ${liste.length}`}`
+ : "";
+ $("quelle").textContent = `#${dokumentId} · ${kurz(doc.title, 60)}`
+ + zaehler;
$("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);
+ $("korr").value = nameVon(zustand.korr, doc.correspondent);
+ $("typ").value = nameVon(zustand.typen, doc.document_type);
zeigeTags(doc.tags || []);
+ if ($("tagSuche")) $("tagSuche").value = "";
} catch (err) {
fehlerAnzeigen(err.message);
}
}
+/** Zum nächsten Dokument, oder schliessen. */
+async function weiter() {
+ stelle += 1;
+ if (stelle >= liste.length) {
+ window.close();
+ return;
+ }
+ await anzeigen();
+}
+
function fuelleListe(id, eintraege) {
const dl = $(id);
dl.replaceChildren();
@@ -204,7 +254,7 @@ async function speichern() {
typName: $("typ").value.trim()
});
meldungAnzeigen(browser.i18n.getMessage("msgSaved") || "Gespeichert.");
- setTimeout(() => window.close(), 900);
+ setTimeout(weiter, 600);
} catch (err) {
fehlerAnzeigen(err.message);
$("speichern").disabled = false;
diff --git a/ui/gemeinsam.css b/ui/gemeinsam.css
index 8c29060..22a358c 100644
--- a/ui/gemeinsam.css
+++ b/ui/gemeinsam.css
@@ -1,16 +1,21 @@
-/* Gemeinsames Aussehen der Dialoge. Bewusst zurückhaltend, damit sie sich
- in die Oberfläche von Thunderbird einfügen statt sich abzuheben. */
+/* 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; }
+
/* Muss VOR den Anzeigearten stehen und !important tragen: Eine Regel wie
".wahl { display: flex }" überstimmt sonst das eingebaute
"[hidden] { display: none }", und ausgeblendete Elemente erscheinen. */
[hidden] { display: none !important; }
+
body {
font: message-box;
font-size: 13px;
@@ -19,9 +24,16 @@ body {
display: flex;
flex-direction: column;
gap: 10px;
+
+ /* Feste Höhe UND overflow:hidden. Mit min-height wuchs die Seite über
+ das Fenster hinaus, und die Fußzeile lag unerreichbar darunter.
+ Gestaucht wird stattdessen innen: Die Listen blättern selbst. */
height: 100vh;
+ overflow: hidden;
}
+
h1 { font-size: 14px; margin: 0 0 2px; font-weight: 600; }
+
fieldset {
border: 1px solid var(--rand);
border-radius: 4px;
@@ -30,7 +42,9 @@ fieldset {
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;
@@ -39,14 +53,18 @@ input[type=text], input[type=password], input[type=number], select {
background: Field;
color: FieldText;
}
+
.zeile { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.zeile > input[type=text] { flex: 1; }
+
/* Kompakte Zahlenfelder mit vorangestellter Beschriftung. */
.masse { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.mass { display: flex; gap: 5px; align-items: center; }
.mass label { margin: 0; white-space: nowrap; }
.mass input { width: 68px; }
-.zeile > input[type=text] { flex: 1; }
+
.wahl { display: flex; gap: 6px; align-items: center; margin: 3px 0; }
+.wahl input { width: auto; margin: 0; }
/* Dateiliste mit Wahl der Ablageart je Datei. */
.dateizeile {
@@ -58,7 +76,7 @@ input[type=text], input[type=password], input[type=number], select {
.dateizeile .art label { margin: 0; white-space: nowrap; font-size: 12px; }
.dateizeile .art input { width: auto; margin: 0; }
.dateizeile .art label.aus { color: GrayText; }
-.wahl input { width: auto; margin: 0; }
+
button {
padding: 5px 14px;
border: 1px solid var(--rand);
@@ -67,18 +85,28 @@ button {
color: ButtonText;
cursor: pointer;
}
-button.haupt { background: var(--gruen); color: #fff; border-color: var(--gruen); }
+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; }
+
+.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;
+ min-height: 80px;
+}
+
+table {
+ width: 100%; border-collapse: collapse;
+ font-variant-numeric: tabular-nums;
}
-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;
@@ -89,18 +117,64 @@ th {
td.zahl, th.zahl { text-align: right; }
tr.gewaehlt { background: SelectedItem; color: SelectedItemText; }
tbody tr { cursor: default; }
-.hinweis { color: GrayText; font-size: 12px; }
-/* Gewählte Tags stehen oben und bleiben sichtbar, auch wenn der Filter
- sie ausschliesst. Sonst verliert man beim Suchen den Überblick, was
- bereits angehakt ist. */
-.gewaehlteTags {
- padding: 4px 8px;
- border-bottom: 1px solid var(--rand);
- background: color-mix(in srgb, Field 94%, CanvasText);
-}
-.gewaehlteTags:empty { display: none; }
-.gewaehlteTags .wahl label { font-weight: 600; }
-.trefferHervor { background: color-mix(in srgb, Mark 60%, transparent); }
+.hinweis { color: GrayText; font-size: 12px; }
.fehler { color: #b32; }
-.wachsen { flex: 1; display: flex; flex-direction: column; gap: 6px; min-height: 0; }
+.trefferHervor { background: color-mix(in srgb, Mark 60%, transparent); }
+
+.wachsen {
+ flex: 1; display: flex; flex-direction: column; gap: 6px; min-height: 0;
+}
+
+/* --------------------------------------------------- Zweispaltig */
+
+/* KEIN flex-wrap.
+ Mit Umbruch bemisst sich die Hoehe einer Flexzeile nach ihrem INHALT,
+ nicht nach dem Behaelter - die Spalten wachsen dann, statt sich zu
+ stauchen, und die Liste laeuft wieder aus dem Fenster.
+ Der urspruengliche Dialog hatte genau deshalb keinen Umbruch. Das
+ Fenster ist ohnehin 980 px breit; zwei Spalten zu 340 px passen
+ immer. */
+.spalten {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: 12px;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+/* KEIN fieldset für wachsende Bereiche.
+ Ein fieldset taugt nicht als Flex-Behälter: Browser behandeln seinen
+ inneren anonymen Kasten gesondert, height:100% löst sich darin nicht
+ verlässlich auf, und eine Liste darin wächst über den Rahmen hinaus.
+ Ein gewöhnliches Element verhält sich wie erwartet.
+ Der einzige sichtbare Unterschied: Die Überschrift steht innen statt
+ auf dem Rahmen. */
+.kasten {
+ border: 1px solid var(--rand);
+ border-radius: 4px;
+ padding: 10px;
+ /* Grundbreite 0 statt 340 px: Bei nowrap wuerde eine Grundbreite die
+ Spalten ueber das Fenster hinausschieben. Die Aufteilung ergibt
+ sich aus flex-grow zu gleichen Teilen. */
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-height: 0;
+ min-width: 0;
+}
+.kasten > h2 {
+ font-size: 13px;
+ font-weight: 600;
+ margin: 0 0 4px;
+}
+
+/* Die Liste füllt, was nach den festen Elementen übrig bleibt, und
+ blättert selbst. Genau das war das Ziel - es scheiterte bisher am
+ fieldset, nicht an der Vorgehensweise. */
+.kasten .liste {
+ flex: 1;
+ min-height: 80px;
+}
diff --git a/ui/gemeinsam.js b/ui/gemeinsam.js
index d4358c2..61a75dd 100644
--- a/ui/gemeinsam.js
+++ b/ui/gemeinsam.js
@@ -13,13 +13,33 @@
*/
let dauerverbindung = null;
+let lebenszeichen = null;
+
export function verbindungHalten() {
if (dauerverbindung) return;
try {
dauerverbindung = browser.runtime.connect({ name: "dialog" });
dauerverbindung.onDisconnect.addListener(() => {
dauerverbindung = null;
+ if (lebenszeichen) {
+ clearInterval(lebenszeichen);
+ lebenszeichen = null;
+ }
});
+
+ // Zusätzlich regelmäßig ein Lebenszeichen senden. Die offene
+ // Verbindung allein hat sich als nicht ausreichend erwiesen: Bei
+ // mehreren Dateien hintereinander wurde die Ereignisseite trotzdem
+ // beendet. Ein tatsächliches Ereignis alle 15 Sekunden setzt die
+ // Untätigkeitsuhr zuverlässig zurück.
+ lebenszeichen = setInterval(() => {
+ try {
+ dauerverbindung?.postMessage({ befehl: "lebenszeichen" });
+ } catch {
+ clearInterval(lebenszeichen);
+ lebenszeichen = null;
+ }
+ }, 15000);
} catch (err) {
console.warn("[paperless] Dauerverbindung nicht möglich:", err);
}
diff --git a/ui/schritt.html b/ui/schritt.html
new file mode 100644
index 0000000..2f08213
--- /dev/null
+++ b/ui/schritt.html
@@ -0,0 +1,98 @@
+
+
+
+
+ In Paperless ablegen
+
+
+
+
+ In Paperless ablegen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/schritt.js b/ui/schritt.js
new file mode 100644
index 0000000..aaf5e36
--- /dev/null
+++ b/ui/schritt.js
@@ -0,0 +1,357 @@
+/**
+ * schritt.js - Ziel und Klassifizierung, Datei für Datei.
+ *
+ * Der Ablauf ist bewusst in zwei Fenster geteilt:
+ *
+ * 1. Auswahl der abzulegenden Dateien (store.html)
+ * 2. Je Datei: Ziel und Klassifizierung (dieses Fenster)
+ *
+ * Der Grund: Ziel und Klassifizierung sind je Datei verschieden. Ein
+ * einzelner Dialog für alle würde die Angaben der ersten Datei auf alle
+ * übertragen - bei einer Rechnung samt Lastschriftmandat wäre das falsch.
+ *
+ * Beim Anhängen an ein bestehendes Dokument wird dessen Klassifizierung
+ * übernommen und kann angepasst werden. Sie neu einzugeben wäre Arbeit
+ * für nichts, und ein Tippfehler brächte die Version vom Original weg.
+ */
+
+import { kurz, groesse } from "../lib/settings.js";
+import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
+ from "./gemeinsam.js";
+
+const $ = (id) => document.getElementById(id);
+
+let auftrag = null;
+let liste = []; // die abzulegenden Dateien
+let stelle = 0;
+let stamm = { tags: [], korr: [], typen: [] };
+
+let treffer = [];
+let zielDokument = null;
+let gewaehlteTags = new Set();
+
+const abgelegt = []; // für die Abschlussmeldung
+
+document.addEventListener("DOMContentLoaded", start);
+
+async function start() {
+ uebersetzeSeite();
+ const nummer = Number(new URLSearchParams(location.search).get("auftrag"));
+
+ try {
+ auftrag = await ruf("auftrag", { nummer });
+ if (!auftrag) throw new Error("Der Auftrag ist nicht mehr vorhanden.");
+ liste = await ruf("dateiListe", { auftrag });
+ stamm = await ruf("stammdaten");
+ } catch (err) {
+ fehlerAnzeigen(err.message);
+ return;
+ }
+
+ if (!liste.length) {
+ fehlerAnzeigen(browser.i18n.getMessage("errNothingSelected")
+ || "Nichts ausgewählt.");
+ return;
+ }
+
+ fuelleListe("korrListe", stamm.korr);
+ fuelleListe("typListe", stamm.typen);
+ verdrahten();
+ await anzeigen();
+}
+
+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();
+ });
+ $("tagSuche").addEventListener("input", zeichneTags);
+ $("abbrechen").addEventListener("click", () => window.close());
+ $("ueberspringen").addEventListener("click", () => weiter());
+ $("ablegen").addEventListener("click", ablegen);
+}
+
+// ------------------------------------------------------ Datei anzeigen
+
+async function anzeigen() {
+ const eintrag = liste[stelle];
+
+ $("kopf").textContent = browser.i18n.getMessage("stepTitle")
+ || "In Paperless ablegen";
+
+ const zaehler = liste.length > 1
+ ? `${browser.i18n.getMessage("classifyProgress",
+ [String(stelle + 1), String(liste.length)])
+ || `${stelle + 1} von ${liste.length}`} · `
+ : "";
+ const name = eintrag.art === "nachricht"
+ ? (browser.i18n.getMessage("stepWholeMessage") || "Die ganze Nachricht")
+ : `${eintrag.name}${eintrag.groesse ? ` (${groesse(eintrag.groesse)})` : ""}`;
+ $("quelle").textContent = `${zaehler}${name}`;
+
+ // Zurücksetzen für die neue Datei
+ zielDokument = null;
+ treffer = [];
+ $("treffer").replaceChildren();
+ $("suche").value = "";
+ $("tagSuche").value = "";
+ $("zielNeu").checked = true;
+ gewaehlteTags = new Set();
+
+ $("titel").value = vorschlagTitel(eintrag);
+ $("erstellt").value = auftrag.datum || "";
+ $("korr").value = "";
+ $("typ").value = "";
+
+ zielGewechselt();
+ zeichneTags();
+ meldungAnzeigen("");
+ $("ablegen").disabled = false;
+ $("ueberspringen").hidden = liste.length < 2;
+}
+
+function vorschlagTitel(eintrag) {
+ if (eintrag.art === "nachricht") return auftrag.titelVorschlag || "";
+ // Endung weg: Sie steht schon im Dateinamen des Originals und trägt
+ // im Titel nichts bei.
+ return String(eintrag.name || "").replace(/\.[^.]{1,8}$/, "");
+}
+
+// ----------------------------------------------------------- Zielwahl
+
+function zielGewechselt() {
+ const vorhanden = $("zielVorhanden").checked;
+ $("suche").disabled = !vorhanden;
+ $("suchen").disabled = !vorhanden;
+
+ // Die Trefferliste abblenden statt ausblenden: Ein Feld, das
+ // verschwindet und wieder auftaucht, lässt die Spalte springen.
+ const liste = $("treffer").closest(".liste");
+ if (liste) liste.style.opacity = vorhanden ? "1" : ".45";
+
+ if (vorhanden && !treffer.length) suchen();
+ pruefen();
+}
+
+async function suchen() {
+ $("treffer").replaceChildren();
+ zielDokument = 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", () => waehleZiel(i));
+ $("treffer").append(tr);
+ }
+ if (!treffer.length) {
+ meldungAnzeigen(browser.i18n.getMessage("msgNoHits") || "Keine Treffer.");
+ }
+ pruefen();
+}
+
+/**
+ * Zieldokument wählen und dessen Klassifizierung übernehmen.
+ *
+ * Eine neue Version gehört inhaltlich zum selben Vorgang wie das
+ * Original. Die Angaben neu einzutippen wäre Arbeit für nichts - und ein
+ * abweichender Korrespondent brächte die Version vom Original weg.
+ */
+async function waehleZiel(i) {
+ zielDokument = treffer[i] || null;
+ for (const [j, tr] of [...$("treffer").children].entries()) {
+ tr.classList.toggle("gewaehlt", j === i);
+ }
+ pruefen();
+ if (!zielDokument) return;
+
+ meldungAnzeigen(browser.i18n.getMessage("msgLoadingTarget")
+ || "Klassifizierung des Ziels wird geladen …");
+ try {
+ const doc = await ruf("dokument", { id: zielDokument.id });
+ $("titel").value = doc.title || "";
+ $("erstellt").value = (doc.created || "").slice(0, 10);
+ $("korr").value = nameVon(stamm.korr, doc.correspondent);
+ $("typ").value = nameVon(stamm.typen, doc.document_type);
+ gewaehlteTags = new Set((doc.tags || []).map(Number));
+ zeichneTags();
+ meldungAnzeigen("");
+ } catch (err) {
+ fehlerAnzeigen(err.message);
+ }
+}
+
+function pruefen() {
+ const ok = $("zielNeu").checked || Boolean(zielDokument);
+ $("ablegen").disabled = !ok;
+}
+
+// --------------------------------------------------------------- Tags
+
+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(rows, id) {
+ return rows.find((x) => x.id === id)?.name || "";
+}
+
+/**
+ * Tagliste zeichnen: Gewählte oben, dann die gefilterten übrigen.
+ *
+ * Gewählte bleiben sichtbar, auch wenn der Filter sie ausschliessen
+ * würde - sonst verliert man beim Suchen den Überblick, was schon
+ * angehakt ist.
+ */
+function zeichneTags() {
+ const ziel = $("tags");
+ ziel.replaceChildren();
+ const filter = ($("tagSuche").value || "").trim().toLowerCase();
+
+ const oben = stamm.tags.filter((t) => gewaehlteTags.has(Number(t.id)));
+ const unten = stamm.tags.filter(
+ (t) => !gewaehlteTags.has(Number(t.id))
+ && (!filter || t.name.toLowerCase().includes(filter)));
+
+ for (const t of oben) ziel.append(tagZeile(t, true, ""));
+
+ if (oben.length && unten.length) {
+ const tr = document.createElement("hr");
+ tr.style.cssText = "border:0;border-top:1px solid var(--rand);margin:5px 0";
+ ziel.append(tr);
+ }
+
+ for (const t of unten) ziel.append(tagZeile(t, false, filter));
+
+ if (!oben.length && !unten.length) {
+ const h = document.createElement("div");
+ h.className = "hinweis";
+ h.textContent = browser.i18n.getMessage("tagsNoMatch")
+ || "Keine Übereinstimmung.";
+ ziel.append(h);
+ }
+}
+
+function tagZeile(tag, gewaehlt, filter) {
+ const zeile = document.createElement("div");
+ zeile.className = "wahl";
+ const box = document.createElement("input");
+ box.type = "checkbox";
+ box.id = `tag_${tag.id}`;
+ box.checked = gewaehlt;
+ box.addEventListener("change", () => {
+ if (box.checked) gewaehlteTags.add(Number(tag.id));
+ else gewaehlteTags.delete(Number(tag.id));
+ zeichneTags();
+ });
+ const bez = document.createElement("label");
+ bez.htmlFor = box.id;
+ if (gewaehlt) bez.style.fontWeight = "600";
+ beschriften(bez, tag.name, filter);
+ zeile.append(box, bez);
+ return zeile;
+}
+
+/**
+ * Beschriftung mit hervorgehobenem Treffer.
+ *
+ * Aufbau über createElement und textContent, nicht über innerHTML: Ein
+ * Tagname stammt aus Paperless und damit mittelbar aus fremden Quellen -
+ * wer ein Dokument per Mail einliefern kann, bestimmt Tagnamen mit.
+ */
+function beschriften(el, name, filter) {
+ el.replaceChildren();
+ const pos = filter ? name.toLowerCase().indexOf(filter) : -1;
+ if (pos < 0) {
+ el.textContent = name;
+ return;
+ }
+ el.append(document.createTextNode(name.slice(0, pos)));
+ const mark = document.createElement("span");
+ mark.className = "trefferHervor";
+ mark.textContent = name.slice(pos, pos + filter.length);
+ el.append(mark, document.createTextNode(name.slice(pos + filter.length)));
+}
+
+// ------------------------------------------------------------- Ablauf
+
+async function ablegen() {
+ $("ablegen").disabled = true;
+ $("ueberspringen").disabled = true;
+ meldungAnzeigen(browser.i18n.getMessage("msgUploading")
+ || "Wird abgelegt …");
+
+ const eintrag = liste[stelle];
+ const anVorhandenes = $("zielVorhanden").checked;
+
+ try {
+ const ergebnis = await ruf("ablegenEine", {
+ auftrag,
+ eintrag,
+ ziel: anVorhandenes ? "vorhanden" : "neu",
+ zielId: zielDokument?.id,
+ titel: $("titel").value.trim(),
+ erstellt: $("erstellt").value.trim()
+ });
+
+ // Klassifizierung anwenden. Beim Anhängen gilt sie für die Wurzel,
+ // nicht für die neue Version.
+ const zielId = ergebnis.art === "version" ? ergebnis.zielId : ergebnis.id;
+ if (zielId) {
+ meldungAnzeigen(browser.i18n.getMessage("msgSaving")
+ || "Klassifizierung wird gespeichert …");
+ await ruf("klassifizieren", {
+ id: zielId,
+ payload: {
+ title: $("titel").value.trim(),
+ tags: [...gewaehlteTags],
+ ...($("erstellt").value.trim()
+ ? { created: $("erstellt").value.trim() } : {})
+ },
+ korrName: $("korr").value.trim(),
+ typName: $("typ").value.trim()
+ });
+ }
+
+ abgelegt.push(ergebnis);
+ await weiter();
+ } catch (err) {
+ console.error("[paperless] Ablegen gescheitert", err);
+ fehlerAnzeigen(err.message);
+ $("ablegen").disabled = false;
+ $("ueberspringen").disabled = false;
+ }
+}
+
+async function weiter() {
+ $("ueberspringen").disabled = false;
+ stelle += 1;
+ if (stelle >= liste.length) {
+ meldungAnzeigen(browser.i18n.getMessage("msgStored",
+ [String(abgelegt.length)]) || `${abgelegt.length} abgelegt.`);
+ setTimeout(() => window.close(), 1200);
+ return;
+ }
+ await anzeigen();
+}
diff --git a/ui/store.html b/ui/store.html
index 0976896..b5f1bf6 100644
--- a/ui/store.html
+++ b/ui/store.html
@@ -19,65 +19,11 @@
-
-
-
-
-
-
-
-
+
diff --git a/ui/store.js b/ui/store.js
index 73cacb7..14d0e83 100644
--- a/ui/store.js
+++ b/ui/store.js
@@ -8,14 +8,13 @@
import { groesse, kurz, titelAusBetreff } from "../lib/settings.js";
import { umwandelbar } from "../lib/mailpdf.js";
-import { uebersetzeSeite, ruf, fehlerAnzeigen } from "./gemeinsam.js";
+import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
+ from "./gemeinsam.js";
const $ = (id) => document.getElementById(id);
let auftrag = null;
let einstellungen = null;
-let treffer = [];
-let gewaehlterTreffer = null;
document.addEventListener("DOMContentLoaded", start);
@@ -32,7 +31,6 @@ async function start() {
quelleAnzeigen();
dateienAnzeigen();
- vorbelegen();
verdrahten();
}
@@ -175,112 +173,40 @@ function dateiZeile(o) {
return 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() {
+/**
+ * Weiter zum Schrittdialog.
+ *
+ * Die Auswahl wird über einen neuen Auftrag übergeben - das Fenster
+ * schliesst sich, und der Schrittdialog geht die Dateien einzeln durch.
+ */
+async function weiter() {
$("ablegen").disabled = true;
- $("meldung").className = "hinweis";
- $("meldung").textContent =
- browser.i18n.getMessage("msgUploading") || "Wird abgelegt …";
-
+ meldungAnzeigen(browser.i18n.getMessage("msgOpeningStep")
+ || "Wird vorbereitet …");
try {
- const ergebnisse = await ruf("ablegen", {
+ await ruf("schrittOeffnen", {
auftrag: {
...auftrag,
- // Bei einer ganzen Nachricht entscheidet die eine Auswahl, bei
- // Anhängen steht die Wahl je Datei im Eintrag selbst.
+ titelVorschlag: titelAusBetreff(auftrag.betreff),
alsPdf: auftrag.quelle === "nachricht"
? $("art_ganz_pdf")?.checked ?? true
: undefined
- },
- 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);
+ window.close();
} catch (err) {
fehlerAnzeigen(err.message);
$("ablegen").disabled = false;
}
}
+
+function verdrahten() {
+ $("abbrechen").addEventListener("click", () => window.close());
+ $("ablegen").addEventListener("click", weiter);
+}
+
+function knopfPruefen() {
+ const etwasGewaehlt = auftrag.quelle === "nachricht"
+ || (auftrag.dateien || []).some((d) => d.ausgewaehlt);
+ $("ablegen").disabled = !etwasGewaehlt;
+}