Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e9d62cf95 | |||
| 8380dc1584 | |||
| 01a972d719 | |||
| 3f96e6f49d | |||
| d032e3b19c | |||
| fd87813cbe | |||
| 00ca39609f |
157
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Paperless-ngx für Thunderbird / Betterbird
|
# Paperless-Connector für Thunderbird / Betterbird
|
||||||
|
|
||||||
Legt Anhänge und ganze E-Mails in einem Paperless-ngx-Archiv ab und fügt
|
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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 …"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 …"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 …"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
612
background.js
@@ -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(/ /gi, " ").replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<").replace(/>/gi, ">")
|
||||||
|
.replace(/"/gi, '"').replace(/'/gi, "'")
|
||||||
|
.replace(/\n{3,}/g, "\n\n");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nachricht als PDF aufbereiten.
|
||||||
|
*
|
||||||
|
* Das Original und alle Anhänge werden im PDF eingebettet - es geht also
|
||||||
|
* nichts verloren, auch wenn die Darstellung nicht originalgetreu ist.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Adresse des Kontos, in dem die Nachricht liegt.
|
||||||
|
*
|
||||||
|
* Bei Nachrichten an Blindkopie-Empfänger geht aus den Kopfzeilen nicht
|
||||||
|
* hervor, welches Konto sie empfangen hat. Im Archiv ist das später die
|
||||||
|
* entscheidende Angabe.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Adresse des Kontos, in dem die Nachricht liegt.
|
||||||
|
*
|
||||||
|
* Bei Nachrichten an Blindkopie-Empfänger geht aus An und Kopie nicht
|
||||||
|
* hervor, welches Konto sie empfangen hat. Im Archiv ist das später die
|
||||||
|
* entscheidende Angabe.
|
||||||
|
*
|
||||||
|
* Zwei Wege, weil der erste eine Berechtigung braucht:
|
||||||
|
*
|
||||||
|
* 1. accounts-Schnittstelle. Genau, aber accountsRead ist nötig - und
|
||||||
|
* dieselbe Berechtigung entscheidet auch darüber, ob messages.get()
|
||||||
|
* überhaupt eine Ordnerangabe liefert.
|
||||||
|
* 2. Zustellkopfzeilen. Delivered-To und Verwandte tragen die Adresse,
|
||||||
|
* an die tatsächlich zugestellt wurde. Sie sind nicht immer
|
||||||
|
* vorhanden und stammen vom zustellenden Server, reichen aber ohne
|
||||||
|
* zusätzliche Berechtigung aus.
|
||||||
|
*/
|
||||||
|
async function kontoAdresse(kopf, messageId) {
|
||||||
|
const ueberAccounts = await kontoUeberAccounts(kopf);
|
||||||
|
if (ueberAccounts) return ueberAccounts;
|
||||||
|
return kontoUeberKopfzeilen(messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kontoUeberAccounts(kopf) {
|
||||||
|
if (!browser.accounts?.get) {
|
||||||
|
console.debug("[paperless] accounts-Schnittstelle nicht verfügbar - "
|
||||||
|
+ "Berechtigung accountsRead fehlt. Ausweichweg über "
|
||||||
|
+ "die Zustellkopfzeilen.");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Kontokennung steckt je nach Version an verschiedenen Stellen.
|
||||||
|
const kandidaten = [
|
||||||
|
kopf?.folder?.accountId,
|
||||||
|
kopf?.folder?.id,
|
||||||
|
typeof kopf?.folder === "string" ? kopf.folder : null,
|
||||||
|
kopf?.accountId
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const kennungen = [];
|
||||||
|
for (const k of kandidaten) {
|
||||||
|
kennungen.push(String(k));
|
||||||
|
const m = /^([^:/]+)/.exec(String(k));
|
||||||
|
if (m && m[1] !== String(k)) kennungen.push(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const kennung of kennungen) {
|
||||||
|
try {
|
||||||
|
const konto = await browser.accounts.get(kennung);
|
||||||
|
const adresse = adresseAusKonto(konto);
|
||||||
|
if (adresse) return adresse;
|
||||||
|
} catch { /* nächster Kandidat */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gibt es nur ein Konto, ist die Zuordnung ohnehin eindeutig.
|
||||||
|
try {
|
||||||
|
const alle = await browser.accounts.list(false);
|
||||||
|
if (alle?.length === 1) return adresseAusKonto(alle[0]);
|
||||||
|
} catch { /* dann eben nicht */ }
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function adresseAusKonto(konto) {
|
||||||
|
if (!konto) return "";
|
||||||
|
const ident = konto.identities || [];
|
||||||
|
const erste = ident.find((i) => i.email) || ident[0];
|
||||||
|
if (erste?.email) {
|
||||||
|
return erste.name ? `${erste.name} <${erste.email}>` : erste.email;
|
||||||
|
}
|
||||||
|
return konto.name || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empfangsadresse aus den Zustellkopfzeilen.
|
||||||
|
*
|
||||||
|
* Diese Kopfzeilen setzt der zustellende Server. Delivered-To ist die
|
||||||
|
* verbreitetste; X-Original-To und Envelope-To kommen bei anderen
|
||||||
|
* Servern vor.
|
||||||
|
*/
|
||||||
|
async function kontoUeberKopfzeilen(messageId) {
|
||||||
|
if (!messageId || !browser.messages?.getFull) return "";
|
||||||
|
try {
|
||||||
|
const voll = await browser.messages.getFull(messageId);
|
||||||
|
const h = voll?.headers || {};
|
||||||
|
for (const name of ["delivered-to", "x-original-to", "envelope-to",
|
||||||
|
"x-envelope-to", "x-rcpt-to"]) {
|
||||||
|
const wert = h[name];
|
||||||
|
if (Array.isArray(wert) && wert.length && String(wert[0]).trim()) {
|
||||||
|
return String(wert[0]).trim();
|
||||||
|
}
|
||||||
|
if (typeof wert === "string" && wert.trim()) return wert.trim();
|
||||||
|
}
|
||||||
|
console.debug("[paperless] keine Zustellkopfzeile gefunden. Vorhanden:",
|
||||||
|
Object.keys(h).join(", "));
|
||||||
|
} catch (err) {
|
||||||
|
console.debug("[paperless] Kopfzeilen nicht lesbar:", err?.message);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nachrichtAlsPdf(auftrag, s) {
|
||||||
|
const id = auftrag.messageId;
|
||||||
|
const kopf = await browser.messages.get(id).catch(() => ({}));
|
||||||
|
const text = await nachrichtentext(id);
|
||||||
|
const konto = await kontoAdresse(kopf, id);
|
||||||
|
|
||||||
|
const dateien = [];
|
||||||
|
const aufstellung = [];
|
||||||
|
|
||||||
|
// Das Original zuerst - es ist der wichtigste Anhang.
|
||||||
|
try {
|
||||||
|
const roh = await browser.messages.getRaw(id);
|
||||||
|
dateien.push({
|
||||||
|
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.eml`),
|
||||||
|
daten: new TextEncoder().encode(roh),
|
||||||
|
mime: "message/rfc822",
|
||||||
|
beschreibung: "Originalnachricht"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[paperless] getRaw fehlgeschlagen", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dann die Anhänge, sofern gewünscht.
|
||||||
|
if (s.pdfAnhaengeEinbetten !== false) {
|
||||||
|
for (const a of auftrag.dateien || []) {
|
||||||
|
aufstellung.push({ name: a.name, groesse: a.size });
|
||||||
|
try {
|
||||||
|
const datei = await browser.messages.getAttachmentFile(
|
||||||
|
a.messageId, a.partName);
|
||||||
|
dateien.push({
|
||||||
|
name: sichererName(a.name),
|
||||||
|
daten: new Uint8Array(await datei.arrayBuffer()),
|
||||||
|
mime: a.contentType || "application/octet-stream",
|
||||||
|
beschreibung: "Anhang der Nachricht"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[paperless] Anhang nicht lesbar:", a.name, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const a of auftrag.dateien || []) {
|
||||||
|
aufstellung.push({ name: a.name, groesse: a.size });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdf = baueMailPdf({
|
||||||
|
betreff: auftrag.betreff || kopf.subject || "",
|
||||||
|
konto,
|
||||||
|
von: kopf.author || auftrag.absender || "",
|
||||||
|
an: alsEmpfaenger(kopf.recipients),
|
||||||
|
kopie: alsEmpfaenger(kopf.ccList),
|
||||||
|
datum: auftrag.zeitpunkt || auftrag.datum || "",
|
||||||
|
text,
|
||||||
|
anhaenge: aufstellung,
|
||||||
|
dateien
|
||||||
|
}, {
|
||||||
|
schrift: Number(s.pdfSchriftgroesse) || 11,
|
||||||
|
format: s.pdfFormat || "a4",
|
||||||
|
rand: {
|
||||||
|
links: Number(s.pdfRandLinks ?? 20),
|
||||||
|
rechts: Number(s.pdfRandRechts ?? 10),
|
||||||
|
oben: Number(s.pdfRandOben ?? 10),
|
||||||
|
unten: Number(s.pdfRandUnten ?? 10)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob: new Blob([pdf], { type: "application/pdf" }),
|
||||||
|
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.pdf`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function datenHolen(auftrag, s) {
|
||||||
|
if (auftrag.quelle === "nachricht") {
|
||||||
|
// Als PDF aufbereiten oder im Rohformat ablegen - beides hat seine
|
||||||
|
// Berechtigung. Das Rohformat ist verlustfrei und wieder in ein
|
||||||
|
// Mailprogramm einlesbar, das PDF dafür lesbar und durchsuchbar.
|
||||||
|
// Die Auswahl aus dem Dialog hat Vorrang vor der Voreinstellung.
|
||||||
|
const alsPdf = auftrag.alsPdf !== undefined
|
||||||
|
? Boolean(auftrag.alsPdf)
|
||||||
|
: (s.mailFormat || "pdf") === "pdf";
|
||||||
|
if (alsPdf) {
|
||||||
|
try {
|
||||||
|
return [await nachrichtAlsPdf(auftrag, s)];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[paperless] PDF-Aufbereitung gescheitert", err);
|
||||||
|
// Nicht abbrechen: lieber die Rohfassung ablegen als gar nichts.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const roh = await browser.messages.getRaw(auftrag.messageId);
|
||||||
|
return [{
|
||||||
|
blob: new Blob([roh], { type: "message/rfc822" }),
|
||||||
|
name: sichererName(`${titelAusBetreff(auftrag.betreff)}.eml`)
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
const out = [];
|
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) {
|
||||||
|
|||||||
69
build.sh
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
Before Width: | Height: | Size: 885 B After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 680 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 7.5 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 883 B |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 889 B After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
105
lib/client.js
@@ -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
@@ -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
@@ -0,0 +1,470 @@
|
|||||||
|
/**
|
||||||
|
* pdf.js - Schlanker PDF-Erzeuger für Textseiten mit eingebetteten Dateien.
|
||||||
|
*
|
||||||
|
* Warum selbst geschrieben statt einer fertigen Bibliothek: Eine
|
||||||
|
* Erweiterung soll nichts nachladen, und ein mitgeliefertes pdf-lib wäre
|
||||||
|
* rund ein Megabyte für einen Bruchteil seiner Fähigkeiten. Hier werden
|
||||||
|
* nur Text und Anhänge gebraucht.
|
||||||
|
*
|
||||||
|
* Erzeugt wird PDF 1.7 mit den eingebauten Schriften Helvetica und
|
||||||
|
* Helvetica-Bold. Die sind in jedem Betrachter vorhanden und müssen nicht
|
||||||
|
* eingebettet werden.
|
||||||
|
*
|
||||||
|
* AUSDRÜCKLICH KEIN PDF/A. Dafür wären eingebettete Schriftschnitte, ein
|
||||||
|
* Farbprofil und XMP-Metadaten nötig - und vor allem eine Prüfung mit
|
||||||
|
* veraPDF, die hier niemand durchführen kann. Ein Dokument als PDF/A zu
|
||||||
|
* bezeichnen, ohne es geprüft zu haben, wäre eine leere Behauptung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Millimeter in Punkte. Ein Punkt ist 1/72 Zoll. */
|
||||||
|
export function mm(wert) {
|
||||||
|
return (Number(wert) || 0) * 72 / 25.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Papierformate in Millimetern.
|
||||||
|
*
|
||||||
|
* A4 ist ausserhalb Nordamerikas der Regelfall, Letter und Legal dort.
|
||||||
|
* A5 ist selten, aber bei kurzen Nachrichten sparsam im Ausdruck.
|
||||||
|
*/
|
||||||
|
export const FORMATE = {
|
||||||
|
a4: { name: "A4", breite: 210, hoehe: 297 },
|
||||||
|
a5: { name: "A5", breite: 148, hoehe: 210 },
|
||||||
|
letter: { name: "Letter", breite: 215.9, hoehe: 279.4 },
|
||||||
|
legal: { name: "Legal", breite: 215.9, hoehe: 355.6 }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rückwärtskompatible Vorgaben in Punkten
|
||||||
|
export const SEITE_BREITE = mm(FORMATE.a4.breite);
|
||||||
|
export const SEITE_HOEHE = mm(FORMATE.a4.hoehe);
|
||||||
|
|
||||||
|
// Zeichenbreiten der Standardschriften, in Tausendstel der Schriftgröße.
|
||||||
|
// Aus den AFM-Dateien von Adobe. Sie werden nur zum Umbrechen gebraucht -
|
||||||
|
// eine kleine Abweichung verschiebt den Umbruch, verfälscht aber nichts.
|
||||||
|
const BREITE_HELVETICA = {
|
||||||
|
" ": 278, "!": 278, '"': 355, "#": 556, $: 556, "%": 889, "&": 667,
|
||||||
|
"'": 191, "(": 333, ")": 333, "*": 389, "+": 584, ",": 278, "-": 333,
|
||||||
|
".": 278, "/": 278, ":": 278, ";": 278, "<": 584, "=": 584, ">": 584,
|
||||||
|
"?": 556, "@": 1015, "[": 278, "\\": 278, "]": 278, "^": 469, _: 556,
|
||||||
|
"`": 333, "{": 334, "|": 260, "}": 334, "~": 584,
|
||||||
|
A: 667, B: 667, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278,
|
||||||
|
J: 500, K: 667, L: 556, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722,
|
||||||
|
S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611,
|
||||||
|
a: 556, b: 556, c: 500, d: 556, e: 556, f: 278, g: 556, h: 556, i: 222,
|
||||||
|
j: 222, k: 500, l: 222, m: 833, n: 556, o: 556, p: 556, q: 556, r: 333,
|
||||||
|
s: 500, t: 278, u: 556, v: 500, w: 722, x: 500, y: 500, z: 500,
|
||||||
|
"0": 556, "1": 556, "2": 556, "3": 556, "4": 556, "5": 556, "6": 556,
|
||||||
|
"7": 556, "8": 556, "9": 556,
|
||||||
|
"ä": 556, "ö": 556, "ü": 556, "Ä": 667, "Ö": 778, "Ü": 722, "ß": 556,
|
||||||
|
"é": 556, "è": 556, "à": 556, "ç": 500, "–": 556, "—": 1000, "€": 556
|
||||||
|
};
|
||||||
|
|
||||||
|
const BREITE_FETT = {
|
||||||
|
...BREITE_HELVETICA,
|
||||||
|
A: 722, B: 722, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278,
|
||||||
|
J: 556, K: 722, L: 611, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722,
|
||||||
|
S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611,
|
||||||
|
a: 556, b: 611, c: 556, d: 611, e: 556, f: 333, g: 611, h: 611, i: 278,
|
||||||
|
j: 278, k: 556, l: 278, m: 889, n: 611, o: 611, p: 611, q: 611, r: 389,
|
||||||
|
s: 556, t: 333, u: 611, v: 556, w: 778, x: 556, y: 556, z: 500,
|
||||||
|
"'": 238, "-": 333, ":": 333, ";": 333, "(": 333, ")": 333
|
||||||
|
};
|
||||||
|
|
||||||
|
const BREITE_ERSATZ = 556;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichen, die in WinAnsiEncoding an anderer Stelle stehen als in
|
||||||
|
* Latin-1. PDF-Betrachter erwarten diese Belegung; ohne die Umsetzung
|
||||||
|
* erschiene etwa das Eurozeichen als Steuerzeichen.
|
||||||
|
*/
|
||||||
|
const WINANSI_SONDER = {
|
||||||
|
"\u20AC": 0x80, "\u201A": 0x82, "\u0192": 0x83, "\u201E": 0x84,
|
||||||
|
"\u2026": 0x85, "\u2020": 0x86, "\u2021": 0x87, "\u02C6": 0x88,
|
||||||
|
"\u2030": 0x89, "\u0160": 0x8A, "\u2039": 0x8B, "\u0152": 0x8C,
|
||||||
|
"\u017D": 0x8E, "\u2018": 0x91, "\u2019": 0x92, "\u201C": 0x93,
|
||||||
|
"\u201D": 0x94, "\u2022": 0x95, "\u2013": 0x96, "\u2014": 0x97,
|
||||||
|
"\u02DC": 0x98, "\u2122": 0x99, "\u0161": 0x9A, "\u203A": 0x9B,
|
||||||
|
"\u0153": 0x9C, "\u017E": 0x9E, "\u0178": 0x9F
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Breite einer Zeichenkette in Punkten. */
|
||||||
|
export function textbreite(text, groesse, fett = false) {
|
||||||
|
const tabelle = fett ? BREITE_FETT : BREITE_HELVETICA;
|
||||||
|
let summe = 0;
|
||||||
|
for (const z of String(text)) {
|
||||||
|
summe += tabelle[z] ?? BREITE_ERSATZ;
|
||||||
|
}
|
||||||
|
return (summe * groesse) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeichenkette in eine PDF-Literalzeichenkette überführen.
|
||||||
|
*
|
||||||
|
* Zeichen ausserhalb von WinAnsi werden durch ein Fragezeichen ersetzt.
|
||||||
|
* Das betrifft in der Praxis fast nur Emojis - dafür wäre eine
|
||||||
|
* eingebettete Schrift nötig.
|
||||||
|
*/
|
||||||
|
function pdfText(text) {
|
||||||
|
const bytes = [];
|
||||||
|
for (const z of String(text)) {
|
||||||
|
let code;
|
||||||
|
if (WINANSI_SONDER[z] !== undefined) {
|
||||||
|
code = WINANSI_SONDER[z];
|
||||||
|
} else {
|
||||||
|
const cp = z.codePointAt(0);
|
||||||
|
code = cp <= 0xFF ? cp : 0x3F; // Fragezeichen
|
||||||
|
}
|
||||||
|
if (code === 0x28 || code === 0x29 || code === 0x5C) {
|
||||||
|
bytes.push(0x5C); // Rückstrich voranstellen
|
||||||
|
}
|
||||||
|
bytes.push(code);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text in Zeilen umbrechen, die in die angegebene Breite passen. */
|
||||||
|
export function umbrechen(text, breite, groesse, fett = false) {
|
||||||
|
const zeilen = [];
|
||||||
|
for (const absatz of String(text ?? "").split(/\r?\n/)) {
|
||||||
|
if (!absatz.trim()) {
|
||||||
|
zeilen.push("");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let zeile = "";
|
||||||
|
for (const wort of absatz.split(/ +/)) {
|
||||||
|
const versuch = zeile ? `${zeile} ${wort}` : wort;
|
||||||
|
if (textbreite(versuch, groesse, fett) <= breite || !zeile) {
|
||||||
|
// Ein einzelnes zu langes Wort wird hart getrennt - sonst liefe
|
||||||
|
// es über den Rand hinaus.
|
||||||
|
if (!zeile && textbreite(wort, groesse, fett) > breite) {
|
||||||
|
let rest = wort;
|
||||||
|
while (textbreite(rest, groesse, fett) > breite) {
|
||||||
|
let n = 1;
|
||||||
|
while (n < rest.length
|
||||||
|
&& textbreite(rest.slice(0, n + 1), groesse, fett) <= breite) {
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
zeilen.push(rest.slice(0, n));
|
||||||
|
rest = rest.slice(n);
|
||||||
|
}
|
||||||
|
zeile = rest;
|
||||||
|
} else {
|
||||||
|
zeile = versuch;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
zeilen.push(zeile);
|
||||||
|
zeile = wort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zeilen.push(zeile);
|
||||||
|
}
|
||||||
|
return zeilen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Aufbau
|
||||||
|
|
||||||
|
export class PdfDokument {
|
||||||
|
/**
|
||||||
|
* Seitengröße und Ränder werden in PUNKTEN erwartet. Für die Umrechnung
|
||||||
|
* aus Millimetern gibt es mm().
|
||||||
|
*/
|
||||||
|
constructor({ titel = "", autor = "", betreff = "",
|
||||||
|
breite = SEITE_BREITE, hoehe = SEITE_HOEHE,
|
||||||
|
randLinks = mm(20), randRechts = mm(10),
|
||||||
|
randOben = mm(10), randUnten = mm(10) } = {}) {
|
||||||
|
this.titel = titel;
|
||||||
|
this.autor = autor;
|
||||||
|
this.betreff = betreff;
|
||||||
|
this.breite = breite;
|
||||||
|
this.hoehe = hoehe;
|
||||||
|
this.randLinks = randLinks;
|
||||||
|
this.randRechts = randRechts;
|
||||||
|
this.randOben = randOben;
|
||||||
|
this.randUnten = randUnten;
|
||||||
|
this.seiten = []; // je Seite ein Feld von Byte-Stücken
|
||||||
|
this.anhaenge = []; // {name, daten, beschreibung, mime}
|
||||||
|
this.aktuell = null;
|
||||||
|
this.y = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linker Rand. Der Name bleibt kurz, weil er oft gebraucht wird. */
|
||||||
|
get rand() {
|
||||||
|
return this.randLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
get innenbreite() {
|
||||||
|
return this.breite - this.randLinks - this.randRechts;
|
||||||
|
}
|
||||||
|
|
||||||
|
neueSeite() {
|
||||||
|
this.aktuell = [];
|
||||||
|
this.seiten.push(this.aktuell);
|
||||||
|
this.y = this.hoehe - this.randOben;
|
||||||
|
return this.aktuell;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Platz für die nächsten Zeilen sichern, notfalls umblättern. */
|
||||||
|
platzPruefen(hoehe) {
|
||||||
|
if (!this.aktuell) this.neueSeite();
|
||||||
|
if (this.y - hoehe < this.randUnten) this.neueSeite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Textzeile setzen. Gibt die neue Schreibhöhe zurück.
|
||||||
|
*/
|
||||||
|
zeile(text, { groesse = 10, fett = false, einzug = 0,
|
||||||
|
abstand = 1.35, farbe = null } = {}) {
|
||||||
|
const hoehe = groesse * abstand;
|
||||||
|
this.platzPruefen(hoehe);
|
||||||
|
const x = this.rand + einzug;
|
||||||
|
const teile = [];
|
||||||
|
if (farbe) {
|
||||||
|
teile.push(`${farbe[0]} ${farbe[1]} ${farbe[2]} rg\n`);
|
||||||
|
}
|
||||||
|
teile.push("BT\n");
|
||||||
|
teile.push(`/${fett ? "F2" : "F1"} ${groesse} Tf\n`);
|
||||||
|
teile.push(`1 0 0 1 ${x.toFixed(2)} ${(this.y - groesse).toFixed(2)} Tm\n`);
|
||||||
|
this.aktuell.push(...teile.map((t) => new TextEncoder().encode(t)));
|
||||||
|
this.aktuell.push(new Uint8Array([0x28])); // (
|
||||||
|
this.aktuell.push(new Uint8Array(pdfText(text)));
|
||||||
|
this.aktuell.push(new TextEncoder().encode(") Tj\nET\n"));
|
||||||
|
if (farbe) {
|
||||||
|
this.aktuell.push(new TextEncoder().encode("0 0 0 rg\n"));
|
||||||
|
}
|
||||||
|
this.y -= hoehe;
|
||||||
|
return this.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mehrere Zeilen mit automatischem Umbruch. */
|
||||||
|
absatz(text, opt = {}) {
|
||||||
|
const groesse = opt.groesse ?? 10;
|
||||||
|
const einzug = opt.einzug ?? 0;
|
||||||
|
const breite = this.innenbreite - einzug;
|
||||||
|
for (const z of umbrechen(text, breite, groesse, opt.fett)) {
|
||||||
|
this.zeile(z, opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text an einer festen Stelle setzen, ohne die Schreibhöhe zu ändern.
|
||||||
|
*
|
||||||
|
* Wird für Spaltensatz gebraucht: Beschriftung und Wert stehen auf
|
||||||
|
* derselben Grundlinie, aber an verschiedenen Stellen.
|
||||||
|
*/
|
||||||
|
textAn(x, grundlinie, text, { groesse = 10, fett = false,
|
||||||
|
farbe = null } = {}) {
|
||||||
|
if (!this.aktuell) this.neueSeite();
|
||||||
|
const teile = [];
|
||||||
|
if (farbe) teile.push(`${farbe[0]} ${farbe[1]} ${farbe[2]} rg\n`);
|
||||||
|
teile.push("BT\n");
|
||||||
|
teile.push(`/${fett ? "F2" : "F1"} ${groesse} Tf\n`);
|
||||||
|
teile.push(`1 0 0 1 ${x.toFixed(2)} ${grundlinie.toFixed(2)} Tm\n`);
|
||||||
|
this.aktuell.push(...teile.map((t) => new TextEncoder().encode(t)));
|
||||||
|
this.aktuell.push(new Uint8Array([0x28]));
|
||||||
|
this.aktuell.push(new Uint8Array(pdfText(text)));
|
||||||
|
this.aktuell.push(new TextEncoder().encode(") Tj\nET\n"));
|
||||||
|
if (farbe) this.aktuell.push(new TextEncoder().encode("0 0 0 rg\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gefülltes Rechteck.
|
||||||
|
*
|
||||||
|
* Muss VOR dem Text gezeichnet werden - im Inhaltsstrom gilt die
|
||||||
|
* Reihenfolge, Späteres liegt oben.
|
||||||
|
*/
|
||||||
|
rechteck(x, y, breite, hoehe, grau = 0.95) {
|
||||||
|
if (!this.aktuell) this.neueSeite();
|
||||||
|
const s = `${grau} g ${x.toFixed(2)} ${y.toFixed(2)} `
|
||||||
|
+ `${breite.toFixed(2)} ${hoehe.toFixed(2)} re f 0 g\n`;
|
||||||
|
this.aktuell.push(new TextEncoder().encode(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waagerechte Linie. */
|
||||||
|
linie(dicke = 0.6, grau = 0.75) {
|
||||||
|
this.platzPruefen(8);
|
||||||
|
this.y -= 4;
|
||||||
|
const s = `${grau} G ${dicke} w ${this.randLinks.toFixed(2)} `
|
||||||
|
+ `${this.y.toFixed(2)} m `
|
||||||
|
+ `${(this.breite - this.randRechts).toFixed(2)} `
|
||||||
|
+ `${this.y.toFixed(2)} l S\n`;
|
||||||
|
this.aktuell.push(new TextEncoder().encode(s));
|
||||||
|
this.y -= 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstand(punkte) {
|
||||||
|
this.platzPruefen(punkte);
|
||||||
|
this.y -= punkte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datei an das Dokument anhängen.
|
||||||
|
*
|
||||||
|
* daten muss ein Uint8Array sein. Der Name wird im Anhangsbereich des
|
||||||
|
* Betrachters angezeigt.
|
||||||
|
*/
|
||||||
|
anhang(name, daten, { beschreibung = "", mime = "application/octet-stream" } = {}) {
|
||||||
|
this.anhaenge.push({
|
||||||
|
name: String(name),
|
||||||
|
daten: daten instanceof Uint8Array ? daten : new Uint8Array(daten),
|
||||||
|
beschreibung: String(beschreibung),
|
||||||
|
mime: String(mime)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Ausgabe
|
||||||
|
|
||||||
|
bauen() {
|
||||||
|
if (!this.seiten.length) this.neueSeite();
|
||||||
|
|
||||||
|
const enc = (s) => new TextEncoder().encode(s);
|
||||||
|
const objekte = []; // 1-basiert, Index 0 bleibt leer
|
||||||
|
const neu = (inhalt) => { objekte.push(inhalt); return objekte.length; };
|
||||||
|
|
||||||
|
// Reihenfolge festlegen, damit Verweise bekannt sind
|
||||||
|
const katalogNr = 1;
|
||||||
|
const seitenbaumNr = 2;
|
||||||
|
const infoNr = 3;
|
||||||
|
const f1Nr = 4;
|
||||||
|
const f2Nr = 5;
|
||||||
|
objekte.push(null, null, null, null, null); // Plätze 1..5
|
||||||
|
|
||||||
|
// Seiteninhalte und Seitenobjekte
|
||||||
|
const seitenNr = [];
|
||||||
|
for (const stuecke of this.seiten) {
|
||||||
|
const inhalt = verbinden(stuecke);
|
||||||
|
const stromNr = neu({
|
||||||
|
kopf: `<< /Length ${inhalt.length} >>`,
|
||||||
|
strom: inhalt
|
||||||
|
});
|
||||||
|
const seiteNr = neu({
|
||||||
|
kopf: `<< /Type /Page /Parent ${seitenbaumNr} 0 R `
|
||||||
|
+ `/MediaBox [0 0 ${this.breite.toFixed(2)} `
|
||||||
|
+ `${this.hoehe.toFixed(2)}] `
|
||||||
|
+ `/Resources << /Font << /F1 ${f1Nr} 0 R /F2 ${f2Nr} 0 R >> >> `
|
||||||
|
+ `/Contents ${stromNr} 0 R >>`
|
||||||
|
});
|
||||||
|
seitenNr.push(seiteNr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anhänge
|
||||||
|
const namensPaare = [];
|
||||||
|
for (const a of this.anhaenge) {
|
||||||
|
const stromNr = neu({
|
||||||
|
kopf: `<< /Type /EmbeddedFile /Subtype ${nameEscape(a.mime)} `
|
||||||
|
+ `/Length ${a.daten.length} /Params << /Size ${a.daten.length} >> >>`,
|
||||||
|
strom: a.daten
|
||||||
|
});
|
||||||
|
const specNr = neu({
|
||||||
|
kopf: `<< /Type /Filespec /F (${roh(a.name)}) /UF (${roh(a.name)}) `
|
||||||
|
+ (a.beschreibung ? `/Desc (${roh(a.beschreibung)}) ` : "")
|
||||||
|
+ `/AFRelationship /Source /EF << /F ${stromNr} 0 R >> >>`
|
||||||
|
});
|
||||||
|
namensPaare.push(`(${roh(a.name)}) ${specNr} 0 R`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let namenNr = 0;
|
||||||
|
if (namensPaare.length) {
|
||||||
|
namenNr = neu({
|
||||||
|
kopf: `<< /EmbeddedFiles << /Names [ ${namensPaare.join(" ")} ] >> >>`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feste Objekte nachtragen
|
||||||
|
objekte[katalogNr - 1] = {
|
||||||
|
kopf: `<< /Type /Catalog /Pages ${seitenbaumNr} 0 R`
|
||||||
|
+ (namenNr ? ` /Names ${namenNr} 0 R /PageMode /UseAttachments` : "")
|
||||||
|
+ " >>"
|
||||||
|
};
|
||||||
|
objekte[seitenbaumNr - 1] = {
|
||||||
|
kopf: `<< /Type /Pages /Count ${seitenNr.length} `
|
||||||
|
+ `/Kids [ ${seitenNr.map((n) => `${n} 0 R`).join(" ")} ] >>`
|
||||||
|
};
|
||||||
|
objekte[infoNr - 1] = {
|
||||||
|
kopf: "<< " + [
|
||||||
|
this.titel ? `/Title (${roh(this.titel)})` : "",
|
||||||
|
this.autor ? `/Author (${roh(this.autor)})` : "",
|
||||||
|
this.betreff ? `/Subject (${roh(this.betreff)})` : "",
|
||||||
|
"/Producer (Paperless-Connector)",
|
||||||
|
`/CreationDate (${pdfDatum(new Date())})`
|
||||||
|
].filter(Boolean).join(" ") + " >>"
|
||||||
|
};
|
||||||
|
objekte[f1Nr - 1] = {
|
||||||
|
kopf: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica "
|
||||||
|
+ "/Encoding /WinAnsiEncoding >>"
|
||||||
|
};
|
||||||
|
objekte[f2Nr - 1] = {
|
||||||
|
kopf: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold "
|
||||||
|
+ "/Encoding /WinAnsiEncoding >>"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zusammensetzen
|
||||||
|
const teile = [enc("%PDF-1.7\n%\xE2\xE3\xCF\xD3\n")];
|
||||||
|
let pos = teile[0].length;
|
||||||
|
const versatz = [0];
|
||||||
|
|
||||||
|
for (let i = 0; i < objekte.length; i += 1) {
|
||||||
|
const o = objekte[i];
|
||||||
|
versatz.push(pos);
|
||||||
|
const kopf = enc(`${i + 1} 0 obj\n${o.kopf}\n`);
|
||||||
|
teile.push(kopf);
|
||||||
|
pos += kopf.length;
|
||||||
|
if (o.strom) {
|
||||||
|
const a = enc("stream\n");
|
||||||
|
teile.push(a, o.strom, enc("\nendstream\n"));
|
||||||
|
pos += a.length + o.strom.length + 11;
|
||||||
|
}
|
||||||
|
const ende = enc("endobj\n");
|
||||||
|
teile.push(ende);
|
||||||
|
pos += ende.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xrefPos = pos;
|
||||||
|
let xref = `xref\n0 ${objekte.length + 1}\n0000000000 65535 f \n`;
|
||||||
|
for (let i = 1; i <= objekte.length; i += 1) {
|
||||||
|
xref += `${String(versatz[i]).padStart(10, "0")} 00000 n \n`;
|
||||||
|
}
|
||||||
|
xref += `trailer\n<< /Size ${objekte.length + 1} `
|
||||||
|
+ `/Root ${katalogNr} 0 R /Info ${infoNr} 0 R >>\n`
|
||||||
|
+ `startxref\n${xrefPos}\n%%EOF\n`;
|
||||||
|
teile.push(enc(xref));
|
||||||
|
|
||||||
|
return verbinden(teile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Hilfsmittel
|
||||||
|
|
||||||
|
function verbinden(stuecke) {
|
||||||
|
let laenge = 0;
|
||||||
|
for (const s of stuecke) laenge += s.length;
|
||||||
|
const aus = new Uint8Array(laenge);
|
||||||
|
let p = 0;
|
||||||
|
for (const s of stuecke) { aus.set(s, p); p += s.length; }
|
||||||
|
return aus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichenkette für eine PDF-Literalzeichenkette absichern. */
|
||||||
|
function roh(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/\(/g, "\\(")
|
||||||
|
.replace(/\)/g, "\\)")
|
||||||
|
// Zeichen ausserhalb von ASCII vermeiden - in Namen und Titeln reicht
|
||||||
|
// das, und es erspart die Umsetzung nach WinAnsi an dieser Stelle.
|
||||||
|
.replace(/[^\x20-\x7E]/g, (z) => ({
|
||||||
|
"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue",
|
||||||
|
"ß": "ss"
|
||||||
|
}[z] ?? "?"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Namen im PDF-Syntax absichern, etwa für Medientypen. */
|
||||||
|
function nameEscape(text) {
|
||||||
|
return "/" + String(text).replace(/[^A-Za-z0-9._-]/g,
|
||||||
|
(z) => "#" + z.charCodeAt(0).toString(16).padStart(2, "0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pdfDatum(d) {
|
||||||
|
const z = (n) => String(n).padStart(2, "0");
|
||||||
|
return `D:${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`
|
||||||
|
+ `${z(d.getHours())}${z(d.getMinutes())}${z(d.getSeconds())}`;
|
||||||
|
}
|
||||||
@@ -21,6 +21,35 @@ const VORGABEN = {
|
|||||||
// Leer = baseUrl verwenden.
|
// 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
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
|
||||||
109
ui/classify.js
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
ui/gemeinsam.css
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
242
ui/store.js
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||