7 Commits
v1.5.1 ... main

39 changed files with 2795 additions and 623 deletions

157
README.md
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 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. Dokumente aus dem Archiv als Anhang oder Freigabelink in Entwürfe ein.
@@ -14,7 +14,8 @@ WebExtensions, LibreOffice UNO.
| Ort | Befehl | Wirkung | | Ort | Befehl | Wirkung |
|---|---|---| |---|---|---|
| Kontextmenü am Anhang | Anhang in Paperless ablegen | ausgewählte Anhänge als Dokument | | 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` |
| Beides | Ablauf | Dateiauswahl, dann je Datei Ziel und Klassifizierung |
| Schaltfläche im Nachrichtenfenster | dasselbe für die angezeigte Nachricht | | | Schaltfläche im Nachrichtenfenster | dasselbe für die angezeigte Nachricht | |
| Schaltfläche im Verfassenfenster | Aus Paperless einfügen | als Anhang oder als Freigabelink | | Schaltfläche im Verfassenfenster | Aus Paperless einfügen | als Anhang oder als Freigabelink |
@@ -35,15 +36,102 @@ 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. Wer sie alle mit archiviert, hat nach einem Jahr hunderte Logos im Bestand.
Die Schwelle liegt bei 8 KB und ist einstellbar. Die Schwelle liegt bei 8 KB und ist einstellbar.
**Die ganze Nachricht als `.eml`.** Anhänge bleiben darin enthalten; **Die ganze Nachricht als PDF.** Betreff als Überschrift, darunter ein
Paperless legt sie nicht als eigene Dokumente an, aber die Datei ist grau hinterlegter Kopfblock mit Konto, Absender, Empfängern, Datum und
vollständig und lässt sich jederzeit wieder in einem Mailprogramm öffnen. 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
wählt im ersten Fenster je Datei „Original ablegen".
**Titel aus dem Betreff.** Präfixe wie `AW:` oder `Fwd:` werden entfernt — **Titel aus dem Betreff.** Präfixe wie `AW:` oder `Fwd:` werden entfernt —
sie tragen im Archiv nichts bei und stören beim Sortieren. sie tragen im Archiv nichts bei und stören beim Sortieren.
**Klassifizierung direkt danach.** Sonst trägt ein neues Dokument nur den **Zwei Schritte, dann je Datei einer.**
Titel, und nachträglich macht man es erfahrungsgemäß nicht mehr.
```
Fenster 1 Welche Dateien sollen abgelegt werden?
[x] Rechnung.pdf (•) Original ( ) PDF aufbereiten
[x] Anschreiben.html (•) Original ( ) PDF aufbereiten
Fenster 2 Je Datei: Ziel und Klassifizierung
┌─ Ziel ──────────┐ ┌─ Klassifizierung ─┐
│ ( ) Neu │ │ Titel [.......] │
│ (•) An bestehen-│ │ Erstellt[....] │
│ des anhängen│ │ Korresp.[.......] │
│ [suche] │ │ Typ [.......] │
│ Trefferliste │ │ Tags [suche] │
└─────────────────┘ └───────────────────┘
```
Die Trennung hat einen praktischen 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.
**Klassifizierung des Ziels wird übernommen.** Wählt man ein bestehendes
Dokument aus der Trefferliste, stehen Titel, Datum, Korrespondent, Typ und
Tags schon im rechten Kasten und lassen sich anpassen. Eine neue Version
gehört inhaltlich zum selben Vorgang; die Angaben neu einzutippen wäre
Arbeit für nichts, und ein abweichender Korrespondent brächte die Version
vom Original weg.
**Ein Aufruf je Datei.** Bewusst nicht alle in einem Durchgang: Manifest V3
beendet die Ereignisseite, sobald sie untätig scheint, und ein Aufruf über
zehn Anhänge lief regelmäßig in `Receiving end does not exist`. Nebeneffekt:
Der Fortschritt ist sichtbar, und scheitert eine Datei, sind die übrigen
trotzdem abgelegt.
### 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.
--- ---
@@ -85,7 +173,7 @@ für Empfänger, die ohnehin Zugriff auf das Archiv haben sollen.
## Einrichten ## Einrichten
1. Add-ons-Verwaltung → **Paperless-ngx** → Einstellungen 1. Add-ons-Verwaltung → **Paperless-Connector** → Einstellungen
2. Basisadresse eintragen, etwa `https://dms.example.org` — ohne 2. Basisadresse eintragen, etwa `https://dms.example.org` — ohne
abschließenden Schrägstrich abschließenden Schrägstrich
3. API-Token eintragen, erzeugt im **Benutzerprofil** von Paperless 3. API-Token eintragen, erzeugt im **Benutzerprofil** von Paperless
@@ -118,7 +206,7 @@ Dauerhaft:
``` ```
Add-ons-Verwaltung → Zahnrad → Add-on aus Datei installieren Add-ons-Verwaltung → Zahnrad → Add-on aus Datei installieren
→ paperless-thunderbird-1.0.0.xpi → paperless-connector-<version>.xpi
``` ```
--- ---
@@ -173,7 +261,7 @@ bei der LibreOffice-Fassung, weil hier **keine fremden Dokumente geöffnet**
werden — der heikelste Punkt entfällt. Die wichtigsten Maßnahmen: werden — der heikelste Punkt entfällt. Die wichtigsten Maßnahmen:
- Zugriffsrecht nur für die eingetragene Adresse, zur Laufzeit erbeten - Zugriffsrecht nur für die eingetragene Adresse, zur Laufzeit erbeten
- Nur die fünf tatsächlich benötigten Berechtigungen — `compose.send` war - Nur die sechs tatsächlich benötigten Berechtigungen — `compose.send` war
im ersten Entwurf enthalten, ohne je benutzt zu werden, und ist entfernt im ersten Entwurf enthalten, ohne je benutzt zu werden, und ist entfernt
- Nur `http` und `https` als Basisadresse - Nur `http` und `https` als Basisadresse
- Dateinamen aus Anhängen werden bereinigt — sie stammen vom Absender - Dateinamen aus Anhängen werden bereinigt — sie stammen vom Absender
@@ -193,9 +281,11 @@ background.js Menüs, Schaltflächen, Vermittlung
lib/ lib/
client.js REST-Client (fetch, FormData) client.js REST-Client (fetch, FormData)
settings.js Einstellungen, Dateinamen, Hilfsmittel settings.js Einstellungen, Dateinamen, Hilfsmittel
pdf.js PDF-Erzeuger ohne Fremdbibliothek
mailpdf.js Aufbereitung einer Nachricht
ui/ ui/
store.html/.js Ablegen: Anhänge oder ganze Nachricht store.html/.js Schritt 1: welche Dateien
classify.html/.js Klassifizierung direkt danach schritt.html/.js Schritt 2: Ziel und Klassifizierung je Datei
insert.html/.js Einfügen als Anhang oder Link insert.html/.js Einfügen als Anhang oder Link
options.html/.js Verbindung einrichten options.html/.js Verbindung einrichten
gemeinsam.js/.css geteilte Hilfsmittel gemeinsam.js/.css geteilte Hilfsmittel
@@ -204,11 +294,28 @@ icons/
``` ```
Die Dialoge sprechen **nicht** selbst mit Paperless, sondern lassen die Die Dialoge sprechen **nicht** selbst mit Paperless, sondern lassen die
Ereignisseite arbeiten. Dort hängt die zur Laufzeit erteilte Berechtigung; Ereignisseite arbeiten. So liegen Token und Verbindungslogik an einer
ein Dialogfenster müsste sie sonst erneut erbitten. Stelle, und die Dialoge bleiben reine Oberfläche.
Damit die Ereignisseite währenddessen nicht beendet wird, hält jeder
Dialog eine offene Verbindung dorthin und sendet alle 15 Sekunden ein
Lebenszeichen. Manifest V3 räumt sie sonst mitten im Vorgang ab —
Wartezeiten zählen dort nicht als Ereignis.
--- ---
### Eine Stolperstelle bei der Gestaltung
Die Dialoge sind auf feste Fensterhöhe mit blätternden Listen ausgelegt.
Drei Dinge mussten dafür stimmen, und jedes einzelne führte für sich zum
Überlauf:
| | |
|---|---|
| `height: 100vh` am Body | mit `min-height` wächst die Seite über das Fenster hinaus |
| kein `fieldset` als Flex-Behälter | dessen innerer anonymer Kasten löst `height: 100%` nicht auf — deshalb `<section class="kasten">` |
| kein `flex-wrap` bei den Spalten | mit Umbruch bemisst sich die Zeilenhöhe nach dem Inhalt, nicht nach dem Behälter |
## Vor einer Veröffentlichung ## Vor einer Veröffentlichung
| Datei | Was | | Datei | Was |
@@ -219,6 +326,28 @@ ein Dialogfenster müsste sie sonst erneut erbitten.
Die Kennung sollte einer Domain folgen, die dir gehört. Aktuell steht dort Die Kennung sollte einer Domain folgen, die dir gehört. Aktuell steht dort
`paperless@example.org`. `paperless@example.org`.
### Anforderungen der Prüfung auf addons.thunderbird.net
Aus der Ablehnung von 1.16.0 übernommen, seit 1.16.1 umgesetzt:
- **Kein async-Listener an `runtime.onMessage`.** Ein async-Listener
gibt immer ein Promise zurück und beansprucht damit jede Nachricht. Der
Listener prüft deshalb synchron Absender und Befehl und gibt nur für
bekannte Befehle das Promise zurück, sonst `false`. Neue Befehle kommen
in die Tabelle `BEFEHLE` in `background.js`.
- **Keine ungenutzten Dateien im Paket.** `build.sh` packt nur die in
`DATEIEN` aufgeführten Dateien und weist auf alles andere im Quellbaum
hin. `make_icons.py` erzeugt weiterhin alle Symbole samt SVG — im Paket
landen nur die sieben, die Manifest und Code verwenden.
- **Beschreibung mit Bedienungsanleitung und Bildschirmfotos** auf der
Add-on-Seite, siehe `ATN-BESCHREIBUNG.md`.
- Vor dem Hochladen den Linter laufen lassen:
```bash
git clone https://github.com/thunderbird/webext-linter
cd webext-linter && npm install
node verify.js /pfad/zu/paperless-connector-<version>.xpi
```
--- ---
## Lizenz ## Lizenz

View File

@@ -1,7 +1,7 @@
# Sicherheitsbetrachtung # Sicherheitsbetrachtung
Was die Erweiterung tut, welche Angriffswege sich daraus ergeben und was 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 Die Lage ist deutlich entspannter als bei der LibreOffice-Fassung. Dort
war der kritischste Schritt das **Öffnen** eines heruntergeladenen war der kritischste Schritt das **Öffnen** eines heruntergeladenen
Dokuments in einer Anwendung mit Makrofähigkeit. Das entfällt hier 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 | | 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 | | Anhänge und Nachrichten hochladen | Inhalt vom Absender, nicht vertrauenswürdig |
| Dokumente als Anhang in Entwürfe | Inhalt aus dem Archiv | | Dokumente als Anhang in Entwürfe | Inhalt aus dem Archiv |
| Freigabelink in den Nachrichtentext | erzeugte Zeichenkette | | Freigabelink in den Nachrichtentext | erzeugte Zeichenkette |
| PDF aus einer Nachricht erzeugen | Inhalt vom Absender |
| Token dauerhaft speichern | Geheimnis | | Token dauerhaft speichern | Geheimnis |
--- ---
@@ -30,7 +35,8 @@ Bewusst knapp gehalten:
|---|---|---| |---|---|---|
| `storage` | Einstellungen | | | `storage` | Einstellungen | |
| `menus` | Kontextmenüs | | | `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 | | | `compose` | Anhang und Link einfügen | |
| `notifications` | Fehlermeldungen | | | `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 Anhangdaten werden **erst beim Hochladen** geladen, nicht beim Öffnen des
Dialogs. Bei einer Nachricht mit mehreren großen Anhängen läge sonst alles 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 **Adressprüfung.** Nur `http` und `https`. Ohne diese Prüfung liessen sich
andere Schemata hinterlegen, die `fetch` teilweise bedient. 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 Er liegt im Klartext in `browser.storage.local`, also im Profil von
Thunderbird. Das entspricht dem, was Erweiterungen üblicherweise tun, ist 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 - kein `eval`, kein `innerHTML` mit fremden Inhalten — alle Listen werden
über `textContent` und `createElement` aufgebaut über `textContent` und `createElement` aufgebaut
- kein Öffnen heruntergeladener Dateien - kein Öffnen heruntergeladener Dateien
- kein ausführbarer Inhalt im erzeugten PDF
- kein Schreiben ins Dateisystem - kein Schreiben ins Dateisystem
- kein Versenden von Nachrichten - kein Versenden von Nachrichten
- keine Übertragung an Dritte; einzige Gegenstelle ist die konfigurierte - 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 | | 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 | | 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 | | 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 | | 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": { "extName": {
"message": "Paperless-ngx" "message": "Paperless-Connector"
}, },
"extDescription": { "extDescription": {
"message": "Anhänge und E-Mails in ein Paperless-ngx-Archiv ablegen, Dokumente als Anhang oder Freigabelink einfügen." "message": "Anhänge und E-Mails in ein Paperless-ngx-Archiv ablegen, Dokumente als Anhang oder Freigabelink einfügen."
@@ -26,9 +26,6 @@
"storeHintPreselect": { "storeHintPreselect": {
"message": "Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist Teil der Nachricht, keine eigenen Dokumente." "message": "Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist Teil der Nachricht, keine eigenen Dokumente."
}, },
"storeWholeMessage": {
"message": "Die vollständige Nachricht als .eml"
},
"storeAttachmentsInside": { "storeAttachmentsInside": {
"message": "Die $1 Anhänge bleiben darin enthalten.", "message": "Die $1 Anhänge bleiben darin enthalten.",
"placeholders": { "placeholders": {
@@ -44,28 +41,11 @@
"message": "Neues Dokument anlegen" "message": "Neues Dokument anlegen"
}, },
"storeTargetExisting": { "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."
},
"storeClassifyAfter": {
"message": "Nach dem Ablegen die Klassifizierung bearbeiten"
}, },
"classifyTitle": { "classifyTitle": {
"message": "Klassifizierung bearbeiten" "message": "Klassifizierung bearbeiten"
}, },
"classifyHintNew": {
"message": "Unbekannte Korrespondenten und Dokumenttypen werden angelegt."
},
"classifyMoreStored": {
"message": "$1 Dateien abgelegt",
"placeholders": {
"1": {
"content": "$1"
}
}
},
"insertTitle": { "insertTitle": {
"message": "Aus Paperless einfügen" "message": "Aus Paperless einfügen"
}, },
@@ -79,7 +59,7 @@
"message": "Freigabelink im Nachrichtentext" "message": "Freigabelink im Nachrichtentext"
}, },
"insertHintLink": { "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": { "optionsTitle": {
"message": "Paperless — Verbindung" "message": "Paperless — Verbindung"
@@ -235,9 +215,6 @@
"errNoPermission": { "errNoPermission": {
"message": "Ohne Zugriffsrecht auf die Adresse kann die Erweiterung nicht arbeiten." "message": "Ohne Zugriffsrecht auf die Adresse kann die Erweiterung nicht arbeiten."
}, },
"linkLabel": {
"message": "Datei"
},
"linkValidUntil": { "linkValidUntil": {
"message": "Link gültig bis $1", "message": "Link gültig bis $1",
"placeholders": { "placeholders": {
@@ -249,12 +226,99 @@
"linkValidUnlimited": { "linkValidUnlimited": {
"message": "Link ohne zeitliche Begrenzung" "message": "Link ohne zeitliche Begrenzung"
}, },
"linkHeading": { "optionsHintTag": {
"message": "Datei $1", "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."
},
"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."
},
"classifyProgress": {
"message": "$1 von $2",
"placeholders": { "placeholders": {
"1": { "1": {
"content": "$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": { "extName": {
"message": "Paperless-ngx" "message": "Paperless-Connector"
}, },
"extDescription": { "extDescription": {
"message": "Store attachments and e-mails in a Paperless-ngx archive, insert documents as attachment or share link." "message": "Store attachments and e-mails in a Paperless-ngx archive, insert documents as attachment or share link."
@@ -26,9 +26,6 @@
"storeHintPreselect": { "storeHintPreselect": {
"message": "Small images and signatures are deselected — they usually belong to the message, not to the content." "message": "Small images and signatures are deselected — they usually belong to the message, not to the content."
}, },
"storeWholeMessage": {
"message": "The complete message as .eml"
},
"storeAttachmentsInside": { "storeAttachmentsInside": {
"message": "The $1 attachments remain inside it.", "message": "The $1 attachments remain inside it.",
"placeholders": { "placeholders": {
@@ -44,28 +41,11 @@
"message": "Create a new document" "message": "Create a new document"
}, },
"storeTargetExisting": { "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."
},
"storeClassifyAfter": {
"message": "Edit the classification after storing"
}, },
"classifyTitle": { "classifyTitle": {
"message": "Edit classification" "message": "Edit classification"
}, },
"classifyHintNew": {
"message": "Unknown correspondents and document types will be created."
},
"classifyMoreStored": {
"message": "$1 files stored",
"placeholders": {
"1": {
"content": "$1"
}
}
},
"insertTitle": { "insertTitle": {
"message": "Insert from Paperless" "message": "Insert from Paperless"
}, },
@@ -79,7 +59,7 @@
"message": "Share link in the message body" "message": "Share link in the message body"
}, },
"insertHintLink": { "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": { "optionsTitle": {
"message": "Paperless — Connection" "message": "Paperless — Connection"
@@ -235,9 +215,6 @@
"errNoPermission": { "errNoPermission": {
"message": "Without access permission for the address the extension cannot work." "message": "Without access permission for the address the extension cannot work."
}, },
"linkLabel": {
"message": "File"
},
"linkValidUntil": { "linkValidUntil": {
"message": "Link valid until $1", "message": "Link valid until $1",
"placeholders": { "placeholders": {
@@ -249,12 +226,99 @@
"linkValidUnlimited": { "linkValidUnlimited": {
"message": "Link without time limit" "message": "Link without time limit"
}, },
"linkHeading": { "optionsHintTag": {
"message": "File $1", "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."
},
"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."
},
"classifyProgress": {
"message": "$1 of $2",
"placeholders": { "placeholders": {
"1": { "1": {
"content": "$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": { "extName": {
"message": "Paperless-ngx" "message": "Paperless-Connector"
}, },
"extDescription": { "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." "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."
@@ -26,9 +26,6 @@
"storeHintPreselect": { "storeHintPreselect": {
"message": "Les petites images et les signatures sont décochées — elles font partie du message, pas du contenu." "message": "Les petites images et les signatures sont décochées — elles font partie du message, pas du contenu."
}, },
"storeWholeMessage": {
"message": "Le message complet au format .eml"
},
"storeAttachmentsInside": { "storeAttachmentsInside": {
"message": "Les $1 pièces jointes y restent incluses.", "message": "Les $1 pièces jointes y restent incluses.",
"placeholders": { "placeholders": {
@@ -44,28 +41,11 @@
"message": "Créer un nouveau document" "message": "Créer un nouveau document"
}, },
"storeTargetExisting": { "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."
},
"storeClassifyAfter": {
"message": "Modifier le classement après l'enregistrement"
}, },
"classifyTitle": { "classifyTitle": {
"message": "Modifier le classement" "message": "Modifier le classement"
}, },
"classifyHintNew": {
"message": "Les correspondants et types de document inconnus seront créés."
},
"classifyMoreStored": {
"message": "$1 fichiers enregistrés",
"placeholders": {
"1": {
"content": "$1"
}
}
},
"insertTitle": { "insertTitle": {
"message": "Insérer depuis Paperless" "message": "Insérer depuis Paperless"
}, },
@@ -79,7 +59,7 @@
"message": "Lien de partage dans le corps du message" "message": "Lien de partage dans le corps du message"
}, },
"insertHintLink": { "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": { "optionsTitle": {
"message": "Paperless — Connexion" "message": "Paperless — Connexion"
@@ -235,9 +215,6 @@
"errNoPermission": { "errNoPermission": {
"message": "Sans droit d'accès à l'adresse, l'extension ne peut pas fonctionner." "message": "Sans droit d'accès à l'adresse, l'extension ne peut pas fonctionner."
}, },
"linkLabel": {
"message": "Fichier"
},
"linkValidUntil": { "linkValidUntil": {
"message": "Lien valable jusqu'au $1", "message": "Lien valable jusqu'au $1",
"placeholders": { "placeholders": {
@@ -249,12 +226,99 @@
"linkValidUnlimited": { "linkValidUnlimited": {
"message": "Lien sans limite de durée" "message": "Lien sans limite de durée"
}, },
"linkHeading": { "optionsHintTag": {
"message": "Fichier $1", "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."
},
"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."
},
"classifyProgress": {
"message": "$1 sur $2",
"placeholders": { "placeholders": {
"1": { "1": {
"content": "$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, ladeEinstellungen, vollstaendig, sichererName, versionslabel,
titelAusBetreff, zugriffsmuster, t titelAusBetreff, zugriffsmuster, t
} from "./lib/settings.js"; } from "./lib/settings.js";
import { baueMailPdf, baueTextPdf, alsEmpfaenger, umwandelbar }
from "./lib/mailpdf.js";
/** /**
* Ergebnis einer Nachrichten-Abfrage vereinheitlichen. * Ergebnis einer Nachrichten-Abfrage vereinheitlichen.
@@ -90,6 +92,16 @@ function alsDatum(wert) {
return Number.isNaN(d.getTime()) ? undefined : d.toISOString().slice(0, 10); 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 // Beim Laden eine Zeile ausgeben. Fehlt sie in der Konsole, ist das
// Skript gar nicht erst gestartet - und dann kommt bei jedem Aufruf aus // Skript gar nicht erst gestartet - und dann kommt bei jedem Aufruf aus
// einem Dialog nur "An unexpected error occurred". // einem Dialog nur "An unexpected error occurred".
@@ -102,7 +114,22 @@ let naechsteAuftragsnummer = 1;
// ---------------------------------------------------------------- Menüs // ---------------------------------------------------------------- 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({ browser.menus.create({
id: "paperless-anhang", id: "paperless-anhang",
title: t("menuStoreAttachment"), title: t("menuStoreAttachment"),
@@ -113,7 +140,9 @@ browser.runtime.onInstalled.addListener(() => {
title: t("menuStoreMessage"), title: t("menuStoreMessage"),
contexts: ["message_list"] contexts: ["message_list"]
}); });
}); }
menueAufbauen();
browser.menus.onClicked.addListener(async (info, tab) => { browser.menus.onClicked.addListener(async (info, tab) => {
try { try {
@@ -179,6 +208,7 @@ async function anhangAblegen(info, tab) {
betreff: nachricht.subject, betreff: nachricht.subject,
absender: nachricht.author, absender: nachricht.author,
datum: alsDatum(nachricht.date), datum: alsDatum(nachricht.date),
zeitpunkt: alsZeitpunkt(nachricht.date),
dateien dateien
}); });
} }
@@ -200,6 +230,7 @@ async function nachrichtAblegen(info, tab) {
betreff: nachricht.subject, betreff: nachricht.subject,
absender: nachricht.author, absender: nachricht.author,
datum: alsDatum(nachricht.date), datum: alsDatum(nachricht.date),
zeitpunkt: alsZeitpunkt(nachricht.date),
dateien: anhaenge.map((a) => ({ dateien: anhaenge.map((a) => ({
name: sichererName(a.name), name: sichererName(a.name),
size: a.size, size: a.size,
@@ -225,7 +256,10 @@ async function dialogOeffnen(art, daten) {
// Absender und Dateinamen; das muss nicht bis zum Neustart im Speicher // Absender und Dateinamen; das muss nicht bis zum Neustart im Speicher
// liegen, nur weil jemand den Dialog offen stehen liess. // liegen, nur weil jemand den Dialog offen stehen liess.
setTimeout(() => auftraege.delete(nummer), 30 * 60 * 1000); 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 // Die Adresse MUSS über runtime.getURL aufgelöst werden. Ein relativer
// Pfad wird in Manifest V3 nicht gegen das Erweiterungsverzeichnis // Pfad wird in Manifest V3 nicht gegen das Erweiterungsverzeichnis
@@ -236,8 +270,12 @@ async function dialogOeffnen(art, daten) {
await browser.windows.create({ await browser.windows.create({
url, url,
type: "popup", type: "popup",
width: art === "einfuegen" ? 760 : 820, // Der Schrittdialog steht zweispaltig. Die Höhe ist auf den Inhalt
height: art === "einfuegen" ? 640 : 720 // 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) { } catch (err) {
// Manche Stände lehnen type "popup" ab. Dann ein gewöhnliches Fenster. // Manche Stände lehnen type "popup" ab. Dann ein gewöhnliches Fenster.
@@ -249,74 +287,109 @@ async function dialogOeffnen(art, daten) {
// -------------------------------------------- Vermittlung für die Dialoge // -------------------------------------------- Vermittlung für die Dialoge
browser.runtime.onMessage.addListener(async (nachricht, absender) => { /**
// Nur Nachrichten aus dieser Erweiterung annehmen. Thunderbird lässt * Offene Verbindungen der Dialoge.
// fremde Absender hier zwar ohnehin nicht durch, solange kein *
// externally_connectable gesetzt ist - die Prüfung kostet nichts und * SEHR WICHTIG: Eine Ereignisseite wird beendet, sobald keine Ereignisse
// hält die Zusicherung explizit. * mehr anstehen. Eine laufende Wartezeit in waitForTask zählt NICHT als
if (absender?.id && absender.id !== browser.runtime.id) { * Ereignis - mitten im Ablegen verschwindet sonst der Empfänger, und der
return { ok: false, fehler: "Fremder Absender" }; * Dialog bekommt "Could not establish connection. Receiving end does not
} * exist."
try { *
switch (nachricht?.befehl) { * Eine offene Verbindung hält die Seite dagegen am Leben. Die Dialoge
case "auftrag": * öffnen deshalb beim Laden eine und halten sie bis zum Schliessen.
return { ok: true, daten: auftraege.get(nachricht.nummer) || null }; */
const verbindungen = new Set();
case "einstellungen": browser.runtime.onConnect.addListener((port) => {
return { ok: true, daten: await ladeEinstellungen() }; verbindungen.add(port);
console.debug("[paperless] Dialog verbunden, offen:", verbindungen.size);
case "berechtigungPruefen": // Die Lebenszeichen werden nicht ausgewertet - allein ihr Eintreffen
return { ok: true, daten: await berechtigungPruefen() }; // hält die Ereignisseite wach.
port.onMessage.addListener(() => {});
case "suchen": { port.onDisconnect.addListener(() => {
verbindungen.delete(port);
console.debug("[paperless] Dialog getrennt, offen:", verbindungen.size);
});
});
/*
* Befehle aus den Dialogen.
*
* Der Listener selbst ist bewusst NICHT async: Ein async-Listener liefert
* immer ein Promise zurück und beansprucht damit jede Nachricht - auch
* solche, die gar nicht für ihn bestimmt sind. Stattdessen prüft er
* synchron, ob die Nachricht aus dieser Erweiterung stammt und einen
* bekannten Befehl trägt, und gibt nur dann das Promise des Handlers
* zurück. Alles andere lehnt er mit false ab, sodass andere Empfänger
* antworten können.
* Siehe https://webextension-api.thunderbird.net/en/mv3/guides/runtimeMessaging.html
*/
const BEFEHLE = {
auftrag: async (n) => auftraege.get(n.nummer) || null,
einstellungen: async () => ladeEinstellungen(),
berechtigungPruefen: async () => berechtigungPruefen(),
suchen: async (n) => (await client()).search(n.query),
dokument: async (n) => (await client()).document(n.id),
versionen: async (n) => {
const c = await client(); const c = await client();
return { ok: true, daten: await c.search(nachricht.query) }; return c.versions(await c.document(n.id));
} },
case "dokument": { stammdaten: async () => {
const c = await client();
return { ok: true, daten: await c.document(nachricht.id) };
}
case "versionen": {
const c = await client();
const doc = await c.document(nachricht.id);
return { ok: true, daten: await c.versions(doc) };
}
case "stammdaten": {
const c = await client(); const c = await client();
const [tags, korr, typen] = await Promise.all( const [tags, korr, typen] = await Promise.all(
[c.tags(), c.correspondents(), c.documentTypes()]); [c.tags(), c.correspondents(), c.documentTypes()]);
return { ok: true, daten: { tags, korr, typen } }; return { tags, korr, typen };
} },
case "ablegen": schrittOeffnen: async (n) => {
return { ok: true, daten: await ablegen(nachricht) }; await dialogOeffnen("schritt", n.auftrag);
return undefined;
},
case "klassifizieren": { dateiListe: async (n) => dateiListe(n.auftrag),
ablegenEine: async (n) => ablegenEine(n),
klassifizieren: async (n) => {
const c = await client(); const c = await client();
const payload = { ...nachricht.payload }; const payload = { ...n.payload };
payload.correspondent = await aufloesen( payload.correspondent = await aufloesen(c, "correspondents/", n.korrName);
c, "correspondents/", nachricht.korrName); payload.document_type = await aufloesen(c, "document_types/", n.typName);
payload.document_type = await aufloesen( await c.patchDocument(n.id, payload);
c, "document_types/", nachricht.typName); return undefined;
await c.patchDocument(nachricht.id, payload); },
return { ok: true };
}
case "anhaengen": anhaengen: async (n) => anhaengenAnEntwurf(n),
return { ok: true, daten: await anhaengenAnEntwurf(nachricht) };
case "freigabelink": freigabelink: async (n) => freigabelink(n),
return { ok: true, daten: await freigabelink(nachricht) }; };
default: async function befehlAusfuehren(handler, nachricht) {
return { ok: false, fehler: `Unbekannter Befehl: ${nachricht?.befehl}` }; try {
} const daten = await handler(nachricht);
return daten === undefined ? { ok: true } : { ok: true, daten };
} catch (err) { } catch (err) {
return { ok: false, fehler: String(err?.message || err) }; return { ok: false, fehler: String(err?.message || err) };
} }
}
browser.runtime.onMessage.addListener((nachricht, absender) => {
// Nur Nachrichten aus dieser Erweiterung mit bekanntem Befehl annehmen.
if (absender?.id !== browser.runtime.id) return false;
const befehl = nachricht?.befehl;
if (typeof befehl !== "string" || !Object.hasOwn(BEFEHLE, befehl)) {
return false;
}
return befehlAusfuehren(BEFEHLE[befehl], nachricht);
}); });
async function client() { async function client() {
@@ -360,48 +433,431 @@ async function berechtigungPruefen() {
// -------------------------------------------------------------- Vorgänge // -------------------------------------------------------------- Vorgänge
async function datenHolen(auftrag) { /**
if (auftrag.quelle === "nachricht") { * Nachrichtentext als reinen Text beschaffen.
const roh = await browser.messages.getRaw(auftrag.messageId); *
const blob = new Blob([roh], { type: "message/rfc822" }); * Bevorzugt wird der text/plain-Teil. Gibt es nur HTML, übernimmt
const name = sichererName( * Thunderbird die Umwandlung - convertToPlainText ist dieselbe Funktion,
`${titelAusBetreff(auftrag.betreff)}.eml`); * die beim Antworten in Nur-Text greift, und liefert deutlich bessere
return [{ blob, name }]; * 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 = []; const out = [];
for (const d of auftrag.dateien || []) { for (const d of auftrag.dateien || []) {
if (!d.ausgewaehlt) continue; if (!d.ausgewaehlt) continue;
const datei = await browser.messages.getAttachmentFile( const datei = await browser.messages.getAttachmentFile(
d.messageId, d.partName); 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) }); out.push({ blob: datei, name: sichererName(d.name) });
} }
return out; 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 c = await client();
const s = await ladeEinstellungen(); const s = await ladeEinstellungen();
const dateien = await datenHolen(nachricht.auftrag); const auftrag = nachricht.auftrag;
if (!dateien.length) throw new PaperlessError(t("errNothingSelected")); 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") { if (nachricht.ziel === "vorhanden") {
const antwort = await c.updateVersion( const antwort = await c.updateVersion(
nachricht.zielId, datei.blob, datei.name, nachricht.zielId, datei.blob, datei.name,
versionslabel(s.versionLabel)); versionslabel(s.versionLabel));
const id = await c.waitForTask(antwort); const id = await c.waitForTask(antwort, melden);
ergebnisse.push({ id, name: datei.name, art: "version", return { id, name: datei.name, art: "version", zielId: nachricht.zielId };
zielId: nachricht.zielId }); }
} else {
const felder = { title: nachricht.titel || datei.name }; // Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt -
// sonst hiessen alle gleich und wären im Archiv nicht auseinander-
// zuhalten.
const grundtitel = nachricht.titel || datei.name;
const titel = nachricht.mehrere
? `${grundtitel} - ${datei.name.replace(/\.[^.]{1,8}$/, "")}`
: grundtitel;
const felder = { title: titel.slice(0, 127) };
if (nachricht.erstellt) felder.created = nachricht.erstellt; if (nachricht.erstellt) felder.created = nachricht.erstellt;
const antwort = await c.postDocument(datei.blob, datei.name, felder); const antwort = await c.postDocument(datei.blob, datei.name, felder);
const id = await c.waitForTask(antwort); const id = await c.waitForTask(antwort, melden);
ergebnisse.push({ id, name: datei.name, art: "neu" }); return { id, name: datei.name, art: "neu" };
}
}
return ergebnisse;
} }
async function aufloesen(c, pfad, name) { async function aufloesen(c, pfad, name) {

View File

@@ -7,22 +7,49 @@
set -eu set -eu
cd "$(dirname "$0")" cd "$(dirname "$0")"
NAME="paperless-thunderbird" NAME="paperless-connector"
VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' manifest.json | head -1)" VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' manifest.json | head -1)"
OUT="${NAME}-${VERSION}.xpi" OUT="${NAME}-${VERSION}.xpi"
# Vollständigkeit prüfen. zip überspringt fehlende Pfade stillschweigend - # Paketinhalt als feste Liste. Nur was hier steht, landet in der .xpi -
# ohne diese Prüfung entstünde ein Paket, das sich nicht laden lässt. # so geraten keine Altlasten (alte Dialoge, ungenutzte Symbole, Quell-SVGs
FEHLT="" # aus make_icons.py) ins Paket. addons.thunderbird.net lehnt Pakete mit
for f in manifest.json background.js \ # ungenutzten Dateien ab.
lib/client.js lib/settings.js \ DATEIEN="
ui/store.html ui/store.js ui/classify.html ui/classify.js \ manifest.json
ui/insert.html ui/insert.js ui/options.html ui/options.js \ background.js
ui/gemeinsam.js ui/gemeinsam.css \ LICENSE
_locales/de/messages.json _locales/en/messages.json \ lib/client.js
_locales/fr/messages.json \ lib/settings.js
lib/pdf.js
lib/mailpdf.js
ui/gemeinsam.css
ui/gemeinsam.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
_locales/de/messages.json
_locales/en/messages.json
_locales/fr/messages.json
icons/paperless-logo_16.png
icons/paperless-logo_32.png
icons/paperless-logo_64.png icons/paperless-logo_64.png
do icons/paperless-store_16.png
icons/paperless-store_32.png
icons/paperless-insert_16.png
icons/paperless-insert_32.png
"
# Vollständigkeit prüfen. zip überspringt fehlende Pfade nicht immer
# lautstark - ohne diese Prüfung entstünde ein Paket, das sich nicht
# laden lässt.
FEHLT=""
for f in $DATEIEN; do
[ -e "$f" ] || FEHLT="$FEHLT $f\n" [ -e "$f" ] || FEHLT="$FEHLT $f\n"
done done
if [ -n "$FEHLT" ]; then if [ -n "$FEHLT" ]; then
@@ -32,14 +59,18 @@ if [ -n "$FEHLT" ]; then
exit 1 exit 1
fi fi
rm -f "$OUT" # Umgekehrte Prüfung: Dateien im Quellbaum, die nicht im Paket landen.
# Nur ein Hinweis - README, build.sh usw. gehören bewusst nicht hinein.
for f in $(find lib ui icons _locales -type f | sort); do
case " $(echo $DATEIEN) " in
*" $f "*) ;;
*) echo "Hinweis: nicht im Paket: $f" >&2 ;;
esac
done
zip -r -q "$OUT" \ rm -f "$OUT"
manifest.json background.js \ # shellcheck disable=SC2086
lib ui icons _locales \ zip -q -X "$OUT" $DATEIEN
LICENSE \
-x "*.oxt" "*.xpi" "build.sh" "README.md" "SICHERHEIT.md" \
"make_icons.py" "*/.*"
if ! unzip -l "$OUT" | grep -q 'manifest.json'; then if ! unzip -l "$OUT" | grep -q 'manifest.json'; then
echo "ABBRUCH: manifest.json fehlt im erzeugten Paket." >&2 echo "ABBRUCH: manifest.json fehlt im erzeugten Paket." >&2

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<polygon points="0.6,0.5 13,0.5 18.4,5.9 18.4,23.4 0.6,23.4" fill="#fcfcfa" stroke="#175420" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="13,0.5 18.4,5.9 13,5.9" fill="#175420"/>
<line x1="2.8" y1="7.9" x2="13.2" y2="7.9" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="11.7" x2="15.4" y2="11.7" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="15.5" x2="11.2" y2="15.5" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="7.8" fill="#ffffff"/>
<circle cx="16.9" cy="17.1" r="6.7" fill="#1e60a8"/>
<polygon points="16.9,20.6 14,17.4 19.8,17.4" fill="#fcfcfa"/>
<line x1="16.9" y1="14.125" x2="16.9" y2="17.7" fill="none" stroke="#fcfcfa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 885 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect x="0.4" y="0.4" width="23.2" height="23.2" rx="4" fill="#175420"/>
<polygon points="5,3.9 14.6,3.9 19,8.3 19,20.1 5,20.1" fill="#fcfcfa"/>
<polygon points="14.6,3.9 19,8.3 14.6,8.3" fill="#3a8a44"/>
<line x1="7" y1="9.9" x2="14.4" y2="9.9" fill="none" stroke="#175420" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="7" y1="13.3" x2="16.2" y2="13.3" fill="none" stroke="#175420" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="7" y1="16.7" x2="13" y2="16.7" fill="none" stroke="#175420" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<polygon points="0.6,0.5 13,0.5 18.4,5.9 18.4,23.4 0.6,23.4" fill="#fcfcfa" stroke="#175420" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="13,0.5 18.4,5.9 13,5.9" fill="#175420"/>
<line x1="2.8" y1="7.9" x2="13.2" y2="7.9" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="11.7" x2="15.4" y2="11.7" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="15.5" x2="11.2" y2="15.5" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="7.8" fill="#ffffff"/>
<circle cx="16.9" cy="17.1" r="6.7" fill="#117a7a"/>
<line x1="13.7" y1="17.1" x2="19.5" y2="13.9" fill="none" stroke="#fcfcfa" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="13.7" y1="17.1" x2="19.5" y2="20.3" fill="none" stroke="#fcfcfa" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="19.5" cy="13.9" r="1.65" fill="#fcfcfa"/>
<circle cx="19.5" cy="20.3" r="1.65" fill="#fcfcfa"/>
<circle cx="13.7" cy="17.1" r="1.65" fill="#fcfcfa"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<polygon points="0.6,0.5 13,0.5 18.4,5.9 18.4,23.4 0.6,23.4" fill="#fcfcfa" stroke="#175420" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<polygon points="13,0.5 18.4,5.9 13,5.9" fill="#175420"/>
<line x1="2.8" y1="7.9" x2="13.2" y2="7.9" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="11.7" x2="15.4" y2="11.7" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="2.8" y1="15.5" x2="11.2" y2="15.5" fill="none" stroke="#175420" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="16.9" cy="17.1" r="7.8" fill="#ffffff"/>
<circle cx="16.9" cy="17.1" r="6.7" fill="#c86a14"/>
<polygon points="15.7,13.6 12.8,16.8 18.6,16.8" fill="#fcfcfa"/>
<line x1="15.7" y1="16.5" x2="15.7" y2="20.075" fill="none" stroke="#fcfcfa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="18.6" y1="14.5" x2="22" y2="14.5" fill="none" stroke="#fcfcfa" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="20.3" y1="12.8" x2="20.3" y2="16.2" fill="none" stroke="#fcfcfa" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 889 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -183,16 +183,113 @@ export class PaperlessClient {
return true; return true;
} }
/**
* Dokumente suchen. Vier Wege, in dieser Reihenfolge:
*
* 1. Archiv-Seriennummer, wenn die Eingabe nur aus Ziffern besteht.
* Sie ist ein eigenes Feld - die Volltextsuche findet sie NICHT.
*
* 2. Die GLOBALE SUCHE von Paperless (/api/search/). Dieselbe
* Schnittstelle, die die Suchleiste oben in der Weboberfläche
* bedient. Sie verbindet Titelsuche und Volltext und liefert
* damit die Ergebnisse, die man aus der Oberfläche kennt.
*
* 3. Teilzeichenkette in Titel und Inhalt. Greift, wenn die globale
* Suche nichts liefert oder in der eingesetzten Version fehlt.
*
* 4. Volltextsuche über den Index, mit UND-Verknüpfung. Sie
* beherrscht Wortstämme und findet auch Beugungsformen.
*
* Zwei Fallstricke, die hier berücksichtigt sind:
*
* * Bei der Volltextsuche darf KEIN ordering mitgegeben werden.
* Paperless sortiert dort nach Relevanz; eine eigene
* Sortierangabe führte dazu, dass die Suchanfrage verworfen wurde
* und schlicht die zuletzt hinzugefügten Dokumente kamen.
*
* * Ohne UND-Verknüpfung sucht der Index mit ODER. "Anschreiben
* Markus Straub" lieferte dann alles, was irgendeines der drei
* Wörter enthält - der gesuchte Treffer ging darin unter.
*/
async search(query, limit = 50) { async search(query, limit = 50) {
const q = String(query || "").trim();
if (!q) return this.#suchlauf({ ordering: "-added" }, limit);
// 1. Seriennummer
if (/^\d+$/.test(q)) {
const asn = await this.#suchlauf({ archive_serial_number: q }, limit);
if (asn.length) return asn;
}
// 2. Globale Suche wie in der Weboberfläche
const global = await this.#globaleSuche(q, limit);
if (global.length) return global;
// 3. Titel und Inhalt
const text = await this.#suchlauf(
{ title_content: q, ordering: "-created" }, limit);
if (text.length) return text;
// 4. Volltext mit UND, ohne ordering.
//
// Nach dem Ersetzen der Sonderzeichen ERNEUT zerlegen: Aus
// "Müller-Lüdenscheidt" würde sonst ein Wort mit Leerzeichen, und
// die UND-Verknüpfung wäre wieder aufgehoben.
const worte = q.replace(/[+\-&|!(){}[\]^"~*?:\\/]/g, " ")
.split(/\s+/).filter(Boolean);
if (!worte.length) return [];
return this.#suchlauf({ query: worte.join(" AND ") }, limit);
}
/**
* Die Schnittstelle hinter der Suchleiste der Weboberfläche.
*
* Sie gibt es erst ab neueren Paperless-Ständen, und die Form der
* Antwort ist nicht überall gleich - mal ein Feld "documents", mal
* "results". Deshalb wird beides angenommen und bei jedem Fehler
* stillschweigend auf den nächsten Weg ausgewichen.
*/
async #globaleSuche(q, limit) {
let antwort;
try {
const params = new URLSearchParams({ query: q,
page_size: String(limit) });
antwort = await this.#json(`search/?${params}`);
} catch {
return [];
}
if (!antwort) return [];
for (const schluessel of ["documents", "results"]) {
const rows = antwort[schluessel];
if (Array.isArray(rows) && rows.length) {
// Die globale Suche liefert je nach Version verschiedene
// Objektarten. Nur echte Dokumente durchlassen.
return rows
.filter((r) => r && typeof r === "object"
&& r.id !== undefined && r.title !== undefined)
.slice(0, limit);
}
}
return [];
}
async #suchlauf(zusatz, limit) {
const params = new URLSearchParams({ const params = new URLSearchParams({
page_size: String(limit), page_size: String(limit),
ordering: "-added", // Ohne diese Einschränkung liefert Paperless je Treffer auch den
fields: "id,title,created,added,archive_serial_number,mime_type," + // vollständigen Inhalt - bei fünfzig Treffern mehrere Megabyte.
"original_file_name" fields: "id,title,created,added,archive_serial_number,mime_type,"
+ "original_file_name"
}); });
if (query) params.set("query", query); for (const [k, v] of Object.entries(zusatz)) params.set(k, String(v));
try {
const data = await this.#json(`documents/?${params}`); const data = await this.#json(`documents/?${params}`);
return data?.results || []; return data?.results || [];
} catch {
return [];
}
} }
document(id) { document(id) {

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. // Leer = baseUrl verwenden.
shareBaseUrl: "", shareBaseUrl: "",
versionLabel: "Thunderbird {datum}", 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, shareDays: 7,
shareTag: "Sharelink", shareTag: "Sharelink",
// Beschriftung der eingefügten Linkblöcke. Leer lassen für keine // Beschriftung der eingefügten Linkblöcke. Leer lassen für keine

View File

@@ -2,13 +2,13 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extName__", "name": "__MSG_extName__",
"description": "__MSG_extDescription__", "description": "__MSG_extDescription__",
"version": "1.6.1", "version": "1.16.1",
"author": "Name des Herausgebers", "author": "Marco Morath",
"homepage_url": "https://example.org/paperless-thunderbird", "homepage_url": "https://gitea.cloud.mmorath.de/marco.morath/thunderbird-paperless-connector",
"default_locale": "de", "default_locale": "de",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "paperless@example.org", "id": "webmaster@mmorath.de",
"strict_min_version": "128.0" "strict_min_version": "128.0"
} }
}, },
@@ -16,6 +16,7 @@
"storage", "storage",
"menus", "menus",
"messagesRead", "messagesRead",
"accountsRead",
"compose", "compose",
"notifications" "notifications"
], ],

View File

@@ -1,44 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="classifyTitle">Klassifizierung bearbeiten</title>
<link rel="stylesheet" href="gemeinsam.css">
</head>
<body>
<h1 data-i18n="classifyTitle">Klassifizierung bearbeiten</h1>
<div id="quelle" class="hinweis"></div>
<label for="titel" data-i18n="fieldTitle">Titel</label>
<input type="text" id="titel">
<label for="erstellt" data-i18n="fieldCreated">Erstellt</label>
<input type="text" id="erstellt" placeholder="JJJJ-MM-TT">
<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>
<div class="wachsen">
<label data-i18n="fieldTags">Tags</label>
<div class="liste"><div id="tags" style="padding:4px 8px"></div></div>
</div>
<div class="hinweis" data-i18n="classifyHintNew">
Unbekannte Korrespondenten und Dokumenttypen werden angelegt.
</div>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="ueberspringen" data-i18n="btnSkip">Überspringen</button>
<button id="speichern" class="haupt" data-i18n="btnSave">Speichern</button>
</div>
<script type="module" src="classify.js"></script>
</body>
</html>

View File

@@ -1,109 +0,0 @@
/**
* classify.js - Klassifizierung eines gerade abgelegten Dokuments.
*
* Wird unmittelbar nach dem Ablegen aufgerufen. Ohne diesen Schritt trägt
* ein neues Dokument nur den Titel, und nachträglich macht man es
* erfahrungsgemäß nicht mehr.
*/
import { kurz } from "../lib/settings.js";
import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
from "./gemeinsam.js";
const $ = (id) => document.getElementById(id);
let dokumentId = null;
let alleTags = [];
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());
$("speichern").addEventListener("click", speichern);
try {
const [doc, stamm] = await Promise.all([
ruf("dokument", { id: dokumentId }),
ruf("stammdaten")
]);
alleTags = stamm.tags;
$("quelle").textContent =
`#${dokumentId} · ${kurz(doc.title, 70)}`
+ (weitere > 1
? ` · ${browser.i18n.getMessage("classifyMoreStored",
[String(weitere)]) || `${weitere} Dateien abgelegt`}`
: "");
$("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);
zeigeTags(doc.tags || []);
} catch (err) {
fehlerAnzeigen(err.message);
}
}
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(liste, id) {
return liste.find((x) => x.id === id)?.name || "";
}
function zeigeTags(gesetzt) {
const ziel = $("tags");
ziel.replaceChildren();
for (const tag of alleTags) {
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);
const bez = document.createElement("label");
bez.htmlFor = box.id;
bez.textContent = tag.name;
zeile.append(box, bez);
ziel.append(zeile);
}
}
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));
const payload = { title: $("titel").value.trim(), tags };
const erstellt = $("erstellt").value.trim();
if (erstellt) payload.created = erstellt;
await ruf("klassifizieren", {
id: dokumentId,
payload,
korrName: $("korr").value.trim(),
typName: $("typ").value.trim()
});
meldungAnzeigen(browser.i18n.getMessage("msgSaved") || "Gespeichert.");
setTimeout(() => window.close(), 900);
} catch (err) {
fehlerAnzeigen(err.message);
$("speichern").disabled = false;
}
}

View File

@@ -1,12 +1,21 @@
/* Gemeinsames Aussehen der Dialoge. Bewusst zurückhaltend, damit sie sich /* Gemeinsames Aussehen der Dialoge.
in die Oberfläche von Thunderbird einfügen statt sich abzuheben. */ Bewusst zurückhaltend, damit sie sich in die Oberfläche von
Thunderbird einfügen statt sich abzuheben. */
:root { :root {
--gruen: #175420; --gruen: #175420;
--rand: #c8ccd0; --rand: #c8ccd0;
--hell: #f6f6f4; --hell: #f6f6f4;
color-scheme: light dark; color-scheme: light dark;
} }
* { box-sizing: border-box; } * { 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 { body {
font: message-box; font: message-box;
font-size: 13px; font-size: 13px;
@@ -15,9 +24,16 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; 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; height: 100vh;
overflow: hidden;
} }
h1 { font-size: 14px; margin: 0 0 2px; font-weight: 600; } h1 { font-size: 14px; margin: 0 0 2px; font-weight: 600; }
fieldset { fieldset {
border: 1px solid var(--rand); border: 1px solid var(--rand);
border-radius: 4px; border-radius: 4px;
@@ -26,7 +42,9 @@ fieldset {
min-width: 0; min-width: 0;
} }
legend { padding: 0 4px; font-weight: 600; } legend { padding: 0 4px; font-weight: 600; }
label { display: block; margin: 4px 0 2px; } label { display: block; margin: 4px 0 2px; }
input[type=text], input[type=password], input[type=number], select { input[type=text], input[type=password], input[type=number], select {
width: 100%; width: 100%;
padding: 4px 6px; padding: 4px 6px;
@@ -35,10 +53,30 @@ input[type=text], input[type=password], input[type=number], select {
background: Field; background: Field;
color: FieldText; 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; } .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 { display: flex; gap: 6px; align-items: center; margin: 3px 0; }
.wahl input { width: auto; margin: 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 { button {
padding: 5px 14px; padding: 5px 14px;
border: 1px solid var(--rand); border: 1px solid var(--rand);
@@ -47,18 +85,28 @@ button {
color: ButtonText; color: ButtonText;
cursor: pointer; 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; } 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 { .liste {
border: 1px solid var(--rand); border: 1px solid var(--rand);
border-radius: 3px; border-radius: 3px;
overflow: auto; overflow: auto;
background: Field; background: Field;
flex: 1; 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, td { padding: 3px 8px; text-align: left; white-space: nowrap; }
th { th {
position: sticky; top: 0; position: sticky; top: 0;
@@ -69,6 +117,64 @@ th {
td.zahl, th.zahl { text-align: right; } td.zahl, th.zahl { text-align: right; }
tr.gewaehlt { background: SelectedItem; color: SelectedItemText; } tr.gewaehlt { background: SelectedItem; color: SelectedItemText; }
tbody tr { cursor: default; } tbody tr { cursor: default; }
.hinweis { color: GrayText; font-size: 12px; } .hinweis { color: GrayText; font-size: 12px; }
.fehler { color: #b32; } .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. * 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. * 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. * im HTML hinterlegte deutsche Text stehen.
*/ */
export function uebersetzeSeite() { 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]")) { for (const el of document.querySelectorAll("[data-i18n]")) {
const s = browser.i18n.getMessage(el.dataset.i18n); const s = browser.i18n.getMessage(el.dataset.i18n);
if (s) el.textContent = s; if (s) el.textContent = s;

View File

@@ -29,7 +29,7 @@
<div class="wachsen"> <div class="wachsen">
<label data-i18n="colVersions">Versionen</label> <label data-i18n="colVersions">Versionen</label>
<div class="liste" style="min-height:70px"> <div class="liste" >
<table> <table>
<thead><tr> <thead><tr>
<th class="zahl" data-i18n="colNo">Nr</th> <th class="zahl" data-i18n="colNo">Nr</th>
@@ -68,6 +68,7 @@
</div> </div>
<div class="hinweis" data-i18n="insertHintLink"> <div class="hinweis" data-i18n="insertHintLink">
Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt. Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt.
Leeres Tag-Feld bedeutet: keine Kennzeichnung setzen.
</div> </div>
</fieldset> </fieldset>

View File

@@ -46,7 +46,75 @@
<fieldset> <fieldset>
<legend data-i18n="optionsBehaviour">Verhalten</legend> <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 Bezeichnung neuer Versionen
</label> </label>
<input type="text" id="versionLabel"> <input type="text" id="versionLabel">

View File

@@ -14,12 +14,18 @@ import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const FELDER = ["baseUrl", "shareBaseUrl", "token", "versionLabel", const FELDER = ["baseUrl", "shareBaseUrl", "token", "versionLabel",
"shareDays", "shareTag", "ignorierteKleiner"]; "mailFormat", "pdfSchriftgroesse", "pdfFormat",
"pdfRandLinks", "pdfRandRechts", "pdfRandOben",
"pdfRandUnten", "shareDays", "shareTag",
"ignorierteKleiner"];
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
uebersetzeSeite(); uebersetzeSeite();
const s = await ladeEinstellungen(); const s = await ladeEinstellungen();
for (const f of FELDER) if ($(f)) $(f).value = s[f] ?? ""; for (const f of FELDER) if ($(f)) $(f).value = s[f] ?? "";
if ($("pdfAnhaengeEinbetten")) {
$("pdfAnhaengeEinbetten").checked = s.pdfAnhaengeEinbetten !== false;
}
$("speichern").addEventListener("click", speichern); $("speichern").addEventListener("click", speichern);
$("pruefen").addEventListener("click", pruefen); $("pruefen").addEventListener("click", pruefen);
await rechtAnzeigen(); await rechtAnzeigen();
@@ -56,6 +62,13 @@ async function rechtAnzeigen() {
: `Zugriffsrecht fehlt für ${origin} — auf Speichern klicken.`; : `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() { function gelesen() {
return { return {
baseUrl: $("baseUrl").value.trim().replace(/\/+$/, ""), baseUrl: $("baseUrl").value.trim().replace(/\/+$/, ""),
@@ -64,6 +77,15 @@ function gelesen() {
versionLabel: $("versionLabel").value.trim() || "Thunderbird {datum}", versionLabel: $("versionLabel").value.trim() || "Thunderbird {datum}",
shareDays: Number($("shareDays").value || 0), shareDays: Number($("shareDays").value || 0),
shareTag: $("shareTag").value.trim(), 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) 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> </div>
</fieldset> </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 id="meldung" class="hinweis"></div>
<div class="fuss"> <div class="fuss">
<button id="abbrechen" data-i18n="btnCancel">Abbrechen</button> <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> </div>
<script type="module" src="store.js"></script> <script type="module" src="store.js"></script>

View File

@@ -7,14 +7,14 @@
*/ */
import { groesse, kurz, titelAusBetreff } from "../lib/settings.js"; 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); const $ = (id) => document.getElementById(id);
let auftrag = null; let auftrag = null;
let einstellungen = null; let einstellungen = null;
let treffer = [];
let gewaehlterTreffer = null;
document.addEventListener("DOMContentLoaded", start); document.addEventListener("DOMContentLoaded", start);
@@ -31,7 +31,6 @@ async function start() {
quelleAnzeigen(); quelleAnzeigen();
dateienAnzeigen(); dateienAnzeigen();
vorbelegen();
verdrahten(); verdrahten();
} }
@@ -50,28 +49,35 @@ function quelleAnzeigen() {
* Darstellung der Nachricht, nicht zum Inhalt - wer sie alle mit * Darstellung der Nachricht, nicht zum Inhalt - wer sie alle mit
* archiviert, hat nach einem Jahr hunderte Logos im Bestand. * 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() { function dateienAnzeigen() {
const ziel = $("dateien"); const ziel = $("dateien");
ziel.replaceChildren(); ziel.replaceChildren();
if (auftrag.quelle === "nachricht") { if (auftrag.quelle === "nachricht") {
// Aufbau über createElement statt innerHTML. Hier stünde zwar nur const name = `${titelAusBetreff(auftrag.betreff)}.eml`;
// festes Markup, aber die Zusicherung "kein innerHTML" soll ohne ziel.append(dateiZeile({
// Ausnahme gelten - sonst muss man bei jeder Änderung neu prüfen, ob schluessel: "ganz",
// sich inzwischen ein Fremdwert eingeschlichen hat. beschriftung: name,
const zeile = document.createElement("div"); gewaehlt: true,
zeile.className = "wahl"; festGewaehlt: true,
const box = document.createElement("input"); pdfMoeglich: true,
box.type = "checkbox"; alsPdf: (einstellungen.mailFormat || "pdf") === "pdf",
box.id = "ganz"; hinweisPdf: browser.i18n.getMessage("artMessagePdf")
box.checked = true; || "als PDF aufbereiten"
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);
if (auftrag.dateien?.length) { if (auftrag.dateien?.length) {
const h = document.createElement("div"); const h = document.createElement("div");
@@ -81,6 +87,7 @@ function dateienAnzeigen() {
|| `Die ${auftrag.dateien.length} Anhänge bleiben darin enthalten.`; || `Die ${auftrag.dateien.length} Anhänge bleiben darin enthalten.`;
ziel.append(h); ziel.append(h);
} }
knopfPruefen();
return; return;
} }
@@ -88,125 +95,118 @@ function dateienAnzeigen() {
const uninteressant = const uninteressant =
einstellungen.ignorierteTypen.includes(d.contentType) einstellungen.ignorierteTypen.includes(d.contentType)
|| (d.size || 0) < einstellungen.ignorierteKleiner; || (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"); const zeile = document.createElement("div");
zeile.className = "wahl"; zeile.className = "dateizeile";
const box = document.createElement("input"); const box = document.createElement("input");
box.type = "checkbox"; box.type = "checkbox";
box.id = `d${i}`; box.id = o.schluessel;
box.checked = d.ausgewaehlt; box.checked = o.gewaehlt;
if (o.festGewaehlt) box.disabled = true;
box.addEventListener("change", () => { box.addEventListener("change", () => {
d.ausgewaehlt = box.checked; o.beiWahl?.(box.checked);
knopfPruefen(); 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() { const name = document.createElement("label");
$("titel").value = titelAusBetreff(auftrag.betreff); name.className = "name";
if (auftrag.datum) $("erstellt").value = auftrag.datum; name.htmlFor = box.id;
} name.textContent = o.beschriftung;
function verdrahten() { zeile.append(box, name);
for (const r of document.querySelectorAll('input[name=ziel]')) {
r.addEventListener("change", zielGewechselt); const art = document.createElement("div");
} art.className = "art";
$("suchen").addEventListener("click", suchen); const gruppe = `art_${o.schluessel}`;
$("suche").addEventListener("keydown", (e) => {
if (e.key === "Enter") suchen(); 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()); const l = document.createElement("label");
$("ablegen").addEventListener("click", ablegen); l.htmlFor = r.id;
zielGewechselt(); 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() { zeile.append(art);
const neu = $("zielNeu").checked; return zeile;
for (const id of ["titel", "erstellt"]) $(id).disabled = !neu;
for (const id of ["suche", "suchen"]) $(id).disabled = neu;
if (!neu && !treffer.length) suchen();
knopfPruefen();
} }
async function suchen() { /**
const liste = $("treffer"); * Weiter zum Schrittdialog.
liste.replaceChildren(); *
try { * Die Auswahl wird über einen neuen Auftrag übergeben - das Fenster
treffer = await ruf("suchen", { query: $("suche").value.trim() }); * schliesst sich, und der Schrittdialog geht die Dateien einzeln durch.
} catch (err) { */
fehlerAnzeigen(err.message); async function weiter() {
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() {
$("ablegen").disabled = true; $("ablegen").disabled = true;
$("meldung").className = "hinweis"; meldungAnzeigen(browser.i18n.getMessage("msgOpeningStep")
$("meldung").textContent = || "Wird vorbereitet …");
browser.i18n.getMessage("msgUploading") || "Wird abgelegt …";
try { try {
const ergebnisse = await ruf("ablegen", { await ruf("schrittOeffnen", {
auftrag, auftrag: {
ziel: $("zielNeu").checked ? "neu" : "vorhanden", ...auftrag,
zielId: gewaehlterTreffer?.id, titelVorschlag: titelAusBetreff(auftrag.betreff),
titel: $("titel").value.trim(), alsPdf: auftrag.quelle === "nachricht"
erstellt: $("erstellt").value.trim() ? $("art_ganz_pdf")?.checked ?? true
}); : undefined
if ($("klassifizieren").checked && ergebnisse.length) {
// Beim Anhängen an ein vorhandenes Dokument gilt dessen
// Klassifizierung - dann ist die Wurzel gemeint, nicht die neue
// Version.
const erstes = ergebnisse[0];
const id = erstes.art === "version" ? erstes.zielId : erstes.id;
location.href = `classify.html?id=${id}&weiter=${ergebnisse.length}`;
return;
} }
});
$("meldung").textContent = window.close();
browser.i18n.getMessage("msgStored", [String(ergebnisse.length)])
|| `${ergebnisse.length} abgelegt.`;
setTimeout(() => window.close(), 1200);
} catch (err) { } catch (err) {
fehlerAnzeigen(err.message); fehlerAnzeigen(err.message);
$("ablegen").disabled = false; $("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;
}