Initial commit

This commit is contained in:
2026-09-04 19:50:29 +02:00
parent 742e5bc6b2
commit 3ae0ab0a88
37 changed files with 3459 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@@ -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.

178
SICHERHEIT.md Normal file
View File

@@ -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 `<all_urls>` 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

260
_locales/de/messages.json Normal file
View File

@@ -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"
}
}
}
}

260
_locales/en/messages.json Normal file
View File

@@ -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"
}
}
}
}

260
_locales/fr/messages.json Normal file
View File

@@ -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"
}
}
}
}

552
background.js Normal file
View File

@@ -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 </body>.
*
* 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 ? `<strong>${escapeHtml(name)}</strong><br>` : "";
const block =
`<p style="margin:0 0 .8em 0">${kopf}`
+ `<a href="${sicherUrl}">${sicherUrl}</a><br>`
+ `<span style="font-size:90%;color:#666">${escapeHtml(hinweis)}</span>`
+ `</p>`;
const neuerKoerper = koerper.includes("</body>")
? koerper.replace("</body>", `${block}</body>`)
: koerper + block;
await browser.compose.setComposeDetails(tabId, { body: neuerKoerper });
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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);
}
}

57
build.sh Normal file
View File

@@ -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"

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

9
icons/paperless-logo.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 763 B

BIN
icons/paperless-logo_16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

BIN
icons/paperless-logo_32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
icons/paperless-logo_64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

15
icons/paperless-share.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

14
icons/paperless-store.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

324
lib/client.js Normal file
View File

@@ -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;
}
}

97
lib/settings.js Normal file
View File

@@ -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;
}

282
make_icons.py Normal file
View File

@@ -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 = ['<?xml version="1.0" encoding="UTF-8"?>',
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" '
'viewBox="0 0 %g %g">' % (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(' <polygon points="%s"%s/>'
% (pts, attrs(fill, stroke, width)))
elif kind == "circle":
cx, cy, r = data
out.append(' <circle cx="%g" cy="%g" r="%g"%s/>'
% (cx, cy, r, attrs(fill, stroke, width)))
elif kind == "line":
(x1, y1), (x2, y2) = data
out.append(' <line x1="%g" y1="%g" x2="%g" y2="%g"%s/>'
% (x1, y1, x2, y2, attrs(None, stroke, width)))
elif kind == "rrect":
x0, y0, x1, y1, r = data
out.append(' <rect x="%g" y="%g" width="%g" height="%g" '
'rx="%g"%s/>'
% (x0, y0, x1 - x0, y1 - y0, r,
attrs(fill, stroke, width)))
out.append("</svg>")
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()

55
manifest.json Normal file
View File

@@ -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"
}
}

44
ui/classify.html Normal file
View File

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

109
ui/classify.js Normal file
View File

@@ -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;
}
}

74
ui/gemeinsam.css Normal file
View File

@@ -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; }

48
ui/gemeinsam.js Normal file
View File

@@ -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 || "");
}

83
ui/insert.html Normal file
View File

@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="insertTitle">Aus Paperless einfügen</title>
<link rel="stylesheet" href="gemeinsam.css">
</head>
<body>
<h1 data-i18n="insertTitle">Aus Paperless einfügen</h1>
<div class="zeile">
<input type="text" id="suche">
<button id="suchen" data-i18n="btnSearch">Suchen</button>
</div>
<div class="wachsen">
<label data-i18n="colDocuments">Dokumente</label>
<div class="liste">
<table>
<thead><tr>
<th class="zahl" data-i18n="colAsn">ASN</th>
<th data-i18n="colCreated">Erstellt</th>
<th data-i18n="colTitle">Titel</th>
</tr></thead>
<tbody id="treffer"></tbody>
</table>
</div>
</div>
<div class="wachsen">
<label data-i18n="colVersions">Versionen</label>
<div class="liste" style="min-height:70px">
<table>
<thead><tr>
<th class="zahl" data-i18n="colNo">Nr</th>
<th data-i18n="colCreated">Erstellt</th>
<th data-i18n="colFormat">Format</th>
<th data-i18n="colLabel">Bezeichnung</th>
</tr></thead>
<tbody id="versionen"></tbody>
</table>
</div>
</div>
<fieldset>
<legend data-i18n="insertHow">Einfügen als</legend>
<div class="wahl">
<input type="radio" name="art" id="artAnhang" value="anhang" checked>
<label for="artAnhang" data-i18n="insertAsAttachment">
Anhang der Nachricht
</label>
</div>
<div class="wahl">
<input type="radio" name="art" id="artLink" value="link">
<label for="artLink" data-i18n="insertAsLink">
Freigabelink im Nachrichtentext
</label>
</div>
<div id="linkOptionen" class="zeile" style="margin-top:6px">
<label for="tage" style="margin:0" data-i18n="fieldShareDays">
Gültigkeit in Tagen
</label>
<input type="number" id="tage" min="0" max="3650" style="width:80px">
<label for="tagName" style="margin:0 0 0 10px" data-i18n="fieldShareTag">
Tag
</label>
<input type="text" id="tagName">
</div>
<div class="hinweis" data-i18n="insertHintLink">
Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt.
</div>
</fieldset>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="abbrechen" data-i18n="btnCancel">Abbrechen</button>
<button id="einfuegen" class="haupt" data-i18n="btnInsert">Einfügen</button>
</div>
<script type="module" src="insert.js"></script>
</body>
</html>

211
ui/insert.js Normal file
View File

@@ -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;
}
}

76
ui/options.html Normal file
View File

@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="optionsTitle">Paperless — Verbindung</title>
<link rel="stylesheet" href="gemeinsam.css">
<style>body { max-width: 620px; height: auto; }</style>
</head>
<body>
<h1 data-i18n="optionsTitle">Paperless — Verbindung</h1>
<fieldset>
<legend data-i18n="optionsConnection">Zugang</legend>
<label for="baseUrl" data-i18n="fieldBaseUrl">Basisadresse</label>
<input type="text" id="baseUrl" placeholder="https://dms.example.org">
<div class="hinweis" data-i18n="optionsHintUrl">
Ohne abschließenden Schrägstrich. Nur http und https.
</div>
<label for="token" data-i18n="fieldToken">API-Token</label>
<input type="password" id="token">
<div class="hinweis" data-i18n="optionsHintToken">
Im Benutzerprofil von Paperless erzeugen. Ein eigener Token für diese
Erweiterung lässt sich einzeln widerrufen.
</div>
<div style="margin-top:10px" class="zeile">
<button id="pruefen" data-i18n="btnTest">Verbindung prüfen</button>
<span id="status" class="hinweis"></span>
</div>
<div id="recht" class="hinweis" style="margin-top:6px"></div>
</fieldset>
<fieldset>
<legend data-i18n="optionsBehaviour">Verhalten</legend>
<label for="versionLabel" data-i18n="fieldVersionLabel">
Bezeichnung neuer Versionen
</label>
<input type="text" id="versionLabel">
<div class="hinweis" data-i18n="optionsHintLabel">
{datum} wird durch das heutige Datum ersetzt.
</div>
<div class="zeile" style="margin-top:8px">
<label for="shareDays" style="margin:0" data-i18n="fieldShareDays">
Gültigkeit in Tagen
</label>
<input type="number" id="shareDays" min="0" max="3650" style="width:90px">
<label for="shareTag" style="margin:0 0 0 12px" data-i18n="fieldShareTag">
Tag beim Teilen
</label>
<input type="text" id="shareTag" style="width:160px">
</div>
<label for="ignorierteKleiner" style="margin-top:8px"
data-i18n="fieldIgnoreSmaller">
Anhänge vorab abwählen unter … Bytes
</label>
<input type="number" id="ignorierteKleiner" min="0" style="width:120px">
<div class="hinweis" data-i18n="optionsHintIgnore">
Signaturbilder und Logos sind meist kleiner als 8 KB. Sie lassen sich
im Dialog jederzeit anhaken.
</div>
</fieldset>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="speichern" class="haupt" data-i18n="btnSave">Speichern</button>
</div>
<script type="module" src="options.js"></script>
</body>
</html>

125
ui/options.js Normal file
View File

@@ -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);
}
}

85
ui/store.html Normal file
View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="storeTitle">In Paperless ablegen</title>
<link rel="stylesheet" href="gemeinsam.css">
</head>
<body>
<h1 data-i18n="storeTitle">In Paperless ablegen</h1>
<div id="quelle" class="hinweis"></div>
<fieldset>
<legend data-i18n="storeWhat">Was soll abgelegt werden?</legend>
<div id="dateien"></div>
<div class="hinweis" data-i18n="storeHintPreselect">
Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist
Teil der Nachricht, keine eigenen Dokumente.
</div>
</fieldset>
<fieldset class="wachsen">
<legend data-i18n="storeTarget">Ziel</legend>
<div class="wahl">
<input type="radio" name="ziel" id="zielNeu" value="neu" checked>
<label for="zielNeu" data-i18n="storeTargetNew">
Neues Dokument anlegen
</label>
</div>
<div id="bereichNeu">
<label for="titel" data-i18n="fieldTitle">Titel</label>
<input type="text" id="titel">
<div class="hinweis" data-i18n="storeHintTitleMulti">
Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt.
</div>
<label for="erstellt" data-i18n="fieldCreated">Erstellt</label>
<input type="text" id="erstellt" placeholder="JJJJ-MM-TT">
</div>
<div class="wahl">
<input type="radio" name="ziel" id="zielVorhanden" value="vorhanden">
<label for="zielVorhanden" data-i18n="storeTargetExisting">
An bestehendes Dokument anhängen
</label>
</div>
<div id="bereichVorhanden" class="wachsen">
<div class="zeile">
<input type="text" id="suche" disabled>
<button id="suchen" disabled data-i18n="btnSearch">Suchen</button>
</div>
<div class="liste">
<table>
<thead>
<tr>
<th class="zahl" data-i18n="colAsn">ASN</th>
<th data-i18n="colCreated">Erstellt</th>
<th data-i18n="colTitle">Titel</th>
</tr>
</thead>
<tbody id="treffer"></tbody>
</table>
</div>
</div>
</fieldset>
<div class="wahl">
<input type="checkbox" id="klassifizieren" checked>
<label for="klassifizieren" data-i18n="storeClassifyAfter">
Nach dem Ablegen die Klassifizierung bearbeiten
</label>
</div>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="abbrechen" data-i18n="btnCancel">Abbrechen</button>
<button id="ablegen" class="haupt" data-i18n="btnStore">Ablegen</button>
</div>
<script type="module" src="store.js"></script>
</body>
</html>

212
ui/store.js Normal file
View File

@@ -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;
}
}