From 3ae0ab0a88d525be79e7f251476a2788db8338cd Mon Sep 17 00:00:00 2001 From: Marco Morath Date: Fri, 4 Sep 2026 19:50:29 +0200 Subject: [PATCH] Initial commit --- .gitignore | 17 ++ SICHERHEIT.md | 178 +++++++++++ _locales/de/messages.json | 260 ++++++++++++++++ _locales/en/messages.json | 260 ++++++++++++++++ _locales/fr/messages.json | 260 ++++++++++++++++ background.js | 552 ++++++++++++++++++++++++++++++++++ build.sh | 57 ++++ icons/paperless-insert.svg | 12 + icons/paperless-insert_16.png | Bin 0 -> 885 bytes icons/paperless-insert_32.png | Bin 0 -> 2012 bytes icons/paperless-insert_64.png | Bin 0 -> 3864 bytes icons/paperless-logo.svg | 9 + icons/paperless-logo_16.png | Bin 0 -> 680 bytes icons/paperless-logo_32.png | Bin 0 -> 1186 bytes icons/paperless-logo_64.png | Bin 0 -> 1927 bytes icons/paperless-share.svg | 15 + icons/paperless-share_16.png | Bin 0 -> 883 bytes icons/paperless-share_32.png | Bin 0 -> 2142 bytes icons/paperless-share_64.png | Bin 0 -> 4504 bytes icons/paperless-store.svg | 14 + icons/paperless-store_16.png | Bin 0 -> 889 bytes icons/paperless-store_32.png | Bin 0 -> 2060 bytes icons/paperless-store_64.png | Bin 0 -> 4073 bytes lib/client.js | 324 ++++++++++++++++++++ lib/settings.js | 97 ++++++ make_icons.py | 282 +++++++++++++++++ manifest.json | 55 ++++ ui/classify.html | 44 +++ ui/classify.js | 109 +++++++ ui/gemeinsam.css | 74 +++++ ui/gemeinsam.js | 48 +++ ui/insert.html | 83 +++++ ui/insert.js | 211 +++++++++++++ ui/options.html | 76 +++++ ui/options.js | 125 ++++++++ ui/store.html | 85 ++++++ ui/store.js | 212 +++++++++++++ 37 files changed, 3459 insertions(+) create mode 100644 .gitignore create mode 100644 SICHERHEIT.md create mode 100644 _locales/de/messages.json create mode 100644 _locales/en/messages.json create mode 100644 _locales/fr/messages.json create mode 100644 background.js create mode 100644 build.sh create mode 100644 icons/paperless-insert.svg create mode 100644 icons/paperless-insert_16.png create mode 100644 icons/paperless-insert_32.png create mode 100644 icons/paperless-insert_64.png create mode 100644 icons/paperless-logo.svg create mode 100644 icons/paperless-logo_16.png create mode 100644 icons/paperless-logo_32.png create mode 100644 icons/paperless-logo_64.png create mode 100644 icons/paperless-share.svg create mode 100644 icons/paperless-share_16.png create mode 100644 icons/paperless-share_32.png create mode 100644 icons/paperless-share_64.png create mode 100644 icons/paperless-store.svg create mode 100644 icons/paperless-store_16.png create mode 100644 icons/paperless-store_32.png create mode 100644 icons/paperless-store_64.png create mode 100644 lib/client.js create mode 100644 lib/settings.js create mode 100644 make_icons.py create mode 100644 manifest.json create mode 100644 ui/classify.html create mode 100644 ui/classify.js create mode 100644 ui/gemeinsam.css create mode 100644 ui/gemeinsam.js create mode 100644 ui/insert.html create mode 100644 ui/insert.js create mode 100644 ui/options.html create mode 100644 ui/options.js create mode 100644 ui/store.html create mode 100644 ui/store.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07d02f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Bauergebnis. Wird von build.sh erzeugt und gehoert nicht ins Repository - +# Releases werden als Anhang veroeffentlicht, nicht eingecheckt. +*.xpi +*.zip + +# Editoren und Betriebssysteme +.vscode/ +.idea/ +*.swp +*~ +.DS_Store +Thumbs.db +desktop.ini + +# Symbole werden von make_icons.py erzeugt. Sie sind BEWUSST NICHT +# ausgenommen: Ohne sie laesst sich das Paket nicht bauen, und wer nur die +# Erweiterung nutzen will, soll nicht Pillow installieren muessen. diff --git a/SICHERHEIT.md b/SICHERHEIT.md new file mode 100644 index 0000000..9cbaaf0 --- /dev/null +++ b/SICHERHEIT.md @@ -0,0 +1,178 @@ +# Sicherheitsbetrachtung + +Was die Erweiterung tut, welche Angriffswege sich daraus ergeben und was +dagegen unternommen wird. Stand: Version 1.5.0. + +--- + +## Ausgangslage + +Die Lage ist deutlich entspannter als bei der LibreOffice-Fassung. Dort +war der kritischste Schritt das **Öffnen** eines heruntergeladenen +Dokuments in einer Anwendung mit Makrofähigkeit. Das entfällt hier +vollständig — die Erweiterung öffnet nichts, sie überträgt nur. + +| Handlung | Vertrauensgrenze | +|---|---| +| HTTPS-Anfragen an die konfigurierte Adresse | Gegenstelle nicht vertrauenswürdig | +| Anhänge und Nachrichten hochladen | Inhalt vom Absender, nicht vertrauenswürdig | +| Dokumente als Anhang in Entwürfe | Inhalt aus dem Archiv | +| Freigabelink in den Nachrichtentext | erzeugte Zeichenkette | +| Token dauerhaft speichern | Geheimnis | + +--- + +## 1. Berechtigungen + +Bewusst knapp gehalten: + +| Recht | Wofür | Anmerkung | +|---|---|---| +| `storage` | Einstellungen | | +| `menus` | Kontextmenüs | | +| `messagesRead` | Anhänge und `.eml` lesen | nur auf Anforderung genutzt | +| `compose` | Anhang und Link einfügen | | +| `notifications` | Fehlermeldungen | | + +**`compose.send` wurde entfernt.** Es stand im ersten Entwurf, ohne je +benutzt zu werden — die Erweiterung verschickt nichts. Ein Recht, das +nicht gebraucht wird, gehört nicht ins Manifest. + +**Kein pauschales Zugriffsrecht auf Adressen.** Statt `host_permissions` +steht dort `optional_host_permissions`, und beim Speichern der +Einstellungen wird das Recht für genau die eingetragene Adresse erbeten. +Der Unterschied ist erheblich: Eine Erweiterung mit `` darf jede +Adresse erreichen. Hier ist es genau ein Ursprung, und der Anwender sieht +beim Erteilen, welcher. + +Die Abfrage muss aus einer Benutzeraktion heraus erfolgen und steht +deshalb als erste Anweisung im Klickhandler — **vor jedem `await`**. Nach +dem ersten `await` gilt der Aufruf nicht mehr als aus einer Benutzeraktion +stammend, und Thunderbird lehnt ihn ab. + +--- + +## 2. Dateinamen aus Anhängen + +Der Name eines Anhangs stammt vom **Absender** und ist damit nicht +vertrauenswürdig. Er wird zwar nur an Paperless weitergereicht und nicht +ins Dateisystem geschrieben, aber ein Wert wie `../../x` hat in einem +Dateinamen nichts zu suchen — und was die Gegenstelle damit tut, ist nicht +Sache dieser Erweiterung. + +`sichererName()` entfernt: + +- Pfadanteile, **beide** Trennzeichen — unter Linux ist `\` ein gültiges + Zeichen im Dateinamen, unter Windows ein Trenner +- unter Windows verbotene Zeichen und Steuerzeichen +- reservierte Gerätenamen (`CON`, `AUX`, `NUL`, `COM1`…) +- Punkte und Leerzeichen am Ende + +--- + +## 3. Freigabelinks im Nachrichtentext + +Beim Einfügen in eine HTML-Nachricht werden Adresse und Dokumentname +maskiert (`escapeHtml`). Ohne das könnte ein Dokumenttitel mit spitzen +Klammern Auszeichnung in den Entwurf einschleusen. + +Der Titel stammt aus Paperless und damit mittelbar aus fremden Quellen — +wer ein Dokument per Mail ins Archiv einliefern kann, bestimmt dessen +Titel mit. + +--- + +## 4. Nachrichten zwischen Dialog und Ereignisseite + +Die Dialoge sprechen nicht selbst mit Paperless, sondern rufen die +Ereignisseite. Dort wird der Absender geprüft: + +```js +if (absender?.id && absender.id !== browser.runtime.id) { … } +``` + +Thunderbird lässt fremde Absender ohnehin nicht durch, solange kein +`externally_connectable` gesetzt ist. Die Prüfung kostet nichts und hält +die Zusicherung explizit. + +**Aufträge werden nach 30 Minuten verworfen.** Ein Auftrag enthält +Betreff, Absender und Dateinamen; das muss nicht bis zum Neustart im +Speicher liegen, nur weil jemand einen Dialog offen stehen liess. + +--- + +## 5. Speicherverhalten + +Anhangdaten werden **erst beim Hochladen** geladen, nicht beim Öffnen des +Dialogs. Bei einer Nachricht mit mehreren großen Anhängen läge sonst alles +im Speicher, während der Anwender noch überlegt. + +--- + +## 6. Transport + +**Adressprüfung.** Nur `http` und `https`. Ohne diese Prüfung liessen sich +andere Schemata hinterlegen, die `fetch` teilweise bedient. + +**Keine eigenen Kopfzeileneinträge.** Ein selbst gesetzter Eintrag wie +`X-Client` ist nicht CORS-sicher und löst eine Vorabanfrage aus. Antwortet +die Gegenstelle darauf nicht wie erwartet, scheitert schon der erste +Aufruf mit `NetworkError` — einer Meldung, die über die Ursache nichts +verrät. Das stand im ersten Entwurf und ist entfernt. + +**Zertifikatsprüfung.** Nicht abschaltbar. WebExtensions können das +ohnehin nicht umgehen, und das ist richtig so. + +--- + +## 7. Der Token + +Er liegt im Klartext in `browser.storage.local`, also im Profil von +Thunderbird. Das entspricht dem, was Erweiterungen üblicherweise tun, ist +aber **kein Geheimnisspeicher**. + +Was sichergestellt ist: + +- Er erscheint nie in einer Fehlermeldung — bei HTTP-Fehlern werden + Adresse und Antworttext gezeigt, nie das Kopfzeilenfeld. +- Im Eingabefeld erscheinen Punkte (`type="password"`). + +**Empfehlung:** In Paperless einen eigenen Token für diese Erweiterung +anlegen. Dann lässt er sich einzeln widerrufen, ohne andere Werkzeuge zu +stören. Wer die Rechte begrenzen will, legt einen Benutzer mit +eingeschränkten Objektrechten an und erzeugt dessen Token — die +Erweiterung kann dann nur, was dieser Benutzer darf. + +--- + +## 8. Was die Erweiterung NICHT tut + +- kein `eval`, kein `innerHTML` mit fremden Inhalten — alle Listen werden + über `textContent` und `createElement` aufgebaut +- kein Öffnen heruntergeladener Dateien +- kein Schreiben ins Dateisystem +- kein Versenden von Nachrichten +- keine Übertragung an Dritte; einzige Gegenstelle ist die konfigurierte + Adresse + +--- + +## 9. Bekannte Grenzen + +| Punkt | Bewertung | +|---|---| +| Token im Klartext im Profil | üblich für Erweiterungen | +| `messagesRead` erlaubt technisch das Lesen aller Nachrichten | für die Funktion nötig; genutzt nur auf Anforderung | +| Kompromittierte Paperless-Instanz | könnte manipulierte Dokumente als Anhang liefern — sie werden aber nicht geöffnet, nur angehängt | +| Kein Schutz gegen Zugriff auf das eigene Benutzerkonto | systembedingt | +| Der Nachrichtentext wird beim Einfügen vollständig neu geschrieben | Vorgabe der Compose-Schnittstelle, siehe README | + +--- + +## Empfehlungen für den Betrieb + +1. Eigenen Token für die Erweiterung, einzeln widerrufbar +2. Paperless nur hinter einem Reverse Proxy mit TLS erreichbar machen +3. Freigabelinks mit Ablauf verwenden, nicht unbegrenzt +4. Den Tag beim Teilen benutzen — so lässt sich später prüfen, was einmal + nach draußen gegeben wurde diff --git a/_locales/de/messages.json b/_locales/de/messages.json new file mode 100644 index 0000000..d7d4f51 --- /dev/null +++ b/_locales/de/messages.json @@ -0,0 +1,260 @@ +{ + "extName": { + "message": "Paperless-ngx" + }, + "extDescription": { + "message": "Anhänge und E-Mails in ein Paperless-ngx-Archiv ablegen, Dokumente als Anhang oder Freigabelink einfügen." + }, + "actionStore": { + "message": "In Paperless ablegen" + }, + "actionInsert": { + "message": "Aus Paperless einfügen" + }, + "menuStoreAttachment": { + "message": "Anhang in Paperless ablegen…" + }, + "menuStoreMessage": { + "message": "E-Mail in Paperless ablegen…" + }, + "storeTitle": { + "message": "In Paperless ablegen" + }, + "storeWhat": { + "message": "Was soll abgelegt werden?" + }, + "storeHintPreselect": { + "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": { + "message": "Die $1 Anhänge bleiben darin enthalten.", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "storeTarget": { + "message": "Ziel" + }, + "storeTargetNew": { + "message": "Neues Dokument anlegen" + }, + "storeTargetExisting": { + "message": "An bestehendes Dokument anhängen" + }, + "storeHintTitleMulti": { + "message": "Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt." + }, + "storeClassifyAfter": { + "message": "Nach dem Ablegen die Klassifizierung bearbeiten" + }, + "classifyTitle": { + "message": "Klassifizierung bearbeiten" + }, + "classifyHintNew": { + "message": "Unbekannte Korrespondenten und Dokumenttypen werden angelegt." + }, + "classifyMoreStored": { + "message": "$1 Dateien abgelegt", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "insertTitle": { + "message": "Aus Paperless einfügen" + }, + "insertHow": { + "message": "Einfügen als" + }, + "insertAsAttachment": { + "message": "Anhang der Nachricht" + }, + "insertAsLink": { + "message": "Freigabelink im Nachrichtentext" + }, + "insertHintLink": { + "message": "Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt." + }, + "optionsTitle": { + "message": "Paperless — Verbindung" + }, + "optionsConnection": { + "message": "Zugang" + }, + "optionsBehaviour": { + "message": "Verhalten" + }, + "optionsHintUrl": { + "message": "Ohne abschließenden Schrägstrich. Nur http und https." + }, + "optionsHintToken": { + "message": "Im Benutzerprofil von Paperless erzeugen. Ein eigener Token für diese Erweiterung lässt sich einzeln widerrufen." + }, + "optionsHintLabel": { + "message": "{datum} wird durch das heutige Datum ersetzt." + }, + "optionsHintIgnore": { + "message": "Signaturbilder und Logos sind meist kleiner als 8 KB. Sie lassen sich im Dialog jederzeit anhaken." + }, + "fieldBaseUrl": { + "message": "Basisadresse" + }, + "fieldToken": { + "message": "API-Token" + }, + "fieldTitle": { + "message": "Titel" + }, + "fieldCreated": { + "message": "Erstellt" + }, + "fieldCorrespondent": { + "message": "Korrespondent" + }, + "fieldDocType": { + "message": "Dokumenttyp" + }, + "fieldTags": { + "message": "Tags" + }, + "fieldVersionLabel": { + "message": "Bezeichnung neuer Versionen" + }, + "fieldShareDays": { + "message": "Gültigkeit in Tagen" + }, + "fieldShareTag": { + "message": "Tag beim Teilen" + }, + "fieldIgnoreSmaller": { + "message": "Anhänge vorab abwählen unter … Bytes" + }, + "colAsn": { + "message": "ASN" + }, + "colCreated": { + "message": "Erstellt" + }, + "colTitle": { + "message": "Titel" + }, + "colNo": { + "message": "Nr" + }, + "colFormat": { + "message": "Format" + }, + "colLabel": { + "message": "Bezeichnung" + }, + "colDocuments": { + "message": "Dokumente" + }, + "colVersions": { + "message": "Versionen" + }, + "btnSearch": { + "message": "Suchen" + }, + "btnCancel": { + "message": "Abbrechen" + }, + "btnStore": { + "message": "Ablegen" + }, + "btnSave": { + "message": "Speichern" + }, + "btnSkip": { + "message": "Überspringen" + }, + "btnInsert": { + "message": "Einfügen" + }, + "btnTest": { + "message": "Verbindung prüfen" + }, + "msgNoHits": { + "message": "Keine Treffer." + }, + "msgUploading": { + "message": "Wird abgelegt …" + }, + "msgStored": { + "message": "$1 abgelegt.", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "msgSaving": { + "message": "Wird gespeichert …" + }, + "msgSaved": { + "message": "Gespeichert." + }, + "msgLoadingVersions": { + "message": "Versionen werden geladen …" + }, + "msgCreatingLink": { + "message": "Link wird erzeugt …" + }, + "msgAttaching": { + "message": "Wird angehängt …" + }, + "msgConnectionOk": { + "message": "Verbindung steht ($1 Dokumente sichtbar).", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "errNoMessage": { + "message": "Keine Nachricht ausgewählt." + }, + "errNoSettings": { + "message": "Bitte zuerst Basisadresse und Token hinterlegen." + }, + "errNothingSelected": { + "message": "Nichts ausgewählt." + }, + "errNoLink": { + "message": "Der Link konnte nicht ermittelt werden." + }, + "errBadUrl": { + "message": "Ungültige Adresse. Erwartet wird https://rechner oder http://rechner." + }, + "errNoPermission": { + "message": "Ohne Zugriffsrecht auf die Adresse kann die Erweiterung nicht arbeiten." + }, + "linkLabel": { + "message": "Datei" + }, + "linkValidUntil": { + "message": "Link gültig bis $1", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "linkValidUnlimited": { + "message": "Link ohne zeitliche Begrenzung" + }, + "linkHeading": { + "message": "Datei $1", + "placeholders": { + "1": { + "content": "$1" + } + } + } +} diff --git a/_locales/en/messages.json b/_locales/en/messages.json new file mode 100644 index 0000000..a19f45b --- /dev/null +++ b/_locales/en/messages.json @@ -0,0 +1,260 @@ +{ + "extName": { + "message": "Paperless-ngx" + }, + "extDescription": { + "message": "Store attachments and e-mails in a Paperless-ngx archive, insert documents as attachment or share link." + }, + "actionStore": { + "message": "Store in Paperless" + }, + "actionInsert": { + "message": "Insert from Paperless" + }, + "menuStoreAttachment": { + "message": "Store attachment in Paperless…" + }, + "menuStoreMessage": { + "message": "Store e-mail in Paperless…" + }, + "storeTitle": { + "message": "Store in Paperless" + }, + "storeWhat": { + "message": "What should be stored?" + }, + "storeHintPreselect": { + "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": { + "message": "The $1 attachments remain inside it.", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "storeTarget": { + "message": "Target" + }, + "storeTargetNew": { + "message": "Create a new document" + }, + "storeTargetExisting": { + "message": "Append 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": { + "message": "Edit classification" + }, + "classifyHintNew": { + "message": "Unknown correspondents and document types will be created." + }, + "classifyMoreStored": { + "message": "$1 files stored", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "insertTitle": { + "message": "Insert from Paperless" + }, + "insertHow": { + "message": "Insert as" + }, + "insertAsAttachment": { + "message": "Attachment of the message" + }, + "insertAsLink": { + "message": "Share link in the message body" + }, + "insertHintLink": { + "message": "The link works without signing in. 0 days means unlimited." + }, + "optionsTitle": { + "message": "Paperless — Connection" + }, + "optionsConnection": { + "message": "Access" + }, + "optionsBehaviour": { + "message": "Behaviour" + }, + "optionsHintUrl": { + "message": "Without trailing slash. Only http and https." + }, + "optionsHintToken": { + "message": "Create it in your Paperless user profile. A dedicated token for this extension can be revoked separately." + }, + "optionsHintLabel": { + "message": "{datum} is replaced by today's date." + }, + "optionsHintIgnore": { + "message": "Signature images and logos are usually smaller than 8 KB. They can always be ticked in the dialog." + }, + "fieldBaseUrl": { + "message": "Base address" + }, + "fieldToken": { + "message": "API token" + }, + "fieldTitle": { + "message": "Title" + }, + "fieldCreated": { + "message": "Created" + }, + "fieldCorrespondent": { + "message": "Correspondent" + }, + "fieldDocType": { + "message": "Document type" + }, + "fieldTags": { + "message": "Tags" + }, + "fieldVersionLabel": { + "message": "Label for new versions" + }, + "fieldShareDays": { + "message": "Validity in days" + }, + "fieldShareTag": { + "message": "Tag when sharing" + }, + "fieldIgnoreSmaller": { + "message": "Deselect attachments below … bytes" + }, + "colAsn": { + "message": "ASN" + }, + "colCreated": { + "message": "Created" + }, + "colTitle": { + "message": "Title" + }, + "colNo": { + "message": "No" + }, + "colFormat": { + "message": "Format" + }, + "colLabel": { + "message": "Label" + }, + "colDocuments": { + "message": "Documents" + }, + "colVersions": { + "message": "Versions" + }, + "btnSearch": { + "message": "Search" + }, + "btnCancel": { + "message": "Cancel" + }, + "btnStore": { + "message": "Store" + }, + "btnSave": { + "message": "Save" + }, + "btnSkip": { + "message": "Skip" + }, + "btnInsert": { + "message": "Insert" + }, + "btnTest": { + "message": "Test connection" + }, + "msgNoHits": { + "message": "No matches." + }, + "msgUploading": { + "message": "Storing …" + }, + "msgStored": { + "message": "$1 stored.", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "msgSaving": { + "message": "Saving …" + }, + "msgSaved": { + "message": "Saved." + }, + "msgLoadingVersions": { + "message": "Loading versions …" + }, + "msgCreatingLink": { + "message": "Creating link …" + }, + "msgAttaching": { + "message": "Attaching …" + }, + "msgConnectionOk": { + "message": "Connection works ($1 documents visible).", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "errNoMessage": { + "message": "No message selected." + }, + "errNoSettings": { + "message": "Please configure base address and token first." + }, + "errNothingSelected": { + "message": "Nothing selected." + }, + "errNoLink": { + "message": "The link could not be determined." + }, + "errBadUrl": { + "message": "Invalid address. Expected https://host or http://host." + }, + "errNoPermission": { + "message": "Without access permission for the address the extension cannot work." + }, + "linkLabel": { + "message": "File" + }, + "linkValidUntil": { + "message": "Link valid until $1", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "linkValidUnlimited": { + "message": "Link without time limit" + }, + "linkHeading": { + "message": "File $1", + "placeholders": { + "1": { + "content": "$1" + } + } + } +} diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json new file mode 100644 index 0000000..7a2c150 --- /dev/null +++ b/_locales/fr/messages.json @@ -0,0 +1,260 @@ +{ + "extName": { + "message": "Paperless-ngx" + }, + "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." + }, + "actionStore": { + "message": "Enregistrer dans Paperless" + }, + "actionInsert": { + "message": "Insérer depuis Paperless" + }, + "menuStoreAttachment": { + "message": "Enregistrer la pièce jointe dans Paperless…" + }, + "menuStoreMessage": { + "message": "Enregistrer le courriel dans Paperless…" + }, + "storeTitle": { + "message": "Enregistrer dans Paperless" + }, + "storeWhat": { + "message": "Que faut-il enregistrer ?" + }, + "storeHintPreselect": { + "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": { + "message": "Les $1 pièces jointes y restent incluses.", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "storeTarget": { + "message": "Cible" + }, + "storeTargetNew": { + "message": "Créer un nouveau document" + }, + "storeTargetExisting": { + "message": "Ajouter à 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": { + "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": { + "message": "Insérer depuis Paperless" + }, + "insertHow": { + "message": "Insérer comme" + }, + "insertAsAttachment": { + "message": "Pièce jointe du message" + }, + "insertAsLink": { + "message": "Lien de partage dans le corps du message" + }, + "insertHintLink": { + "message": "Le lien est accessible sans connexion. 0 jour signifie sans limite." + }, + "optionsTitle": { + "message": "Paperless — Connexion" + }, + "optionsConnection": { + "message": "Accès" + }, + "optionsBehaviour": { + "message": "Comportement" + }, + "optionsHintUrl": { + "message": "Sans barre oblique finale. Uniquement http et https." + }, + "optionsHintToken": { + "message": "À créer dans votre profil utilisateur Paperless. Un jeton dédié à cette extension peut être révoqué séparément." + }, + "optionsHintLabel": { + "message": "{datum} est remplacé par la date du jour." + }, + "optionsHintIgnore": { + "message": "Les images de signature et les logos font généralement moins de 8 Ko. Ils restent cochables dans la boîte de dialogue." + }, + "fieldBaseUrl": { + "message": "Adresse de base" + }, + "fieldToken": { + "message": "Jeton d'API" + }, + "fieldTitle": { + "message": "Titre" + }, + "fieldCreated": { + "message": "Créé le" + }, + "fieldCorrespondent": { + "message": "Correspondant" + }, + "fieldDocType": { + "message": "Type de document" + }, + "fieldTags": { + "message": "Étiquettes" + }, + "fieldVersionLabel": { + "message": "Libellé des nouvelles versions" + }, + "fieldShareDays": { + "message": "Validité en jours" + }, + "fieldShareTag": { + "message": "Étiquette lors du partage" + }, + "fieldIgnoreSmaller": { + "message": "Décocher les pièces jointes de moins de … octets" + }, + "colAsn": { + "message": "ASN" + }, + "colCreated": { + "message": "Créé le" + }, + "colTitle": { + "message": "Titre" + }, + "colNo": { + "message": "N°" + }, + "colFormat": { + "message": "Format" + }, + "colLabel": { + "message": "Libellé" + }, + "colDocuments": { + "message": "Documents" + }, + "colVersions": { + "message": "Versions" + }, + "btnSearch": { + "message": "Rechercher" + }, + "btnCancel": { + "message": "Annuler" + }, + "btnStore": { + "message": "Enregistrer" + }, + "btnSave": { + "message": "Enregistrer" + }, + "btnSkip": { + "message": "Ignorer" + }, + "btnInsert": { + "message": "Insérer" + }, + "btnTest": { + "message": "Tester la connexion" + }, + "msgNoHits": { + "message": "Aucun résultat." + }, + "msgUploading": { + "message": "Enregistrement …" + }, + "msgStored": { + "message": "$1 enregistré(s).", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "msgSaving": { + "message": "Enregistrement …" + }, + "msgSaved": { + "message": "Enregistré." + }, + "msgLoadingVersions": { + "message": "Chargement des versions …" + }, + "msgCreatingLink": { + "message": "Création du lien …" + }, + "msgAttaching": { + "message": "Ajout en pièce jointe …" + }, + "msgConnectionOk": { + "message": "La connexion fonctionne ($1 documents visibles).", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "errNoMessage": { + "message": "Aucun message sélectionné." + }, + "errNoSettings": { + "message": "Veuillez d'abord renseigner l'adresse de base et le jeton." + }, + "errNothingSelected": { + "message": "Rien de sélectionné." + }, + "errNoLink": { + "message": "Le lien n'a pas pu être déterminé." + }, + "errBadUrl": { + "message": "Adresse invalide. Attendu : https://hote ou http://hote." + }, + "errNoPermission": { + "message": "Sans droit d'accès à l'adresse, l'extension ne peut pas fonctionner." + }, + "linkLabel": { + "message": "Fichier" + }, + "linkValidUntil": { + "message": "Lien valable jusqu'au $1", + "placeholders": { + "1": { + "content": "$1" + } + } + }, + "linkValidUnlimited": { + "message": "Lien sans limite de durée" + }, + "linkHeading": { + "message": "Fichier $1", + "placeholders": { + "1": { + "content": "$1" + } + } + } +} diff --git a/background.js b/background.js new file mode 100644 index 0000000..a10964b --- /dev/null +++ b/background.js @@ -0,0 +1,552 @@ +/** + * background.js - Ereignisseite der Erweiterung. + * + * Hier liegen die Menüeinträge, die Schaltflächen und die Vermittlung + * zwischen den Dialogfenstern und der Schnittstelle. Die Dialoge selbst + * sind gewöhnliche HTML-Seiten; sie rufen über runtime.sendMessage hier + * an, statt selbst mit Paperless zu sprechen. + * + * Der Grund für diese Aufteilung: Die Berechtigung für die Zieladresse + * wird zur Laufzeit angefordert und hängt am Hintergrundkontext. Ein + * Dialogfenster müsste sie sonst erneut erbitten. + */ + +import { PaperlessClient, PaperlessError } from "./lib/client.js"; +import { + ladeEinstellungen, vollstaendig, sichererName, versionslabel, + titelAusBetreff, t +} from "./lib/settings.js"; + +/** + * Ergebnis einer Nachrichten-Abfrage vereinheitlichen. + * + * Je nach Version liefert Thunderbird ein Feld, ein MessageList-Objekt mit + * der Eigenschaft "messages" oder eine einzelne Nachricht. Ohne diese + * Vereinheitlichung bricht die Erweiterung bei jedem Versionswechsel. + */ +function alsListe(wert) { + if (!wert) return []; + if (Array.isArray(wert)) return wert; + if (Array.isArray(wert.messages)) return wert.messages; + if (wert.id !== undefined) return [wert]; + return []; +} + +/** + * Angezeigte Nachricht(en) eines Fensters holen. + * + * getDisplayedMessage (Einzahl) ist in neueren Ständen entfallen; dort + * gibt es nur noch getDisplayedMessages (Mehrzahl). Beide werden + * probiert, dazu als letzter Ausweg die Auswahl im Nachrichtenbereich. + */ +async function angezeigteNachrichten(tabId) { + const md = browser.messageDisplay; + const versuche = []; + + // OHNE Reiterkennung zuerst. Beim Kontextmenü eines Anhangs übergibt + // Thunderbird einen Reiter mit type null - das ist nicht der + // Nachrichtenbereich, und jeder Aufruf mit dieser Kennung geht ins + // Leere. Ohne Argument nimmt Thunderbird den aktiven Reiter, und das + // ist der richtige. + if (md?.getDisplayedMessages) { + versuche.push(["getDisplayedMessages()", + () => md.getDisplayedMessages()]); + if (tabId !== undefined && tabId !== null) { + versuche.push(["getDisplayedMessages(tabId)", + () => md.getDisplayedMessages(tabId)]); + } + } + // Einzahl-Fassung für ältere Stände. + if (md?.getDisplayedMessage) { + versuche.push(["getDisplayedMessage()", + () => md.getDisplayedMessage()]); + } + // Auswahl in der Nachrichtenliste, wenn kein Nachrichtenbereich offen ist. + if (browser.mailTabs?.getSelectedMessages) { + versuche.push(["mailTabs.getSelectedMessages()", + () => browser.mailTabs.getSelectedMessages()]); + } + + for (const [name, fn] of versuche) { + try { + const r = alsListe(await fn()); + if (r.length) return r; + } catch (err) { + console.debug("[paperless]", name, "ohne Erfolg:", err?.message); + } + } + return []; +} + + +/** + * Datum einer Nachricht als JJJJ-MM-TT. + * + * Je nach Version ist date ein Date-Objekt oder eine Zeichenkette. + */ +function alsDatum(wert) { + if (!wert) return undefined; + const d = wert instanceof Date ? wert : new Date(wert); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString().slice(0, 10); +} + +// Offene Dialoge, damit sie ihre Aufgabe abholen können. +const auftraege = new Map(); +let naechsteAuftragsnummer = 1; + +// ---------------------------------------------------------------- Menüs + +browser.runtime.onInstalled.addListener(() => { + browser.menus.create({ + id: "paperless-anhang", + title: t("menuStoreAttachment"), + contexts: ["message_attachments"] + }); + browser.menus.create({ + id: "paperless-nachricht", + title: t("menuStoreMessage"), + contexts: ["message_list"] + }); +}); + +browser.menus.onClicked.addListener(async (info, tab) => { + try { + console.debug("[paperless] Menü", info.menuItemId, + "Anhänge:", alsListe(info.attachments).length); + if (info.menuItemId === "paperless-anhang") { + await anhangAblegen(info, tab); + } else if (info.menuItemId === "paperless-nachricht") { + await nachrichtAblegen(info, tab); + } + } catch (err) { + await melde(err); + } +}); + +browser.messageDisplayAction.onClicked.addListener(async (tab) => { + try { + await nachrichtAblegen({}, tab); + } catch (err) { + await melde(err); + } +}); + +browser.composeAction.onClicked.addListener(async (tab) => { + try { + await dialogOeffnen("einfuegen", { tabId: tab.id }); + } catch (err) { + await melde(err); + } +}); + +// -------------------------------------------------------------- Ablegen + +async function anhangAblegen(info, tab) { + const nachricht = alsListe(info.displayedMessages)[0] + || alsListe(info.selectedMessages)[0] + || (await angezeigteNachrichten(tab?.id))[0]; + if (!nachricht) throw new PaperlessError(t("errNoMessage")); + + const alle = await browser.messages.listAttachments(nachricht.id); + // Beim Klick auf einen einzelnen Anhang liefert das Menü nur diesen. + // Über die Sammelfunktion "Alle Anhänge" kommen mehrere. + const gewaehlt = alsListe(info.attachments).length + ? alsListe(info.attachments).map((a) => a.partName) + : alle.map((a) => a.partName); + + // Nur die Beschreibung sammeln. Die eigentlichen Daten werden erst beim + // Hochladen geholt - sonst läge ein grosser Anhang unnötig im Speicher, + // während der Anwender noch im Dialog überlegt. + const dateien = alle + .filter((teil) => gewaehlt.includes(teil.partName)) + .map((teil) => ({ + name: sichererName(teil.name), + size: teil.size, + contentType: teil.contentType, + partName: teil.partName, + messageId: nachricht.id + })); + + await dialogOeffnen("ablegen", { + quelle: "anhaenge", + messageId: nachricht.id, + betreff: nachricht.subject, + absender: nachricht.author, + datum: alsDatum(nachricht.date), + dateien + }); +} + +async function nachrichtAblegen(info, tab) { + let nachrichten = alsListe(info.selectedMessages); + if (!nachrichten.length) nachrichten = alsListe(info.displayedMessages); + if (!nachrichten.length) { + nachrichten = await angezeigteNachrichten(tab?.id); + } + if (!nachrichten.length) throw new PaperlessError(t("errNoMessage")); + + const nachricht = nachrichten[0]; + const anhaenge = await browser.messages.listAttachments(nachricht.id); + + await dialogOeffnen("ablegen", { + quelle: "nachricht", + messageId: nachricht.id, + betreff: nachricht.subject, + absender: nachricht.author, + datum: alsDatum(nachricht.date), + dateien: anhaenge.map((a) => ({ + name: sichererName(a.name), + size: a.size, + contentType: a.contentType, + partName: a.partName, + messageId: nachricht.id + })) + }); +} + +// -------------------------------------------------------------- Dialoge + +async function dialogOeffnen(art, daten) { + const einstellungen = await ladeEinstellungen(); + if (!vollstaendig(einstellungen)) { + await browser.runtime.openOptionsPage(); + return; + } + const nummer = naechsteAuftragsnummer++; + auftraege.set(nummer, daten); + + // Nach einer halben Stunde verwerfen. Ein Auftrag enthält Betreff, + // Absender und Dateinamen; das muss nicht bis zum Neustart im Speicher + // liegen, nur weil jemand den Dialog offen stehen liess. + setTimeout(() => auftraege.delete(nummer), 30 * 60 * 1000); + const seite = art === "einfuegen" ? "ui/insert.html" : "ui/store.html"; + + // Die Adresse MUSS über runtime.getURL aufgelöst werden. Ein relativer + // Pfad wird in Manifest V3 nicht gegen das Erweiterungsverzeichnis + // aufgelöst - das Fenster öffnet dann stillschweigend nicht. + const url = browser.runtime.getURL(`${seite}?auftrag=${nummer}`); + + try { + await browser.windows.create({ + url, + type: "popup", + width: art === "einfuegen" ? 760 : 820, + height: art === "einfuegen" ? 640 : 720 + }); + } catch (err) { + // Manche Stände lehnen type "popup" ab. Dann ein gewöhnliches Fenster. + console.warn("[paperless] popup abgelehnt, versuche normales Fenster", + err); + await browser.windows.create({ url }); + } +} + +// -------------------------------------------- Vermittlung für die Dialoge + +browser.runtime.onMessage.addListener(async (nachricht, absender) => { + // Nur Nachrichten aus dieser Erweiterung annehmen. Thunderbird lässt + // fremde Absender hier zwar ohnehin nicht durch, solange kein + // externally_connectable gesetzt ist - die Prüfung kostet nichts und + // hält die Zusicherung explizit. + if (absender?.id && absender.id !== browser.runtime.id) { + return { ok: false, fehler: "Fremder Absender" }; + } + try { + switch (nachricht?.befehl) { + case "auftrag": + return { ok: true, daten: auftraege.get(nachricht.nummer) || null }; + + case "einstellungen": + return { ok: true, daten: await ladeEinstellungen() }; + + case "berechtigungPruefen": + return { ok: true, daten: await berechtigungPruefen() }; + + case "suchen": { + const c = await client(); + return { ok: true, daten: await c.search(nachricht.query) }; + } + + case "dokument": { + 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 [tags, korr, typen] = await Promise.all( + [c.tags(), c.correspondents(), c.documentTypes()]); + return { ok: true, daten: { tags, korr, typen } }; + } + + case "ablegen": + return { ok: true, daten: await ablegen(nachricht) }; + + case "klassifizieren": { + const c = await client(); + const payload = { ...nachricht.payload }; + payload.correspondent = await aufloesen( + c, "correspondents/", nachricht.korrName); + payload.document_type = await aufloesen( + c, "document_types/", nachricht.typName); + await c.patchDocument(nachricht.id, payload); + return { ok: true }; + } + + case "anhaengen": + return { ok: true, daten: await anhaengenAnEntwurf(nachricht) }; + + case "freigabelink": + return { ok: true, daten: await freigabelink(nachricht) }; + + default: + return { ok: false, fehler: `Unbekannter Befehl: ${nachricht?.befehl}` }; + } + } catch (err) { + return { ok: false, fehler: String(err?.message || err) }; + } +}); + +async function client() { + const s = await ladeEinstellungen(); + if (!vollstaendig(s)) throw new PaperlessError(t("errNoSettings")); + + // Ohne Zugriffsrecht für den Ursprung schlägt fetch mit einem nackten + // "NetworkError" fehl. Die Prüfung vorweg erspart eine irreführende + // Fehlersuche beim Netzwerk. + let origin; + try { + origin = new URL(s.baseUrl).origin + "/*"; + } catch { + throw new PaperlessError(t("errBadUrl")); + } + const erteilt = await browser.permissions.contains({ origins: [origin] }); + if (!erteilt) { + throw new PaperlessError( + `${t("errNoPermission")}\n\n${origin}\n\n` + + `In den Einstellungen erneut auf Speichern klicken - die Abfrage ` + + `kann nur aus einer Benutzeraktion heraus erscheinen.`); + } + return new PaperlessClient(s.baseUrl, s.token); +} + +/** + * Zugriffsrecht für die Zieladresse. + * + * Es wird bewusst nicht pauschal im Manifest verlangt, sondern erst zur + * Laufzeit für genau die eingetragene Adresse angefordert. Eine + * Erweiterung, die von Haus aus jede Adresse erreichen darf, ist eine + * unnötig große Angriffsfläche. + */ +async function berechtigungPruefen() { + const s = await ladeEinstellungen(); + if (!vollstaendig(s)) return { noetig: false, erteilt: false }; + const muster = new URL(s.baseUrl).origin + "/*"; + const erteilt = await browser.permissions.contains({ origins: [muster] }); + return { noetig: true, erteilt, muster }; +} + +// -------------------------------------------------------------- Vorgänge + +async function datenHolen(auftrag) { + if (auftrag.quelle === "nachricht") { + const roh = await browser.messages.getRaw(auftrag.messageId); + const blob = new Blob([roh], { type: "message/rfc822" }); + const name = sichererName( + `${titelAusBetreff(auftrag.betreff)}.eml`); + return [{ blob, name }]; + } + const out = []; + for (const d of auftrag.dateien || []) { + if (!d.ausgewaehlt) continue; + const datei = await browser.messages.getAttachmentFile( + d.messageId, d.partName); + out.push({ blob: datei, name: sichererName(d.name) }); + } + return out; +} + +async function ablegen(nachricht) { + const c = await client(); + const s = await ladeEinstellungen(); + const dateien = await datenHolen(nachricht.auftrag); + if (!dateien.length) throw new PaperlessError(t("errNothingSelected")); + + const ergebnisse = []; + for (const datei of dateien) { + if (nachricht.ziel === "vorhanden") { + const antwort = await c.updateVersion( + nachricht.zielId, datei.blob, datei.name, + versionslabel(s.versionLabel)); + const id = await c.waitForTask(antwort); + ergebnisse.push({ id, name: datei.name, art: "version", + zielId: nachricht.zielId }); + } else { + const felder = { title: nachricht.titel || datei.name }; + if (nachricht.erstellt) felder.created = nachricht.erstellt; + const antwort = await c.postDocument(datei.blob, datei.name, felder); + const id = await c.waitForTask(antwort); + ergebnisse.push({ id, name: datei.name, art: "neu" }); + } + } + return ergebnisse; +} + +async function aufloesen(c, pfad, name) { + const gesucht = String(name || "").trim(); + if (!gesucht) return null; + const liste = pfad.startsWith("correspondents") + ? await c.correspondents() : await c.documentTypes(); + const treffer = liste.find( + (x) => x.name.trim().toLowerCase() === gesucht.toLowerCase()); + if (treffer) return treffer.id; + const neu = await c.create(pfad, gesucht); + return neu.id; +} + +async function anhaengenAnEntwurf(nachricht) { + const c = await client(); + const { blob, name } = await c.download(nachricht.versionId, true); + const datei = new File([blob], sichererName(name || `paperless-${nachricht.versionId}`)); + await browser.compose.addAttachment(nachricht.tabId, { file: datei }); + return { name: datei.name }; +} + +async function freigabelink(nachricht) { + const c = await client(); + const url = await c.shareLink(nachricht.id, nachricht.tage, + nachricht.variante); + if (!url) throw new PaperlessError(t("errNoLink")); + + // Kennzeichnung setzen. Fehler werden NICHT verschluckt: Der Tag ist der + // Grund, warum sich geteilte Dokumente später wiederfinden lassen - ein + // stiller Fehlschlag wäre schlimmer als eine Meldung. + let tagStatus = null; + if (nachricht.tagName) { + try { + const name = String(nachricht.tagName).trim(); + const tags = await c.tags(); + let treffer = tags.find( + (x) => String(x.name).trim().toLowerCase() === name.toLowerCase()); + if (!treffer) treffer = await c.create("tags/", name); + if (!treffer || treffer.id === undefined) { + throw new PaperlessError( + `Tag "${name}" ohne Kennung: ${JSON.stringify(treffer)}`); + } + const doc = await c.document(nachricht.id); + const vorhanden = (doc.tags || []).map(Number); + if (!vorhanden.includes(Number(treffer.id))) { + await c.patchDocument( + nachricht.id, { tags: [...vorhanden, Number(treffer.id)] }); + } + tagStatus = { ok: true, name }; + } catch (err) { + tagStatus = { ok: false, fehler: String(err?.message || err) }; + console.error("[paperless] Tag konnte nicht gesetzt werden", err); + } + } + + // Ablaufdatum selbst rechnen. Es steht auch in der Antwort, aber nicht + // in jeder Version unter demselben Namen - die eigene Rechnung stimmt + // mit dem überein, was im Dialog eingestellt wurde. + const gueltigBis = nachricht.tage + ? new Date(Date.now() + Number(nachricht.tage) * 86400000) + : null; + + if (nachricht.tabId !== undefined && nachricht.einfuegen) { + await linkEinfuegen(nachricht.tabId, url, nachricht.linktext, + gueltigBis); + } + return { + url, + tagStatus, + gueltigBis: gueltigBis ? gueltigBis.toISOString() : null + }; +} + +/** + * Datum als TT.MM.JJJJ. + * + * Bewusst kein toLocaleDateString: Die Nachricht geht an einen Empfänger + * mit unbekannter Spracheinstellung. 03.09. und 09.03. sehen gleich aus + * und bedeuten Verschiedenes - bei einem Ablaufdatum ist das der falsche + * Ort für Mehrdeutigkeit. + */ +function alsDeutschesDatum(d) { + const z = (n) => String(n).padStart(2, "0"); + return `${z(d.getDate())}.${z(d.getMonth() + 1)}.${d.getFullYear()}`; +} + +/** + * Freigabelink in einen Entwurf einfügen. + * + * ACHTUNG: Die Compose-Schnittstelle kennt kein Einfügen an der + * Schreibmarke. Der Körper muss vollständig gelesen und zurückgeschrieben + * werden. Der Eintrag landet deshalb am Ende, bei HTML vor dem + * schliessenden . + * + * Name UND Adresse stehen sichtbar. Ein Link, dessen Ziel man nicht sieht, + * ist beim Empfänger schwer einzuschätzen - erst recht bei einer Adresse, + * die ohne Anmeldung Zugriff gewährt. + */ +async function linkEinfuegen(tabId, url, linktext, gueltigBis) { + const details = await browser.compose.getComposeDetails(tabId); + const name = String(linktext || "").trim(); + const hinweis = gueltigBis + ? t("linkValidUntil", [alsDeutschesDatum(gueltigBis)]) + : t("linkValidUnlimited"); + + if (details.isPlainText) { + const koerper = details.plainTextBody || ""; + const zeilen = [name, url, hinweis].filter(Boolean); + await browser.compose.setComposeDetails(tabId, { + plainTextBody: `${koerper}\n\n${zeilen.join("\n")}\n` + }); + return; + } + + const koerper = details.body || ""; + const sicherUrl = escapeHtml(url); + const kopf = name ? `${escapeHtml(name)}
` : ""; + const block = + `

${kopf}` + + `${sicherUrl}
` + + `${escapeHtml(hinweis)}` + + `

`; + + const neuerKoerper = koerper.includes("") + ? koerper.replace("", `${block}`) + : koerper + block; + await browser.compose.setComposeDetails(tabId, { body: neuerKoerper }); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'" + }[c])); +} + +// ------------------------------------------------------------- Meldungen + +async function melde(err) { + const text = String(err?.message || err); + // Immer auch auf die Konsole. Benachrichtigungen können abgeschaltet + // sein, und dann bliebe ein Fehler völlig unsichtbar. + console.error("[paperless]", err); + try { + await browser.notifications.create({ + type: "basic", + iconUrl: browser.runtime.getURL("icons/paperless-logo_64.png"), + title: "Paperless", + message: text.slice(0, 300) + }); + } catch { + console.error("[paperless]", err); + } +} diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..81b0c9f --- /dev/null +++ b/build.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Baut die Erweiterung zu einer .xpi-Datei. +# +# Eine .xpi ist ein ZIP. Wichtig: aus dem Wurzelverzeichnis heraus packen, +# nicht das Verzeichnis selbst einpacken - sonst findet Thunderbird die +# manifest.json nicht. +set -eu +cd "$(dirname "$0")" + +NAME="paperless-thunderbird" +VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' manifest.json | head -1)" +OUT="${NAME}-${VERSION}.xpi" + +# Vollständigkeit prüfen. zip überspringt fehlende Pfade stillschweigend - +# ohne diese Prüfung entstünde ein Paket, das sich nicht laden lässt. +FEHLT="" +for f in manifest.json background.js \ + lib/client.js lib/settings.js \ + ui/store.html ui/store.js ui/classify.html ui/classify.js \ + ui/insert.html ui/insert.js ui/options.html ui/options.js \ + ui/gemeinsam.js ui/gemeinsam.css \ + _locales/de/messages.json _locales/en/messages.json \ + _locales/fr/messages.json \ + icons/paperless-logo_64.png +do + [ -e "$f" ] || FEHLT="$FEHLT $f\n" +done +if [ -n "$FEHLT" ]; then + printf "ABBRUCH - diese Dateien fehlen:\n%b" "$FEHLT" >&2 + printf "\nWahrscheinlich wurden beim Kopieren die Unterverzeichnisse\n" >&2 + printf "ausgelassen. Benoetigt werden lib/, ui/, icons/ und _locales/.\n" >&2 + exit 1 +fi + +rm -f "$OUT" + +zip -r -q "$OUT" \ + manifest.json background.js \ + lib ui icons _locales \ + LICENSE \ + -x "*.oxt" "*.xpi" "build.sh" "README.md" "SICHERHEIT.md" \ + "make_icons.py" "*/.*" + +if ! unzip -l "$OUT" | grep -q 'manifest.json'; then + echo "ABBRUCH: manifest.json fehlt im erzeugten Paket." >&2 + exit 1 +fi + +echo "Erstellt: $OUT ($(wc -c < "$OUT") Bytes)" +echo +echo "Laden zum Ausprobieren:" +echo " Extras -> Entwicklerwerkzeuge -> Debuggen von Add-ons" +echo " -> Temporaeres Add-on laden -> manifest.json waehlen" +echo +echo "Dauerhaft installieren:" +echo " Add-ons-Verwaltung -> Zahnrad -> Add-on aus Datei installieren" +echo " -> $OUT" diff --git a/icons/paperless-insert.svg b/icons/paperless-insert.svg new file mode 100644 index 0000000..a58fab3 --- /dev/null +++ b/icons/paperless-insert.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/icons/paperless-insert_16.png b/icons/paperless-insert_16.png new file mode 100644 index 0000000000000000000000000000000000000000..d8af0b867343efa92641ff8f0044f1df255cd0ad GIT binary patch literal 885 zcmV-*1B(2KP)I+t|X+ok8m@26XB?Pd=j20qEf3PZHT50F!_Rie7=WHLQ6yt+mva;V+*4}$j zed^BF^RC@q$juvo1a3lL5D*H4XPaXUJJWq>8!1Q(K;vJioSj?O-nwD)#+Ei{<3TKX z7&eeX;ndhLPG3Az-@UtgTi?Mx8$A%%3vT`q_E4;5g;ZJ&&!MxH|D5_wot4M1%oo<@X0Llbyxk;~yhyXCbAm zWF!H%>>}CNjDs)lhZP8m>QKn!N3PEikw#CuV*Y`E4eHq5qx+eeJ^mlA{ar98&R;MS zfBkuqnNv$&2!tY-yf%ebKiZ9)or97Rf&}d10=7TYg;gsa#-1bRke-?&Dg21X@*Uls z+`-KK0G?M`ySOb}g{=>4LBYvGNd;zx>y;6%)Zvl)mt$Z5FESkT;n2WY zeP6$`rfECM{@bf~eG%0RW!%pp6GlyNFl&VEHRA&CC~(TpmT#3WA7`cRUzoG}T2(L2F|LLK=X! zf{~>XCE|W;ZM_Sh45md{8`^Pb7@O8C!v|koMpa0$Ab}m7Nm9|OnDyh>uq=JpC@l1=##KZ8XxZ5NbB#3zo8=Gs-d&#bH(pY4H{5o zC6ELhhsMS)^3gYsuRi>Bq-AhqnjKeDV@=q+*0n}&S{>g-EWJ^c`Fq400000 LNkvXXu0mjfUOS*A literal 0 HcmV?d00001 diff --git a/icons/paperless-insert_32.png b/icons/paperless-insert_32.png new file mode 100644 index 0000000000000000000000000000000000000000..323ab4b0d191d5538fe94ac8410e1df9092ab9dd GIT binary patch literal 2012 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 + + + + + + + + diff --git a/icons/paperless-logo_16.png b/icons/paperless-logo_16.png new file mode 100644 index 0000000000000000000000000000000000000000..cca5463ab363443e4049b8cb414b57502b93f295 GIT binary patch literal 680 zcmV;Z0$2TsP)3n8ELC&Z+bL^ZO?NEBY!22<4m=TNV`o1OXHf zP*pPLKv4{emBcswt?v&<9?6Qsm0q>-)L0uNwS+WF5s`V|(1w%>MN}04L_ihI^k|?W z7p%2GZE~u4aC@&^+qn+!93q0MVyvb5v&!SQ&j?IF%b7X}B~;Oi0ZuKGd+<)Bc z>)Fe~7#bbsU3HLTWE_vjt8Y0}B7@)o5fR2~3C=s}jT+5ngGM@qSI>{JANZn@TNRPi zla`BCCO$xCo?wl^h(V_hfOn3r_D;@hKS8ljVrY1X`!64mFXT~0f*G*$j#5-)@Y6?5 z^`E7^6e9w^CdN3i=NOldUjpDm{DC{q?-CIK5X`AtlKLdOHt*!s^(TZj1cA&sI?C~^ z$i!4K-DBnxcy&Z=c|KP^bM4*@%7qfndje~zHKyq7SjpAXS1`s*C%1q=5Hgn$mtuBr z*-6wEfj~2B@^fO0$3Ynf1faQH_j?%_?tF~+q(fPPPtFInahf5@5VD#aa+rI(UI1VIYTA|?p O0000 zZD`e19LGPubN>JPFt?dzsk1q!(j7ju(Ag6N5h~b&qe6UuA@rgL=uKEc7ML%D2qf{1 z5JqTkCK?iDT9|=E1X57g3&Cn5DaF~;ncKbpbIz|9+jN_|Pi_9-a4+1;`F+p#_x(Ky z0NXL_AOL3OhWSs3>VxV-8bB3c0w#hEeQ{#+OzqY9F}q5@~iXV&7M!YkFgFb05jJ$KZMa!A||a#P#|z;Oe7K`A_UJf7Z3e@ zRF=QAJU)N9=d`43s}_8MNPH8xYL&NFM4J;UQE>WKwpJHM=X)JM55{kpKFcf%D-1>fAnc~zW6SQ%VUf!>DUKP zDoXRwsr~Hf+)J(05M(IkMd?BjT@h)}c&y}n2-Mfq(mZ?KkbU5LE&oic%$iAI{y5hd1!-(luCPP{27yCR4+%&fV<&Y#(>m zPse#z9e_AW@qXtnzW(tfbv1Q_;BTiH2B-A^-{l{k*kt3pve^PX>sL#d$|MmBj^DoZWH&5?5fy=C^sR{beE> zq2rV|0YXq(=H8F<304dua_f|+BV_>u_2e3JN7A> z15pIM{VZ)>!s@n_RgZ8L0dPKH+om_@ShbNjibpvyBIJ_-56oXe2)^pqmjs{;b~xt} z+H!5Q<=V=KHIE>pYW`pc4JDb0iVawiu^2IA(%CV?{rKZc9HqypAu(WxqVWqDKqN{S zd%HD;Yd5a*$KPEQ4W-3X(UFQX{Bx}rYwTDM3KC@2^G#0?o1^M9Ah!mTYEr5EVx*(P zpm<3c8w&o_D6;;jRNCmeYA_O-gnn(mbloP3CesIA31Chiw-RcYl0yIUQb=r?xlU zl}*c9MV1jXjjBvi3{(Uc;1@+dzjUbk!tlfY0~96*_-$`vwEzGB07*qoM6N<$g6JPH Ai2wiq literal 0 HcmV?d00001 diff --git a/icons/paperless-logo_64.png b/icons/paperless-logo_64.png new file mode 100644 index 0000000000000000000000000000000000000000..cd3bd396269c3ec673d96170cff6a5f98e73a012 GIT binary patch literal 1927 zcmV;22YC32P)up%i1)MX{tUEu^MRlbU3+CY#N^%*(yc@x$!YWZl`!WbSTe z_Q0JFcjn%?=l}fAdH&~l?h&fl2#zt5OU{j=Jd81l(2Q1jwucEz_!0nHe92OdRL)u7 zNMDDt?k*8-Gm~9leP&g!YYLO{$Be)$LF6Tr-%T8R>-0qf7^(ws(GRX2+4@bx4v5#y zB47b9qa{p0Q~)8wVanjic>4VJ&;I`G^kQbqV&{ABdEcOq^l|a}NsA3jEJ4gLk!BwG zB|r#ZAmTOSusC^@ICJ~4LeFO>mTKo~(VFioCZaK4C0v<93n53)#Pr4R!hf*ibdmxdT177R0P1I%LxBkZvxfGwZ)RFw;J7kKDP-{aO@w-CogRMU+X zGjPr`F*Ct|UwoVMXU^jzkENwS1hrrwZ13NpsBVogZ9L z229SLVQ|L~!2&arGq}j52SWKaopF+E@C&ZR^-y!JFi#Q!#Zn%pE@cK7-eAi-y6qjd+l+3q6VN)6L!5u>&=`x{p1dp*IOlU2|cQFWk>44B$07&{wXkE>3q{4)z z20%or9#~nEY}~Pl&c05PIJu(Qp=oCUGb3Nh6D(BM=LV?Iy{(5rQlv1IN1fJHJNPso zI7z8M3Z*=~9UFN33y+qIgJtL)LLiDVoR~Pl13!Ea3szS+qy~WZnN;bNiQ2ev^PZZ2 z-{U(Gk-D`(!_OQn;9bPwqlbCwj|a(Ra}5a;6=fzr%V$1vHy^(KL&QmG*@`n*Ao5w} zigQ;-37Q!q${WXz@RO&0L~eagL!l5k&+ODBn|g=1W!KGR04}50D~l*sRSM@aS-Lk2 z(%sS35CBd+lg`tgZC{mSn;HP~#RBL4bCP_q&=3ILdFG}jna$6&1^_Bb2njcB-_0Wj z9;Gd6BUrfd`>8TtnCGK=ZY6{y^%bYo0VrqQ!0@JFzOiXI)rU)Ji2tSufjBOvrUNQW z&{bHjXj&y0LLe;K<#y^)T?}bzA+%grG!+15hN|Ppi6cBe{)dK>V6{Y>FXg#w@11NP z+C~UTYD`E+32KIa{_8LY9{GC1LTG7i;F!N~mcbv4F}!J*I4R-8HCG6vC4{O0a4wT2 zm&vYuA+#I==8J{q)d4#<5A&`2zteCrRP8_J3-j#UJd9aru4VCx65PFQH$VRJ?p2uu z($oh{D}<_rLeuI1D6i37(Vo5mLhAdBt1g6^P2{Yq5IR0_oWH$3PUJIK9jlUpkc5Qw z?H$~4XrwYAf;zWb^aU8w<3C>EHSnjns2;yMACE6mf|)!Ui?RD{r$fz8}^=NIW{ zTSo|?uIme>A{%>$S`&bZ5Zb$QFHe7WZ^N^It?~=msS2S+%ixyQ0V0xGiMpD$c3+9D zlL2)FUa&wC%E#4iAqW=G5b8?2O8{W5R!0n@J)6U+l3S(Sn_8?Rx32D}H8rigC^=J$ z|Ayh!^V-|5)3biVio7aYKLZT!BHo>Rx8BMvZ^6w9K>ugAJmE5Ke<+6da*0t83h~On zEPW+OI2&ba8O0!8)RJ)c^fSly_=T(g46^^dy7V$E$VQo_YbG(QHVq*JaUvm>f4r9i z6#v^$ihl~)zR(&bm#YAc7U>}Sf270=3Cgd^zK`NhjlFrsg8Wjms+c9LRWlNa)P-U) zKFQmM$~{9bVvM5M>E1(Hi0_q1`$#NCMAw+}0S6d)L_a+{cJ37Ys8%$<*{9!}PKuiRpwj=Ttpl3lXTayh3l_kFLAlkWB}`Nh zS9$huD42ci%-FG)tGCWB0s)}+-Yqvdr}qIjo7r}OP7Bu55G<@r=fU1WbsWO4PygoF z3yXcL4nXDnqBit_`?vKwv94kfniT|5u?%2A@+ZfRzH`Z!OLUF{{1^O6VgrPCvjPAB N002ovPDHLkV1fYJndtxk literal 0 HcmV?d00001 diff --git a/icons/paperless-share.svg b/icons/paperless-share.svg new file mode 100644 index 0000000..0b18cdd --- /dev/null +++ b/icons/paperless-share.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/icons/paperless-share_16.png b/icons/paperless-share_16.png new file mode 100644 index 0000000000000000000000000000000000000000..8bb01366e3acd91c902b1eaea463f1016f37433c GIT binary patch literal 883 zcmV-(1C0EMP)LiP6D`pg>ZQck1jL5=;6rH@qO?tbnK^SgXZG1X45h{gSF-Xy{VOZ$ zU%#m8xbLN;>olhlOFjt6Z3qkkLV@tDjj_9*y*zl?p#zIiH2;E1IEk&zjXTe?oDlsag8zhzZ@AK^<-Pb zRCcVNsBN!}uKHVnL`a!)a}tnXR^}Z>?69hh)#TTeS8Q{X8LR57E2kH6TSQrwg_Cud z{C$d4CWW%psyW~HsfboE^Wi0x&ZNE8lkHotUccI8UNh&#Z$dDNR>j2uZZ6;A-OmPC zc9yX$tKd;cT+gMhdLyTg^5hTSx6yqvS~VZ?qk!Y?Y#KK=SCuVHV)Zc>>f%2F6(aPuC4Xbtg!0TvR=go=tt=CYh>+oc!N zY4ghX_`&{`7Ok|_*g>1d2ey#T7DDuWpLJ!G?D^m$<`(`Y6p5gXq4%-J>1uey0;ufi z(GSLAPpM!q%pW)Yv{d@S8=^8LQ0CPVkTy15yq!yS1ZLu0Y1L;GtUg2T}iG8+YWKV zaZrWY8UXS-k2Dq%k9$f>aJA;>$SBoiQEH-5#=4(pa&8_cm*buBaW<@7D->G$b|9ny zZ3iQ(C8~{;)48RQ_rDpF!DyMtx*ktH{1C%kT>xfydYZ!x4MLTyiP_&@xoG7+aV_oV zk+SeiUOvy!=KahkxAXPPuaw#$My98!j+O#&qODD|*43R5RR=b7Cv(Ydsih?!L19~1 zkjaQATH4J}&$;@yE?n6B<;;vpd!8t>ZU2RMTpy^fe?th-_YVT1gSYcmvqt~`002ov JPDHLkV1fszu=oG~ literal 0 HcmV?d00001 diff --git a/icons/paperless-share_32.png b/icons/paperless-share_32.png new file mode 100644 index 0000000000000000000000000000000000000000..959b8f8903a0a46ec4422a2cd25d52b45c3aa907 GIT binary patch literal 2142 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%0D+c*-*4W`+(nD{DuL!9A8+r zjPa+RF0QDp1;_wDan4;L-)W+yphy^%PkBU9m^e%p>m0ZI{O9a#ZYJw_C$;!O6f$P; zAU=E9<%ChtPN7OljJ1SOz-KSNoKZD36vL3T=P`fD681JVse~Oa25yt@*b4~REkh}d zL!hMO@ilAs-G&YH%VvpA!f4S_QVJq28#WB3R4ms!sKofjjYBHM6}7drlmjY#pY8Sa zeD%5K+KvO9g?r`kc!I{-lO7$NBndPYi#)J=Ik`;cgcmP_Kubw$5MaZQv->3H^<)9fzkqB5V2CE3c4F1kRDnBXQ1QoQ+dP#L`$MIbB~UFu1ak z`)ADHmPr?r_0xn=fak^PFpPr3-{~BFD$Q?RUC&>3?V=);Y8O2g%B6FBd+9?g{NEqZFW>)!g^(1I)^d^2XN~0LAFMzK!PWx@SiffvnGOJmgdz_X z3iw)+^*l;ZL?H;sr_(eSi)2zB|9Z(}zI@eH49-^(Mgd^)bc$uK{sSD$8Z-8Y=RdP? z1D{#?6sp6mKL@0gVt;cpf7-sCkBl2fmno1=rK!kPQkkj5xDx|FNkwELt{gv&l;;8P zr`KPnp;&AWf05|ZANs~KFQ7~!fP;R~S5{6e- z10oK0onl{e6W?F?91CB1iKcRye%UP6IiB6LiDer$0N_bUrSBsWHaN=cRf^3A4j?+~ z1*DJ^N(B~Na|_eQO(mU5pKuM3LQoC@#-BUBUGs0Lt3xY=OA2CLYb$q5nZo=@lPLuy zWa9n1B#(zz4w!A@X0!C~K%@C|y4_odYl%B1LpZ=wiZ>e? zI8-bi8-Vx#B4F}}iHtk@{BBc!yBizPD$dEsTKc5Z+;s7USQi&N)zR@0f*=f#Qt{m8 zO?>&czhlj=T~wyi?J3leMT~RDCD+@)KUgSmprxhj6tLD(4oWB`PnP}gbgJD{^^(O4 zAqb73rZUIr`6?vtmr%Tn=Mx zeDtOi`vW9QYn#D_>yNw$rjHjtormq&&Sg#e9y<2 zxbb=XhL7>gEjRQ2^UkBCT&6V$&`Bs4!jJ>4tsH1=r4WWFDLPH<&=^jys$xJc*Yf~4 z=h_)$)Sy8)=VB^!4qqwuHaBziqmT0I_3H_ZjW13kc;U6pT=U~cSop_3Qi==^-x8UA z?g*Z~`DT9pi5nR^crf*a0*49(#ta(7UDKv<*R*Mj8B{|`5sYD3=t(Iw_QF30o?JcUsILIw0jPrwe-tonKFEMdxzQ^ zWe{WI!Z-JV3n&J`5rwMF@y&Q1`Ls_qX72pULpj_8nZeXc5;x{urxw?nDYn zIV=-|Wo*LlT1s$^FbcT!(n}dXWC*Rv^R$#Smr7hwTg%XX{V>+LUSa`@N)+BFFT9W; zRaKOYIVwNfBI51xwGE6uzE3`r=9laK!KCkfpNChiBBfJD<$9Y0mQpdT_H2ql>=wI( zEpEH~@-~_my-I^NaenWdI+dnEpxUg2j^Qj+mAq&)wWho@7CLX`Mg^XT>0lkXhzVt{;}+qi)jx4lk2ou;8!WZ|4S4Da9HI8eYw;G3Ox zgRgA;JLM=Mio$jpJGEnh_*`JZITP?apOG~+EWGYIZdkmSs%*C1$UG6ZDWw#j_}P;z z`InC~wf3AN{{G_DEqrpxlT@Tq>~Ct~o~y3ny7SL>)>WSbnAhr6q1%=E7xA~L6+Y81c8Z2Db1oaYq)vI60)fjX{}GF(UFOtprqu( zW5+V%yb-Y*v}rTHdi7O8V`wfGx#Nl}`0lmW5*ZU}r99vsA;g2uIo&A$F$aror9vqc z^^Nu1HhTdN-tquJ6q3>&zumZz1xuImW@97$6NBS;qoFNwno6a_cMxb5Q(;PLzB+Rz z|2}03#@bLx=>ZQ5Ar`cq5-Wh*sH%&E67O|~kAN*;9j+S();en|(iQgkzpk(?#TGl` z!uMLho-<$ob1t|*Y^|$f-R|8O=SX|ran%NwY*wZ3lk+4oKGF<6{)dEhG+{>>WW=?)fDOS#Nz2M(|$NsmICj242ig9cGsUCp@z2Qo01 qYyaNJ7@LT!Wx$<6h^?L75&Az|etzg + + + + + + + + + + + + + diff --git a/icons/paperless-store_16.png b/icons/paperless-store_16.png new file mode 100644 index 0000000000000000000000000000000000000000..9f0cfa5264759b434c3254497dd2d932c6cd34c3 GIT binary patch literal 889 zcmV-<1BU#GP)z5&O1{YU>px%ATYoTMh!908=qd*2&Dt? z84pbwf=l#Vzlb0DGRf`xwr}Y;-r=GR90#EvTBPOYhfg0>MC22Z`mZ|Ab(Q%>n`&B8 znfm7XIkWj@;9#7W{1OMi0h*Z)Wm^$hUsV?*s^e>2NoSJVmQ>8akydyFxaZi75e#DI13i-V_*5D^9RKY0J#)>kff z>%Z;{eNRL!vn_;88^d@0z#H%HgX21oLVz(2ujrzA{Z?#QxCjHC`%xI_V<7;A>f{FJ zsTVd7Q73?xq*6X&ktm*B`3&-2Zu$nKQ3?V)9;v{!qfKxpM!`cS$})s}c3k;pf@-~~ zt+7pqlvGN=GAyiG`Xma)JSv2Q(i(}mH5fec9CCk;qOxHH9&K*L;K@Bmq<4u0+m2zl zYrj&NPhOOU5y9yA7+!9F4UX$TFa}m(p#AyX@NfMDbN)gse)S~mx)qSo1pfT$5GMP+ zM(+2Fa7X${dR`t2=B4n?j!{Aw;lw!2?(%c?H4d~YA=+Rg)pN~evvrmlw7|XvNBp_UATTx zls@x?(g8T>~c5Uv8#|~6r>fP11#V5A+wh>z9sEQ^xTjAmx|x@ ze5=8xR{|4&=ds>fH|R|3>E)vC*s{rA&XV#RX2esvZqr_6Ki2dvW9+^A2t1sN5zf1* P00000NkvXXu0mjfFleNN literal 0 HcmV?d00001 diff --git a/icons/paperless-store_32.png b/icons/paperless-store_32.png new file mode 100644 index 0000000000000000000000000000000000000000..6c51b23a4e34a547b1c941163d3c57501a69d5a3 GIT binary patch literal 2060 zcmV+n2=n)eP)dHHc6db;@_>+g$<00YoV|DdxCsvdlXTClnS18W zIqTbNf9u=dlIj~8YcO1f!zn>PGu|c2TIVUnQ25sVwR`@}Ts%67n81?i#r5-5%v+Ch z7-Mh~dD}TW-$yJxVLn>Wzoz@`iGuKyQjrUt#W|NMDif_V4hP7X$wh?0p+OtCs3sPR zt(&x{{__5{-EU0L%1cr>XOJ-f6pG+DzwJC`HO|z3+q%J|%2HK2#=7cj>z4Me z`DopEKpdAPiXxnIgi%N|jtHX=Yc0kYw5PRLtP)kBV%{GoFFyTpI*FKj{3bsWfQU>u zlu`syKsJ{p7v@wpJ7i5{;-05oo_t;H6?77DKWbb6+(;)FJN}4FMBU_C&S+?3M(uRY zIK5SsB};8C$i+QBzP5UC{d~TUW68<;+dDRS%#We9M(d)9G$TL1<7vV$z~NBJW7qy& zYSDd5!@l8z2~T;8ferM|73e%UfYIdLy?gm!*S`uR9IFjSjw~OA)K}MX_Vn3UYjMsp zecE(p)U`oAy>8AWbD#Uc?Qe)kZUl%D5epzSi<{@6%tnQBn}4!S8mBZ(C~**EQA*+a2>?tk zL(sp61P)bH29$@Ja2(o)K`PAdZkwYfXRpAO|vsur@+Y*}4v;G@0QHRizc2 z*)$7)!ELY7)Ax^3Yk(}%bEvY!~}=g`TQO9ZC(Y|pkhUc_VAOX;2btL zs0QBsGY7W3WUJ0uWZJ&IYF@IU;??2dflDM=v0G{uH_x+#8%q);?#CUE%9ibKqtvMh zs6;W88{)=`mvC0oOx}O=ax&XD;g?m9+@KC{IBQ2u1EPI&yol)V0pgW);p}_lB(Hz;yr^7@6k%{Qr@&^vQ{sMmLv_f*_ zkx%zBb-}l&{=(M?2YUfWtb8i93-2U3r4^khqv1;{2nP0f`_}!`0=E@Nc%-yh_VjdP zYl7pD^7^&AjC_T>Ajlgyau>O#f>yk!rh`#v2~P z+6a@+QFry-LgBL3Tgm7>_6=yG``R1oEQGh6q>C2xZfAAC1+dA3)>@pgze}s5tEjxbQLFT`k zi86hpcf4KzAR^?$JoVMJEW7kZv_7!~1x1k0a$ei#7}~i77YreZ$(U@Ks`Hl8+VQ&) zPZ8iuh?gj3*8Q*1^~k04bpDp&`q{)MHPH9k^H2~1hIVZ|Mv8M;&Tl)HrSq1INxpsW z>?z2#btpfMUs}cP-+zZ>-I+|j;3fciHm@T0aToDP4a6&|_-M@o1z^SHAPO7i$RFBw z^g14`g0Y4$I=LrwHX;@;M)`@Ord2e(n|?(&n5BB|^;BQD41j@m|3q)+YW(sNR4j># z7b7A#YmkvP7cYLiT@n$r(paTW4ge5@M0FG5`A0g#D=IH2WTF&j3wMo5l;Wq7=tL>b zgavJIN56w8l2ckx6Vd%qqgq_oPAqjAHp;`uuAB)GQ7ENRN+Y5QiJd+1LngvYmQmV# zfjhyHS!YijIKprkT~bNKtS@46X+#yggD9Uc(@T2C8>Dxe5sRZl5%-g0R%6?h)q< zC~fN(-KT($j{~sdlU+o{5}N#H4kpeTrq|7&@yk!J{h6EaOR5m1k0c(QoQ~c7=>Ne$ zk8A$Q6Sk-oM5|i>akw$VIx8_hCL7=XhXnay zS$ORoQq_K?4EO9L=--VAvS1^eGdPpS=7+%GeqpOSUFMe3x(l~PkFzry+1`c(tlkDCPiLz;QqL`woZKTea q?Tx3g0000aG z{?51e{`TJI!AtKYqyA&9Z=3RFGVXFM$6gUTsv9r_j0i!uZD-SUti-b%!;OT|_ZT3E08j@l&4PHzGTyq`a1?j9t2n4Ku@UpIMg%-b$zEJQs0Y=kktaJ zC_q(%N!!IOwW%w&v*}_$M}r9o0|W!&>YH)^K6q4hP8ky+gO_Gv$BS==&#m3Wq5M+E>88I@(kpeUtkxt?|Q+k^`hAB&R;NmtM76~@yLYneT_~7?bB_f7ut-|k~d5pA6 zA-(R9vU6?K-rxAvDX(e2XLCorXwknZ0Iacu5OdXe@8hcTt{C#!oA3G}pZ($IC}s-; zg4JpW#>iZkq6OGnSBt|3K+sS>jgc>kVjtJFfjRj1G*PG5U9yZnZ`;WC@Ba~FicNU$ zjRuX4bMdgp09D0^A)C&PYGc6r_~7zepcdCqRVIy_NE~9M{~1G28CC#-26CAk-~Hte z`0qbHKx4j02qRaP8)N7yck|K3OF4bkX~e#U5i_7Nt;V%|jUS<6NR^aJ@w=!0z+Jz( zgJM%N-usb@1J=^nxrfECc?%0>FG%_jGI-D&@oKOagvdomI!iW{IUq6*e;_QSvsg2# z?Z}}42pVV^)55IdXE3(dgx8UC0V{^~o(>Ax{2>Fb5reZf$sQWepu-Pdd#fU=!Afs-$O;R3; zsv^DaARq_VJd#Og0Zyu~r4Y8N>VE=X5s~%1K!}JxawQl-y)`y6E?|xAKV_-AS})tg z^v!6hZCk1Ad7hrXJWZ&T>S|HQPMk$*++3H@b{&L?JXn$rEzTAz8GDwuhr1IEwasPTx0~$P~ z7uV7K{FA)6>YEg%%#(@dT;s-{ahU=hmic+h#ieKnL=7#duN_nMt-gkafXx)~!wVjnYk{A7#**0Fc(13Yu&avsP>k;GRxN|@5Lh#-gd#kGcK}6OJSqn5bHq+eLJR*v(w&w*leEa>$CS8OM z;bLNJUmO_x8Aq*aH&fnn1FdJhpWtiwQU_~4zX<26Y`*(bjdRWh$O7L|)id3p zHd?8bhou$}48~fd&ZG_B{UFu$7jgM9L;JcI;+}TII&343#*1~t-Fs*~ z=fgCeyok7~4YAR{dQ}V z>fI~f)6NU`-h|B-(J++pDQfLonee(Rn11Ok#N{s5-uy{4zeSU^97UT>X7g<0j)vopr@1 zNyN`Jkr_XY?8I5vTvNYN5<_OfbTSjBlWJ-Oln__g`m3*k2*JlX==}g^92>W7V#|&# zq@xsi6uC+3>F<;iUrEZPP#?4F_dmu&*+B!ESV!Emm&UnoVD>dDG3gxYYxr7)T_MrrWaTqAqe{E|xBR?=gR6E|29RLB&# zW#yMx@n2tOY~#2ib_FAb?n;T1rp)2@U;Pb;P};G9o)^|&QrQ86Sz>$#neo$^^`ReO zGX;FDf|#WF5m!s(CeCK&(mPr6xi=8v3MQ4MwDlSGJaI2^xs&|VQ^~fRh-yIND!bSG z0#T)N;~x;(s@ie2kJqf{h3uXFSvMc07QhYQWxFryOUPc z5i-D8N9W#MOl>`(zn%4$wF6GC#29>~hp4HQIUl=+?8MplYR`ZXUyLKJ_AqYV1H8=)DtI+P=Q-uezT?R3Bqf8D@RtC*+TtL(qUt@}a#F?dbZvbKV-sbVYHY=%3;O^j_)-Ve_7@X% z=wq7ZEM&s#-p9^|zlY5f>Du&vbgX~0-b@i}rnvv@*Lu*5yZ2D**g3Qdgb=7y%UElU z)@C9XVd{Q7pome$L>ac;_XS?K=d&PE&+l!_TzV(Xr(Hx`D<^IhWoUcq*R1>6I}$Ar zJSdoS7L&@MbtP!i{Y{)fCAP7MwL`bWj4@bik4XyeJINWe*wKJ5clHlPRfg34iQ4O3 z?4t|nO_Ia#)}-9_j8?xJfT&@@peG{6)~VXNhT59xf3D7$BhjWBJTBWvR2VZl0H_8K zA=^3w^|iyC@99x~cIdKP;~MEPlW@iHBM(3_>_|48J8o{B4S$6z`TNE;UnSFWVv-R? z2Y}>fiZjlv7eSuC-YdDH-!SX73^-{R0SFSxtCF2KhivOCLamgPsfQD&LVUnFHt^Kf zaD}mT07mJWoT8|DjEiVK{bEA32RT$`QN?8&5aWo_1)_8TF%Fk)IO_JC7>BQP(lqDw zWSXZ!@aje@2%7p|ktY1zkGJ$+gm(Y0kY5~Yt4 zgv1>up7W93JTIdJfTEJf(Wqeplg?en#ygi0HMS0@4O9arm1W0ozsvTxy8kJ<6gE>B zct&uD)30^yX8akK&@l70TE9|Qazl56k~JK!pf!xKnBZw$xRhOgyqm5~kK?ip1N(K| z1rE*_dgO6H3?Z%(6~}SHMK=P3q<_Ze2L&LX&S8xqn@nGaHx3)ij4Qsy>YH9ih-*oK zG{A`tSGEul;&KNwuDpxP_~{hCMJcYm z?UTbg5H(`xDs}VLGtXz`@;mTxjpkRqolq;Y;r6TQ1C7j(jnX4!nQEZAcN>$=yOt?S zZX)qW6inhL+DM=&N)&pMdYGkSgsKx~ zjW{zxOAJ;_W3j>fdi6u5t!J-UIR9*8F=DfgVf-1FN_OHbDm(v7dFMuawWrUIMGRt0 z{qB(IzeA*o`WRpC#8DkGbpdq!?tdXs+o@*Ca*%p(NF7}4NqU;7}t9{VX>fBplN-CGFNQa$(U ze(B9bX`+S+6sF8)%)Ij%`^xii`7zM{pWj`;heTxMK$oWedSw}4F?a&1+6a6a!iR$o zk+H6i`@Y;oY5RK0+t<^*Wi_Ev0+aLt^2fiN%!Cukw$31Gn9%>lJ`N { + const n = parseInt(v, 10); + return Number.isFinite(n) && n > 0 ? n : null; + }; + for (const key of ["related_document_ids", "document_ids"]) { + const val = task?.[key]; + if (Array.isArray(val) && val.length) { + const got = asInt(val[0]); + if (got) return got; + } + } + const nested = task?.result_data; + if (nested && typeof nested === "object") { + for (const key of ["document_id", "related_document", "id"]) { + const got = asInt(nested[key]); + if (got) return got; + } + } + for (const key of ["related_document", "document_id", + "related_document_id"]) { + const got = asInt(task?.[key]); + if (got) return got; + } + const m = /[Dd]ocument\D+(\d+)/.exec(String(task?.result || "")); + return m ? parseInt(m[1], 10) : null; + } + + async #findTask(taskId) { + for (const query of [`tasks/?task_id=${encodeURIComponent(taskId)}`, + "tasks/"]) { + let data; + try { + data = await this.#json(query); + } catch { + continue; + } + const rows = Array.isArray(data) ? data : (data?.results || []); + const hit = rows.find((t) => String(t.task_id) === String(taskId)); + if (hit) return hit; + } + return null; + } + + async waitForTask(taskId, onProgress) { + const deadline = Date.now() + TASK_TIMEOUT; + let waited = 0; + while (Date.now() < deadline) { + const task = await this.#findTask(taskId); + if (task) { + const status = String(task.status || "").toUpperCase(); + if (["SUCCESS", "SUCCEEDED"].includes(status)) { + const id = PaperlessClient.docIdFromTask(task); + if (id) return id; + throw new PaperlessError( + `Aufgabe erfolgreich, aber ohne Dokument-ID: ${JSON.stringify(task)}`); + } + if (["FAILURE", "FAILED", "REVOKED"].includes(status)) { + const rd = task.result_data || {}; + const msg = task.result || rd.error || rd.exc_message + || `Aufgabe ${status}`; + throw new PaperlessError(String(msg).slice(0, 400)); + } + } + await new Promise((r) => setTimeout(r, TASK_POLL)); + waited += TASK_POLL; + onProgress?.(Math.round(waited / 1000)); + } + throw new PaperlessError(`Zeitüberschreitung bei Aufgabe ${taskId}`); + } + + // ----------------------------------------------------------- Dokumente + + async ping() { + await this.#json("documents/?page_size=1"); + return true; + } + + async search(query, limit = 50) { + const params = new URLSearchParams({ + page_size: String(limit), + ordering: "-added", + fields: "id,title,created,added,archive_serial_number,mime_type," + + "original_file_name" + }); + if (query) params.set("query", query); + const data = await this.#json(`documents/?${params}`); + return data?.results || []; + } + + document(id) { + return this.#json(`documents/${Number(id)}/`); + } + + async versions(doc) { + const list = doc?.versions || []; + if (!list.length) { + return [{ + id: doc.id, no: 1, added: doc.added, is_root: true, + version_label: null, mime_type: doc.mime_type, + filename: doc.original_file_name || "" + }]; + } + // Paperless liefert absteigend, neueste zuerst. Die Versionsnummer + // ergibt sich aus der Position von unten gezählt. + const total = list.length; + const out = []; + for (const [idx, v] of list.entries()) { + const entry = { ...v, no: total - idx }; + try { + const full = await this.document(v.id); + entry.mime_type = full.mime_type; + entry.filename = full.original_file_name || ""; + } catch { + entry.mime_type = null; + entry.filename = ""; + } + out.push(entry); + } + return out; + } + + async download(id, original = true) { + const url = `${this.base}/api/documents/${Number(id)}/download/` + + (original ? "?original=true" : ""); + const r = await this.#fetch(url); + const disp = r.headers.get("Content-Disposition") || ""; + let name = null; + const m = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disp); + if (m) { + try { + name = decodeURIComponent(m[1]); + } catch { + name = m[1]; + } + } + return { blob: await r.blob(), name }; + } + + async postDocument(file, filename, fields = {}) { + const form = new FormData(); + form.append("document", file, filename); + for (const [k, v] of Object.entries(fields)) { + if (v === null || v === undefined || v === "") continue; + if (Array.isArray(v)) { + v.forEach((x) => form.append(k, String(x))); + } else { + form.append(k, String(v)); + } + } + return this.#json("documents/post_document/", + { method: "POST", body: form }); + } + + async updateVersion(docId, file, filename, versionLabel) { + const form = new FormData(); + form.append("document", file, filename); + if (versionLabel) form.append("version_label", versionLabel); + return this.#json(`documents/${Number(docId)}/update_version/`, + { method: "POST", body: form }); + } + + patchDocument(id, payload) { + return this.#json(`documents/${Number(id)}/`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + // ----------------------------------------------------------- Stammdaten + + async #all(path, fields = "id,name") { + const out = []; + let page = 1; + for (;;) { + const data = await this.#json( + `${path}?page_size=200&page=${page}&fields=${fields}`); + if (!data) break; + out.push(...(data.results || [])); + if (!data.next) break; + page += 1; + } + return out; + } + + tags() { return this.#all("tags/"); } + correspondents() { return this.#all("correspondents/"); } + documentTypes() { return this.#all("document_types/"); } + + create(path, name) { + return this.#json(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }) + }); + } + + // ------------------------------------------------------------- Freigabe + + async shareLink(docId, days = 7, fileVersion = "original") { + const payload = { document: Number(docId), file_version: fileVersion }; + if (days) { + const d = new Date(Date.now() + days * 86400_000); + payload.expiration = d.toISOString(); + } + let last = null; + // Der Endpunkt heißt je nach Version anders. Beide probieren. + for (const path of ["share_links/", "sharelinks/"]) { + try { + const res = await this.#json(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + if (res?.slug) return `${this.base}/share/${res.slug}`; + return null; + } catch (err) { + last = err; + } + } + throw last; + } +} diff --git a/lib/settings.js b/lib/settings.js new file mode 100644 index 0000000..a751d86 --- /dev/null +++ b/lib/settings.js @@ -0,0 +1,97 @@ +/** + * settings.js - Einstellungen und gemeinsam genutzte Hilfsmittel. + * + * Die Einstellungen liegen in browser.storage.local, also im Profil von + * Thunderbird. Der Token steht dort im Klartext - das entspricht dem, was + * Erweiterungen üblicherweise tun, ist aber kein Geheimnisspeicher. + * Einzelheiten in SICHERHEIT.md. + */ + +const VORGABEN = { + baseUrl: "", + token: "", + versionLabel: "Thunderbird {datum}", + shareDays: 7, + shareTag: "Sharelink", + // Anhänge, die üblicherweise keine Dokumente sind. Sie werden im + // Auswahldialog vorab abgewählt, lassen sich aber anhaken. + ignorierteTypen: ["image/gif", "image/png", "image/jpeg", "text/vcard", + "text/calendar", "application/pkcs7-signature", + "application/pgp-signature"], + ignorierteKleiner: 8192 // Bytes; darunter meist Signaturbilder +}; + +export async function ladeEinstellungen() { + const gespeichert = await browser.storage.local.get(Object.keys(VORGABEN)); + return { ...VORGABEN, ...gespeichert }; +} + +export async function speichereEinstellungen(werte) { + await browser.storage.local.set(werte); +} + +export function vollstaendig(s) { + return Boolean(s?.baseUrl) && Boolean(s?.token); +} + +/** + * Beliebige Zeichenkette in einen unbedenklichen Dateinamen überführen. + * + * Der Name eines E-Mail-Anhangs stammt vom Absender und ist damit nicht + * vertrauenswürdig. Er wird zwar nur an Paperless weitergereicht und nicht + * ins Dateisystem geschrieben, aber ein Wert wie "../../x" hat in einem + * Dateinamen nichts zu suchen. + */ +export function sichererName(name, vorgabe = "Dokument", maxlen = 120) { + let n = String(name || ""); + n = n.replace(/\\/g, "/").split("/").pop(); + n = n.replace(/[<>:"|?*\u0000-\u001f]/g, "").trim(); + n = n.replace(/\s+/g, " ").replace(/^[. ]+|[. ]+$/g, ""); + if (!n) return vorgabe; + const reserviert = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i; + if (reserviert.test(n)) n = "_" + n; + if (n.length > maxlen) { + const punkt = n.lastIndexOf("."); + const endung = punkt > 0 && n.length - punkt <= 9 ? n.slice(punkt) : ""; + n = n.slice(0, maxlen - endung.length) + endung; + } + return n || vorgabe; +} + +export function versionslabel(vorlage) { + const heute = new Date().toISOString().slice(0, 10); + return String(vorlage || "Thunderbird {datum}").replace("{datum}", heute); +} + +/** Kürzt einen Text für die Anzeige. */ +export function kurz(text, laenge = 70) { + const t = String(text || ""); + return t.length > laenge ? t.slice(0, laenge - 1) + "…" : t; +} + +/** Menschenlesbare Größenangabe. */ +export function groesse(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`; +} + +/** + * Betreff einer Nachricht in einen Dokumenttitel überführen. + * + * Präfixe wie "AW:" oder "Fwd:" tragen im Archiv nichts bei und stören + * beim Sortieren. + */ +export function titelAusBetreff(betreff) { + let t = String(betreff || "").trim(); + const praefixe = /^((AW|RE|WG|FWD|FW|TR|RÉF|REF)\s*(\[\d+\])?\s*:\s*)+/i; + t = t.replace(praefixe, "").trim(); + return t || "E-Mail ohne Betreff"; +} + +/** Übersetzung über die eingebaute Verwaltung von Thunderbird. */ +export function t(schluessel, ersetzungen) { + const s = browser.i18n.getMessage(schluessel, ersetzungen); + return s || schluessel; +} diff --git a/make_icons.py b/make_icons.py new file mode 100644 index 0000000..f09dc25 --- /dev/null +++ b/make_icons.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +""" +make_icons.py - Erzeugt die Symbole der Erweiterung als SVG und PNG. + +Die Geometrie ist EINMAL beschrieben und wird von zwei Ausgaben gelesen. +So können SVG und PNG nicht auseinanderlaufen. + + SVG für die Symbolleisten, wird von Thunderbird skaliert. + PNG in 16, 32 und 64 Pixeln - das Manifest verlangt feste Größen, und + für die Erweiterungsverwaltung wird 64 gebraucht. + +Alle Symbole teilen dieselbe Grundform - ein Blatt mit umgeknickter Ecke in +Paperless-Grün - damit die Leiste als zusammengehörige Gruppe erkennbar +bleibt. Unterschieden wird über eine farbige Scheibe unten rechts. + +Aufruf: python3 make_icons.py (benötigt Pillow für die PNG-Ausgabe) +""" + +import math +import os + +OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icons") + +# Zeichenfläche in Einheiten. Die Formen reichen bewusst bis dicht an den +# Rand, damit bei kleiner Darstellung möglichst viel Fläche trägt. +BOX = 24.0 +PNG_SIZES = (16, 32, 64) +SCALE = 12 # Überabtastung für die PNG-Ausgabe + +GREEN = "#175420" +GREEN_LIGHT = "#3a8a44" +PAPER = "#fcfcfa" +WHITE = "#ffffff" +BLUE = "#1e60a8" # herunterladen +ORANGE = "#c86a14" # hinauf +VIOLET = "#703e98" # klassifizieren +TEAL = "#117a7a" # teilen +GREY = "#5c6064" # einstellungen + + +# ----------------------------------------------------------- Grundformen + +def poly(points, fill=None, stroke=None, width=0.0): + return ("poly", points, fill, stroke, width) + + +def circle(cx, cy, r, fill=None, stroke=None, width=0.0): + return ("circle", (cx, cy, r), fill, stroke, width) + + +def line(p1, p2, stroke, width): + return ("line", (p1, p2), None, stroke, width) + + +def rrect(x0, y0, x1, y1, r, fill=None, stroke=None, width=0.0): + return ("rrect", (x0, y0, x1, y1, r), fill, stroke, width) + + +def sheet(colour=GREEN): + """Blatt mit umgeknickter Ecke, füllt die Fläche links oben.""" + x0, y0, x1, y1 = 0.6, 0.5, 18.4, 23.4 + fold = 5.4 + out = [ + poly([(x0, y0), (x1 - fold, y0), (x1, y0 + fold), (x1, y1), (x0, y1)], + fill=PAPER, stroke=colour, width=1.8), + poly([(x1 - fold, y0), (x1, y0 + fold), (x1 - fold, y0 + fold)], + fill=colour), + ] + for i, w in enumerate((10.4, 12.6, 8.4)): + yy = y0 + 7.4 + i * 3.8 + out.append(line((x0 + 2.2, yy), (x0 + 2.2 + w, yy), colour, 1.4)) + return out + + +def badge(colour): + """Scheibe unten rechts, mit hellem Ring zur Abgrenzung vom Blatt.""" + cx, cy, r = 16.9, 17.1, 6.7 + return cx, cy, r, [ + circle(cx, cy, r + 1.1, fill=WHITE), + circle(cx, cy, r, fill=colour), + ] + + +def arrow(cx, cy, up=True, colour=PAPER): + w, h = 2.9, 3.5 + if up: + return [poly([(cx, cy - h), (cx - w, cy - 0.3), (cx + w, cy - 0.3)], + fill=colour), + line((cx, cy - 0.6), (cx, cy + h * 0.85), colour, 2.0)] + return [poly([(cx, cy + h), (cx - w, cy + 0.3), (cx + w, cy + 0.3)], + fill=colour), + line((cx, cy - h * 0.85), (cx, cy + 0.6), colour, 2.0)] + + +# --------------------------------------------------------------- Symbole + +def icon_logo(): + """Markensymbol für den Anfang der Leiste: gefüllte Fläche, weißes Blatt.""" + out = [rrect(0.4, 0.4, 23.6, 23.6, 4.0, fill=GREEN)] + x0, y0, x1, y1 = 5.0, 3.9, 19.0, 20.1 + fold = 4.4 + out += [ + poly([(x0, y0), (x1 - fold, y0), (x1, y0 + fold), (x1, y1), (x0, y1)], + fill=PAPER), + poly([(x1 - fold, y0), (x1, y0 + fold), (x1 - fold, y0 + fold)], + fill=GREEN_LIGHT), + ] + for i, w in enumerate((7.4, 9.2, 6.0)): + yy = y0 + 6.0 + i * 3.4 + out.append(line((x0 + 2.0, yy), (x0 + 2.0 + w, yy), GREEN, 1.5)) + return out + + +def icon_open(): + out = sheet() + cx, cy, _r, b = badge(BLUE) + return out + b + arrow(cx, cy, up=False) + + +def icon_version(): + out = sheet() + cx, cy, _r, b = badge(ORANGE) + out += b + arrow(cx - 1.2, cy, up=True) + px, py, a = cx + 3.4, cy - 2.6, 1.7 + out += [line((px - a, py), (px + a, py), PAPER, 1.4), + line((px, py - a), (px, py + a), PAPER, 1.4)] + return out + + +def icon_save_as(): + # Zweites Blatt dahinter: es kann ein eigenständiges Dokument entstehen. + out = [poly([(4.4, 0.5), (18.4, 0.5), (18.4, 4.6), (4.4, 4.6)], + fill=PAPER, stroke=GREEN_LIGHT, width=1.5)] + out += sheet() + cx, cy, _r, b = badge(ORANGE) + out += b + arrow(cx - 1.2, cy, up=True) + sx, sy, a = cx + 3.4, cy - 2.6, 1.9 + for dx, dy in ((a, 0), (0, a), (a * .7, a * .7), (-a * .7, a * .7)): + out.append(line((sx - dx, sy - dy), (sx + dx, sy + dy), PAPER, 1.15)) + return out + + +def icon_classify(): + out = sheet() + cx, cy, _r, b = badge(VIOLET) + out += b + [ + poly([(cx - 3.6, cy - 0.5), (cx + 0.4, cy - 4.0), + (cx + 3.7, cy - 0.6), (cx - 0.2, cy + 3.6)], fill=PAPER), + circle(cx + 1.4, cy - 1.5, 0.9, fill=VIOLET), + ] + return out + + +def icon_share(): + out = sheet() + cx, cy, _r, b = badge(TEAL) + a, bb, c = (cx + 2.6, cy - 3.2), (cx + 2.6, cy + 3.2), (cx - 3.2, cy) + out += b + [line(c, a, PAPER, 1.3), line(c, bb, PAPER, 1.3)] + for p in (a, bb, c): + out.append(circle(p[0], p[1], 1.65, fill=PAPER)) + return out + + +def icon_settings(): + out = sheet() + cx, cy, _r, b = badge(GREY) + out += b + outer, inner, hole = 5.0, 3.4, 1.6 + for i in range(8): + ang = math.pi * i / 4 + dx, dy = math.cos(ang), math.sin(ang) + out.append(line((cx + dx * inner * .7, cy + dy * inner * .7), + (cx + dx * outer, cy + dy * outer), PAPER, 1.7)) + out += [circle(cx, cy, inner, stroke=PAPER, width=1.5), + circle(cx, cy, hole, fill=GREY)] + return out + + +# Thunderbird braucht weniger Symbole als die LibreOffice-Fassung: Es gibt +# nur zwei Schaltflächen (Ablegen im Nachrichtenfenster, Einfügen im +# Verfassenfenster) und das Symbol der Erweiterung selbst. +ICONS = { + "paperless-logo": icon_logo, + "paperless-store": icon_version, + "paperless-insert": icon_open, + "paperless-share": icon_share, +} + + +# ----------------------------------------------------------- Ausgabe SVG + +def to_svg(shapes): + out = ['', + '' % (BOX, BOX)] + + def attrs(fill, stroke, width): + a = ' fill="%s"' % (fill or "none") + if stroke: + a += (' stroke="%s" stroke-width="%g" stroke-linecap="round"' + ' stroke-linejoin="round"' % (stroke, width)) + return a + + for kind, data, fill, stroke, width in shapes: + if kind == "poly": + pts = " ".join("%g,%g" % p for p in data) + out.append(' ' + % (pts, attrs(fill, stroke, width))) + elif kind == "circle": + cx, cy, r = data + out.append(' ' + % (cx, cy, r, attrs(fill, stroke, width))) + elif kind == "line": + (x1, y1), (x2, y2) = data + out.append(' ' + % (x1, y1, x2, y2, attrs(None, stroke, width))) + elif kind == "rrect": + x0, y0, x1, y1, r = data + out.append(' ' + % (x0, y0, x1 - x0, y1 - y0, r, + attrs(fill, stroke, width))) + out.append("") + return "\n".join(out) + "\n" + + +# ----------------------------------------------------------- Ausgabe PNG + +def to_png(shapes, size): + from PIL import Image, ImageDraw + + def rgb(c): + c = c.lstrip("#") + return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4)) + (255,) + + s = SCALE + img = Image.new("RGBA", (int(BOX * s), int(BOX * s)), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + + for kind, data, fill, stroke, width in shapes: + f = rgb(fill) if fill else None + st = rgb(stroke) if stroke else None + w = max(1, int(width * s)) + if kind == "poly": + pts = [(x * s, y * s) for x, y in data] + d.polygon(pts, fill=f, outline=st, width=w if st else 0) + elif kind == "circle": + cx, cy, r = data + box = [(cx - r) * s, (cy - r) * s, (cx + r) * s, (cy + r) * s] + if f: + d.ellipse(box, fill=f) + if st: + d.ellipse(box, outline=st, width=w) + elif kind == "line": + (x1, y1), (x2, y2) = data + d.line([(x1 * s, y1 * s), (x2 * s, y2 * s)], fill=st, width=w) + elif kind == "rrect": + x0, y0, x1, y1, r = data + d.rounded_rectangle([x0 * s, y0 * s, x1 * s, y1 * s], + radius=r * s, fill=f, + outline=st, width=w if st else 0) + + return img.resize((size, size), Image.LANCZOS) + + +def main(): + os.makedirs(OUT, exist_ok=True) + for name, fn in ICONS.items(): + shapes = fn() + with open(os.path.join(OUT, name + ".svg"), "w", + encoding="utf-8") as fh: + fh.write(to_svg(shapes)) + for size in PNG_SIZES: + to_png(shapes, size).save( + os.path.join(OUT, "%s_%d.png" % (name, size)), + "PNG", optimize=True) + print("erzeugt: %s.svg und PNG in %s" + % (name, ", ".join("%d" % z for z in PNG_SIZES))) + + +if __name__ == "__main__": + main() diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2b45633 --- /dev/null +++ b/manifest.json @@ -0,0 +1,55 @@ +{ + "manifest_version": 3, + "name": "__MSG_extName__", + "description": "__MSG_extDescription__", + "version": "1.5.1", + "author": "Marco Morath", + "homepage_url": "https://gitea.cloud.mmorath.de/marco.morath/thunderbird-paperless-connector", + "default_locale": "de", + "browser_specific_settings": { + "gecko": { + "id": "webmaster@mmorath.de", + "strict_min_version": "128.0" + } + }, + "permissions": [ + "storage", + "menus", + "messagesRead", + "compose", + "notifications" + ], + "optional_host_permissions": [ + "http://*/*", + "https://*/*" + ], + "background": { + "scripts": [ + "background.js" + ], + "type": "module" + }, + "options_ui": { + "page": "ui/options.html", + "open_in_tab": true + }, + "message_display_action": { + "default_title": "__MSG_actionStore__", + "default_icon": { + "16": "icons/paperless-store_16.png", + "32": "icons/paperless-store_32.png" + } + }, + "compose_action": { + "default_title": "__MSG_actionInsert__", + "default_icon": { + "16": "icons/paperless-insert_16.png", + "32": "icons/paperless-insert_32.png" + } + }, + "icons": { + "16": "icons/paperless-logo_16.png", + "32": "icons/paperless-logo_32.png", + "64": "icons/paperless-logo_64.png" + } +} diff --git a/ui/classify.html b/ui/classify.html new file mode 100644 index 0000000..789374b --- /dev/null +++ b/ui/classify.html @@ -0,0 +1,44 @@ + + + + + Klassifizierung bearbeiten + + + +

Klassifizierung bearbeiten

+
+ + + + + + + + + + + + + + + +
+ +
+
+ +
+ Unbekannte Korrespondenten und Dokumenttypen werden angelegt. +
+ +
+ +
+ + +
+ + + + diff --git a/ui/classify.js b/ui/classify.js new file mode 100644 index 0000000..e512dce --- /dev/null +++ b/ui/classify.js @@ -0,0 +1,109 @@ +/** + * 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; + } +} diff --git a/ui/gemeinsam.css b/ui/gemeinsam.css new file mode 100644 index 0000000..19bcfb3 --- /dev/null +++ b/ui/gemeinsam.css @@ -0,0 +1,74 @@ +/* Gemeinsames Aussehen der Dialoge. Bewusst zurückhaltend, damit sie sich + in die Oberfläche von Thunderbird einfügen statt sich abzuheben. */ +:root { + --gruen: #175420; + --rand: #c8ccd0; + --hell: #f6f6f4; + color-scheme: light dark; +} +* { box-sizing: border-box; } +body { + font: message-box; + font-size: 13px; + margin: 0; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; + height: 100vh; +} +h1 { font-size: 14px; margin: 0 0 2px; font-weight: 600; } +fieldset { + border: 1px solid var(--rand); + border-radius: 4px; + margin: 0; + padding: 8px 10px 10px; + min-width: 0; +} +legend { padding: 0 4px; font-weight: 600; } +label { display: block; margin: 4px 0 2px; } +input[type=text], input[type=password], input[type=number], select { + width: 100%; + padding: 4px 6px; + border: 1px solid var(--rand); + border-radius: 3px; + background: Field; + color: FieldText; +} +.zeile { display: flex; gap: 8px; align-items: center; } +.zeile > input[type=text] { flex: 1; } +.wahl { display: flex; gap: 6px; align-items: center; margin: 3px 0; } +.wahl input { width: auto; margin: 0; } +button { + padding: 5px 14px; + border: 1px solid var(--rand); + border-radius: 3px; + background: ButtonFace; + color: ButtonText; + cursor: pointer; +} +button.haupt { background: var(--gruen); color: #fff; border-color: var(--gruen); } +button:disabled { opacity: .5; cursor: default; } +.fuss { display: flex; justify-content: flex-end; gap: 8px; margin-top: auto; } +.liste { + border: 1px solid var(--rand); + border-radius: 3px; + overflow: auto; + background: Field; + flex: 1; + min-height: 90px; +} +table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } +th, td { padding: 3px 8px; text-align: left; white-space: nowrap; } +th { + position: sticky; top: 0; + background: var(--hell); + border-bottom: 1px solid var(--rand); + font-weight: 600; +} +td.zahl, th.zahl { text-align: right; } +tr.gewaehlt { background: SelectedItem; color: SelectedItemText; } +tbody tr { cursor: default; } +.hinweis { color: GrayText; font-size: 12px; } +.fehler { color: #b32; } +.wachsen { flex: 1; display: flex; flex-direction: column; gap: 6px; min-height: 0; } diff --git a/ui/gemeinsam.js b/ui/gemeinsam.js new file mode 100644 index 0000000..d2d23c4 --- /dev/null +++ b/ui/gemeinsam.js @@ -0,0 +1,48 @@ +/** + * gemeinsam.js - Hilfsmittel für alle Dialogseiten. + */ + +/** Ruft die Ereignisseite und wirft bei Fehlern eine Ausnahme. */ +export async function ruf(befehl, daten = {}) { + const antwort = await browser.runtime.sendMessage({ befehl, ...daten }); + if (!antwort?.ok) { + throw new Error(antwort?.fehler || `Aufruf ${befehl} fehlgeschlagen`); + } + return antwort.daten; +} + +/** + * Beschriftungen aus _locales einsetzen. + * + * Elemente mit data-i18n bekommen ihren Text, solche mit + * data-i18n-placeholder ihren Platzhalter. Fehlt ein Eintrag, bleibt der + * im HTML hinterlegte deutsche Text stehen. + */ +export function uebersetzeSeite() { + for (const el of document.querySelectorAll("[data-i18n]")) { + const s = browser.i18n.getMessage(el.dataset.i18n); + if (s) el.textContent = s; + } + for (const el of document.querySelectorAll("[data-i18n-placeholder]")) { + const s = browser.i18n.getMessage(el.dataset.i18nPlaceholder); + if (s) el.placeholder = s; + } + for (const el of document.querySelectorAll("[data-i18n-title]")) { + const s = browser.i18n.getMessage(el.dataset.i18nTitle); + if (s) el.title = s; + } +} + +export function fehlerAnzeigen(text) { + const ziel = document.getElementById("meldung"); + if (!ziel) return; + ziel.className = "fehler"; + ziel.textContent = String(text || "").slice(0, 300); +} + +export function meldungAnzeigen(text) { + const ziel = document.getElementById("meldung"); + if (!ziel) return; + ziel.className = "hinweis"; + ziel.textContent = String(text || ""); +} diff --git a/ui/insert.html b/ui/insert.html new file mode 100644 index 0000000..ce7d005 --- /dev/null +++ b/ui/insert.html @@ -0,0 +1,83 @@ + + + + + Aus Paperless einfügen + + + +

Aus Paperless einfügen

+ +
+ + +
+ +
+ +
+ + + + + + + +
ASNErstelltTitel
+
+
+ +
+ +
+ + + + + + + + +
NrErstelltFormatBezeichnung
+
+
+ +
+ Einfügen als +
+ + +
+
+ + +
+
+ + + + +
+
+ Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt. +
+
+ +
+ +
+ + +
+ + + + diff --git a/ui/insert.js b/ui/insert.js new file mode 100644 index 0000000..2f90b1b --- /dev/null +++ b/ui/insert.js @@ -0,0 +1,211 @@ +/** + * insert.js - Dokument aus Paperless in einen Entwurf einfügen. + * + * Zwei Wege: als Anhang oder als Freigabelink. Der Link ist die bessere + * Wahl für grosse Dateien und für Empfänger, die ohnehin Zugriff auf das + * Archiv haben sollen - der Anhang für alles, was auch in fünf Jahren noch + * lesbar sein muss, wenn der Link längst abgelaufen ist. + */ + +import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen } + from "./gemeinsam.js"; + +const $ = (id) => document.getElementById(id); + +let auftrag = null; +let einstellungen = null; +let treffer = []; +let versionen = []; +let gewaehltesDokument = null; +let gewaehlteVersion = null; + +// Formate, die LibreOffice und Thunderbird sinnvoll darstellen. Nur für +// die Anzeige - eingefügt werden kann jedes. +const FORMATE = { + "application/pdf": "PDF", + "application/vnd.oasis.opendocument.text": "ODF Text", + "application/vnd.oasis.opendocument.spreadsheet": "ODF Tabelle", + "application/vnd.oasis.opendocument.presentation": "ODF Präsentation", + "application/msword": "Word 97", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "Word", + "application/vnd.ms-excel": "Excel 97", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "Excel", + "message/rfc822": "E-Mail", + "image/png": "Bild", + "image/jpeg": "Bild", + "text/plain": "Text" +}; + +document.addEventListener("DOMContentLoaded", start); + +async function start() { + uebersetzeSeite(); + const nummer = Number(new URLSearchParams(location.search).get("auftrag")); + einstellungen = await ruf("einstellungen"); + auftrag = await ruf("auftrag", { nummer }); + if (!auftrag) { + fehlerAnzeigen("Der Auftrag ist nicht mehr vorhanden."); + return; + } + + $("tage").value = String(einstellungen.shareDays ?? 7); + // ?? statt ||: Ein bewusst geleertes Feld in den Einstellungen soll + // leer bleiben. Mit || käme die Vorgabe zurück, und die Kennzeichnung + // liesse sich nicht dauerhaft abschalten. + $("tagName").value = einstellungen.shareTag ?? "Sharelink"; + + $("suchen").addEventListener("click", suchen); + $("suche").addEventListener("keydown", (e) => { + if (e.key === "Enter") suchen(); + }); + $("abbrechen").addEventListener("click", () => window.close()); + $("einfuegen").addEventListener("click", einfuegen); + for (const r of document.querySelectorAll('input[name=art]')) { + r.addEventListener("change", artGewechselt); + } + artGewechselt(); + suchen(); +} + +function artGewechselt() { + const link = $("artLink").checked; + for (const id of ["tage", "tagName"]) $(id).disabled = !link; + // Bei einem Freigabelink zählt das Dokument, nicht die einzelne Version - + // der Link zeigt immer auf die Kopfversion. + $("versionen").closest(".wachsen").style.opacity = link ? ".5" : "1"; + knopfPruefen(); +} + +async function suchen() { + $("treffer").replaceChildren(); + $("versionen").replaceChildren(); + gewaehltesDokument = null; + gewaehlteVersion = 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", () => waehleDokument(i)); + $("treffer").append(tr); + } + if (treffer.length) waehleDokument(0); + else meldungAnzeigen(browser.i18n.getMessage("msgNoHits") || "Keine Treffer."); +} + +async function waehleDokument(i) { + gewaehltesDokument = treffer[i] || null; + for (const [j, tr] of [...$("treffer").children].entries()) { + tr.classList.toggle("gewaehlt", j === i); + } + $("versionen").replaceChildren(); + gewaehlteVersion = null; + knopfPruefen(); + if (!gewaehltesDokument) return; + + meldungAnzeigen(browser.i18n.getMessage("msgLoadingVersions") + || "Versionen werden geladen …"); + try { + versionen = await ruf("versionen", { id: gewaehltesDokument.id }); + } catch (err) { + fehlerAnzeigen(err.message); + return; + } + meldungAnzeigen(""); + + for (const [k, v] of versionen.entries()) { + const tr = document.createElement("tr"); + const a = document.createElement("td"); + a.className = "zahl"; + a.textContent = v.no; + const b = document.createElement("td"); + b.textContent = (v.added || "").slice(0, 10); + const c = document.createElement("td"); + c.textContent = FORMATE[v.mime_type] || (v.mime_type || "?"); + const d = document.createElement("td"); + d.textContent = v.version_label || ""; + tr.append(a, b, c, d); + tr.addEventListener("click", () => waehleVersion(k)); + $("versionen").append(tr); + } + if (versionen.length) waehleVersion(0); +} + +function waehleVersion(k) { + gewaehlteVersion = versionen[k] || null; + for (const [j, tr] of [...$("versionen").children].entries()) { + tr.classList.toggle("gewaehlt", j === k); + } + knopfPruefen(); +} + +function knopfPruefen() { + const ok = $("artLink").checked + ? Boolean(gewaehltesDokument) + : Boolean(gewaehlteVersion); + $("einfuegen").disabled = !ok; +} + +async function einfuegen() { + $("einfuegen").disabled = true; + try { + if ($("artLink").checked) { + meldungAnzeigen(browser.i18n.getMessage("msgCreatingLink") + || "Link wird erzeugt …"); + // ACHTUNG: linktext darf NICHT auf url zurückgreifen - die Variable + // entsteht erst durch dieselbe Zuweisung. Bei einem Dokument ohne + // Titel gäbe das eine Ausnahme. + const { url, tagStatus, gueltigBis } = await ruf("freigabelink", { + id: gewaehltesDokument.id, + tage: Number($("tage").value || 0), + variante: "original", + tagName: $("tagName").value.trim() || null, + tabId: auftrag.tabId, + einfuegen: true, + linktext: gewaehltesDokument.title + || `Dokument ${gewaehltesDokument.id}` + }); + // Ein fehlgeschlagener Tag darf nicht untergehen. Er ist der Grund, + // warum sich geteilte Dokumente später wiederfinden lassen. + if (tagStatus && !tagStatus.ok) { + fehlerAnzeigen(`Link erzeugt, aber der Tag konnte nicht gesetzt ` + + `werden:\n${tagStatus.fehler}`); + return; + } + const teile = [url]; + if (gueltigBis) { + const d = new Date(gueltigBis); + const z = (n) => String(n).padStart(2, "0"); + teile.push(`gültig bis ${z(d.getDate())}.${z(d.getMonth() + 1)}.` + + `${d.getFullYear()}`); + } else { + teile.push("ohne zeitliche Begrenzung"); + } + if (tagStatus?.ok) teile.push(`Tag „${tagStatus.name}" gesetzt`); + meldungAnzeigen(teile.join(" · ")); + } else { + meldungAnzeigen(browser.i18n.getMessage("msgAttaching") + || "Wird angehängt …"); + await ruf("anhaengen", { + versionId: gewaehlteVersion.id, + tabId: auftrag.tabId + }); + } + setTimeout(() => window.close(), 1000); + } catch (err) { + fehlerAnzeigen(err.message); + $("einfuegen").disabled = false; + } +} diff --git a/ui/options.html b/ui/options.html new file mode 100644 index 0000000..b30e1da --- /dev/null +++ b/ui/options.html @@ -0,0 +1,76 @@ + + + + + Paperless — Verbindung + + + + +

Paperless — Verbindung

+ +
+ Zugang + + + +
+ Ohne abschließenden Schrägstrich. Nur http und https. +
+ + + +
+ Im Benutzerprofil von Paperless erzeugen. Ein eigener Token für diese + Erweiterung lässt sich einzeln widerrufen. +
+ +
+ + +
+
+
+ +
+ Verhalten + + + +
+ {datum} wird durch das heutige Datum ersetzt. +
+ +
+ + + + +
+ + + +
+ Signaturbilder und Logos sind meist kleiner als 8 KB. Sie lassen sich + im Dialog jederzeit anhaken. +
+
+ +
+ +
+ +
+ + + + diff --git a/ui/options.js b/ui/options.js new file mode 100644 index 0000000..4e38432 --- /dev/null +++ b/ui/options.js @@ -0,0 +1,125 @@ +/** + * options.js - Einstellungsseite. + * + * Beim Speichern wird zusätzlich das Zugriffsrecht für genau die + * eingetragene Adresse angefordert. Es steht bewusst nicht pauschal im + * Manifest: Eine Erweiterung, die von Haus aus jede Adresse erreichen + * darf, ist eine unnötig grosse Angriffsfläche. + */ + +import { ladeEinstellungen, speichereEinstellungen } from "../lib/settings.js"; +import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen } + from "./gemeinsam.js"; + +const $ = (id) => document.getElementById(id); +const FELDER = ["baseUrl", "token", "versionLabel", "shareDays", "shareTag", + "ignorierteKleiner"]; + +document.addEventListener("DOMContentLoaded", async () => { + uebersetzeSeite(); + const s = await ladeEinstellungen(); + for (const f of FELDER) if ($(f)) $(f).value = s[f] ?? ""; + $("speichern").addEventListener("click", speichern); + $("pruefen").addEventListener("click", pruefen); + await rechtAnzeigen(); +}); + +/** + * Zustand des Zugriffsrechts anzeigen. + * + * Es ist die häufigste Ursache für ein scheiterndes fetch, und ohne + * Anzeige merkt man erst beim ersten Ablegen, dass es fehlt. + */ +async function rechtAnzeigen() { + // Diese Anzeige ist Beiwerk. Fehlt das Element - etwa weil eine ältere + // options.html geladen ist -, darf das nicht das Speichern verhindern. + const ziel = $("recht"); + if (!ziel) return; + const url = $("baseUrl").value.trim(); + if (!url) { + ziel.textContent = ""; + return; + } + let origin; + try { + origin = new URL(url).origin + "/*"; + } catch { + ziel.className = "fehler"; + ziel.textContent = browser.i18n.getMessage("errBadUrl") || "Ungültige Adresse."; + return; + } + const erteilt = await browser.permissions.contains({ origins: [origin] }); + ziel.className = erteilt ? "hinweis" : "fehler"; + ziel.textContent = erteilt + ? `Zugriffsrecht erteilt für ${origin}` + : `Zugriffsrecht fehlt für ${origin} — auf Speichern klicken.`; +} + +function gelesen() { + return { + baseUrl: $("baseUrl").value.trim().replace(/\/+$/, ""), + token: $("token").value.trim(), + versionLabel: $("versionLabel").value.trim() || "Thunderbird {datum}", + shareDays: Number($("shareDays").value || 0), + shareTag: $("shareTag").value.trim(), + ignorierteKleiner: Number($("ignorierteKleiner").value || 0) + }; +} + +/** + * Einstellungen speichern und das Zugriffsrecht anfordern. + * + * WICHTIG: permissions.request MUSS als erstes stehen, noch vor jedem + * await. Der Aufruf ist nur aus einer Benutzeraktion heraus zulässig, und + * diese Eigenschaft geht beim ersten await verloren - die Folge wäre + * "permissions.request may only be called from a user input handler". + * + * Ein vorgeschaltetes permissions.contains() ist deshalb ausgeschlossen. + * Das ist auch nicht nötig: request() löst keine Abfrage aus, wenn das + * Recht bereits erteilt ist, sondern liefert sofort true. + */ +function speichern() { + const werte = gelesen(); + + let origin; + try { + origin = new URL(werte.baseUrl).origin + "/*"; + } catch { + fehlerAnzeigen(browser.i18n.getMessage("errBadUrl") + || "Ungültige Adresse. Erwartet wird https://rechner oder http://rechner."); + return; + } + + // Kein await davor. Die Auswertung läuft in der Zusage. + browser.permissions.request({ origins: [origin] }) + .then(async (erteilt) => { + if (!erteilt) { + fehlerAnzeigen(browser.i18n.getMessage("errNoPermission") + || "Ohne Zugriffsrecht auf die Adresse kann die Erweiterung nicht arbeiten."); + await rechtAnzeigen(); + return; + } + await speichereEinstellungen(werte); + meldungAnzeigen(browser.i18n.getMessage("msgSaved") || "Gespeichert."); + // Anzeige und Prüfung sind Beiwerk; ein Fehler darin soll nicht + // aussehen, als wäre das Speichern gescheitert. + try { await rechtAnzeigen(); } catch { /* egal */ } + try { await pruefen(); } catch { /* egal */ } + }) + .catch((err) => fehlerAnzeigen(err.message)); +} + +async function pruefen() { + $("status").className = "hinweis"; + $("status").textContent = "…"; + try { + await speichereEinstellungen(gelesen()); + const treffer = await ruf("suchen", { query: "" }); + $("status").textContent = + browser.i18n.getMessage("msgConnectionOk", [String(treffer.length)]) + || `Verbindung steht (${treffer.length} Dokumente sichtbar).`; + } catch (err) { + $("status").className = "fehler"; + $("status").textContent = String(err.message).slice(0, 200); + } +} diff --git a/ui/store.html b/ui/store.html new file mode 100644 index 0000000..0976896 --- /dev/null +++ b/ui/store.html @@ -0,0 +1,85 @@ + + + + + In Paperless ablegen + + + + +

In Paperless ablegen

+
+ +
+ Was soll abgelegt werden? +
+
+ Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist + Teil der Nachricht, keine eigenen Dokumente. +
+
+ +
+ Ziel + +
+ + +
+ +
+ + +
+ Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt. +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + + + + + + + + +
ASNErstelltTitel
+
+
+
+ +
+ + +
+ +
+ +
+ + +
+ + + + diff --git a/ui/store.js b/ui/store.js new file mode 100644 index 0000000..544b3f9 --- /dev/null +++ b/ui/store.js @@ -0,0 +1,212 @@ +/** + * store.js - Dialog zum Ablegen von Anhängen und ganzen Nachrichten. + * + * Der Dialog spricht nicht selbst mit Paperless, sondern lässt die + * Ereignisseite arbeiten. Dort hängt die zur Laufzeit erteilte + * Berechtigung für die Zieladresse. + */ + +import { groesse, kurz, titelAusBetreff } from "../lib/settings.js"; +import { uebersetzeSeite, ruf, fehlerAnzeigen } from "./gemeinsam.js"; + +const $ = (id) => document.getElementById(id); + +let auftrag = null; +let einstellungen = null; +let treffer = []; +let gewaehlterTreffer = null; + +document.addEventListener("DOMContentLoaded", start); + +async function start() { + uebersetzeSeite(); + const nummer = Number(new URLSearchParams(location.search).get("auftrag")); + + einstellungen = await ruf("einstellungen"); + auftrag = await ruf("auftrag", { nummer }); + if (!auftrag) { + fehlerAnzeigen("Der Auftrag ist nicht mehr vorhanden."); + return; + } + + quelleAnzeigen(); + dateienAnzeigen(); + vorbelegen(); + verdrahten(); +} + +function quelleAnzeigen() { + const teile = []; + if (auftrag.betreff) teile.push(kurz(auftrag.betreff, 80)); + if (auftrag.absender) teile.push(kurz(auftrag.absender, 40)); + if (auftrag.datum) teile.push(auftrag.datum); + $("quelle").textContent = teile.join(" · "); +} + +/** + * Anhänge zur Auswahl anbieten. + * + * Vorab abgewählt werden kleine Bilder und Signaturen. Sie gehören zur + * Darstellung der Nachricht, nicht zum Inhalt - wer sie alle mit + * archiviert, hat nach einem Jahr hunderte Logos im Bestand. + */ +function dateienAnzeigen() { + const ziel = $("dateien"); + ziel.replaceChildren(); + + if (auftrag.quelle === "nachricht") { + // Aufbau über createElement statt innerHTML. Hier stünde zwar nur + // festes Markup, aber die Zusicherung "kein innerHTML" soll ohne + // Ausnahme gelten - sonst muss man bei jeder Änderung neu prüfen, ob + // sich inzwischen ein Fremdwert eingeschlichen hat. + const zeile = document.createElement("div"); + zeile.className = "wahl"; + const box = document.createElement("input"); + box.type = "checkbox"; + box.id = "ganz"; + box.checked = true; + box.disabled = true; + const bez = document.createElement("label"); + bez.htmlFor = "ganz"; + bez.textContent = browser.i18n.getMessage("storeWholeMessage") + || "Die vollständige Nachricht als .eml"; + zeile.append(box, bez); + ziel.append(zeile); + + if (auftrag.dateien?.length) { + const h = document.createElement("div"); + h.className = "hinweis"; + h.textContent = browser.i18n.getMessage("storeAttachmentsInside", + [String(auftrag.dateien.length)]) + || `Die ${auftrag.dateien.length} Anhänge bleiben darin enthalten.`; + ziel.append(h); + } + return; + } + + for (const [i, d] of (auftrag.dateien || []).entries()) { + const uninteressant = + einstellungen.ignorierteTypen.includes(d.contentType) + || (d.size || 0) < einstellungen.ignorierteKleiner; + d.ausgewaehlt = !uninteressant; + + const zeile = document.createElement("div"); + zeile.className = "wahl"; + const box = document.createElement("input"); + box.type = "checkbox"; + box.id = `d${i}`; + box.checked = d.ausgewaehlt; + box.addEventListener("change", () => { + d.ausgewaehlt = box.checked; + knopfPruefen(); + }); + const bez = document.createElement("label"); + bez.htmlFor = box.id; + bez.textContent = `${d.name} (${groesse(d.size)}, ${d.contentType})`; + zeile.append(box, bez); + ziel.append(zeile); + } +} + +function vorbelegen() { + $("titel").value = titelAusBetreff(auftrag.betreff); + if (auftrag.datum) $("erstellt").value = auftrag.datum; +} + +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(); + }); + $("abbrechen").addEventListener("click", () => window.close()); + $("ablegen").addEventListener("click", ablegen); + zielGewechselt(); +} + +function zielGewechselt() { + const neu = $("zielNeu").checked; + for (const id of ["titel", "erstellt"]) $(id).disabled = !neu; + for (const id of ["suche", "suchen"]) $(id).disabled = neu; + if (!neu && !treffer.length) suchen(); + knopfPruefen(); +} + +async function suchen() { + const liste = $("treffer"); + liste.replaceChildren(); + try { + treffer = await ruf("suchen", { query: $("suche").value.trim() }); + } catch (err) { + fehlerAnzeigen(err.message); + return; + } + for (const [i, r] of treffer.entries()) { + const tr = document.createElement("tr"); + const asn = document.createElement("td"); + asn.className = "zahl"; + asn.textContent = r.archive_serial_number ?? "—"; + const erstellt = document.createElement("td"); + erstellt.textContent = (r.created || "").slice(0, 10); + const titel = document.createElement("td"); + titel.textContent = r.title || ""; + titel.title = r.title || ""; + tr.append(asn, erstellt, titel); + tr.addEventListener("click", () => waehle(i)); + liste.append(tr); + } + if (treffer.length) waehle(0); + else fehlerAnzeigen(browser.i18n.getMessage("msgNoHits") || "Keine Treffer."); +} + +function waehle(i) { + gewaehlterTreffer = treffer[i] || null; + for (const [j, tr] of [...$("treffer").children].entries()) { + tr.classList.toggle("gewaehlt", j === i); + } + knopfPruefen(); +} + +function knopfPruefen() { + const etwasGewaehlt = auftrag.quelle === "nachricht" + || (auftrag.dateien || []).some((d) => d.ausgewaehlt); + const zielOk = $("zielNeu").checked || Boolean(gewaehlterTreffer); + $("ablegen").disabled = !(etwasGewaehlt && zielOk); +} + +async function ablegen() { + $("ablegen").disabled = true; + $("meldung").className = "hinweis"; + $("meldung").textContent = + browser.i18n.getMessage("msgUploading") || "Wird abgelegt …"; + + try { + const ergebnisse = await ruf("ablegen", { + auftrag, + ziel: $("zielNeu").checked ? "neu" : "vorhanden", + zielId: gewaehlterTreffer?.id, + titel: $("titel").value.trim(), + erstellt: $("erstellt").value.trim() + }); + + if ($("klassifizieren").checked && ergebnisse.length) { + // Beim Anhängen an ein vorhandenes Dokument gilt dessen + // Klassifizierung - dann ist die Wurzel gemeint, nicht die neue + // Version. + const erstes = ergebnisse[0]; + const id = erstes.art === "version" ? erstes.zielId : erstes.id; + location.href = `classify.html?id=${id}&weiter=${ergebnisse.length}`; + return; + } + + $("meldung").textContent = + browser.i18n.getMessage("msgStored", [String(ergebnisse.length)]) + || `${ergebnisse.length} abgelegt.`; + setTimeout(() => window.close(), 1200); + } catch (err) { + fehlerAnzeigen(err.message); + $("ablegen").disabled = false; + } +}