2 Commits

15 changed files with 1196 additions and 285 deletions

View File

@@ -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."
@@ -308,5 +308,60 @@
},
"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."
},
"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 …"
}
}

View File

@@ -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."
@@ -308,5 +308,60 @@
},
"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."
},
"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 …"
}
}

View File

@@ -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."
@@ -308,5 +308,60 @@
},
"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."
},
"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 …"
}
}

View File

@@ -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.
@@ -255,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
@@ -266,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.
@@ -296,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);
@@ -344,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();
@@ -684,43 +704,160 @@ 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;
}
async function ablegen(nachricht) {
/**
* 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`)
};
}
/**
* 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;
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 {
datei = { blob: roh, name: sichererName(beschreibung.name) };
}
}
if (!datei) throw new PaperlessError(t("errNothingSelected"));
const melden = (sek) => {
if (sek % 15 === 0) {
console.debug("[paperless] warte auf Aufgabe:", sek, "s");
}
};
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");
const id = await c.waitForTask(antwort, melden);
return { id, name: datei.name, art: "version", zielId: nachricht.zielId };
}
});
ergebnisse.push({ id, name: datei.name, art: "version",
zielId: nachricht.zielId });
} else {
const felder = { title: nachricht.titel || datei.name };
// 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, (sek) => {
if (sek % 15 === 0) {
console.debug("[paperless] warte auf Aufgabe:", sek, "s");
}
});
ergebnisse.push({ id, name: datei.name, art: "neu" });
}
}
return ergebnisse;
const id = await c.waitForTask(antwort, melden);
return { id, name: datei.name, art: "neu" };
}
async function aufloesen(c, pfad, name) {

View File

@@ -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 \

View File

@@ -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));
}

View File

@@ -2,13 +2,13 @@
"manifest_version": 3,
"name": "__MSG_extName__",
"description": "__MSG_extDescription__",
"version": "1.11.3",
"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"
],

View File

@@ -44,6 +44,7 @@
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="abbrechenAlle" data-i18n="btnSkipAll">Alle überspringen</button>
<button id="ueberspringen" data-i18n="btnSkip">Überspringen</button>
<button id="speichern" class="haupt" data-i18n="btnSave">Speichern</button>
</div>

View File

@@ -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;

View File

@@ -1,12 +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;
@@ -15,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;
@@ -26,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;
@@ -35,15 +53,30 @@ 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 {
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; }
button {
padding: 5px 14px;
border: 1px solid var(--rand);
@@ -52,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;
@@ -74,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;
}

View File

@@ -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);
}

98
ui/schritt.html Normal file
View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="stepTitle">In Paperless ablegen</title>
<link rel="stylesheet" href="gemeinsam.css">
</head>
<body>
<h1 id="kopf">In Paperless ablegen</h1>
<div id="quelle" class="hinweis"></div>
<div class="spalten">
<section class="kasten">
<h2 data-i18n="storeTarget">Ziel</h2>
<div class="wahl">
<input type="radio" name="ziel" id="zielNeu" value="neu" checked>
<label for="zielNeu" data-i18n="storeTargetNew">
Neues Dokument anlegen
</label>
</div>
<div class="wahl">
<input type="radio" name="ziel" id="zielVorhanden" value="vorhanden">
<label for="zielVorhanden" data-i18n="storeTargetExisting">
Als neue Version an ein bestehendes anhängen
</label>
</div>
<div class="zeile" style="margin-top:4px">
<input type="text" id="suche" disabled>
<button id="suchen" disabled data-i18n="btnSearch">Suchen</button>
</div>
<div class="liste">
<table>
<thead><tr>
<th class="zahl" data-i18n="colAsn">ASN</th>
<th data-i18n="colCreated">Erstellt</th>
<th data-i18n="colTitle">Titel</th>
</tr></thead>
<tbody id="treffer"></tbody>
</table>
</div>
<div class="hinweis" data-i18n="stepHintExisting">
Die Klassifizierung des Ziels wird übernommen und kann angepasst
werden.
</div>
</section>
<section class="kasten">
<h2 data-i18n="classifyTitle">Klassifizierung</h2>
<label for="titel" data-i18n="fieldTitle">Titel</label>
<input type="text" id="titel">
<div class="zeile">
<span class="mass">
<label for="erstellt" data-i18n="fieldCreated">Erstellt</label>
<input type="text" id="erstellt" placeholder="JJJJ-MM-TT"
style="width:110px">
</span>
</div>
<label for="korr" data-i18n="fieldCorrespondent">Korrespondent</label>
<input type="text" id="korr" list="korrListe" autocomplete="off">
<datalist id="korrListe"></datalist>
<label for="typ" data-i18n="fieldDocType">Dokumenttyp</label>
<input type="text" id="typ" list="typListe" autocomplete="off">
<datalist id="typListe"></datalist>
<label for="tagSuche" data-i18n="fieldTags">Tags</label>
<input type="text" id="tagSuche"
data-i18n-placeholder="tagsFilterPlaceholder"
placeholder="Tippen zum Filtern …">
<div class="liste">
<div id="tags" style="padding:4px 8px"></div>
</div>
</section>
</div>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="abbrechen" data-i18n="btnCancel">Abbrechen</button>
<button id="ueberspringen" data-i18n="btnSkip">Überspringen</button>
<button id="ablegen" class="haupt" data-i18n="btnStore">Ablegen</button>
</div>
<script type="module" src="schritt.js"></script>
</body>
</html>

357
ui/schritt.js Normal file
View File

@@ -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();
}

View File

@@ -13,77 +13,17 @@
<fieldset>
<legend data-i18n="storeWhat">Was soll abgelegt werden?</legend>
<div id="dateien"></div>
<div class="wahl" id="pdfWahl" hidden>
<input type="checkbox" id="alsPdf" checked>
<label for="alsPdf" data-i18n="storeAsPdf">
Als PDF aufbereiten, Original und Anhänge einbetten
</label>
</div>
<div class="hinweis" data-i18n="storeHintPreselect">
Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist
Teil der Nachricht, keine eigenen Dokumente.
</div>
</fieldset>
<fieldset class="wachsen">
<legend data-i18n="storeTarget">Ziel</legend>
<div class="wahl">
<input type="radio" name="ziel" id="zielNeu" value="neu" checked>
<label for="zielNeu" data-i18n="storeTargetNew">
Neues Dokument anlegen
</label>
</div>
<div id="bereichNeu">
<label for="titel" data-i18n="fieldTitle">Titel</label>
<input type="text" id="titel">
<div class="hinweis" data-i18n="storeHintTitleMulti">
Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt.
</div>
<label for="erstellt" data-i18n="fieldCreated">Erstellt</label>
<input type="text" id="erstellt" placeholder="JJJJ-MM-TT">
</div>
<div class="wahl">
<input type="radio" name="ziel" id="zielVorhanden" value="vorhanden">
<label for="zielVorhanden" data-i18n="storeTargetExisting">
An bestehendes Dokument anhängen
</label>
</div>
<div id="bereichVorhanden" class="wachsen">
<div class="zeile">
<input type="text" id="suche" disabled>
<button id="suchen" disabled data-i18n="btnSearch">Suchen</button>
</div>
<div class="liste">
<table>
<thead>
<tr>
<th class="zahl" data-i18n="colAsn">ASN</th>
<th data-i18n="colCreated">Erstellt</th>
<th data-i18n="colTitle">Titel</th>
</tr>
</thead>
<tbody id="treffer"></tbody>
</table>
</div>
</div>
</fieldset>
<div class="wahl">
<input type="checkbox" id="klassifizieren" checked>
<label for="klassifizieren" data-i18n="storeClassifyAfter">
Nach dem Ablegen die Klassifizierung bearbeiten
</label>
</div>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="abbrechen" data-i18n="btnCancel">Abbrechen</button>
<button id="ablegen" class="haupt" data-i18n="btnStore">Ablegen</button>
<button id="ablegen" class="haupt" data-i18n="btnNext">Weiter</button>
</div>
<script type="module" src="store.js"></script>

View File

@@ -7,14 +7,14 @@
*/
import { groesse, kurz, titelAusBetreff } from "../lib/settings.js";
import { uebersetzeSeite, ruf, fehlerAnzeigen } from "./gemeinsam.js";
import { umwandelbar } from "../lib/mailpdf.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);
@@ -31,7 +31,6 @@ async function start() {
quelleAnzeigen();
dateienAnzeigen();
vorbelegen();
verdrahten();
}
@@ -50,43 +49,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 +87,7 @@ function dateienAnzeigen() {
|| `Die ${auftrag.dateien.length} Anhänge bleiben darin enthalten.`;
ziel.append(h);
}
knopfPruefen();
return;
}
@@ -103,128 +95,118 @@ 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;
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 = "wahl";
zeile.className = "dateizeile";
const box = document.createElement("input");
box.type = "checkbox";
box.id = `d${i}`;
box.checked = d.ausgewaehlt;
box.id = o.schluessel;
box.checked = o.gewaehlt;
if (o.festGewaehlt) box.disabled = true;
box.addEventListener("change", () => {
d.ausgewaehlt = box.checked;
o.beiWahl?.(box.checked);
knopfPruefen();
});
const bez = document.createElement("label");
bez.htmlFor = box.id;
bez.textContent = `${d.name} (${groesse(d.size)}, ${d.contentType})`;
zeile.append(box, bez);
ziel.append(zeile);
}
}
function vorbelegen() {
$("titel").value = titelAusBetreff(auftrag.betreff);
if (auftrag.datum) $("erstellt").value = auftrag.datum;
}
const name = document.createElement("label");
name.className = "name";
name.htmlFor = box.id;
name.textContent = o.beschriftung;
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();
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");
});
$("abbrechen").addEventListener("click", () => window.close());
$("ablegen").addEventListener("click", ablegen);
zielGewechselt();
}
const l = document.createElement("label");
l.htmlFor = r.id;
l.textContent = text;
if (r.disabled) l.className = "aus";
art.append(r, l);
};
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();
}
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);
async function suchen() {
const liste = $("treffer");
liste.replaceChildren();
try {
treffer = await ruf("suchen", { query: $("suche").value.trim() });
} catch (err) {
fehlerAnzeigen(err.message);
return;
if (!o.pdfMoeglich) {
art.title = browser.i18n.getMessage("artPdfUnavailable")
|| "Für dieses Format nicht möglich - nur Textdateien lassen sich "
+ "aufbereiten.";
}
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.");
zeile.append(art);
return zeile;
}
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", {
auftrag: { ...auftrag,
// Nur mitgeben, wenn die Wahl überhaupt angeboten wurde.
alsPdf: $("pdfWahl") && !$("pdfWahl").hidden
? $("alsPdf").checked : 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;
await ruf("schrittOeffnen", {
auftrag: {
...auftrag,
titelVorschlag: titelAusBetreff(auftrag.betreff),
alsPdf: auftrag.quelle === "nachricht"
? $("art_ganz_pdf")?.checked ?? true
: undefined
}
$("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;
}