Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d032e3b19c | |||
| fd87813cbe | |||
| 00ca39609f |
70
README.md
70
README.md
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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."
|
||||
@@ -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,64 @@
|
||||
"linkValidUnlimited": {
|
||||
"message": "Link ohne zeitliche Begrenzung"
|
||||
},
|
||||
"linkHeading": {
|
||||
"message": "Datei $1",
|
||||
"placeholders": {
|
||||
"1": {
|
||||
"content": "$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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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,64 @@
|
||||
"linkValidUnlimited": {
|
||||
"message": "Link without time limit"
|
||||
},
|
||||
"linkHeading": {
|
||||
"message": "File $1",
|
||||
"placeholders": {
|
||||
"1": {
|
||||
"content": "$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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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,64 @@
|
||||
"linkValidUnlimited": {
|
||||
"message": "Lien sans limite de durée"
|
||||
},
|
||||
"linkHeading": {
|
||||
"message": "Fichier $1",
|
||||
"placeholders": {
|
||||
"1": {
|
||||
"content": "$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"
|
||||
}
|
||||
}
|
||||
|
||||
343
background.js
343
background.js
@@ -16,6 +16,7 @@ import {
|
||||
ladeEinstellungen, vollstaendig, sichererName, versionslabel,
|
||||
titelAusBetreff, zugriffsmuster, t
|
||||
} from "./lib/settings.js";
|
||||
import { baueMailPdf, alsEmpfaenger } from "./lib/mailpdf.js";
|
||||
|
||||
/**
|
||||
* Ergebnis einer Nachrichten-Abfrage vereinheitlichen.
|
||||
@@ -90,6 +91,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 +113,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 +139,9 @@ browser.runtime.onInstalled.addListener(() => {
|
||||
title: t("menuStoreMessage"),
|
||||
contexts: ["message_list"]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
menueAufbauen();
|
||||
|
||||
browser.menus.onClicked.addListener(async (info, tab) => {
|
||||
try {
|
||||
@@ -179,6 +207,7 @@ async function anhangAblegen(info, tab) {
|
||||
betreff: nachricht.subject,
|
||||
absender: nachricht.author,
|
||||
datum: alsDatum(nachricht.date),
|
||||
zeitpunkt: alsZeitpunkt(nachricht.date),
|
||||
dateien
|
||||
});
|
||||
}
|
||||
@@ -200,6 +229,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,
|
||||
@@ -249,6 +279,29 @@ 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);
|
||||
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
|
||||
@@ -360,14 +413,272 @@ 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(/ /gi, " ").replace(/&/gi, "&")
|
||||
.replace(/</gi, "<").replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"').replace(/'/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;
|
||||
@@ -381,7 +692,7 @@ async function datenHolen(auftrag) {
|
||||
async function ablegen(nachricht) {
|
||||
const c = await client();
|
||||
const s = await ladeEinstellungen();
|
||||
const dateien = await datenHolen(nachricht.auftrag);
|
||||
const dateien = await datenHolen(nachricht.auftrag, s);
|
||||
if (!dateien.length) throw new PaperlessError(t("errNothingSelected"));
|
||||
|
||||
const ergebnisse = [];
|
||||
@@ -390,14 +701,22 @@ async function ablegen(nachricht) {
|
||||
const antwort = await c.updateVersion(
|
||||
nachricht.zielId, datei.blob, datei.name,
|
||||
versionslabel(s.versionLabel));
|
||||
const id = await c.waitForTask(antwort);
|
||||
const id = await c.waitForTask(antwort, (sek) => {
|
||||
if (sek % 15 === 0) {
|
||||
console.debug("[paperless] warte auf Aufgabe:", sek, "s");
|
||||
}
|
||||
});
|
||||
ergebnisse.push({ id, name: datei.name, art: "version",
|
||||
zielId: nachricht.zielId });
|
||||
} else {
|
||||
const felder = { title: nachricht.titel || datei.name };
|
||||
if (nachricht.erstellt) felder.created = nachricht.erstellt;
|
||||
const antwort = await c.postDocument(datei.blob, datei.name, felder);
|
||||
const id = await c.waitForTask(antwort);
|
||||
const id = await c.waitForTask(antwort, (sek) => {
|
||||
if (sek % 15 === 0) {
|
||||
console.debug("[paperless] warte auf Aufgabe:", sek, "s");
|
||||
}
|
||||
});
|
||||
ergebnisse.push({ id, name: datei.name, art: "neu" });
|
||||
}
|
||||
}
|
||||
|
||||
4
build.sh
4
build.sh
@@ -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,7 +15,7 @@ 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 \
|
||||
lib/client.js lib/settings.js lib/pdf.js lib/mailpdf.js \
|
||||
ui/store.html ui/store.js ui/classify.html ui/classify.js \
|
||||
ui/insert.html ui/insert.js ui/options.html ui/options.js \
|
||||
ui/gemeinsam.js ui/gemeinsam.css \
|
||||
|
||||
213
lib/mailpdf.js
Normal file
213
lib/mailpdf.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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
470
lib/pdf.js
Normal 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())}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"name": "__MSG_extName__",
|
||||
"description": "__MSG_extDescription__",
|
||||
"version": "1.6.1",
|
||||
"version": "1.11.3",
|
||||
"author": "Name des Herausgebers",
|
||||
"homepage_url": "https://example.org/paperless-thunderbird",
|
||||
"default_locale": "de",
|
||||
@@ -16,6 +16,7 @@
|
||||
"storage",
|
||||
"menus",
|
||||
"messagesRead",
|
||||
"accountsRead",
|
||||
"compose",
|
||||
"notifications"
|
||||
],
|
||||
|
||||
@@ -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">
|
||||
|
||||
119
ui/classify.js
119
ui/classify.js
@@ -14,6 +14,11 @@ const $ = (id) => document.getElementById(id);
|
||||
let dokumentId = null;
|
||||
let alleTags = [];
|
||||
|
||||
// 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() {
|
||||
@@ -66,30 +71,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;
|
||||
|
||||
@@ -35,7 +35,12 @@ 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; }
|
||||
/* Kompakte Zahlenfelder mit vorangestellter Beschriftung. */
|
||||
.masse { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.mass { display: flex; gap: 5px; align-items: center; }
|
||||
.mass label { margin: 0; white-space: nowrap; }
|
||||
.mass input { width: 68px; }
|
||||
.zeile > input[type=text] { flex: 1; }
|
||||
.wahl { display: flex; gap: 6px; align-items: center; margin: 3px 0; }
|
||||
.wahl input { width: auto; margin: 0; }
|
||||
@@ -70,5 +75,17 @@ td.zahl, th.zahl { text-align: right; }
|
||||
tr.gewaehlt { background: SelectedItem; color: SelectedItemText; }
|
||||
tbody tr { cursor: default; }
|
||||
.hinweis { color: GrayText; font-size: 12px; }
|
||||
|
||||
/* Gewählte Tags stehen oben und bleiben sichtbar, auch wenn der Filter
|
||||
sie ausschliesst. Sonst verliert man beim Suchen den Überblick, was
|
||||
bereits angehakt ist. */
|
||||
.gewaehlteTags {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--rand);
|
||||
background: color-mix(in srgb, Field 94%, CanvasText);
|
||||
}
|
||||
.gewaehlteTags:empty { display: none; }
|
||||
.gewaehlteTags .wahl label { font-weight: 600; }
|
||||
.trefferHervor { background: color-mix(in srgb, Mark 60%, transparent); }
|
||||
.fehler { color: #b32; }
|
||||
.wachsen { flex: 1; display: flex; flex-direction: column; gap: 6px; min-height: 0; }
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
* 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;
|
||||
|
||||
export function verbindungHalten() {
|
||||
if (dauerverbindung) return;
|
||||
try {
|
||||
dauerverbindung = browser.runtime.connect({ name: "dialog" });
|
||||
dauerverbindung.onDisconnect.addListener(() => {
|
||||
dauerverbindung = null;
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[paperless] Dauerverbindung nicht möglich:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft die Ereignisseite und wirft bei Fehlern eine Ausnahme.
|
||||
*
|
||||
@@ -43,6 +66,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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<fieldset>
|
||||
<legend data-i18n="storeWhat">Was soll abgelegt werden?</legend>
|
||||
<div id="dateien"></div>
|
||||
<div class="wahl" id="pdfWahl" hidden>
|
||||
<input type="checkbox" id="alsPdf" checked>
|
||||
<label for="alsPdf" data-i18n="storeAsPdf">
|
||||
Als PDF aufbereiten, Original und Anhänge einbetten
|
||||
</label>
|
||||
</div>
|
||||
<div class="hinweis" data-i18n="storeHintPreselect">
|
||||
Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist
|
||||
Teil der Nachricht, keine eigenen Dokumente.
|
||||
|
||||
24
ui/store.js
24
ui/store.js
@@ -55,6 +55,16 @@ function dateienAnzeigen() {
|
||||
ziel.replaceChildren();
|
||||
|
||||
if (auftrag.quelle === "nachricht") {
|
||||
// Die Wahl zwischen PDF und Rohformat gibt es nur hier. Bei einem
|
||||
// einzelnen Anhang waere sie sinnlos - dort wird die Datei so
|
||||
// abgelegt, wie sie ist.
|
||||
const wahl = $("pdfWahl");
|
||||
if (wahl && wahl.hidden) {
|
||||
wahl.hidden = false;
|
||||
$("alsPdf").checked = (einstellungen.mailFormat || "pdf") === "pdf";
|
||||
$("alsPdf").addEventListener("change", dateienAnzeigen);
|
||||
}
|
||||
|
||||
// Aufbau über createElement statt innerHTML. Hier stünde zwar nur
|
||||
// festes Markup, aber die Zusicherung "kein innerHTML" soll ohne
|
||||
// Ausnahme gelten - sonst muss man bei jeder Änderung neu prüfen, ob
|
||||
@@ -68,8 +78,13 @@ function dateienAnzeigen() {
|
||||
box.disabled = true;
|
||||
const bez = document.createElement("label");
|
||||
bez.htmlFor = "ganz";
|
||||
bez.textContent = browser.i18n.getMessage("storeWholeMessage")
|
||||
|| "Die vollständige Nachricht als .eml";
|
||||
const alsPdf = $("alsPdf") ? $("alsPdf").checked
|
||||
: (einstellungen.mailFormat || "pdf") === "pdf";
|
||||
bez.textContent = alsPdf
|
||||
? (browser.i18n.getMessage("storeWholePdf")
|
||||
|| "Die Nachricht als PDF, mit eingebettetem Original")
|
||||
: (browser.i18n.getMessage("storeWholeMessage")
|
||||
|| "Die vollständige Nachricht als .eml");
|
||||
zeile.append(box, bez);
|
||||
ziel.append(zeile);
|
||||
|
||||
@@ -184,7 +199,10 @@ async function ablegen() {
|
||||
|
||||
try {
|
||||
const ergebnisse = await ruf("ablegen", {
|
||||
auftrag,
|
||||
auftrag: { ...auftrag,
|
||||
// Nur mitgeben, wenn die Wahl überhaupt angeboten wurde.
|
||||
alsPdf: $("pdfWahl") && !$("pdfWahl").hidden
|
||||
? $("alsPdf").checked : undefined },
|
||||
ziel: $("zielNeu").checked ? "neu" : "vorhanden",
|
||||
zielId: gewaehlterTreffer?.id,
|
||||
titel: $("titel").value.trim(),
|
||||
|
||||
Reference in New Issue
Block a user