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

+
+ +
+ +
+

Ziel

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + + + + +
ASNErstelltTitel
+
+ +
+ Die Klassifizierung des Ziels wird übernommen und kann angepasst + werden. +
+
+ +
+

Klassifizierung

+ + + + +
+ + + + +
+ + + + + + + + + + + + +
+
+
+
+ +
+ +
+ +
+ + + +
+ + + + 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 @@ -
- Ziel - -
- - -
- -
- - -
- Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt. -
- - -
- -
- - -
- -
-
- - -
-
- - - - - - - - - -
ASNErstelltTitel
-
-
-
- -
- - -
-
- +
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; +}