Initial commit
17
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
|
}[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
@@ -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"
|
||||||
12
icons/paperless-insert.svg
Normal 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 |
BIN
icons/paperless-insert_16.png
Normal file
|
After Width: | Height: | Size: 885 B |
BIN
icons/paperless-insert_32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
icons/paperless-insert_64.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
9
icons/paperless-logo.svg
Normal 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
|
After Width: | Height: | Size: 680 B |
BIN
icons/paperless-logo_32.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
icons/paperless-logo_64.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
15
icons/paperless-share.svg
Normal 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 |
BIN
icons/paperless-share_16.png
Normal file
|
After Width: | Height: | Size: 883 B |
BIN
icons/paperless-share_32.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
icons/paperless-share_64.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
14
icons/paperless-store.svg
Normal 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 |
BIN
icons/paperless-store_16.png
Normal file
|
After Width: | Height: | Size: 889 B |
BIN
icons/paperless-store_32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
icons/paperless-store_64.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
324
lib/client.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||