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 0000000..d8af0b8 Binary files /dev/null and b/icons/paperless-insert_16.png differ diff --git a/icons/paperless-insert_32.png b/icons/paperless-insert_32.png new file mode 100644 index 0000000..323ab4b Binary files /dev/null and b/icons/paperless-insert_32.png differ diff --git a/icons/paperless-insert_64.png b/icons/paperless-insert_64.png new file mode 100644 index 0000000..0f58de4 Binary files /dev/null and b/icons/paperless-insert_64.png differ diff --git a/icons/paperless-logo.svg b/icons/paperless-logo.svg new file mode 100644 index 0000000..1685cae --- /dev/null +++ b/icons/paperless-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/icons/paperless-logo_16.png b/icons/paperless-logo_16.png new file mode 100644 index 0000000..cca5463 Binary files /dev/null and b/icons/paperless-logo_16.png differ diff --git a/icons/paperless-logo_32.png b/icons/paperless-logo_32.png new file mode 100644 index 0000000..4471dfc Binary files /dev/null and b/icons/paperless-logo_32.png differ diff --git a/icons/paperless-logo_64.png b/icons/paperless-logo_64.png new file mode 100644 index 0000000..cd3bd39 Binary files /dev/null and b/icons/paperless-logo_64.png differ 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 0000000..8bb0136 Binary files /dev/null and b/icons/paperless-share_16.png differ diff --git a/icons/paperless-share_32.png b/icons/paperless-share_32.png new file mode 100644 index 0000000..959b8f8 Binary files /dev/null and b/icons/paperless-share_32.png differ diff --git a/icons/paperless-share_64.png b/icons/paperless-share_64.png new file mode 100644 index 0000000..ee538c5 Binary files /dev/null and b/icons/paperless-share_64.png differ diff --git a/icons/paperless-store.svg b/icons/paperless-store.svg new file mode 100644 index 0000000..953713d --- /dev/null +++ b/icons/paperless-store.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/icons/paperless-store_16.png b/icons/paperless-store_16.png new file mode 100644 index 0000000..9f0cfa5 Binary files /dev/null and b/icons/paperless-store_16.png differ diff --git a/icons/paperless-store_32.png b/icons/paperless-store_32.png new file mode 100644 index 0000000..6c51b23 Binary files /dev/null and b/icons/paperless-store_32.png differ diff --git a/icons/paperless-store_64.png b/icons/paperless-store_64.png new file mode 100644 index 0000000..7b76fbf Binary files /dev/null and b/icons/paperless-store_64.png differ diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..e87d1dc --- /dev/null +++ b/lib/client.js @@ -0,0 +1,324 @@ +/** + * client.js - REST-Client für Paperless-ngx. + * + * Die Eigenheiten der Schnittstelle sind hier festgehalten, weil sie + * nirgends dokumentiert und durch Beobachtung erschlossen sind: + * + * * update_version nimmt NUR "document" und "version_label" entgegen - + * keinen Zeitstempel. Ein "added" je Version lässt sich über die API + * nicht setzen. + * * Der Status einer Aufgabe kommt kleingeschrieben ("success"), in + * älteren Ständen groß. Vergleich deshalb unabhängig von der + * Schreibweise. + * * Die Dokument-ID steht je nach Stand in related_document_ids, + * verschachtelt in result_data.document_id oder direkt in + * related_document. + * * Der Endpunkt für Freigabelinks heißt je nach Version share_links/ + * oder sharelinks/. + */ + +const TASK_TIMEOUT = 900_000; // 15 Minuten +const TASK_POLL = 1500; + +// KEIN eigener Kopfzeileneintrag zur Kennzeichnung. +// +// Ein selbst gesetzter Eintrag wie "X-Client" ist nicht CORS-sicher und +// löst eine Vorabanfrage (OPTIONS) aus. Antwortet Paperless darauf nicht +// wie erwartet, scheitert schon der erste Aufruf mit "NetworkError" - +// einer Meldung, die nichts über die Ursache verrät. +// +// Der Browser sendet ohnehin seine eigene Kennung. Sollte ein Reverse +// Proxy diese abweisen, gehört die Ausnahme dort konfiguriert, nicht hier +// umgangen. + +export class PaperlessError extends Error {} + +export class PaperlessClient { + constructor(baseUrl, token) { + let parsed; + try { + parsed = new URL(String(baseUrl || "").trim()); + } catch { + throw new PaperlessError("badUrl"); + } + // Nur http und https. Ohne Prüfung liessen sich hier andere Schemata + // hinterlegen, die fetch teilweise bedient. + if (!["http:", "https:"].includes(parsed.protocol) || !parsed.host) { + throw new PaperlessError("badUrl"); + } + this.base = String(baseUrl).trim().replace(/\/+$/, ""); + this.token = String(token || "").trim(); + } + + get origin() { + return new URL(this.base).origin + "/*"; + } + + async #fetch(path, options = {}) { + const url = path.startsWith("http") ? path + : `${this.base}/api/${String(path).replace(/^\/+/, "")}`; + const headers = { + "Authorization": `Token ${this.token}`, + "Accept": "application/json, */*", + ...(options.headers || {}) + }; + let response; + try { + response = await fetch(url, { ...options, headers, redirect: "follow" }); + } catch (err) { + // "NetworkError" ist die Sammelmeldung des Browsers für alles, was + // die Anfrage gar nicht erst erreichen liess: fehlendes + // Zugriffsrecht, unerreichbarer Rechner, abgewiesenes Zertifikat. + // Der Hinweis auf die Berechtigung steht hier, weil das mit Abstand + // der häufigste Fall ist. + throw new PaperlessError( + `Verbindung fehlgeschlagen: ${err.message}\n\n` + + `Häufigste Ursachen:\n` + + `• Das Zugriffsrecht für ${new URL(this.base).origin} wurde nicht ` + + `erteilt — in den Einstellungen erneut speichern\n` + + `• Der Rechner ist nicht erreichbar\n` + + `• Das TLS-Zertifikat wird abgewiesen (bei selbst ausgestellten ` + + `Zertifikaten in Thunderbird einmal im Browser bestätigen)`); + } + if (!response.ok) { + let body = ""; + try { + body = (await response.text()).slice(0, 400); + } catch { /* egal */ } + throw new PaperlessError(`HTTP ${response.status}: ${body}`); + } + return response; + } + + async #json(path, options) { + const r = await this.#fetch(path, options); + const text = await r.text(); + return text ? JSON.parse(text) : null; + } + + // ------------------------------------------------------------ Aufgaben + + static docIdFromTask(task) { + const asInt = (v) => { + 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; + } +}