5 Commits

21 changed files with 2763 additions and 297 deletions

View File

@@ -1,4 +1,4 @@
# Paperless-ngx für Thunderbird / Betterbird
# Paperless-Connector für Thunderbird / Betterbird
Legt Anhänge und ganze E-Mails in einem Paperless-ngx-Archiv ab und fügt
Dokumente aus dem Archiv als Anhang oder Freigabelink in Entwürfe ein.
@@ -14,7 +14,7 @@ WebExtensions, LibreOffice UNO.
| Ort | Befehl | Wirkung |
|---|---|---|
| Kontextmenü am Anhang | Anhang in Paperless ablegen | ausgewählte Anhänge als Dokument |
| Kontextmenü in der Nachrichtenliste | E-Mail in Paperless ablegen | vollständige Nachricht als `.eml` |
| Kontextmenü in der Nachrichtenliste | E-Mail in Paperless ablegen | als PDF mit eingebettetem Original, wahlweise als `.eml` |
| Schaltfläche im Nachrichtenfenster | dasselbe für die angezeigte Nachricht | |
| Schaltfläche im Verfassenfenster | Aus Paperless einfügen | als Anhang oder als Freigabelink |
@@ -35,9 +35,18 @@ abgewählt — sie gehören zur Darstellung der Nachricht, nicht zum Inhalt.
Wer sie alle mit archiviert, hat nach einem Jahr hunderte Logos im Bestand.
Die Schwelle liegt bei 8 KB und ist einstellbar.
**Die ganze Nachricht als `.eml`.** Anhänge bleiben darin enthalten;
Paperless legt sie nicht als eigene Dokumente an, aber die Datei ist
vollständig und lässt sich jederzeit wieder in einem Mailprogramm öffnen.
**Die ganze Nachricht als PDF.** Betreff als Überschrift, darunter ein
grau hinterlegter Kopfblock mit Konto, Absender, Empfängern, Datum und
Anlagen, dann der Nachrichtentext. Zitierte Abschnitte stehen grau und
eingerückt — bei langen Verläufen sieht man so auf einen Blick, was neu
ist.
Eingebettet werden die vollständige Originalnachricht als `.eml` sowie
alle Anhänge im Original. Es geht also nichts verloren, auch wenn die
Darstellung nicht originalgetreu ist.
Wer lieber die Rohfassung will, stellt das in den Einstellungen um oder
nimmt das Häkchen im Ablegen-Dialog heraus.
**Titel aus dem Betreff.** Präfixe wie `AW:` oder `Fwd:` werden entfernt —
sie tragen im Archiv nichts bei und stören beim Sortieren.
@@ -45,6 +54,52 @@ sie tragen im Archiv nichts bei und stören beim Sortieren.
**Klassifizierung direkt danach.** Sonst trägt ein neues Dokument nur den
Titel, und nachträglich macht man es erfahrungsgemäß nicht mehr.
### Warum kein originalgetreues Abbild
HTML-Nachrichten werden als Text gesetzt. Eine WebExtension hat keine
Darstellungsmaschine, die HTML nach PDF überträgt, und **Thunderbird
bietet Erweiterungen keine Druckfunktion an** — die tabs-Schnittstelle
kennt in Manifest V3 kein `saveAsPDF`, anders als in Firefox. Geprüft an
der API-Dokumentation für Thunderbird 156.
Für die Umwandlung von HTML nach Text übernimmt
`messengerUtilities.convertToPlainText()` die Arbeit. Das ist dieselbe
Funktion, die beim Antworten in Nur-Text greift, und liefert deutlich
bessere Ergebnisse als eine selbstgebaute Entschlackung.
### Der PDF-Erzeuger
Selbst geschrieben, in `lib/pdf.js`. Der Grund ist Vorsatz: Eine
Erweiterung soll nichts nachladen, und eine mitgelieferte Bibliothek wie
`pdf-lib` wäre rund ein Megabyte für einen Bruchteil ihrer Fähigkeiten.
Gebraucht werden hier nur Text und eingebettete Dateien.
Erzeugt wird PDF 1.7 mit den eingebauten Schriften Helvetica und
Helvetica-Bold. Die sind in jedem Betrachter vorhanden und müssen nicht
eingebettet werden.
**Ausdrücklich kein PDF/A.** Dafür wären eingebettete Schriftschnitte, ein
Farbprofil und XMP-Metadaten nötig — und vor allem eine Prüfung mit
veraPDF. Ein Dokument als PDF/A zu bezeichnen, ohne es geprüft zu haben,
wäre eine leere Behauptung.
Einstellbar sind Papierformat (A4, A5, Letter, Legal), die vier Ränder in
Millimetern und die Grundschriftgröße (Vorgabe 11 Punkt).
### Das Konto im Kopfblock
Bei Nachrichten an Blindkopie-Empfänger geht aus An und Kopie nicht
hervor, welches Konto sie empfangen hat. Im Archiv ist das später die
entscheidende Angabe.
Ermittelt wird sie auf zwei Wegen: über die accounts-Schnittstelle, die
allerdings die Berechtigung `accountsRead` braucht, und ersatzweise über
die Zustellkopfzeilen `Delivered-To`, `X-Original-To` und Verwandte.
Letztere setzt der zustellende Server, und sie sind nicht immer vorhanden
— Postfix und Dovecot setzen sie zuverlässig, Microsoft-Umgebungen oft
nicht. Findet sich nichts, fehlt die Zeile im PDF ganz.
---
## Einfügen in Entwürfe
@@ -85,7 +140,7 @@ für Empfänger, die ohnehin Zugriff auf das Archiv haben sollen.
## Einrichten
1. Add-ons-Verwaltung → **Paperless-ngx** → Einstellungen
1. Add-ons-Verwaltung → **Paperless-Connector** → Einstellungen
2. Basisadresse eintragen, etwa `https://dms.example.org` — ohne
abschließenden Schrägstrich
3. API-Token eintragen, erzeugt im **Benutzerprofil** von Paperless
@@ -233,3 +288,6 @@ schließt jede Gewährleistung aus.
Vor dem ersten produktiven Einsatz: eine Testnachricht ablegen und in
Paperless nachsehen, ob Titel, Datum und Anhänge stimmen.
Es wird weder eine Gewähr noch eine Haftung übernommen. Für entstandene
Schäden haftet der jeweilige Anwender. Die Nutzun erfolgt auf eigenes Risiko.

View File

@@ -1,7 +1,7 @@
# Sicherheitsbetrachtung
Was die Erweiterung tut, welche Angriffswege sich daraus ergeben und was
dagegen unternommen wird. Stand: Version 1.5.0.
dagegen unternommen wird. Stand: Version 1.11.3.
---
@@ -10,7 +10,11 @@ dagegen unternommen wird. Stand: Version 1.5.0.
Die Lage ist deutlich entspannter als bei der LibreOffice-Fassung. Dort
war der kritischste Schritt das **Öffnen** eines heruntergeladenen
Dokuments in einer Anwendung mit Makrofähigkeit. Das entfällt hier
vollständig — die Erweiterung öffnet nichts, sie überträgt nur.
vollständig — die Erweiterung öffnet nichts.
Seit Version 1.9 erzeugt sie allerdings PDF-Dateien aus E-Mails. Das ist
sicherheitstechnisch unauffällig, gehört aber der Vollständigkeit halber
in diese Aufstellung — siehe Abschnitt 5.
| Handlung | Vertrauensgrenze |
|---|---|
@@ -18,6 +22,7 @@ vollständig — die Erweiterung öffnet nichts, sie überträgt nur.
| Anhänge und Nachrichten hochladen | Inhalt vom Absender, nicht vertrauenswürdig |
| Dokumente als Anhang in Entwürfe | Inhalt aus dem Archiv |
| Freigabelink in den Nachrichtentext | erzeugte Zeichenkette |
| PDF aus einer Nachricht erzeugen | Inhalt vom Absender |
| Token dauerhaft speichern | Geheimnis |
---
@@ -30,7 +35,8 @@ Bewusst knapp gehalten:
|---|---|---|
| `storage` | Einstellungen | |
| `menus` | Kontextmenüs | |
| `messagesRead` | Anhänge und `.eml` lesen | nur auf Anforderung genutzt |
| `messagesRead` | Anhänge, Text und `.eml` lesen | nur auf Anforderung genutzt |
| `accountsRead` | Konto der Nachricht ermitteln | ohne sie greift der Weg über die Zustellkopfzeilen |
| `compose` | Anhang und Link einfügen | |
| `notifications` | Fehlermeldungen | |
@@ -101,7 +107,38 @@ Speicher liegen, nur weil jemand einen Dialog offen stehen liess.
---
## 5. Speicherverhalten
## 5. Das erzeugte PDF
Seit Version 1.9 werden ganze Nachrichten als PDF aufbereitet. Der
Erzeuger ist selbst geschrieben (`lib/pdf.js`), ohne Fremdbibliothek.
**Das PDF enthält keinen ausführbaren Inhalt.** Kein JavaScript, keine
OpenAction, keine Verweise nach außen — nur Textoperatoren, ein
Rechteck und eingebettete Dateien. Ein PDF-Betrachter hat darin nichts
zu tun, was über das Darstellen hinausgeht.
**Eingebettet werden Originalnachricht und Anhänge.** Deren Inhalt
stammt vom Absender und ist nicht vertrauenswürdig. Die Erweiterung
öffnet sie nicht, sie legt sie nur ab — was ein Betrachter tut, wenn
jemand später auf einen Anhang doppelklickt, liegt außerhalb ihres
Einflusses. Das entspricht der Lage bei jedem anderen Archivdokument.
**Dateinamen werden bereinigt**, bevor sie in die Anhangstabelle des PDF
geschrieben werden. Sie stammen vom Absender.
**Texte werden für die PDF-Syntax abgesichert.** Klammern und
Rückstriche haben dort besondere Bedeutung; ohne Maskierung könnte ein
Betreff mit einer schliessenden Klammer die Struktur der Datei stören.
Zeichen ausserhalb von WinAnsi werden durch ein Fragezeichen ersetzt.
**Ränder und Schriftgröße werden begrenzt** — 0 bis 60 mm, 7 bis 16
Punkt, und zusätzlich auf höchstens ein Drittel der Seitenbreite. Eine
Zahl aus einem Eingabefeld kann alles sein, und ein Dokument mit
negativer Textbreite wäre schwer zu deuten.
---
## 6. Speicherverhalten
Anhangdaten werden **erst beim Hochladen** geladen, nicht beim Öffnen des
Dialogs. Bei einer Nachricht mit mehreren großen Anhängen läge sonst alles
@@ -109,7 +146,7 @@ im Speicher, während der Anwender noch überlegt.
---
## 6. Transport
## 7. Transport
**Adressprüfung.** Nur `http` und `https`. Ohne diese Prüfung liessen sich
andere Schemata hinterlegen, die `fetch` teilweise bedient.
@@ -125,7 +162,7 @@ ohnehin nicht umgehen, und das ist richtig so.
---
## 7. Der Token
## 8. Der Token
Er liegt im Klartext in `browser.storage.local`, also im Profil von
Thunderbird. Das entspricht dem, was Erweiterungen üblicherweise tun, ist
@@ -145,11 +182,12 @@ Erweiterung kann dann nur, was dieser Benutzer darf.
---
## 8. Was die Erweiterung NICHT tut
## 9. Was die Erweiterung NICHT tut
- kein `eval`, kein `innerHTML` mit fremden Inhalten — alle Listen werden
über `textContent` und `createElement` aufgebaut
- kein Öffnen heruntergeladener Dateien
- kein ausführbarer Inhalt im erzeugten PDF
- kein Schreiben ins Dateisystem
- kein Versenden von Nachrichten
- keine Übertragung an Dritte; einzige Gegenstelle ist die konfigurierte
@@ -157,7 +195,7 @@ Erweiterung kann dann nur, was dieser Benutzer darf.
---
## 9. Bekannte Grenzen
## 10. Bekannte Grenzen
| Punkt | Bewertung |
|---|---|
@@ -166,6 +204,7 @@ Erweiterung kann dann nur, was dieser Benutzer darf.
| Kompromittierte Paperless-Instanz | könnte manipulierte Dokumente als Anhang liefern — sie werden aber nicht geöffnet, nur angehängt |
| Kein Schutz gegen Zugriff auf das eigene Benutzerkonto | systembedingt |
| Der Nachrichtentext wird beim Einfügen vollständig neu geschrieben | Vorgabe der Compose-Schnittstelle, siehe README |
| Das erzeugte PDF ist **kein PDF/A** | dafür fehlen Schrifteinbettung, Farbprofil und eine Prüfung mit veraPDF |
---

View File

@@ -1,6 +1,6 @@
{
"extName": {
"message": "Paperless-ngx"
"message": "Paperless-Connector"
},
"extDescription": {
"message": "Anhänge und E-Mails in ein Paperless-ngx-Archiv ablegen, Dokumente als Anhang oder Freigabelink einfügen."
@@ -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."
@@ -79,7 +79,7 @@
"message": "Freigabelink im Nachrichtentext"
},
"insertHintLink": {
"message": "Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt."
"message": "Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt. Leeres Tag-Feld bedeutet: keine Kennzeichnung setzen."
},
"optionsTitle": {
"message": "Paperless — Verbindung"
@@ -249,12 +249,119 @@
"linkValidUnlimited": {
"message": "Link ohne zeitliche Begrenzung"
},
"linkHeading": {
"message": "Datei $1",
"optionsHintTag": {
"message": "Tag beim Teilen leer lassen, um keine Kennzeichnung zu setzen."
},
"fieldShareBaseUrl": {
"message": "Adresse für Freigabelinks (optional)"
},
"optionsHintShareUrl": {
"message": "Nur nötig, wenn der interne Zugang über einen abweichenden Port oder Namen läuft. Freigabelinks werden dann unter dieser Adresse gebaut, damit sie auch von außen funktionieren. Leer = Basisadresse."
},
"tagsFilterPlaceholder": {
"message": "Tippen zum Filtern …"
},
"tagsNoMatch": {
"message": "Keine Übereinstimmung."
},
"fieldMailFormat": {
"message": "Ganze E-Mails ablegen als"
},
"mailFormatPdf": {
"message": "PDF mit eingebettetem Original"
},
"mailFormatEml": {
"message": "Rohformat (.eml)"
},
"fieldPdfAttachments": {
"message": "Anhänge zusätzlich einzeln ins PDF einbetten"
},
"optionsHintMailFormat": {
"message": "Das PDF ist lesbar und durchsuchbar; HTML-Nachrichten werden dabei als Text gesetzt. Die Originalnachricht steckt als Datei im PDF, es geht also nichts verloren."
},
"storeWholePdf": {
"message": "Die Nachricht als PDF, mit eingebettetem Original"
},
"storeAsPdf": {
"message": "Als PDF aufbereiten, Original und Anhänge einbetten"
},
"fieldPdfFontSize": {
"message": "Schriftgröße"
},
"optionsHintPdfFontSize": {
"message": "7 bis 16 Punkt"
},
"fieldPdfFormat": {
"message": "Papierformat"
},
"fieldPdfMargins": {
"message": "Ränder in mm"
},
"marginLeft": {
"message": "links"
},
"marginRight": {
"message": "rechts"
},
"marginTop": {
"message": "oben"
},
"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

@@ -1,6 +1,6 @@
{
"extName": {
"message": "Paperless-ngx"
"message": "Paperless-Connector"
},
"extDescription": {
"message": "Store attachments and e-mails in a Paperless-ngx archive, insert documents as attachment or share link."
@@ -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."
@@ -79,7 +79,7 @@
"message": "Share link in the message body"
},
"insertHintLink": {
"message": "The link works without signing in. 0 days means unlimited."
"message": "The link works without signing in. 0 days means unlimited. An empty tag field means: do not set a tag."
},
"optionsTitle": {
"message": "Paperless — Connection"
@@ -249,12 +249,119 @@
"linkValidUnlimited": {
"message": "Link without time limit"
},
"linkHeading": {
"message": "File $1",
"optionsHintTag": {
"message": "Leave the sharing tag empty to set no tag at all."
},
"fieldShareBaseUrl": {
"message": "Address for share links (optional)"
},
"optionsHintShareUrl": {
"message": "Only needed if internal access uses a different port or name. Share links are then built with this address so they work from outside as well. Empty = base address."
},
"tagsFilterPlaceholder": {
"message": "Type to filter …"
},
"tagsNoMatch": {
"message": "No match."
},
"fieldMailFormat": {
"message": "Store whole e-mails as"
},
"mailFormatPdf": {
"message": "PDF with embedded original"
},
"mailFormatEml": {
"message": "Raw format (.eml)"
},
"fieldPdfAttachments": {
"message": "Also embed attachments individually in the PDF"
},
"optionsHintMailFormat": {
"message": "The PDF is readable and searchable; HTML messages are rendered as text. The original message is embedded in the PDF, so nothing is lost."
},
"storeWholePdf": {
"message": "The message as PDF, with the original embedded"
},
"storeAsPdf": {
"message": "Prepare as PDF, embedding original and attachments"
},
"fieldPdfFontSize": {
"message": "Font size"
},
"optionsHintPdfFontSize": {
"message": "7 to 16 points"
},
"fieldPdfFormat": {
"message": "Paper size"
},
"fieldPdfMargins": {
"message": "Margins in mm"
},
"marginLeft": {
"message": "left"
},
"marginRight": {
"message": "right"
},
"marginTop": {
"message": "top"
},
"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

@@ -1,6 +1,6 @@
{
"extName": {
"message": "Paperless-ngx"
"message": "Paperless-Connector"
},
"extDescription": {
"message": "Enregistrer des pièces jointes et des courriels dans une archive Paperless-ngx, insérer des documents en pièce jointe ou en lien de partage."
@@ -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."
@@ -79,7 +79,7 @@
"message": "Lien de partage dans le corps du message"
},
"insertHintLink": {
"message": "Le lien est accessible sans connexion. 0 jour signifie sans limite."
"message": "Le lien est accessible sans connexion. 0 jour signifie sans limite. Un champ d'étiquette vide signifie : ne pas marquer."
},
"optionsTitle": {
"message": "Paperless — Connexion"
@@ -249,12 +249,119 @@
"linkValidUnlimited": {
"message": "Lien sans limite de durée"
},
"linkHeading": {
"message": "Fichier $1",
"optionsHintTag": {
"message": "Laisser l'étiquette de partage vide pour ne poser aucune étiquette."
},
"fieldShareBaseUrl": {
"message": "Adresse pour les liens de partage (facultatif)"
},
"optionsHintShareUrl": {
"message": "Nécessaire uniquement si l'accès interne utilise un port ou un nom différent. Les liens de partage seront alors construits avec cette adresse afin de fonctionner aussi depuis l'extérieur. Vide = adresse de base."
},
"tagsFilterPlaceholder": {
"message": "Saisir pour filtrer …"
},
"tagsNoMatch": {
"message": "Aucune correspondance."
},
"fieldMailFormat": {
"message": "Enregistrer les courriels entiers comme"
},
"mailFormatPdf": {
"message": "PDF avec l'original intégré"
},
"mailFormatEml": {
"message": "Format brut (.eml)"
},
"fieldPdfAttachments": {
"message": "Intégrer aussi les pièces jointes séparément dans le PDF"
},
"optionsHintMailFormat": {
"message": "Le PDF est lisible et consultable ; les messages HTML y sont rendus sous forme de texte. Le message d'origine est intégré au PDF, rien n'est perdu."
},
"storeWholePdf": {
"message": "Le message en PDF, avec l'original intégré"
},
"storeAsPdf": {
"message": "Préparer en PDF, avec l'original et les pièces jointes intégrés"
},
"fieldPdfFontSize": {
"message": "Taille de police"
},
"optionsHintPdfFontSize": {
"message": "7 à 16 points"
},
"fieldPdfFormat": {
"message": "Format de papier"
},
"fieldPdfMargins": {
"message": "Marges en mm"
},
"marginLeft": {
"message": "gauche"
},
"marginRight": {
"message": "droite"
},
"marginTop": {
"message": "haut"
},
"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,6 +16,8 @@ import {
ladeEinstellungen, vollstaendig, sichererName, versionslabel,
titelAusBetreff, zugriffsmuster, t
} from "./lib/settings.js";
import { baueMailPdf, baueTextPdf, alsEmpfaenger, umwandelbar }
from "./lib/mailpdf.js";
/**
* Ergebnis einer Nachrichten-Abfrage vereinheitlichen.
@@ -90,6 +92,16 @@ function alsDatum(wert) {
return Number.isNaN(d.getTime()) ? undefined : d.toISOString().slice(0, 10);
}
/** Datum mit Uhrzeit für die Anzeige im PDF. */
function alsZeitpunkt(wert) {
if (!wert) return "";
const d = wert instanceof Date ? wert : new Date(wert);
if (Number.isNaN(d.getTime())) return "";
const z = (n) => String(n).padStart(2, "0");
return `${z(d.getDate())}.${z(d.getMonth() + 1)}.${d.getFullYear()} `
+ `${z(d.getHours())}:${z(d.getMinutes())} Uhr`;
}
// Beim Laden eine Zeile ausgeben. Fehlt sie in der Konsole, ist das
// Skript gar nicht erst gestartet - und dann kommt bei jedem Aufruf aus
// einem Dialog nur "An unexpected error occurred".
@@ -102,7 +114,22 @@ let naechsteAuftragsnummer = 1;
// ---------------------------------------------------------------- Menüs
browser.runtime.onInstalled.addListener(() => {
/**
* Menüeinträge anlegen.
*
* NICHT in onInstalled: Eine Ereignisseite wird beendet und bei Bedarf
* neu geladen, das Skript läuft dabei erneut durch. Die Menüs überleben
* das aber - ein zweiter menus.create mit derselben Kennung scheitert
* dann mit "already exists".
*
* Deshalb erst removeAll, dann neu anlegen. Das ist bei jedem Aufwachen
* richtig und kostet nichts.
*/
async function menueAufbauen() {
try {
await browser.menus.removeAll();
} catch { /* beim ersten Lauf gibt es nichts zu entfernen */ }
browser.menus.create({
id: "paperless-anhang",
title: t("menuStoreAttachment"),
@@ -113,7 +140,9 @@ browser.runtime.onInstalled.addListener(() => {
title: t("menuStoreMessage"),
contexts: ["message_list"]
});
});
}
menueAufbauen();
browser.menus.onClicked.addListener(async (info, tab) => {
try {
@@ -179,6 +208,7 @@ async function anhangAblegen(info, tab) {
betreff: nachricht.subject,
absender: nachricht.author,
datum: alsDatum(nachricht.date),
zeitpunkt: alsZeitpunkt(nachricht.date),
dateien
});
}
@@ -200,6 +230,7 @@ async function nachrichtAblegen(info, tab) {
betreff: nachricht.subject,
absender: nachricht.author,
datum: alsDatum(nachricht.date),
zeitpunkt: alsZeitpunkt(nachricht.date),
dateien: anhaenge.map((a) => ({
name: sichererName(a.name),
size: a.size,
@@ -225,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
@@ -236,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.
@@ -249,6 +287,34 @@ async function dialogOeffnen(art, daten) {
// -------------------------------------------- Vermittlung für die Dialoge
/**
* Offene Verbindungen der Dialoge.
*
* SEHR WICHTIG: Eine Ereignisseite wird beendet, sobald keine Ereignisse
* mehr anstehen. Eine laufende Wartezeit in waitForTask zählt NICHT als
* Ereignis - mitten im Ablegen verschwindet sonst der Empfänger, und der
* Dialog bekommt "Could not establish connection. Receiving end does not
* exist."
*
* Eine offene Verbindung hält die Seite dagegen am Leben. Die Dialoge
* öffnen deshalb beim Laden eine und halten sie bis zum Schliessen.
*/
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);
});
});
browser.runtime.onMessage.addListener(async (nachricht, absender) => {
// Nur Nachrichten aus dieser Erweiterung annehmen. Thunderbird lässt
// fremde Absender hier zwar ohnehin nicht durch, solange kein
@@ -291,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();
@@ -360,48 +433,431 @@ async function berechtigungPruefen() {
// -------------------------------------------------------------- Vorgänge
async function datenHolen(auftrag) {
if (auftrag.quelle === "nachricht") {
const roh = await browser.messages.getRaw(auftrag.messageId);
const blob = new Blob([roh], { type: "message/rfc822" });
const name = sichererName(
`${titelAusBetreff(auftrag.betreff)}.eml`);
return [{ blob, name }];
/**
* Nachrichtentext als reinen Text beschaffen.
*
* Bevorzugt wird der text/plain-Teil. Gibt es nur HTML, übernimmt
* Thunderbird die Umwandlung - convertToPlainText ist dieselbe Funktion,
* die beim Antworten in Nur-Text greift, und liefert deutlich bessere
* Ergebnisse als eine selbstgebaute Entschlackung.
*/
async function nachrichtentext(messageId) {
let teile = [];
if (browser.messages.listInlineTextParts) {
try {
teile = await browser.messages.listInlineTextParts(messageId) || [];
} catch { /* ältere Stände kennen die Funktion nicht */ }
}
// Rückfall: die Baumstruktur aus getFull durchwandern
if (!teile.length) {
try {
const voll = await browser.messages.getFull(messageId);
const sammeln = (teil) => {
if (teil?.body && /^text\/(plain|html)$/.test(teil.contentType || "")) {
teile.push({ contentType: teil.contentType, content: teil.body });
}
for (const k of teil?.parts || []) sammeln(k);
};
sammeln(voll);
} catch (err) {
console.warn("[paperless] getFull fehlgeschlagen", err);
}
}
const klartext = teile.find((x) => (x.contentType || "") === "text/plain");
if (klartext?.content?.trim()) return klartext.content;
const html = teile.find((x) => (x.contentType || "") === "text/html");
if (html?.content) {
if (browser.messengerUtilities?.convertToPlainText) {
try {
return await browser.messengerUtilities.convertToPlainText(
html.content);
} catch { /* dann der grobe Weg */ }
}
// Notbehelf ohne die Thunderbird-Funktion. Bewusst schlicht: Blöcke
// werden zu Zeilenumbrüchen, Auszeichnung fällt weg.
return html.content
.replace(/<\s*(br|\/p|\/div|\/tr|\/li|\/h[1-6])[^>]*>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/gi, " ").replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<").replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"').replace(/&#39;/gi, "'")
.replace(/\n{3,}/g, "\n\n");
}
return "";
}
/**
* Nachricht als PDF aufbereiten.
*
* Das Original und alle Anhänge werden im PDF eingebettet - es geht also
* nichts verloren, auch wenn die Darstellung nicht originalgetreu ist.
*/
/**
* Adresse des Kontos, in dem die Nachricht liegt.
*
* Bei Nachrichten an Blindkopie-Empfänger geht aus den Kopfzeilen nicht
* hervor, welches Konto sie empfangen hat. Im Archiv ist das später die
* entscheidende Angabe.
*/
/**
* Adresse des Kontos, in dem die Nachricht liegt.
*
* Bei Nachrichten an Blindkopie-Empfänger geht aus An und Kopie nicht
* hervor, welches Konto sie empfangen hat. Im Archiv ist das später die
* entscheidende Angabe.
*
* Zwei Wege, weil der erste eine Berechtigung braucht:
*
* 1. accounts-Schnittstelle. Genau, aber accountsRead ist nötig - und
* dieselbe Berechtigung entscheidet auch darüber, ob messages.get()
* überhaupt eine Ordnerangabe liefert.
* 2. Zustellkopfzeilen. Delivered-To und Verwandte tragen die Adresse,
* an die tatsächlich zugestellt wurde. Sie sind nicht immer
* vorhanden und stammen vom zustellenden Server, reichen aber ohne
* zusätzliche Berechtigung aus.
*/
async function kontoAdresse(kopf, messageId) {
const ueberAccounts = await kontoUeberAccounts(kopf);
if (ueberAccounts) return ueberAccounts;
return kontoUeberKopfzeilen(messageId);
}
async function kontoUeberAccounts(kopf) {
if (!browser.accounts?.get) {
console.debug("[paperless] accounts-Schnittstelle nicht verfügbar - "
+ "Berechtigung accountsRead fehlt. Ausweichweg über "
+ "die Zustellkopfzeilen.");
return "";
}
// Die Kontokennung steckt je nach Version an verschiedenen Stellen.
const kandidaten = [
kopf?.folder?.accountId,
kopf?.folder?.id,
typeof kopf?.folder === "string" ? kopf.folder : null,
kopf?.accountId
].filter(Boolean);
const kennungen = [];
for (const k of kandidaten) {
kennungen.push(String(k));
const m = /^([^:/]+)/.exec(String(k));
if (m && m[1] !== String(k)) kennungen.push(m[1]);
}
for (const kennung of kennungen) {
try {
const konto = await browser.accounts.get(kennung);
const adresse = adresseAusKonto(konto);
if (adresse) return adresse;
} catch { /* nächster Kandidat */ }
}
// Gibt es nur ein Konto, ist die Zuordnung ohnehin eindeutig.
try {
const alle = await browser.accounts.list(false);
if (alle?.length === 1) return adresseAusKonto(alle[0]);
} catch { /* dann eben nicht */ }
return "";
}
function adresseAusKonto(konto) {
if (!konto) return "";
const ident = konto.identities || [];
const erste = ident.find((i) => i.email) || ident[0];
if (erste?.email) {
return erste.name ? `${erste.name} <${erste.email}>` : erste.email;
}
return konto.name || "";
}
/**
* Empfangsadresse aus den Zustellkopfzeilen.
*
* Diese Kopfzeilen setzt der zustellende Server. Delivered-To ist die
* verbreitetste; X-Original-To und Envelope-To kommen bei anderen
* Servern vor.
*/
async function kontoUeberKopfzeilen(messageId) {
if (!messageId || !browser.messages?.getFull) return "";
try {
const voll = await browser.messages.getFull(messageId);
const h = voll?.headers || {};
for (const name of ["delivered-to", "x-original-to", "envelope-to",
"x-envelope-to", "x-rcpt-to"]) {
const wert = h[name];
if (Array.isArray(wert) && wert.length && String(wert[0]).trim()) {
return String(wert[0]).trim();
}
if (typeof wert === "string" && wert.trim()) return wert.trim();
}
console.debug("[paperless] keine Zustellkopfzeile gefunden. Vorhanden:",
Object.keys(h).join(", "));
} catch (err) {
console.debug("[paperless] Kopfzeilen nicht lesbar:", err?.message);
}
return "";
}
async function nachrichtAlsPdf(auftrag, s) {
const id = auftrag.messageId;
const kopf = await browser.messages.get(id).catch(() => ({}));
const text = await nachrichtentext(id);
const konto = await kontoAdresse(kopf, id);
const dateien = [];
const aufstellung = [];
// Das Original zuerst - es ist der wichtigste Anhang.
try {
const roh = await browser.messages.getRaw(id);
dateien.push({
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.eml`),
daten: new TextEncoder().encode(roh),
mime: "message/rfc822",
beschreibung: "Originalnachricht"
});
} catch (err) {
console.warn("[paperless] getRaw fehlgeschlagen", err);
}
// Dann die Anhänge, sofern gewünscht.
if (s.pdfAnhaengeEinbetten !== false) {
for (const a of auftrag.dateien || []) {
aufstellung.push({ name: a.name, groesse: a.size });
try {
const datei = await browser.messages.getAttachmentFile(
a.messageId, a.partName);
dateien.push({
name: sichererName(a.name),
daten: new Uint8Array(await datei.arrayBuffer()),
mime: a.contentType || "application/octet-stream",
beschreibung: "Anhang der Nachricht"
});
} catch (err) {
console.warn("[paperless] Anhang nicht lesbar:", a.name, err);
}
}
} else {
for (const a of auftrag.dateien || []) {
aufstellung.push({ name: a.name, groesse: a.size });
}
}
const pdf = baueMailPdf({
betreff: auftrag.betreff || kopf.subject || "",
konto,
von: kopf.author || auftrag.absender || "",
an: alsEmpfaenger(kopf.recipients),
kopie: alsEmpfaenger(kopf.ccList),
datum: auftrag.zeitpunkt || auftrag.datum || "",
text,
anhaenge: aufstellung,
dateien
}, {
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(`${titelAusBetreff(auftrag.betreff)}.pdf`)
};
}
async function datenHolen(auftrag, s) {
if (auftrag.quelle === "nachricht") {
// Als PDF aufbereiten oder im Rohformat ablegen - beides hat seine
// Berechtigung. Das Rohformat ist verlustfrei und wieder in ein
// Mailprogramm einlesbar, das PDF dafür lesbar und durchsuchbar.
// Die Auswahl aus dem Dialog hat Vorrang vor der Voreinstellung.
const alsPdf = auftrag.alsPdf !== undefined
? Boolean(auftrag.alsPdf)
: (s.mailFormat || "pdf") === "pdf";
if (alsPdf) {
try {
return [await nachrichtAlsPdf(auftrag, s)];
} catch (err) {
console.error("[paperless] PDF-Aufbereitung gescheitert", err);
// Nicht abbrechen: lieber die Rohfassung ablegen als gar nichts.
}
}
const roh = await browser.messages.getRaw(auftrag.messageId);
return [{
blob: new Blob([roh], { type: "message/rfc822" }),
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.eml`)
}];
}
const out = [];
for (const d of auftrag.dateien || []) {
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);
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);
ergebnisse.push({ id, name: datei.name, art: "version",
zielId: nachricht.zielId });
} else {
const felder = { title: nachricht.titel || datei.name };
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);
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

@@ -7,7 +7,7 @@
set -eu
cd "$(dirname "$0")"
NAME="paperless-thunderbird"
NAME="paperless-connector"
VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' manifest.json | head -1)"
OUT="${NAME}-${VERSION}.xpi"
@@ -15,8 +15,8 @@ OUT="${NAME}-${VERSION}.xpi"
# ohne diese Prüfung entstünde ein Paket, das sich nicht laden lässt.
FEHLT=""
for f in manifest.json background.js \
lib/client.js lib/settings.js \
ui/store.html ui/store.js ui/classify.html ui/classify.js \
lib/client.js lib/settings.js lib/pdf.js lib/mailpdf.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 \

286
lib/mailpdf.js Normal file
View File

@@ -0,0 +1,286 @@
/**
* mailpdf.js - E-Mail als lesbares PDF aufbereiten.
*
* Aufbau: Betreff als Überschrift, darunter ein grau hinterlegter
* Kopfblock mit Konto, Absender, Empfängern, Datum und Anlagen, dann der
* Nachrichtentext.
*
* Eingebettet werden die vollständige Originalnachricht als .eml sowie
* auf Wunsch alle Anhänge im Original.
*
* BEWUSSTE EINSCHRÄNKUNG: HTML-Nachrichten werden als Text gesetzt, nicht
* originalgetreu dargestellt. Eine Erweiterung hat keine
* Darstellungsmaschine für HTML, und Thunderbird bietet Erweiterungen
* keine Druckfunktion an - die tabs-Schnittstelle kennt in Manifest V3
* kein saveAsPDF.
*
* Deshalb steckt das Original mit im PDF. Wer die gestaltete Fassung
* braucht, öffnet den Anhang.
*/
import { PdfDokument, textbreite, umbrechen, mm, FORMATE }
from "./pdf.js";
const GRAU_TEXT = [0.35, 0.35, 0.35];
const GRAU_FLAECHE = 0.95; // etwa 5 Prozent
const INNEN = 8; // Polster im Kopfblock
/**
* @param {object} daten
* betreff, konto, von, an, kopie, datum Zeichenketten
* text Nachrichtentext als Text
* anhaenge [{name, groesse}] für die Aufstellung
* dateien [{name, daten, mime, beschreibung}] wird eingebettet
* @param {object} opt
* schrift Grundschriftgröße in Punkten, Vorgabe 11
* format Papierformat: a4, a5, letter, legal
* rand {links, rechts, oben, unten} in Millimetern
* @returns {Uint8Array}
*/
export function baueMailPdf(daten, opt = {}) {
const basis = begrenzen(Number(opt.schrift) || 11, 7, 16);
// Die Überschrift wächst schwächer mit als der Fließtext. Sonst nimmt
// sie bei größerer Grundschrift zu viel Raum und läuft über zwei
// Zeilen, bevor der Inhalt überhaupt beginnt.
const ueberschrift = basis + 2;
const klein = Math.max(7, basis - 1.5);
const papier = FORMATE[String(opt.format || "a4").toLowerCase()]
|| FORMATE.a4;
const r = opt.rand || {};
// Die Ränder werden begrenzt, damit kein Dokument mit negativer
// Textbreite entsteht - eine Zahl aus einem Eingabefeld kann alles
// sein, und ein unbrauchbares PDF wäre schwer zu deuten.
const grenze = (wert, vorgabe) =>
Math.min(papier.breite / 3, Math.max(0, Number(wert) ?? vorgabe));
const d = new PdfDokument({
titel: daten.betreff || "E-Mail",
autor: daten.von || "",
betreff: daten.betreff || "",
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();
// -- Überschrift
d.absatz(daten.betreff || "(ohne Betreff)", {
groesse: ueberschrift, fett: true, abstand: 1.25
});
d.abstand(6);
// -- Kopfblock
kopfblock(d, daten, klein);
d.abstand(12);
// -- Inhalt
const text = String(daten.text ?? "").replace(/\r\n?/g, "\n").trimEnd();
if (text) {
for (const zeile of text.split("\n")) {
// Zitierte Abschnitte grau und eingerückt. Bei langen Verläufen
// sieht man so auf einen Blick, was neu ist.
const zitat = /^\s*>/.test(zeile);
d.absatz(zeile || " ", {
groesse: basis,
abstand: 1.35,
farbe: zitat ? GRAU_TEXT : null,
einzug: zitat ? 10 : 0
});
}
} else {
d.absatz("(kein Textinhalt - siehe eingebettete Originalnachricht)",
{ groesse: basis, farbe: GRAU_TEXT });
}
// -- Fussbereich
d.abstand(10);
d.linie();
d.absatz("Erzeugt aus der Originalnachricht, die diesem PDF als Datei "
+ "beiliegt. HTML-Nachrichten sind hier als Text gesetzt; die "
+ "gestaltete Fassung enthält der Anhang.",
{ groesse: Math.max(6.5, basis - 3), farbe: GRAU_TEXT,
abstand: 1.3 });
for (const f of daten.dateien ?? []) {
d.anhang(f.name, f.daten, {
beschreibung: f.beschreibung || "",
mime: f.mime || "application/octet-stream"
});
}
return d.bauen();
}
/**
* Kopfblock mit ausgerichteten Beschriftungen auf grauem Grund.
*
* Die Höhe muss VOR dem Zeichnen feststehen: Die Fläche wird zuerst
* gesetzt, sonst läge sie über dem Text - im Inhaltsstrom gilt die
* Reihenfolge. Deshalb wird der Umbruch hier vorweg berechnet und danach
* nur noch ausgegeben.
*/
function kopfblock(d, daten, groesse) {
const zeilenhoehe = groesse * 1.45;
const felder = [];
const nimm = (name, wert) => {
if (wert && String(wert).trim()) {
felder.push([name, [String(wert).trim()]]);
}
};
nimm("Konto", daten.konto);
nimm("Von", daten.von);
nimm("An", daten.an);
nimm("Kopie", daten.kopie);
nimm("Datum", daten.datum);
if (daten.anhaenge?.length) {
felder.push(["Anlagen", daten.anhaenge.map((a) => {
const g = a.groesse ? ` (${lesbareGroesse(a.groesse)})` : "";
return `- ${a.name}${g}`;
})]);
}
if (!felder.length) return;
// Spaltenbreite aus der breitesten Beschriftung. So beginnen alle Werte
// in derselben Spalte, ohne dass eine feste Breite geraten werden muss.
let spalte = 0;
for (const [name] of felder) {
spalte = Math.max(spalte, textbreite(name, groesse, true));
}
spalte += 14;
const wertBreite = d.innenbreite - 2 * INNEN - spalte;
// Umbruch vorweg berechnen
const ausgabe = [];
for (const [name, werte] of felder) {
const zeilen = [];
for (const w of werte) {
for (const z of umbrechen(w, wertBreite, groesse)) zeilen.push(z);
}
ausgabe.push([name, zeilen]);
}
const anzahl = ausgabe.reduce((n, [, z]) => n + z.length, 0);
const hoehe = anzahl * zeilenhoehe + 2 * INNEN;
d.platzPruefen(hoehe);
const oben = d.y;
d.rechteck(d.rand, oben - hoehe, d.innenbreite, hoehe, GRAU_FLAECHE);
let y = oben - INNEN;
for (const [name, zeilen] of ausgabe) {
for (const [i, zeile] of zeilen.entries()) {
const grundlinie = y - groesse;
if (i === 0) {
d.textAn(d.rand + INNEN, grundlinie, name,
{ groesse, fett: true, farbe: GRAU_TEXT });
}
d.textAn(d.rand + INNEN + spalte, grundlinie, zeile, { groesse });
y -= zeilenhoehe;
}
}
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));
}
export function lesbareGroesse(bytes) {
const b = Number(bytes) || 0;
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(0)} KB`;
return `${(b / 1024 / 1024).toFixed(1)} MB`;
}
/**
* Empfängerliste in eine Zeile überführen.
*
* Thunderbird liefert je nach Version ein Feld oder eine Zeichenkette.
*/
export function alsEmpfaenger(wert) {
if (!wert) return "";
if (Array.isArray(wert)) return wert.filter(Boolean).join(", ");
return String(wert);
}

470
lib/pdf.js Normal file
View File

@@ -0,0 +1,470 @@
/**
* pdf.js - Schlanker PDF-Erzeuger für Textseiten mit eingebetteten Dateien.
*
* Warum selbst geschrieben statt einer fertigen Bibliothek: Eine
* Erweiterung soll nichts nachladen, und ein mitgeliefertes pdf-lib wäre
* rund ein Megabyte für einen Bruchteil seiner Fähigkeiten. Hier werden
* nur Text und Anhänge gebraucht.
*
* Erzeugt wird PDF 1.7 mit den eingebauten Schriften Helvetica und
* Helvetica-Bold. Die sind in jedem Betrachter vorhanden und müssen nicht
* eingebettet werden.
*
* AUSDRÜCKLICH KEIN PDF/A. Dafür wären eingebettete Schriftschnitte, ein
* Farbprofil und XMP-Metadaten nötig - und vor allem eine Prüfung mit
* veraPDF, die hier niemand durchführen kann. Ein Dokument als PDF/A zu
* bezeichnen, ohne es geprüft zu haben, wäre eine leere Behauptung.
*/
/** Millimeter in Punkte. Ein Punkt ist 1/72 Zoll. */
export function mm(wert) {
return (Number(wert) || 0) * 72 / 25.4;
}
/**
* Papierformate in Millimetern.
*
* A4 ist ausserhalb Nordamerikas der Regelfall, Letter und Legal dort.
* A5 ist selten, aber bei kurzen Nachrichten sparsam im Ausdruck.
*/
export const FORMATE = {
a4: { name: "A4", breite: 210, hoehe: 297 },
a5: { name: "A5", breite: 148, hoehe: 210 },
letter: { name: "Letter", breite: 215.9, hoehe: 279.4 },
legal: { name: "Legal", breite: 215.9, hoehe: 355.6 }
};
// Rückwärtskompatible Vorgaben in Punkten
export const SEITE_BREITE = mm(FORMATE.a4.breite);
export const SEITE_HOEHE = mm(FORMATE.a4.hoehe);
// Zeichenbreiten der Standardschriften, in Tausendstel der Schriftgröße.
// Aus den AFM-Dateien von Adobe. Sie werden nur zum Umbrechen gebraucht -
// eine kleine Abweichung verschiebt den Umbruch, verfälscht aber nichts.
const BREITE_HELVETICA = {
" ": 278, "!": 278, '"': 355, "#": 556, $: 556, "%": 889, "&": 667,
"'": 191, "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333,
".": 278, "/": 278, ":": 278, ";": 278, "<": 584, "=": 584, ">": 584,
"?": 556, "@": 1015, "[": 278, "\\": 278, "]": 278, "^": 469, _: 556,
"`": 333, "{": 334, "|": 260, "}": 334, "~": 584,
A: 667, B: 667, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278,
J: 500, K: 667, L: 556, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722,
S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611,
a: 556, b: 556, c: 500, d: 556, e: 556, f: 278, g: 556, h: 556, i: 222,
j: 222, k: 500, l: 222, m: 833, n: 556, o: 556, p: 556, q: 556, r: 333,
s: 500, t: 278, u: 556, v: 500, w: 722, x: 500, y: 500, z: 500,
"0": 556, "1": 556, "2": 556, "3": 556, "4": 556, "5": 556, "6": 556,
"7": 556, "8": 556, "9": 556,
"ä": 556, "ö": 556, "ü": 556, "Ä": 667, "Ö": 778, "Ü": 722, "ß": 556,
"é": 556, "è": 556, "à": 556, "ç": 500, "–": 556, "—": 1000, "€": 556
};
const BREITE_FETT = {
...BREITE_HELVETICA,
A: 722, B: 722, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278,
J: 556, K: 722, L: 611, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722,
S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611,
a: 556, b: 611, c: 556, d: 611, e: 556, f: 333, g: 611, h: 611, i: 278,
j: 278, k: 556, l: 278, m: 889, n: 611, o: 611, p: 611, q: 611, r: 389,
s: 556, t: 333, u: 611, v: 556, w: 778, x: 556, y: 556, z: 500,
"'": 238, "-": 333, ":": 333, ";": 333, "(": 333, ")": 333
};
const BREITE_ERSATZ = 556;
/**
* Zeichen, die in WinAnsiEncoding an anderer Stelle stehen als in
* Latin-1. PDF-Betrachter erwarten diese Belegung; ohne die Umsetzung
* erschiene etwa das Eurozeichen als Steuerzeichen.
*/
const WINANSI_SONDER = {
"\u20AC": 0x80, "\u201A": 0x82, "\u0192": 0x83, "\u201E": 0x84,
"\u2026": 0x85, "\u2020": 0x86, "\u2021": 0x87, "\u02C6": 0x88,
"\u2030": 0x89, "\u0160": 0x8A, "\u2039": 0x8B, "\u0152": 0x8C,
"\u017D": 0x8E, "\u2018": 0x91, "\u2019": 0x92, "\u201C": 0x93,
"\u201D": 0x94, "\u2022": 0x95, "\u2013": 0x96, "\u2014": 0x97,
"\u02DC": 0x98, "\u2122": 0x99, "\u0161": 0x9A, "\u203A": 0x9B,
"\u0153": 0x9C, "\u017E": 0x9E, "\u0178": 0x9F
};
/** Breite einer Zeichenkette in Punkten. */
export function textbreite(text, groesse, fett = false) {
const tabelle = fett ? BREITE_FETT : BREITE_HELVETICA;
let summe = 0;
for (const z of String(text)) {
summe += tabelle[z] ?? BREITE_ERSATZ;
}
return (summe * groesse) / 1000;
}
/**
* Zeichenkette in eine PDF-Literalzeichenkette überführen.
*
* Zeichen ausserhalb von WinAnsi werden durch ein Fragezeichen ersetzt.
* Das betrifft in der Praxis fast nur Emojis - dafür wäre eine
* eingebettete Schrift nötig.
*/
function pdfText(text) {
const bytes = [];
for (const z of String(text)) {
let code;
if (WINANSI_SONDER[z] !== undefined) {
code = WINANSI_SONDER[z];
} else {
const cp = z.codePointAt(0);
code = cp <= 0xFF ? cp : 0x3F; // Fragezeichen
}
if (code === 0x28 || code === 0x29 || code === 0x5C) {
bytes.push(0x5C); // Rückstrich voranstellen
}
bytes.push(code);
}
return bytes;
}
/** Text in Zeilen umbrechen, die in die angegebene Breite passen. */
export function umbrechen(text, breite, groesse, fett = false) {
const zeilen = [];
for (const absatz of String(text ?? "").split(/\r?\n/)) {
if (!absatz.trim()) {
zeilen.push("");
continue;
}
let zeile = "";
for (const wort of absatz.split(/ +/)) {
const versuch = zeile ? `${zeile} ${wort}` : wort;
if (textbreite(versuch, groesse, fett) <= breite || !zeile) {
// Ein einzelnes zu langes Wort wird hart getrennt - sonst liefe
// es über den Rand hinaus.
if (!zeile && textbreite(wort, groesse, fett) > breite) {
let rest = wort;
while (textbreite(rest, groesse, fett) > breite) {
let n = 1;
while (n < rest.length
&& textbreite(rest.slice(0, n + 1), groesse, fett) <= breite) {
n += 1;
}
zeilen.push(rest.slice(0, n));
rest = rest.slice(n);
}
zeile = rest;
} else {
zeile = versuch;
}
} else {
zeilen.push(zeile);
zeile = wort;
}
}
zeilen.push(zeile);
}
return zeilen;
}
// ---------------------------------------------------------------- Aufbau
export class PdfDokument {
/**
* Seitengröße und Ränder werden in PUNKTEN erwartet. Für die Umrechnung
* aus Millimetern gibt es mm().
*/
constructor({ titel = "", autor = "", betreff = "",
breite = SEITE_BREITE, hoehe = SEITE_HOEHE,
randLinks = mm(20), randRechts = mm(10),
randOben = mm(10), randUnten = mm(10) } = {}) {
this.titel = titel;
this.autor = autor;
this.betreff = betreff;
this.breite = breite;
this.hoehe = hoehe;
this.randLinks = randLinks;
this.randRechts = randRechts;
this.randOben = randOben;
this.randUnten = randUnten;
this.seiten = []; // je Seite ein Feld von Byte-Stücken
this.anhaenge = []; // {name, daten, beschreibung, mime}
this.aktuell = null;
this.y = 0;
}
/** Linker Rand. Der Name bleibt kurz, weil er oft gebraucht wird. */
get rand() {
return this.randLinks;
}
get innenbreite() {
return this.breite - this.randLinks - this.randRechts;
}
neueSeite() {
this.aktuell = [];
this.seiten.push(this.aktuell);
this.y = this.hoehe - this.randOben;
return this.aktuell;
}
/** Platz für die nächsten Zeilen sichern, notfalls umblättern. */
platzPruefen(hoehe) {
if (!this.aktuell) this.neueSeite();
if (this.y - hoehe < this.randUnten) this.neueSeite();
}
/**
* Eine Textzeile setzen. Gibt die neue Schreibhöhe zurück.
*/
zeile(text, { groesse = 10, fett = false, einzug = 0,
abstand = 1.35, farbe = null } = {}) {
const hoehe = groesse * abstand;
this.platzPruefen(hoehe);
const x = this.rand + einzug;
const teile = [];
if (farbe) {
teile.push(`${farbe[0]} ${farbe[1]} ${farbe[2]} rg\n`);
}
teile.push("BT\n");
teile.push(`/${fett ? "F2" : "F1"} ${groesse} Tf\n`);
teile.push(`1 0 0 1 ${x.toFixed(2)} ${(this.y - groesse).toFixed(2)} Tm\n`);
this.aktuell.push(...teile.map((t) => new TextEncoder().encode(t)));
this.aktuell.push(new Uint8Array([0x28])); // (
this.aktuell.push(new Uint8Array(pdfText(text)));
this.aktuell.push(new TextEncoder().encode(") Tj\nET\n"));
if (farbe) {
this.aktuell.push(new TextEncoder().encode("0 0 0 rg\n"));
}
this.y -= hoehe;
return this.y;
}
/** Mehrere Zeilen mit automatischem Umbruch. */
absatz(text, opt = {}) {
const groesse = opt.groesse ?? 10;
const einzug = opt.einzug ?? 0;
const breite = this.innenbreite - einzug;
for (const z of umbrechen(text, breite, groesse, opt.fett)) {
this.zeile(z, opt);
}
}
/**
* Text an einer festen Stelle setzen, ohne die Schreibhöhe zu ändern.
*
* Wird für Spaltensatz gebraucht: Beschriftung und Wert stehen auf
* derselben Grundlinie, aber an verschiedenen Stellen.
*/
textAn(x, grundlinie, text, { groesse = 10, fett = false,
farbe = null } = {}) {
if (!this.aktuell) this.neueSeite();
const teile = [];
if (farbe) teile.push(`${farbe[0]} ${farbe[1]} ${farbe[2]} rg\n`);
teile.push("BT\n");
teile.push(`/${fett ? "F2" : "F1"} ${groesse} Tf\n`);
teile.push(`1 0 0 1 ${x.toFixed(2)} ${grundlinie.toFixed(2)} Tm\n`);
this.aktuell.push(...teile.map((t) => new TextEncoder().encode(t)));
this.aktuell.push(new Uint8Array([0x28]));
this.aktuell.push(new Uint8Array(pdfText(text)));
this.aktuell.push(new TextEncoder().encode(") Tj\nET\n"));
if (farbe) this.aktuell.push(new TextEncoder().encode("0 0 0 rg\n"));
}
/**
* Gefülltes Rechteck.
*
* Muss VOR dem Text gezeichnet werden - im Inhaltsstrom gilt die
* Reihenfolge, Späteres liegt oben.
*/
rechteck(x, y, breite, hoehe, grau = 0.95) {
if (!this.aktuell) this.neueSeite();
const s = `${grau} g ${x.toFixed(2)} ${y.toFixed(2)} `
+ `${breite.toFixed(2)} ${hoehe.toFixed(2)} re f 0 g\n`;
this.aktuell.push(new TextEncoder().encode(s));
}
/** Waagerechte Linie. */
linie(dicke = 0.6, grau = 0.75) {
this.platzPruefen(8);
this.y -= 4;
const s = `${grau} G ${dicke} w ${this.randLinks.toFixed(2)} `
+ `${this.y.toFixed(2)} m `
+ `${(this.breite - this.randRechts).toFixed(2)} `
+ `${this.y.toFixed(2)} l S\n`;
this.aktuell.push(new TextEncoder().encode(s));
this.y -= 8;
}
abstand(punkte) {
this.platzPruefen(punkte);
this.y -= punkte;
}
/**
* Datei an das Dokument anhängen.
*
* daten muss ein Uint8Array sein. Der Name wird im Anhangsbereich des
* Betrachters angezeigt.
*/
anhang(name, daten, { beschreibung = "", mime = "application/octet-stream" } = {}) {
this.anhaenge.push({
name: String(name),
daten: daten instanceof Uint8Array ? daten : new Uint8Array(daten),
beschreibung: String(beschreibung),
mime: String(mime)
});
}
// ------------------------------------------------------------ Ausgabe
bauen() {
if (!this.seiten.length) this.neueSeite();
const enc = (s) => new TextEncoder().encode(s);
const objekte = []; // 1-basiert, Index 0 bleibt leer
const neu = (inhalt) => { objekte.push(inhalt); return objekte.length; };
// Reihenfolge festlegen, damit Verweise bekannt sind
const katalogNr = 1;
const seitenbaumNr = 2;
const infoNr = 3;
const f1Nr = 4;
const f2Nr = 5;
objekte.push(null, null, null, null, null); // Plätze 1..5
// Seiteninhalte und Seitenobjekte
const seitenNr = [];
for (const stuecke of this.seiten) {
const inhalt = verbinden(stuecke);
const stromNr = neu({
kopf: `<< /Length ${inhalt.length} >>`,
strom: inhalt
});
const seiteNr = neu({
kopf: `<< /Type /Page /Parent ${seitenbaumNr} 0 R `
+ `/MediaBox [0 0 ${this.breite.toFixed(2)} `
+ `${this.hoehe.toFixed(2)}] `
+ `/Resources << /Font << /F1 ${f1Nr} 0 R /F2 ${f2Nr} 0 R >> >> `
+ `/Contents ${stromNr} 0 R >>`
});
seitenNr.push(seiteNr);
}
// Anhänge
const namensPaare = [];
for (const a of this.anhaenge) {
const stromNr = neu({
kopf: `<< /Type /EmbeddedFile /Subtype ${nameEscape(a.mime)} `
+ `/Length ${a.daten.length} /Params << /Size ${a.daten.length} >> >>`,
strom: a.daten
});
const specNr = neu({
kopf: `<< /Type /Filespec /F (${roh(a.name)}) /UF (${roh(a.name)}) `
+ (a.beschreibung ? `/Desc (${roh(a.beschreibung)}) ` : "")
+ `/AFRelationship /Source /EF << /F ${stromNr} 0 R >> >>`
});
namensPaare.push(`(${roh(a.name)}) ${specNr} 0 R`);
}
let namenNr = 0;
if (namensPaare.length) {
namenNr = neu({
kopf: `<< /EmbeddedFiles << /Names [ ${namensPaare.join(" ")} ] >> >>`
});
}
// Feste Objekte nachtragen
objekte[katalogNr - 1] = {
kopf: `<< /Type /Catalog /Pages ${seitenbaumNr} 0 R`
+ (namenNr ? ` /Names ${namenNr} 0 R /PageMode /UseAttachments` : "")
+ " >>"
};
objekte[seitenbaumNr - 1] = {
kopf: `<< /Type /Pages /Count ${seitenNr.length} `
+ `/Kids [ ${seitenNr.map((n) => `${n} 0 R`).join(" ")} ] >>`
};
objekte[infoNr - 1] = {
kopf: "<< " + [
this.titel ? `/Title (${roh(this.titel)})` : "",
this.autor ? `/Author (${roh(this.autor)})` : "",
this.betreff ? `/Subject (${roh(this.betreff)})` : "",
"/Producer (Paperless-Connector)",
`/CreationDate (${pdfDatum(new Date())})`
].filter(Boolean).join(" ") + " >>"
};
objekte[f1Nr - 1] = {
kopf: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica "
+ "/Encoding /WinAnsiEncoding >>"
};
objekte[f2Nr - 1] = {
kopf: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold "
+ "/Encoding /WinAnsiEncoding >>"
};
// Zusammensetzen
const teile = [enc("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n")];
let pos = teile[0].length;
const versatz = [0];
for (let i = 0; i < objekte.length; i += 1) {
const o = objekte[i];
versatz.push(pos);
const kopf = enc(`${i + 1} 0 obj\n${o.kopf}\n`);
teile.push(kopf);
pos += kopf.length;
if (o.strom) {
const a = enc("stream\n");
teile.push(a, o.strom, enc("\nendstream\n"));
pos += a.length + o.strom.length + 11;
}
const ende = enc("endobj\n");
teile.push(ende);
pos += ende.length;
}
const xrefPos = pos;
let xref = `xref\n0 ${objekte.length + 1}\n0000000000 65535 f \n`;
for (let i = 1; i <= objekte.length; i += 1) {
xref += `${String(versatz[i]).padStart(10, "0")} 00000 n \n`;
}
xref += `trailer\n<< /Size ${objekte.length + 1} `
+ `/Root ${katalogNr} 0 R /Info ${infoNr} 0 R >>\n`
+ `startxref\n${xrefPos}\n%%EOF\n`;
teile.push(enc(xref));
return verbinden(teile);
}
}
// ------------------------------------------------------------ Hilfsmittel
function verbinden(stuecke) {
let laenge = 0;
for (const s of stuecke) laenge += s.length;
const aus = new Uint8Array(laenge);
let p = 0;
for (const s of stuecke) { aus.set(s, p); p += s.length; }
return aus;
}
/** Zeichenkette für eine PDF-Literalzeichenkette absichern. */
function roh(text) {
return String(text)
.replace(/\\/g, "\\\\")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")
// Zeichen ausserhalb von ASCII vermeiden - in Namen und Titeln reicht
// das, und es erspart die Umsetzung nach WinAnsi an dieser Stelle.
.replace(/[^\x20-\x7E]/g, (z) => ({
"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue",
"ß": "ss"
}[z] ?? "?"));
}
/** Namen im PDF-Syntax absichern, etwa für Medientypen. */
function nameEscape(text) {
return "/" + String(text).replace(/[^A-Za-z0-9._-]/g,
(z) => "#" + z.charCodeAt(0).toString(16).padStart(2, "0"));
}
function pdfDatum(d) {
const z = (n) => String(n).padStart(2, "0");
return `D:${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`
+ `${z(d.getHours())}${z(d.getMinutes())}${z(d.getSeconds())}`;
}

View File

@@ -21,6 +21,35 @@ const VORGABEN = {
// Leer = baseUrl verwenden.
shareBaseUrl: "",
versionLabel: "Thunderbird {datum}",
// Wie eine ganze Nachricht abgelegt wird.
//
// "pdf" lesbar aufbereitet, Original und Anhänge im PDF eingebettet
// "eml" Rohformat, verlustfrei und wieder in ein Mailprogramm
// einlesbar, aber im Archiv unhandlich
mailFormat: "pdf",
// Anhänge zusätzlich IN das PDF einbetten. Ohne das enthält das PDF
// nur die Originalnachricht - die Anhänge stecken dann zwar in der
// .eml, sind aber nicht einzeln greifbar.
pdfAnhaengeEinbetten: true,
// Grundschriftgröße des erzeugten PDF in Punkten. 11 ist gut lesbar
// und entspricht etwa dem, was Textverarbeitungen als Vorgabe nutzen.
// Die Überschrift wächst schwächer mit, damit sie nicht über zwei
// Zeilen läuft.
pdfSchriftgroesse: 11,
// Papierformat und Ränder. Die Ränder in Millimetern.
//
// Links mehr als rechts, damit beim Abheften nichts im Ordner
// verschwindet - bei einem Archivdokument der wahrscheinlichere Fall
// als eine symmetrische Gestaltung.
pdfFormat: "a4",
pdfRandLinks: 20,
pdfRandRechts: 10,
pdfRandOben: 10,
pdfRandUnten: 10,
shareDays: 7,
shareTag: "Sharelink",
// Beschriftung der eingefügten Linkblöcke. Leer lassen für keine

View File

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

View File

@@ -24,8 +24,17 @@
<datalist id="typListe"></datalist>
<div class="wachsen">
<label data-i18n="fieldTags">Tags</label>
<div class="liste"><div id="tags" style="padding:4px 8px"></div></div>
<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="tagsGewaehlt" class="gewaehlteTags"></div>
<div id="tags" style="padding:4px 8px"></div>
<div id="tagsLeer" class="hinweis"
style="padding:6px 8px; display:none"
data-i18n="tagsNoMatch">Keine Übereinstimmung.</div>
</div>
</div>
<div class="hinweis" data-i18n="classifyHintNew">
@@ -35,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,43 +14,98 @@ 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.
const gewaehlt = new Set();
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();
@@ -66,30 +121,128 @@ function nameVon(liste, id) {
}
function zeigeTags(gesetzt) {
const ziel = $("tags");
ziel.replaceChildren();
for (const tag of alleTags) {
for (const id of gesetzt) gewaehlt.add(Number(id));
const feld = $("tagSuche");
if (feld) {
feld.addEventListener("input", zeichneTags);
// Eingabetaste soll den Dialog nicht abschicken, sondern beim
// einzigen Treffer diesen umschalten - das ist beim Tippen der
// erwartete Ablauf.
feld.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
const treffer = gefilterteTags();
if (treffer.length === 1) {
umschalten(treffer[0].id);
feld.value = "";
zeichneTags();
}
});
}
zeichneTags();
}
/** Tags, die zum aktuellen Filter passen und noch nicht gewählt sind. */
function gefilterteTags() {
const filter = ($("tagSuche")?.value || "").trim().toLowerCase();
return alleTags.filter((t) => {
if (gewaehlt.has(Number(t.id))) return false;
if (!filter) return true;
return String(t.name).toLowerCase().includes(filter);
});
}
function umschalten(id) {
const n = Number(id);
if (gewaehlt.has(n)) gewaehlt.delete(n);
else gewaehlt.add(n);
}
/**
* Tagliste zeichnen.
*
* Zwei Bereiche: oben die gewählten, darunter die übrigen. Die gewählten
* bleiben immer sichtbar, auch wenn der Filter sie ausschliesst - sonst
* verliert man beim Suchen den Überblick, was bereits angehakt ist.
*/
function zeichneTags() {
const filter = ($("tagSuche")?.value || "").trim().toLowerCase();
const oben = $("tagsGewaehlt");
oben.replaceChildren();
const gewaehlteListe = alleTags
.filter((t) => gewaehlt.has(Number(t.id)))
.sort((a, b) => String(a.name).localeCompare(String(b.name), "de"));
for (const tag of gewaehlteListe) {
oben.append(tagZeile(tag, true, ""));
}
const unten = $("tags");
unten.replaceChildren();
const rest = gefilterteTags()
.sort((a, b) => String(a.name).localeCompare(String(b.name), "de"));
for (const tag of rest) {
unten.append(tagZeile(tag, false, filter));
}
const leer = $("tagsLeer");
if (leer) leer.style.display = (rest.length === 0 && filter) ? "" : "none";
}
function tagZeile(tag, istGewaehlt, filter) {
const zeile = document.createElement("div");
zeile.className = "wahl";
const box = document.createElement("input");
box.type = "checkbox";
box.id = `t${tag.id}`;
box.value = String(tag.id);
box.checked = gesetzt.includes(tag.id);
box.checked = istGewaehlt;
box.addEventListener("change", () => {
umschalten(tag.id);
zeichneTags();
});
const bez = document.createElement("label");
bez.htmlFor = box.id;
bez.textContent = tag.name;
beschriftungSetzen(bez, String(tag.name), filter);
zeile.append(box, bez);
ziel.append(zeile);
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.
*/
function beschriftungSetzen(el, name, filter) {
el.replaceChildren();
if (!filter) {
el.textContent = name;
return;
}
const pos = name.toLowerCase().indexOf(filter);
if (pos < 0) {
el.textContent = name;
return;
}
el.append(document.createTextNode(name.slice(0, pos)));
const treffer = document.createElement("span");
treffer.className = "trefferHervor";
treffer.textContent = name.slice(pos, pos + filter.length);
el.append(treffer);
el.append(document.createTextNode(name.slice(pos + filter.length)));
}
async function speichern() {
$("speichern").disabled = true;
meldungAnzeigen(browser.i18n.getMessage("msgSaving") || "Wird gespeichert …");
try {
const tags = [...$("tags").querySelectorAll("input:checked")]
.map((b) => Number(b.value));
// Aus der Menge lesen, nicht aus den Kästchen: Gefilterte Zeilen
// sind gar nicht im Dokument, ihre Auswahl ginge sonst verloren.
const tags = [...gewaehlt];
const payload = { title: $("titel").value.trim(), tags };
const erstellt = $("erstellt").value.trim();
if (erstellt) payload.created = erstellt;
@@ -101,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,10 +53,30 @@ input[type=text], input[type=password], input[type=number], select {
background: Field;
color: FieldText;
}
.zeile { display: flex; gap: 8px; align-items: center; }
.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; }
.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);
@@ -47,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;
@@ -69,6 +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; }
.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

@@ -2,6 +2,49 @@
* gemeinsam.js - Hilfsmittel für alle Dialogseiten.
*/
/**
* Verbindung zur Ereignisseite, die offen bleibt, solange der Dialog
* geöffnet ist.
*
* Sie überträgt nichts - ihr einziger Zweck ist, die Ereignisseite am
* Leben zu halten. Ohne sie wird die Seite während längerer Vorgänge
* beendet, etwa beim Warten auf eine Aufgabe in Paperless, und der
* laufende Aufruf schlägt mit "Receiving end does not exist" fehl.
*/
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);
}
}
/**
* Ruft die Ereignisseite und wirft bei Fehlern eine Ausnahme.
*
@@ -43,6 +86,10 @@ export async function ruf(befehl, daten = {}) {
* im HTML hinterlegte deutsche Text stehen.
*/
export function uebersetzeSeite() {
// Beim Aufbau der Seite gleich die Verbindung öffnen - jede Seite ruft
// diese Funktion, also ist es die verlässlichste Stelle.
verbindungHalten();
for (const el of document.querySelectorAll("[data-i18n]")) {
const s = browser.i18n.getMessage(el.dataset.i18n);
if (s) el.textContent = s;

View File

@@ -46,7 +46,75 @@
<fieldset>
<legend data-i18n="optionsBehaviour">Verhalten</legend>
<label for="versionLabel" data-i18n="fieldVersionLabel">
<label for="mailFormat" data-i18n="fieldMailFormat">
Ganze E-Mails ablegen als
</label>
<select id="mailFormat">
<option value="pdf" data-i18n="mailFormatPdf">
PDF mit eingebettetem Original
</option>
<option value="eml" data-i18n="mailFormatEml">
Rohformat (.eml)
</option>
</select>
<div class="wahl" style="margin-top:6px">
<input type="checkbox" id="pdfAnhaengeEinbetten" checked>
<label for="pdfAnhaengeEinbetten" data-i18n="fieldPdfAttachments">
Anhänge zusätzlich einzeln ins PDF einbetten
</label>
</div>
<div class="zeile" style="margin-top:8px">
<label for="pdfFormat" style="margin:0" data-i18n="fieldPdfFormat">
Papierformat
</label>
<select id="pdfFormat" style="width:130px">
<option value="a4">A4</option>
<option value="a5">A5</option>
<option value="letter">Letter</option>
<option value="legal">Legal</option>
</select>
<label for="pdfSchriftgroesse" style="margin:0 0 0 10px"
data-i18n="fieldPdfFontSize">
Schriftgröße
</label>
<input type="number" id="pdfSchriftgroesse" min="7" max="16" step="1"
style="width:68px">
<span class="hinweis" data-i18n="optionsHintPdfFontSize">
7 bis 16 Punkt
</span>
</div>
<label style="margin-top:8px" data-i18n="fieldPdfMargins">
Ränder in mm
</label>
<div class="masse">
<span class="mass">
<label for="pdfRandLinks" data-i18n="marginLeft">links</label>
<input type="number" id="pdfRandLinks" min="0" max="60">
</span>
<span class="mass">
<label for="pdfRandRechts" data-i18n="marginRight">rechts</label>
<input type="number" id="pdfRandRechts" min="0" max="60">
</span>
<span class="mass">
<label for="pdfRandOben" data-i18n="marginTop">oben</label>
<input type="number" id="pdfRandOben" min="0" max="60">
</span>
<span class="mass">
<label for="pdfRandUnten" data-i18n="marginBottom">unten</label>
<input type="number" id="pdfRandUnten" min="0" max="60">
</span>
</div>
<div class="hinweis" data-i18n="optionsHintMailFormat">
Das PDF ist lesbar und durchsuchbar; HTML-Nachrichten werden dabei
als Text gesetzt. Die Originalnachricht steckt als Datei im PDF, es
geht also nichts verloren.
</div>
<label for="versionLabel" style="margin-top:8px"
data-i18n="fieldVersionLabel">
Bezeichnung neuer Versionen
</label>
<input type="text" id="versionLabel">

View File

@@ -14,12 +14,18 @@ import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
const $ = (id) => document.getElementById(id);
const FELDER = ["baseUrl", "shareBaseUrl", "token", "versionLabel",
"shareDays", "shareTag", "ignorierteKleiner"];
"mailFormat", "pdfSchriftgroesse", "pdfFormat",
"pdfRandLinks", "pdfRandRechts", "pdfRandOben",
"pdfRandUnten", "shareDays", "shareTag",
"ignorierteKleiner"];
document.addEventListener("DOMContentLoaded", async () => {
uebersetzeSeite();
const s = await ladeEinstellungen();
for (const f of FELDER) if ($(f)) $(f).value = s[f] ?? "";
if ($("pdfAnhaengeEinbetten")) {
$("pdfAnhaengeEinbetten").checked = s.pdfAnhaengeEinbetten !== false;
}
$("speichern").addEventListener("click", speichern);
$("pruefen").addEventListener("click", pruefen);
await rechtAnzeigen();
@@ -56,6 +62,13 @@ async function rechtAnzeigen() {
: `Zugriffsrecht fehlt für ${origin} — auf Speichern klicken.`;
}
/** Randangabe lesen und auf einen brauchbaren Bereich begrenzen. */
function rand(id, vorgabe) {
const wert = Number($(id)?.value);
if (!Number.isFinite(wert)) return vorgabe;
return Math.min(60, Math.max(0, wert));
}
function gelesen() {
return {
baseUrl: $("baseUrl").value.trim().replace(/\/+$/, ""),
@@ -64,6 +77,15 @@ function gelesen() {
versionLabel: $("versionLabel").value.trim() || "Thunderbird {datum}",
shareDays: Number($("shareDays").value || 0),
shareTag: $("shareTag").value.trim(),
mailFormat: $("mailFormat")?.value || "pdf",
pdfAnhaengeEinbetten: $("pdfAnhaengeEinbetten")?.checked !== false,
pdfSchriftgroesse: Math.min(16, Math.max(7,
Number($("pdfSchriftgroesse")?.value) || 11)),
pdfFormat: $("pdfFormat")?.value || "a4",
pdfRandLinks: rand("pdfRandLinks", 20),
pdfRandRechts: rand("pdfRandRechts", 10),
pdfRandOben: rand("pdfRandOben", 10),
pdfRandUnten: rand("pdfRandUnten", 10),
ignorierteKleiner: Number($("ignorierteKleiner").value || 0)
};
}

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

@@ -19,65 +19,11 @@
</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,28 +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") {
// Aufbau über createElement statt innerHTML. Hier stünde zwar nur
// festes Markup, aber die Zusicherung "kein innerHTML" soll ohne
// Ausnahme gelten - sonst muss man bei jeder Änderung neu prüfen, ob
// sich inzwischen ein Fremdwert eingeschlichen hat.
const zeile = document.createElement("div");
zeile.className = "wahl";
const box = document.createElement("input");
box.type = "checkbox";
box.id = "ganz";
box.checked = true;
box.disabled = true;
const bez = document.createElement("label");
bez.htmlFor = "ganz";
bez.textContent = browser.i18n.getMessage("storeWholeMessage")
|| "Die vollständige Nachricht als .eml";
zeile.append(box, bez);
ziel.append(zeile);
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");
@@ -81,6 +87,7 @@ function dateienAnzeigen() {
|| `Die ${auftrag.dateien.length} Anhänge bleiben darin enthalten.`;
ziel.append(h);
}
knopfPruefen();
return;
}
@@ -88,125 +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);
};
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.";
}
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();
zeile.append(art);
return zeile;
}
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", {
auftrag,
ziel: $("zielNeu").checked ? "neu" : "vorhanden",
zielId: gewaehlterTreffer?.id,
titel: $("titel").value.trim(),
erstellt: $("erstellt").value.trim()
});
if ($("klassifizieren").checked && ergebnisse.length) {
// Beim Anhängen an ein vorhandenes Dokument gilt dessen
// Klassifizierung - dann ist die Wurzel gemeint, nicht die neue
// Version.
const erstes = ergebnisse[0];
const id = erstes.art === "version" ? erstes.zielId : erstes.id;
location.href = `classify.html?id=${id}&weiter=${ergebnisse.length}`;
return;
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;
}