From 3f96e6f49de0cb90f2270a649b07d563d94fd9a2 Mon Sep 17 00:00:00 2001 From: Marco Morath Date: Tue, 8 Sep 2026 06:45:10 +0200 Subject: [PATCH] Verbesserung Ablagedialog hinsichtlich PDF-Konvertierung und nicht konvertierbaren Dateien --- _locales/de/messages.json | 12 +++ _locales/en/messages.json | 12 +++ _locales/fr/messages.json | 12 +++ background.js | 67 +++++++++++++++- lib/mailpdf.js | 73 +++++++++++++++++ manifest.json | 2 +- ui/gemeinsam.css | 15 ++++ ui/store.html | 6 -- ui/store.js | 160 +++++++++++++++++++++++++------------- 9 files changed, 299 insertions(+), 60 deletions(-) diff --git a/_locales/de/messages.json b/_locales/de/messages.json index c7b8c9f..edfbea2 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -308,5 +308,17 @@ }, "marginBottom": { "message": "unten" + }, + "artOriginal": { + "message": "Original ablegen" + }, + "artPdf": { + "message": "PDF aufbereiten" + }, + "artMessagePdf": { + "message": "als PDF aufbereiten" + }, + "artPdfUnavailable": { + "message": "Für dieses Format nicht möglich - nur Textdateien lassen sich aufbereiten." } } diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7f63074..4e90796 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -308,5 +308,17 @@ }, "marginBottom": { "message": "bottom" + }, + "artOriginal": { + "message": "store original" + }, + "artPdf": { + "message": "prepare as PDF" + }, + "artMessagePdf": { + "message": "prepare as PDF" + }, + "artPdfUnavailable": { + "message": "Not possible for this format - only text files can be prepared." } } diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 011b29c..97d1515 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -308,5 +308,17 @@ }, "marginBottom": { "message": "bas" + }, + "artOriginal": { + "message": "enregistrer l'original" + }, + "artPdf": { + "message": "préparer en PDF" + }, + "artMessagePdf": { + "message": "préparer en PDF" + }, + "artPdfUnavailable": { + "message": "Impossible pour ce format - seuls les fichiers texte peuvent être préparés." } } diff --git a/background.js b/background.js index 3d87b39..324ba2c 100644 --- a/background.js +++ b/background.js @@ -16,7 +16,8 @@ import { ladeEinstellungen, vollstaendig, sichererName, versionslabel, titelAusBetreff, zugriffsmuster, t } from "./lib/settings.js"; -import { baueMailPdf, alsEmpfaenger } from "./lib/mailpdf.js"; +import { baueMailPdf, baueTextPdf, alsEmpfaenger, umwandelbar } + from "./lib/mailpdf.js"; /** * Ergebnis einer Nachrichten-Abfrage vereinheitlichen. @@ -684,11 +685,75 @@ async function datenHolen(auftrag, s) { if (!d.ausgewaehlt) continue; const datei = await browser.messages.getAttachmentFile( d.messageId, d.partName); + + // Aufbereiten nur, wenn der Anwender es gewählt hat UND das Format es + // hergibt. Eine Rechnung als PDF ist bereits das Dokument; sie durch + // eine Textfassung zu ersetzen wäre Unfug. + if (d.alsPdf && umwandelbar(d.contentType, d.name)) { + try { + out.push(await anhangAlsPdf(datei, d, s)); + continue; + } catch (err) { + console.error("[paperless] Anhang konnte nicht aufbereitet werden", + d.name, err); + // Nicht abbrechen: lieber das Original ablegen als gar nichts. + } + } out.push({ blob: datei, name: sichererName(d.name) }); } return out; } +/** + * Einen Textanhang als PDF aufbereiten, mit der Ursprungsdatei darin. + * + * Sinnvoll etwa bei einem HTML-Anhang, den im Archiv sonst niemand + * ansieht. Für Formate ohne Textinhalt greift diese Funktion nicht - + * das prüft umwandelbar() vorher. + */ +async function anhangAlsPdf(datei, beschreibung, s) { + const roh = new Uint8Array(await datei.arrayBuffer()); + let text = new TextDecoder("utf-8", { fatal: false }).decode(roh); + + const istHtml = /^text\/html$/i.test(beschreibung.contentType || "") + || /\.(html?|xhtml)$/i.test(beschreibung.name || "") + || /<\s*html[\s>]/i.test(text.slice(0, 2000)); + + if (istHtml && browser.messengerUtilities?.convertToPlainText) { + try { + text = await browser.messengerUtilities.convertToPlainText(text); + } catch { /* dann bleibt der Rohtext */ } + } + + const basis = String(beschreibung.name || "Dokument") + .replace(/\.[^.]{1,8}$/, ""); + + const pdf = baueTextPdf({ + titel: basis, + text, + dateien: [{ + name: sichererName(beschreibung.name || "original"), + daten: roh, + mime: beschreibung.contentType || "application/octet-stream", + beschreibung: "Originaldatei" + }] + }, { + schrift: Number(s.pdfSchriftgroesse) || 11, + format: s.pdfFormat || "a4", + rand: { + links: Number(s.pdfRandLinks ?? 20), + rechts: Number(s.pdfRandRechts ?? 10), + oben: Number(s.pdfRandOben ?? 10), + unten: Number(s.pdfRandUnten ?? 10) + } + }); + + return { + blob: new Blob([pdf], { type: "application/pdf" }), + name: sichererName(`${basis}.pdf`) + }; +} + async function ablegen(nachricht) { const c = await client(); const s = await ladeEinstellungen(); diff --git a/lib/mailpdf.js b/lib/mailpdf.js index 005225b..9865942 100644 --- a/lib/mailpdf.js +++ b/lib/mailpdf.js @@ -190,6 +190,79 @@ function kopfblock(d, daten, groesse) { d.y = oben - hoehe; } +/** + * Beliebigen Text als PDF setzen, mit der Ursprungsdatei als Anhang. + * + * Für Anhänge, die Text enthalten - HTML, Nur-Text, eingebettete + * Nachrichten. Formate wie DOCX oder XLSX lassen sich hier NICHT + * umwandeln; dafür bräuchte es eine Darstellungsmaschine. + */ +export function baueTextPdf(daten, opt = {}) { + const basis = begrenzen(Number(opt.schrift) || 11, 7, 16); + const papier = FORMATE[String(opt.format || "a4").toLowerCase()] + || FORMATE.a4; + const r = opt.rand || {}; + const grenze = (wert, vorgabe) => + Math.min(papier.breite / 3, Math.max(0, Number(wert) ?? vorgabe)); + + const d = new PdfDokument({ + titel: daten.titel || "Dokument", + breite: mm(papier.breite), + hoehe: mm(papier.hoehe), + randLinks: mm(grenze(r.links ?? 20, 20)), + randRechts: mm(grenze(r.rechts ?? 10, 10)), + randOben: mm(grenze(r.oben ?? 10, 10)), + randUnten: mm(grenze(r.unten ?? 10, 10)) + }); + d.neueSeite(); + + d.absatz(daten.titel || "Dokument", + { groesse: basis + 2, fett: true, abstand: 1.25 }); + d.abstand(6); + d.linie(); + d.abstand(8); + + const text = String(daten.text ?? "").replace(/\r\n?/g, "\n").trimEnd(); + if (text) { + for (const zeile of text.split("\n")) { + d.absatz(zeile || " ", { groesse: basis, abstand: 1.35 }); + } + } else { + d.absatz("(kein Textinhalt - siehe eingebettete Originaldatei)", + { groesse: basis, farbe: GRAU_TEXT }); + } + + d.abstand(10); + d.linie(); + d.absatz("Aus der Originaldatei erzeugt, die diesem PDF beiliegt.", + { groesse: Math.max(6.5, basis - 3), farbe: GRAU_TEXT }); + + for (const f of daten.dateien ?? []) { + d.anhang(f.name, f.daten, { + beschreibung: f.beschreibung || "", + mime: f.mime || "application/octet-stream" + }); + } + return d.bauen(); +} + +/** + * Lässt sich diese Datei als PDF aufbereiten? + * + * Nur was Text enthält. Bei DOCX oder XLSX müsste die Erweiterung das + * Format auswerten und setzen - dafür gibt es keine Grundlage. + */ +export function umwandelbar(mime, name = "") { + const m = String(mime || "").toLowerCase(); + if (/^text\//.test(m)) return true; + if (m === "message/rfc822") return true; + if (["application/json", "application/xml", "application/xhtml+xml", + "application/rtf"].includes(m)) return true; + // Manche Server melden alles als octet-stream. Dann entscheidet die + // Endung. + return /\.(txt|htm|html|xml|json|csv|md|log|eml)$/i.test(String(name)); +} + function begrenzen(wert, min, max) { return Math.min(max, Math.max(min, wert)); } diff --git a/manifest.json b/manifest.json index 006c631..e510475 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "__MSG_extName__", "description": "__MSG_extDescription__", - "version": "1.11.3", + "version": "1.12.0", "author": "Name des Herausgebers", "homepage_url": "https://example.org/paperless-thunderbird", "default_locale": "de", diff --git a/ui/gemeinsam.css b/ui/gemeinsam.css index 9ae33ab..8c29060 100644 --- a/ui/gemeinsam.css +++ b/ui/gemeinsam.css @@ -7,6 +7,10 @@ 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; @@ -43,6 +47,17 @@ input[type=text], input[type=password], input[type=number], select { .mass input { width: 68px; } .zeile > input[type=text] { flex: 1; } .wahl { display: flex; gap: 6px; align-items: center; margin: 3px 0; } + +/* Dateiliste mit Wahl der Ablageart je Datei. */ +.dateizeile { + display: flex; align-items: center; gap: 10px; + padding: 3px 0; flex-wrap: wrap; +} +.dateizeile .name { flex: 1; min-width: 200px; } +.dateizeile .art { display: flex; gap: 12px; align-items: center; } +.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; diff --git a/ui/store.html b/ui/store.html index 462ce12..0976896 100644 --- a/ui/store.html +++ b/ui/store.html @@ -13,12 +13,6 @@
Was soll abgelegt werden?
-
Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist Teil der Nachricht, keine eigenen Dokumente. diff --git a/ui/store.js b/ui/store.js index fca74cf..73cacb7 100644 --- a/ui/store.js +++ b/ui/store.js @@ -7,6 +7,7 @@ */ import { groesse, kurz, titelAusBetreff } from "../lib/settings.js"; +import { umwandelbar } from "../lib/mailpdf.js"; import { uebersetzeSeite, ruf, fehlerAnzeigen } from "./gemeinsam.js"; const $ = (id) => document.getElementById(id); @@ -50,43 +51,35 @@ function quelleAnzeigen() { * Darstellung der Nachricht, nicht zum Inhalt - wer sie alle mit * archiviert, hat nach einem Jahr hunderte Logos im Bestand. */ +/** + * Dateien zur Auswahl anbieten, je Zeile mit der Wahl der Ablageart. + * + * Vorbelegt wird unterschiedlich, weil die Absicht unterschiedlich ist: + * + * Eine NACHRICHT wird als PDF aufbereitet - eine .eml sieht sich im + * Archiv niemand an. + * + * Ein ANHANG wird im Original abgelegt. Eine Rechnung als PDF ist + * bereits das Dokument; sie durch eine Textfassung zu ersetzen wäre + * Unfug. Nur bei Textdateien - etwa einem HTML-Anhang - ist die + * Aufbereitung sinnvoll, und dort steht sie zur Wahl. + */ function dateienAnzeigen() { const ziel = $("dateien"); ziel.replaceChildren(); if (auftrag.quelle === "nachricht") { - // Die Wahl zwischen PDF und Rohformat gibt es nur hier. Bei einem - // einzelnen Anhang waere sie sinnlos - dort wird die Datei so - // abgelegt, wie sie ist. - const wahl = $("pdfWahl"); - if (wahl && wahl.hidden) { - wahl.hidden = false; - $("alsPdf").checked = (einstellungen.mailFormat || "pdf") === "pdf"; - $("alsPdf").addEventListener("change", dateienAnzeigen); - } - - // 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"; - const alsPdf = $("alsPdf") ? $("alsPdf").checked - : (einstellungen.mailFormat || "pdf") === "pdf"; - bez.textContent = alsPdf - ? (browser.i18n.getMessage("storeWholePdf") - || "Die Nachricht als PDF, mit eingebettetem Original") - : (browser.i18n.getMessage("storeWholeMessage") - || "Die vollständige Nachricht als .eml"); - zeile.append(box, bez); - ziel.append(zeile); + const name = `${titelAusBetreff(auftrag.betreff)}.eml`; + ziel.append(dateiZeile({ + schluessel: "ganz", + beschriftung: name, + gewaehlt: true, + festGewaehlt: true, + pdfMoeglich: true, + alsPdf: (einstellungen.mailFormat || "pdf") === "pdf", + hinweisPdf: browser.i18n.getMessage("artMessagePdf") + || "als PDF aufbereiten" + })); if (auftrag.dateien?.length) { const h = document.createElement("div"); @@ -96,6 +89,7 @@ function dateienAnzeigen() { || `Die ${auftrag.dateien.length} Anhänge bleiben darin enthalten.`; ziel.append(h); } + knopfPruefen(); return; } @@ -103,24 +97,82 @@ function dateienAnzeigen() { const uninteressant = einstellungen.ignorierteTypen.includes(d.contentType) || (d.size || 0) < einstellungen.ignorierteKleiner; - d.ausgewaehlt = !uninteressant; + if (d.ausgewaehlt === undefined) d.ausgewaehlt = !uninteressant; + if (d.alsPdf === undefined) d.alsPdf = false; - 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); + ziel.append(dateiZeile({ + schluessel: `d${i}`, + beschriftung: `${d.name} (${groesse(d.size)}, ${d.contentType})`, + gewaehlt: d.ausgewaehlt, + pdfMoeglich: umwandelbar(d.contentType, d.name), + alsPdf: d.alsPdf, + beiWahl: (an) => { d.ausgewaehlt = an; }, + beiArt: (pdf) => { d.alsPdf = pdf; } + })); } + knopfPruefen(); +} + +/** + * Eine Zeile der Dateiliste: Häkchen, Name, und rechts die Wahl zwischen + * Original und PDF. + */ +function dateiZeile(o) { + const zeile = document.createElement("div"); + zeile.className = "dateizeile"; + + const box = document.createElement("input"); + box.type = "checkbox"; + box.id = o.schluessel; + box.checked = o.gewaehlt; + if (o.festGewaehlt) box.disabled = true; + box.addEventListener("change", () => { + o.beiWahl?.(box.checked); + knopfPruefen(); + }); + + const name = document.createElement("label"); + name.className = "name"; + name.htmlFor = box.id; + name.textContent = o.beschriftung; + + zeile.append(box, name); + + const art = document.createElement("div"); + art.className = "art"; + const gruppe = `art_${o.schluessel}`; + + const machen = (wert, text, aktiv) => { + const r = document.createElement("input"); + r.type = "radio"; + r.name = gruppe; + r.id = `${gruppe}_${wert}`; + r.value = wert; + r.checked = aktiv; + if (!o.pdfMoeglich && wert === "pdf") r.disabled = true; + r.addEventListener("change", () => { + if (r.checked) o.beiArt?.(wert === "pdf"); + }); + const l = document.createElement("label"); + l.htmlFor = r.id; + l.textContent = text; + if (r.disabled) l.className = "aus"; + art.append(r, l); + }; + + machen("original", browser.i18n.getMessage("artOriginal") + || "Original ablegen", !o.alsPdf); + machen("pdf", o.hinweisPdf || browser.i18n.getMessage("artPdf") + || "PDF aufbereiten", Boolean(o.alsPdf) && o.pdfMoeglich); + + if (!o.pdfMoeglich) { + art.title = browser.i18n.getMessage("artPdfUnavailable") + || "Für dieses Format nicht möglich - nur Textdateien lassen sich " + + "aufbereiten."; + } + + zeile.append(art); + return zeile; } function vorbelegen() { @@ -199,10 +251,14 @@ async function ablegen() { try { const ergebnisse = await ruf("ablegen", { - auftrag: { ...auftrag, - // Nur mitgeben, wenn die Wahl überhaupt angeboten wurde. - alsPdf: $("pdfWahl") && !$("pdfWahl").hidden - ? $("alsPdf").checked : undefined }, + auftrag: { + ...auftrag, + // Bei einer ganzen Nachricht entscheidet die eine Auswahl, bei + // Anhängen steht die Wahl je Datei im Eintrag selbst. + alsPdf: auftrag.quelle === "nachricht" + ? $("art_ganz_pdf")?.checked ?? true + : undefined + }, ziel: $("zielNeu").checked ? "neu" : "vorhanden", zielId: gewaehlterTreffer?.id, titel: $("titel").value.trim(),