4 Commits

34 changed files with 1381 additions and 764 deletions

View File

@@ -15,6 +15,7 @@ WebExtensions, LibreOffice UNO.
|---|---|---| |---|---|---|
| Kontextmenü am Anhang | Anhang in Paperless ablegen | ausgewählte Anhänge als Dokument | | Kontextmenü am Anhang | Anhang in Paperless ablegen | ausgewählte Anhänge als Dokument |
| Kontextmenü in der Nachrichtenliste | E-Mail in Paperless ablegen | als PDF mit eingebettetem Original, wahlweise als `.eml` | | Kontextmenü in der Nachrichtenliste | E-Mail in Paperless ablegen | als PDF mit eingebettetem Original, wahlweise als `.eml` |
| Beides | Ablauf | Dateiauswahl, dann je Datei Ziel und Klassifizierung |
| Schaltfläche im Nachrichtenfenster | dasselbe für die angezeigte Nachricht | | | Schaltfläche im Nachrichtenfenster | dasselbe für die angezeigte Nachricht | |
| Schaltfläche im Verfassenfenster | Aus Paperless einfügen | als Anhang oder als Freigabelink | | Schaltfläche im Verfassenfenster | Aus Paperless einfügen | als Anhang oder als Freigabelink |
@@ -46,13 +47,45 @@ alle Anhänge im Original. Es geht also nichts verloren, auch wenn die
Darstellung nicht originalgetreu ist. Darstellung nicht originalgetreu ist.
Wer lieber die Rohfassung will, stellt das in den Einstellungen um oder Wer lieber die Rohfassung will, stellt das in den Einstellungen um oder
nimmt das Häkchen im Ablegen-Dialog heraus. wählt im ersten Fenster je Datei „Original ablegen".
**Titel aus dem Betreff.** Präfixe wie `AW:` oder `Fwd:` werden entfernt — **Titel aus dem Betreff.** Präfixe wie `AW:` oder `Fwd:` werden entfernt —
sie tragen im Archiv nichts bei und stören beim Sortieren. sie tragen im Archiv nichts bei und stören beim Sortieren.
**Klassifizierung direkt danach.** Sonst trägt ein neues Dokument nur den **Zwei Schritte, dann je Datei einer.**
Titel, und nachträglich macht man es erfahrungsgemäß nicht mehr.
```
Fenster 1 Welche Dateien sollen abgelegt werden?
[x] Rechnung.pdf (•) Original ( ) PDF aufbereiten
[x] Anschreiben.html (•) Original ( ) PDF aufbereiten
Fenster 2 Je Datei: Ziel und Klassifizierung
┌─ Ziel ──────────┐ ┌─ Klassifizierung ─┐
│ ( ) Neu │ │ Titel [.......] │
│ (•) An bestehen-│ │ Erstellt[....] │
│ des anhängen│ │ Korresp.[.......] │
│ [suche] │ │ Typ [.......] │
│ Trefferliste │ │ Tags [suche] │
└─────────────────┘ └───────────────────┘
```
Die Trennung hat einen praktischen Grund: **Ziel und Klassifizierung sind
je Datei verschieden.** Ein einzelner Dialog für alle würde die Angaben
der ersten Datei auf alle übertragen — bei einer Rechnung samt
Lastschriftmandat wäre das falsch.
**Klassifizierung des Ziels wird übernommen.** Wählt man ein bestehendes
Dokument aus der Trefferliste, stehen Titel, Datum, Korrespondent, Typ und
Tags schon im rechten Kasten und lassen sich anpassen. Eine neue Version
gehört inhaltlich zum selben Vorgang; die Angaben neu einzutippen wäre
Arbeit für nichts, und ein abweichender Korrespondent brächte die Version
vom Original weg.
**Ein Aufruf je Datei.** Bewusst nicht alle in einem Durchgang: Manifest V3
beendet die Ereignisseite, sobald sie untätig scheint, und ein Aufruf über
zehn Anhänge lief regelmäßig in `Receiving end does not exist`. Nebeneffekt:
Der Fortschritt ist sichtbar, und scheitert eine Datei, sind die übrigen
trotzdem abgelegt.
### Warum kein originalgetreues Abbild ### Warum kein originalgetreues Abbild
@@ -173,7 +206,7 @@ Dauerhaft:
``` ```
Add-ons-Verwaltung → Zahnrad → Add-on aus Datei installieren Add-ons-Verwaltung → Zahnrad → Add-on aus Datei installieren
→ paperless-thunderbird-1.0.0.xpi → paperless-connector-<version>.xpi
``` ```
--- ---
@@ -228,7 +261,7 @@ bei der LibreOffice-Fassung, weil hier **keine fremden Dokumente geöffnet**
werden — der heikelste Punkt entfällt. Die wichtigsten Maßnahmen: werden — der heikelste Punkt entfällt. Die wichtigsten Maßnahmen:
- Zugriffsrecht nur für die eingetragene Adresse, zur Laufzeit erbeten - Zugriffsrecht nur für die eingetragene Adresse, zur Laufzeit erbeten
- Nur die fünf tatsächlich benötigten Berechtigungen — `compose.send` war - Nur die sechs tatsächlich benötigten Berechtigungen — `compose.send` war
im ersten Entwurf enthalten, ohne je benutzt zu werden, und ist entfernt im ersten Entwurf enthalten, ohne je benutzt zu werden, und ist entfernt
- Nur `http` und `https` als Basisadresse - Nur `http` und `https` als Basisadresse
- Dateinamen aus Anhängen werden bereinigt — sie stammen vom Absender - Dateinamen aus Anhängen werden bereinigt — sie stammen vom Absender
@@ -248,9 +281,11 @@ background.js Menüs, Schaltflächen, Vermittlung
lib/ lib/
client.js REST-Client (fetch, FormData) client.js REST-Client (fetch, FormData)
settings.js Einstellungen, Dateinamen, Hilfsmittel settings.js Einstellungen, Dateinamen, Hilfsmittel
pdf.js PDF-Erzeuger ohne Fremdbibliothek
mailpdf.js Aufbereitung einer Nachricht
ui/ ui/
store.html/.js Ablegen: Anhänge oder ganze Nachricht store.html/.js Schritt 1: welche Dateien
classify.html/.js Klassifizierung direkt danach schritt.html/.js Schritt 2: Ziel und Klassifizierung je Datei
insert.html/.js Einfügen als Anhang oder Link insert.html/.js Einfügen als Anhang oder Link
options.html/.js Verbindung einrichten options.html/.js Verbindung einrichten
gemeinsam.js/.css geteilte Hilfsmittel gemeinsam.js/.css geteilte Hilfsmittel
@@ -259,11 +294,28 @@ icons/
``` ```
Die Dialoge sprechen **nicht** selbst mit Paperless, sondern lassen die Die Dialoge sprechen **nicht** selbst mit Paperless, sondern lassen die
Ereignisseite arbeiten. Dort hängt die zur Laufzeit erteilte Berechtigung; Ereignisseite arbeiten. So liegen Token und Verbindungslogik an einer
ein Dialogfenster müsste sie sonst erneut erbitten. Stelle, und die Dialoge bleiben reine Oberfläche.
Damit die Ereignisseite währenddessen nicht beendet wird, hält jeder
Dialog eine offene Verbindung dorthin und sendet alle 15 Sekunden ein
Lebenszeichen. Manifest V3 räumt sie sonst mitten im Vorgang ab —
Wartezeiten zählen dort nicht als Ereignis.
--- ---
### Eine Stolperstelle bei der Gestaltung
Die Dialoge sind auf feste Fensterhöhe mit blätternden Listen ausgelegt.
Drei Dinge mussten dafür stimmen, und jedes einzelne führte für sich zum
Überlauf:
| | |
|---|---|
| `height: 100vh` am Body | mit `min-height` wächst die Seite über das Fenster hinaus |
| kein `fieldset` als Flex-Behälter | dessen innerer anonymer Kasten löst `height: 100%` nicht auf — deshalb `<section class="kasten">` |
| kein `flex-wrap` bei den Spalten | mit Umbruch bemisst sich die Zeilenhöhe nach dem Inhalt, nicht nach dem Behälter |
## Vor einer Veröffentlichung ## Vor einer Veröffentlichung
| Datei | Was | | Datei | Was |
@@ -274,6 +326,28 @@ ein Dialogfenster müsste sie sonst erneut erbitten.
Die Kennung sollte einer Domain folgen, die dir gehört. Aktuell steht dort Die Kennung sollte einer Domain folgen, die dir gehört. Aktuell steht dort
`paperless@example.org`. `paperless@example.org`.
### Anforderungen der Prüfung auf addons.thunderbird.net
Aus der Ablehnung von 1.16.0 übernommen, seit 1.16.1 umgesetzt:
- **Kein async-Listener an `runtime.onMessage`.** Ein async-Listener
gibt immer ein Promise zurück und beansprucht damit jede Nachricht. Der
Listener prüft deshalb synchron Absender und Befehl und gibt nur für
bekannte Befehle das Promise zurück, sonst `false`. Neue Befehle kommen
in die Tabelle `BEFEHLE` in `background.js`.
- **Keine ungenutzten Dateien im Paket.** `build.sh` packt nur die in
`DATEIEN` aufgeführten Dateien und weist auf alles andere im Quellbaum
hin. `make_icons.py` erzeugt weiterhin alle Symbole samt SVG — im Paket
landen nur die sieben, die Manifest und Code verwenden.
- **Beschreibung mit Bedienungsanleitung und Bildschirmfotos** auf der
Add-on-Seite, siehe `ATN-BESCHREIBUNG.md`.
- Vor dem Hochladen den Linter laufen lassen:
```bash
git clone https://github.com/thunderbird/webext-linter
cd webext-linter && npm install
node verify.js /pfad/zu/paperless-connector-<version>.xpi
```
--- ---
## Lizenz ## Lizenz
@@ -288,6 +362,3 @@ schließt jede Gewährleistung aus.
Vor dem ersten produktiven Einsatz: eine Testnachricht ablegen und in Vor dem ersten produktiven Einsatz: eine Testnachricht ablegen und in
Paperless nachsehen, ob Titel, Datum und Anhänge stimmen. Paperless nachsehen, ob Titel, Datum und Anhänge stimmen.
Es wird weder eine Gewähr noch eine Haftung übernommen. Für entstandene
Schäden haftet der jeweilige Anwender. Die Nutzun erfolgt auf eigenes Risiko.

View File

@@ -26,9 +26,6 @@
"storeHintPreselect": { "storeHintPreselect": {
"message": "Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist Teil der Nachricht, keine eigenen Dokumente." "message": "Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist Teil der Nachricht, keine eigenen Dokumente."
}, },
"storeWholeMessage": {
"message": "Die vollständige Nachricht als .eml"
},
"storeAttachmentsInside": { "storeAttachmentsInside": {
"message": "Die $1 Anhänge bleiben darin enthalten.", "message": "Die $1 Anhänge bleiben darin enthalten.",
"placeholders": { "placeholders": {
@@ -44,28 +41,11 @@
"message": "Neues Dokument anlegen" "message": "Neues Dokument anlegen"
}, },
"storeTargetExisting": { "storeTargetExisting": {
"message": "An bestehendes Dokument anhängen" "message": "Als neue Version an ein bestehendes anhängen"
},
"storeHintTitleMulti": {
"message": "Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt."
},
"storeClassifyAfter": {
"message": "Nach dem Ablegen die Klassifizierung bearbeiten"
}, },
"classifyTitle": { "classifyTitle": {
"message": "Klassifizierung bearbeiten" "message": "Klassifizierung bearbeiten"
}, },
"classifyHintNew": {
"message": "Unbekannte Korrespondenten und Dokumenttypen werden angelegt."
},
"classifyMoreStored": {
"message": "$1 Dateien abgelegt",
"placeholders": {
"1": {
"content": "$1"
}
}
},
"insertTitle": { "insertTitle": {
"message": "Aus Paperless einfügen" "message": "Aus Paperless einfügen"
}, },
@@ -235,9 +215,6 @@
"errNoPermission": { "errNoPermission": {
"message": "Ohne Zugriffsrecht auf die Adresse kann die Erweiterung nicht arbeiten." "message": "Ohne Zugriffsrecht auf die Adresse kann die Erweiterung nicht arbeiten."
}, },
"linkLabel": {
"message": "Datei"
},
"linkValidUntil": { "linkValidUntil": {
"message": "Link gültig bis $1", "message": "Link gültig bis $1",
"placeholders": { "placeholders": {
@@ -279,12 +256,6 @@
"optionsHintMailFormat": { "optionsHintMailFormat": {
"message": "Das PDF ist lesbar und durchsuchbar; HTML-Nachrichten werden dabei als Text gesetzt. Die Originalnachricht steckt als Datei im PDF, es geht also nichts verloren." "message": "Das PDF ist lesbar und durchsuchbar; HTML-Nachrichten werden dabei als Text gesetzt. Die Originalnachricht steckt als Datei im PDF, es geht also nichts verloren."
}, },
"storeWholePdf": {
"message": "Die Nachricht als PDF, mit eingebettetem Original"
},
"storeAsPdf": {
"message": "Als PDF aufbereiten, Original und Anhänge einbetten"
},
"fieldPdfFontSize": { "fieldPdfFontSize": {
"message": "Schriftgröße" "message": "Schriftgröße"
}, },
@@ -308,5 +279,46 @@
}, },
"marginBottom": { "marginBottom": {
"message": "unten" "message": "unten"
},
"artOriginal": {
"message": "Original ablegen"
},
"artPdf": {
"message": "PDF aufbereiten"
},
"artMessagePdf": {
"message": "als PDF aufbereiten"
},
"artPdfUnavailable": {
"message": "Für dieses Format nicht möglich - nur Textdateien lassen sich aufbereiten."
},
"classifyProgress": {
"message": "$1 von $2",
"placeholders": {
"1": {
"content": "$1"
},
"2": {
"content": "$2"
}
}
},
"stepTitle": {
"message": "In Paperless ablegen"
},
"btnNext": {
"message": "Weiter"
},
"stepWholeMessage": {
"message": "Die ganze Nachricht"
},
"stepHintExisting": {
"message": "Die Klassifizierung des Ziels wird übernommen und kann angepasst werden."
},
"msgLoadingTarget": {
"message": "Klassifizierung des Ziels wird geladen …"
},
"msgOpeningStep": {
"message": "Wird vorbereitet …"
} }
} }

View File

@@ -26,9 +26,6 @@
"storeHintPreselect": { "storeHintPreselect": {
"message": "Small images and signatures are deselected — they usually belong to the message, not to the content." "message": "Small images and signatures are deselected — they usually belong to the message, not to the content."
}, },
"storeWholeMessage": {
"message": "The complete message as .eml"
},
"storeAttachmentsInside": { "storeAttachmentsInside": {
"message": "The $1 attachments remain inside it.", "message": "The $1 attachments remain inside it.",
"placeholders": { "placeholders": {
@@ -44,28 +41,11 @@
"message": "Create a new document" "message": "Create a new document"
}, },
"storeTargetExisting": { "storeTargetExisting": {
"message": "Append to an existing document" "message": "Append as a new version to an existing document"
},
"storeHintTitleMulti": {
"message": "With several files the title is extended by the file name."
},
"storeClassifyAfter": {
"message": "Edit the classification after storing"
}, },
"classifyTitle": { "classifyTitle": {
"message": "Edit classification" "message": "Edit classification"
}, },
"classifyHintNew": {
"message": "Unknown correspondents and document types will be created."
},
"classifyMoreStored": {
"message": "$1 files stored",
"placeholders": {
"1": {
"content": "$1"
}
}
},
"insertTitle": { "insertTitle": {
"message": "Insert from Paperless" "message": "Insert from Paperless"
}, },
@@ -235,9 +215,6 @@
"errNoPermission": { "errNoPermission": {
"message": "Without access permission for the address the extension cannot work." "message": "Without access permission for the address the extension cannot work."
}, },
"linkLabel": {
"message": "File"
},
"linkValidUntil": { "linkValidUntil": {
"message": "Link valid until $1", "message": "Link valid until $1",
"placeholders": { "placeholders": {
@@ -279,12 +256,6 @@
"optionsHintMailFormat": { "optionsHintMailFormat": {
"message": "The PDF is readable and searchable; HTML messages are rendered as text. The original message is embedded in the PDF, so nothing is lost." "message": "The PDF is readable and searchable; HTML messages are rendered as text. The original message is embedded in the PDF, so nothing is lost."
}, },
"storeWholePdf": {
"message": "The message as PDF, with the original embedded"
},
"storeAsPdf": {
"message": "Prepare as PDF, embedding original and attachments"
},
"fieldPdfFontSize": { "fieldPdfFontSize": {
"message": "Font size" "message": "Font size"
}, },
@@ -308,5 +279,46 @@
}, },
"marginBottom": { "marginBottom": {
"message": "bottom" "message": "bottom"
},
"artOriginal": {
"message": "store original"
},
"artPdf": {
"message": "prepare as PDF"
},
"artMessagePdf": {
"message": "prepare as PDF"
},
"artPdfUnavailable": {
"message": "Not possible for this format - only text files can be prepared."
},
"classifyProgress": {
"message": "$1 of $2",
"placeholders": {
"1": {
"content": "$1"
},
"2": {
"content": "$2"
}
}
},
"stepTitle": {
"message": "Store in Paperless"
},
"btnNext": {
"message": "Next"
},
"stepWholeMessage": {
"message": "The whole message"
},
"stepHintExisting": {
"message": "The target's classification is copied and can be adjusted."
},
"msgLoadingTarget": {
"message": "Loading the target's classification …"
},
"msgOpeningStep": {
"message": "Preparing …"
} }
} }

View File

@@ -26,9 +26,6 @@
"storeHintPreselect": { "storeHintPreselect": {
"message": "Les petites images et les signatures sont décochées — elles font partie du message, pas du contenu." "message": "Les petites images et les signatures sont décochées — elles font partie du message, pas du contenu."
}, },
"storeWholeMessage": {
"message": "Le message complet au format .eml"
},
"storeAttachmentsInside": { "storeAttachmentsInside": {
"message": "Les $1 pièces jointes y restent incluses.", "message": "Les $1 pièces jointes y restent incluses.",
"placeholders": { "placeholders": {
@@ -44,28 +41,11 @@
"message": "Créer un nouveau document" "message": "Créer un nouveau document"
}, },
"storeTargetExisting": { "storeTargetExisting": {
"message": "Ajouter à un document existant" "message": "Ajouter comme nouvelle version à un document existant"
},
"storeHintTitleMulti": {
"message": "Avec plusieurs fichiers, le titre est complété par le nom du fichier."
},
"storeClassifyAfter": {
"message": "Modifier le classement après l'enregistrement"
}, },
"classifyTitle": { "classifyTitle": {
"message": "Modifier le classement" "message": "Modifier le classement"
}, },
"classifyHintNew": {
"message": "Les correspondants et types de document inconnus seront créés."
},
"classifyMoreStored": {
"message": "$1 fichiers enregistrés",
"placeholders": {
"1": {
"content": "$1"
}
}
},
"insertTitle": { "insertTitle": {
"message": "Insérer depuis Paperless" "message": "Insérer depuis Paperless"
}, },
@@ -235,9 +215,6 @@
"errNoPermission": { "errNoPermission": {
"message": "Sans droit d'accès à l'adresse, l'extension ne peut pas fonctionner." "message": "Sans droit d'accès à l'adresse, l'extension ne peut pas fonctionner."
}, },
"linkLabel": {
"message": "Fichier"
},
"linkValidUntil": { "linkValidUntil": {
"message": "Lien valable jusqu'au $1", "message": "Lien valable jusqu'au $1",
"placeholders": { "placeholders": {
@@ -279,12 +256,6 @@
"optionsHintMailFormat": { "optionsHintMailFormat": {
"message": "Le PDF est lisible et consultable ; les messages HTML y sont rendus sous forme de texte. Le message d'origine est intégré au PDF, rien n'est perdu." "message": "Le PDF est lisible et consultable ; les messages HTML y sont rendus sous forme de texte. Le message d'origine est intégré au PDF, rien n'est perdu."
}, },
"storeWholePdf": {
"message": "Le message en PDF, avec l'original intégré"
},
"storeAsPdf": {
"message": "Préparer en PDF, avec l'original et les pièces jointes intégrés"
},
"fieldPdfFontSize": { "fieldPdfFontSize": {
"message": "Taille de police" "message": "Taille de police"
}, },
@@ -308,5 +279,46 @@
}, },
"marginBottom": { "marginBottom": {
"message": "bas" "message": "bas"
},
"artOriginal": {
"message": "enregistrer l'original"
},
"artPdf": {
"message": "préparer en PDF"
},
"artMessagePdf": {
"message": "préparer en PDF"
},
"artPdfUnavailable": {
"message": "Impossible pour ce format - seuls les fichiers texte peuvent être préparés."
},
"classifyProgress": {
"message": "$1 sur $2",
"placeholders": {
"1": {
"content": "$1"
},
"2": {
"content": "$2"
}
}
},
"stepTitle": {
"message": "Enregistrer dans Paperless"
},
"btnNext": {
"message": "Suivant"
},
"stepWholeMessage": {
"message": "Le message entier"
},
"stepHintExisting": {
"message": "Le classement de la cible est repris et peut être ajusté."
},
"msgLoadingTarget": {
"message": "Chargement du classement de la cible …"
},
"msgOpeningStep": {
"message": "Préparation …"
} }
} }

View File

@@ -16,7 +16,8 @@ import {
ladeEinstellungen, vollstaendig, sichererName, versionslabel, ladeEinstellungen, vollstaendig, sichererName, versionslabel,
titelAusBetreff, zugriffsmuster, t titelAusBetreff, zugriffsmuster, t
} from "./lib/settings.js"; } from "./lib/settings.js";
import { baueMailPdf, alsEmpfaenger } from "./lib/mailpdf.js"; import { baueMailPdf, baueTextPdf, alsEmpfaenger, umwandelbar }
from "./lib/mailpdf.js";
/** /**
* Ergebnis einer Nachrichten-Abfrage vereinheitlichen. * Ergebnis einer Nachrichten-Abfrage vereinheitlichen.
@@ -255,7 +256,10 @@ async function dialogOeffnen(art, daten) {
// Absender und Dateinamen; das muss nicht bis zum Neustart im Speicher // Absender und Dateinamen; das muss nicht bis zum Neustart im Speicher
// liegen, nur weil jemand den Dialog offen stehen liess. // liegen, nur weil jemand den Dialog offen stehen liess.
setTimeout(() => auftraege.delete(nummer), 30 * 60 * 1000); setTimeout(() => auftraege.delete(nummer), 30 * 60 * 1000);
const seite = art === "einfuegen" ? "ui/insert.html" : "ui/store.html"; const seite = {
einfuegen: "ui/insert.html",
schritt: "ui/schritt.html"
}[art] || "ui/store.html";
// Die Adresse MUSS über runtime.getURL aufgelöst werden. Ein relativer // Die Adresse MUSS über runtime.getURL aufgelöst werden. Ein relativer
// Pfad wird in Manifest V3 nicht gegen das Erweiterungsverzeichnis // Pfad wird in Manifest V3 nicht gegen das Erweiterungsverzeichnis
@@ -266,8 +270,12 @@ async function dialogOeffnen(art, daten) {
await browser.windows.create({ await browser.windows.create({
url, url,
type: "popup", type: "popup",
width: art === "einfuegen" ? 760 : 820, // Der Schrittdialog steht zweispaltig. Die Höhe ist auf den Inhalt
height: art === "einfuegen" ? 640 : 720 // gerechnet: rund 540 px für die Spalten plus Kopf, Meldung und
// Fusszeile, dazu etwas Spielraum - die Systemschrift fällt je
// nach Umgebung anders aus.
width: { schritt: 980, einfuegen: 760 }[art] || 820,
height: { schritt: 760, einfuegen: 640 }[art] || 520
}); });
} catch (err) { } catch (err) {
// Manche Stände lehnen type "popup" ab. Dann ein gewöhnliches Fenster. // Manche Stände lehnen type "popup" ab. Dann ein gewöhnliches Fenster.
@@ -296,80 +304,92 @@ const verbindungen = new Set();
browser.runtime.onConnect.addListener((port) => { browser.runtime.onConnect.addListener((port) => {
verbindungen.add(port); verbindungen.add(port);
console.debug("[paperless] Dialog verbunden, offen:", verbindungen.size); console.debug("[paperless] Dialog verbunden, offen:", verbindungen.size);
// Die Lebenszeichen werden nicht ausgewertet - allein ihr Eintreffen
// hält die Ereignisseite wach.
port.onMessage.addListener(() => {});
port.onDisconnect.addListener(() => { port.onDisconnect.addListener(() => {
verbindungen.delete(port); verbindungen.delete(port);
console.debug("[paperless] Dialog getrennt, offen:", verbindungen.size); console.debug("[paperless] Dialog getrennt, offen:", verbindungen.size);
}); });
}); });
browser.runtime.onMessage.addListener(async (nachricht, absender) => { /*
// Nur Nachrichten aus dieser Erweiterung annehmen. Thunderbird lässt * Befehle aus den Dialogen.
// fremde Absender hier zwar ohnehin nicht durch, solange kein *
// externally_connectable gesetzt ist - die Prüfung kostet nichts und * Der Listener selbst ist bewusst NICHT async: Ein async-Listener liefert
// hält die Zusicherung explizit. * immer ein Promise zurück und beansprucht damit jede Nachricht - auch
if (absender?.id && absender.id !== browser.runtime.id) { * solche, die gar nicht für ihn bestimmt sind. Stattdessen prüft er
return { ok: false, fehler: "Fremder Absender" }; * synchron, ob die Nachricht aus dieser Erweiterung stammt und einen
} * bekannten Befehl trägt, und gibt nur dann das Promise des Handlers
try { * zurück. Alles andere lehnt er mit false ab, sodass andere Empfänger
switch (nachricht?.befehl) { * antworten können.
case "auftrag": * Siehe https://webextension-api.thunderbird.net/en/mv3/guides/runtimeMessaging.html
return { ok: true, daten: auftraege.get(nachricht.nummer) || null }; */
const BEFEHLE = {
auftrag: async (n) => auftraege.get(n.nummer) || null,
case "einstellungen": einstellungen: async () => ladeEinstellungen(),
return { ok: true, daten: await ladeEinstellungen() };
case "berechtigungPruefen": berechtigungPruefen: async () => berechtigungPruefen(),
return { ok: true, daten: await berechtigungPruefen() };
case "suchen": { suchen: async (n) => (await client()).search(n.query),
dokument: async (n) => (await client()).document(n.id),
versionen: async (n) => {
const c = await client(); const c = await client();
return { ok: true, daten: await c.search(nachricht.query) }; return c.versions(await c.document(n.id));
} },
case "dokument": { stammdaten: async () => {
const c = await client();
return { ok: true, daten: await c.document(nachricht.id) };
}
case "versionen": {
const c = await client();
const doc = await c.document(nachricht.id);
return { ok: true, daten: await c.versions(doc) };
}
case "stammdaten": {
const c = await client(); const c = await client();
const [tags, korr, typen] = await Promise.all( const [tags, korr, typen] = await Promise.all(
[c.tags(), c.correspondents(), c.documentTypes()]); [c.tags(), c.correspondents(), c.documentTypes()]);
return { ok: true, daten: { tags, korr, typen } }; return { tags, korr, typen };
} },
case "ablegen": schrittOeffnen: async (n) => {
return { ok: true, daten: await ablegen(nachricht) }; await dialogOeffnen("schritt", n.auftrag);
return undefined;
},
case "klassifizieren": { dateiListe: async (n) => dateiListe(n.auftrag),
ablegenEine: async (n) => ablegenEine(n),
klassifizieren: async (n) => {
const c = await client(); const c = await client();
const payload = { ...nachricht.payload }; const payload = { ...n.payload };
payload.correspondent = await aufloesen( payload.correspondent = await aufloesen(c, "correspondents/", n.korrName);
c, "correspondents/", nachricht.korrName); payload.document_type = await aufloesen(c, "document_types/", n.typName);
payload.document_type = await aufloesen( await c.patchDocument(n.id, payload);
c, "document_types/", nachricht.typName); return undefined;
await c.patchDocument(nachricht.id, payload); },
return { ok: true };
}
case "anhaengen": anhaengen: async (n) => anhaengenAnEntwurf(n),
return { ok: true, daten: await anhaengenAnEntwurf(nachricht) };
case "freigabelink": freigabelink: async (n) => freigabelink(n),
return { ok: true, daten: await freigabelink(nachricht) }; };
default: async function befehlAusfuehren(handler, nachricht) {
return { ok: false, fehler: `Unbekannter Befehl: ${nachricht?.befehl}` }; try {
} const daten = await handler(nachricht);
return daten === undefined ? { ok: true } : { ok: true, daten };
} catch (err) { } catch (err) {
return { ok: false, fehler: String(err?.message || err) }; return { ok: false, fehler: String(err?.message || err) };
} }
}
browser.runtime.onMessage.addListener((nachricht, absender) => {
// Nur Nachrichten aus dieser Erweiterung mit bekanntem Befehl annehmen.
if (absender?.id !== browser.runtime.id) return false;
const befehl = nachricht?.befehl;
if (typeof befehl !== "string" || !Object.hasOwn(BEFEHLE, befehl)) {
return false;
}
return befehlAusfuehren(BEFEHLE[befehl], nachricht);
}); });
async function client() { async function client() {
@@ -684,43 +704,160 @@ async function datenHolen(auftrag, s) {
if (!d.ausgewaehlt) continue; if (!d.ausgewaehlt) continue;
const datei = await browser.messages.getAttachmentFile( const datei = await browser.messages.getAttachmentFile(
d.messageId, d.partName); d.messageId, d.partName);
// Aufbereiten nur, wenn der Anwender es gewählt hat UND das Format es
// hergibt. Eine Rechnung als PDF ist bereits das Dokument; sie durch
// eine Textfassung zu ersetzen wäre Unfug.
if (d.alsPdf && umwandelbar(d.contentType, d.name)) {
try {
out.push(await anhangAlsPdf(datei, d, s));
continue;
} catch (err) {
console.error("[paperless] Anhang konnte nicht aufbereitet werden",
d.name, err);
// Nicht abbrechen: lieber das Original ablegen als gar nichts.
}
}
out.push({ blob: datei, name: sichererName(d.name) }); out.push({ blob: datei, name: sichererName(d.name) });
} }
return out; return out;
} }
async function ablegen(nachricht) { /**
* Einen Textanhang als PDF aufbereiten, mit der Ursprungsdatei darin.
*
* Sinnvoll etwa bei einem HTML-Anhang, den im Archiv sonst niemand
* ansieht. Für Formate ohne Textinhalt greift diese Funktion nicht -
* das prüft umwandelbar() vorher.
*/
async function anhangAlsPdf(datei, beschreibung, s) {
const roh = new Uint8Array(await datei.arrayBuffer());
let text = new TextDecoder("utf-8", { fatal: false }).decode(roh);
const istHtml = /^text\/html$/i.test(beschreibung.contentType || "")
|| /\.(html?|xhtml)$/i.test(beschreibung.name || "")
|| /<\s*html[\s>]/i.test(text.slice(0, 2000));
if (istHtml && browser.messengerUtilities?.convertToPlainText) {
try {
text = await browser.messengerUtilities.convertToPlainText(text);
} catch { /* dann bleibt der Rohtext */ }
}
const basis = String(beschreibung.name || "Dokument")
.replace(/\.[^.]{1,8}$/, "");
const pdf = baueTextPdf({
titel: basis,
text,
dateien: [{
name: sichererName(beschreibung.name || "original"),
daten: roh,
mime: beschreibung.contentType || "application/octet-stream",
beschreibung: "Originaldatei"
}]
}, {
schrift: Number(s.pdfSchriftgroesse) || 11,
format: s.pdfFormat || "a4",
rand: {
links: Number(s.pdfRandLinks ?? 20),
rechts: Number(s.pdfRandRechts ?? 10),
oben: Number(s.pdfRandOben ?? 10),
unten: Number(s.pdfRandUnten ?? 10)
}
});
return {
blob: new Blob([pdf], { type: "application/pdf" }),
name: sichererName(`${basis}.pdf`)
};
}
/**
* Die Dateien eines Auftrags aufzählen, ohne sie zu laden.
*
* Der Dialog fragt damit ab, wie viele Schritte anstehen, und ruft dann
* jeden einzeln auf.
*/
function dateiListe(auftrag) {
if (auftrag.quelle === "nachricht") {
return [{ art: "nachricht" }];
}
return (auftrag.dateien || [])
.map((d, i) => ({ art: "anhang", index: i, name: d.name }))
.filter((_x, i) => auftrag.dateien[i].ausgewaehlt);
}
/**
* EINE Datei ablegen.
*
* Bewusst je Datei ein eigener Aufruf statt einer Schleife über alle:
*
* * Die Ereignisseite wird in Manifest V3 beendet, sobald sie untätig
* scheint. Ein Aufruf über zehn Anhänge lief regelmäßig in
* "Receiving end does not exist".
* * Der Dialog kann den Fortschritt anzeigen.
* * Scheitert eine Datei, sind die übrigen trotzdem abgelegt.
*/
async function ablegenEine(nachricht) {
const c = await client(); const c = await client();
const s = await ladeEinstellungen(); const s = await ladeEinstellungen();
const dateien = await datenHolen(nachricht.auftrag, s); const auftrag = nachricht.auftrag;
if (!dateien.length) throw new PaperlessError(t("errNothingSelected")); const eintrag = nachricht.eintrag;
let datei;
if (eintrag.art === "nachricht") {
const alle = await datenHolen(auftrag, s);
datei = alle[0];
} else {
const beschreibung = auftrag.dateien[eintrag.index];
const roh = await browser.messages.getAttachmentFile(
beschreibung.messageId, beschreibung.partName);
if (beschreibung.alsPdf
&& umwandelbar(beschreibung.contentType, beschreibung.name)) {
try {
datei = await anhangAlsPdf(roh, beschreibung, s);
} catch (err) {
console.error("[paperless] Aufbereitung gescheitert",
beschreibung.name, err);
datei = { blob: roh, name: sichererName(beschreibung.name) };
}
} else {
datei = { blob: roh, name: sichererName(beschreibung.name) };
}
}
if (!datei) throw new PaperlessError(t("errNothingSelected"));
const melden = (sek) => {
if (sek % 15 === 0) {
console.debug("[paperless] warte auf Aufgabe:", sek, "s");
}
};
const ergebnisse = [];
for (const datei of dateien) {
if (nachricht.ziel === "vorhanden") { if (nachricht.ziel === "vorhanden") {
const antwort = await c.updateVersion( const antwort = await c.updateVersion(
nachricht.zielId, datei.blob, datei.name, nachricht.zielId, datei.blob, datei.name,
versionslabel(s.versionLabel)); versionslabel(s.versionLabel));
const id = await c.waitForTask(antwort, (sek) => { const id = await c.waitForTask(antwort, melden);
if (sek % 15 === 0) { return { id, name: datei.name, art: "version", zielId: nachricht.zielId };
console.debug("[paperless] warte auf Aufgabe:", sek, "s");
} }
});
ergebnisse.push({ id, name: datei.name, art: "version", // Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt -
zielId: nachricht.zielId }); // sonst hiessen alle gleich und wären im Archiv nicht auseinander-
} else { // zuhalten.
const felder = { title: nachricht.titel || datei.name }; const grundtitel = nachricht.titel || datei.name;
const titel = nachricht.mehrere
? `${grundtitel} - ${datei.name.replace(/\.[^.]{1,8}$/, "")}`
: grundtitel;
const felder = { title: titel.slice(0, 127) };
if (nachricht.erstellt) felder.created = nachricht.erstellt; if (nachricht.erstellt) felder.created = nachricht.erstellt;
const antwort = await c.postDocument(datei.blob, datei.name, felder); const antwort = await c.postDocument(datei.blob, datei.name, felder);
const id = await c.waitForTask(antwort, (sek) => { const id = await c.waitForTask(antwort, melden);
if (sek % 15 === 0) { return { id, name: datei.name, art: "neu" };
console.debug("[paperless] warte auf Aufgabe:", sek, "s");
}
});
ergebnisse.push({ id, name: datei.name, art: "neu" });
}
}
return ergebnisse;
} }
async function aufloesen(c, pfad, name) { async function aufloesen(c, pfad, name) {

View File

@@ -11,18 +11,45 @@ NAME="paperless-connector"
VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' manifest.json | head -1)" VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' manifest.json | head -1)"
OUT="${NAME}-${VERSION}.xpi" OUT="${NAME}-${VERSION}.xpi"
# Vollständigkeit prüfen. zip überspringt fehlende Pfade stillschweigend - # Paketinhalt als feste Liste. Nur was hier steht, landet in der .xpi -
# ohne diese Prüfung entstünde ein Paket, das sich nicht laden lässt. # so geraten keine Altlasten (alte Dialoge, ungenutzte Symbole, Quell-SVGs
# aus make_icons.py) ins Paket. addons.thunderbird.net lehnt Pakete mit
# ungenutzten Dateien ab.
DATEIEN="
manifest.json
background.js
LICENSE
lib/client.js
lib/settings.js
lib/pdf.js
lib/mailpdf.js
ui/gemeinsam.css
ui/gemeinsam.js
ui/store.html
ui/store.js
ui/schritt.html
ui/schritt.js
ui/insert.html
ui/insert.js
ui/options.html
ui/options.js
_locales/de/messages.json
_locales/en/messages.json
_locales/fr/messages.json
icons/paperless-logo_16.png
icons/paperless-logo_32.png
icons/paperless-logo_64.png
icons/paperless-store_16.png
icons/paperless-store_32.png
icons/paperless-insert_16.png
icons/paperless-insert_32.png
"
# Vollständigkeit prüfen. zip überspringt fehlende Pfade nicht immer
# lautstark - ohne diese Prüfung entstünde ein Paket, das sich nicht
# laden lässt.
FEHLT="" FEHLT=""
for f in manifest.json background.js \ for f in $DATEIEN; do
lib/client.js lib/settings.js lib/pdf.js lib/mailpdf.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" [ -e "$f" ] || FEHLT="$FEHLT $f\n"
done done
if [ -n "$FEHLT" ]; then if [ -n "$FEHLT" ]; then
@@ -32,14 +59,18 @@ if [ -n "$FEHLT" ]; then
exit 1 exit 1
fi fi
rm -f "$OUT" # Umgekehrte Prüfung: Dateien im Quellbaum, die nicht im Paket landen.
# Nur ein Hinweis - README, build.sh usw. gehören bewusst nicht hinein.
for f in $(find lib ui icons _locales -type f | sort); do
case " $(echo $DATEIEN) " in
*" $f "*) ;;
*) echo "Hinweis: nicht im Paket: $f" >&2 ;;
esac
done
zip -r -q "$OUT" \ rm -f "$OUT"
manifest.json background.js \ # shellcheck disable=SC2086
lib ui icons _locales \ zip -q -X "$OUT" $DATEIEN
LICENSE \
-x "*.oxt" "*.xpi" "build.sh" "README.md" "SICHERHEIT.md" \
"make_icons.py" "*/.*"
if ! unzip -l "$OUT" | grep -q 'manifest.json'; then if ! unzip -l "$OUT" | grep -q 'manifest.json'; then
echo "ABBRUCH: manifest.json fehlt im erzeugten Paket." >&2 echo "ABBRUCH: manifest.json fehlt im erzeugten Paket." >&2

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 885 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

Before

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 889 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -183,16 +183,113 @@ export class PaperlessClient {
return true; return true;
} }
/**
* Dokumente suchen. Vier Wege, in dieser Reihenfolge:
*
* 1. Archiv-Seriennummer, wenn die Eingabe nur aus Ziffern besteht.
* Sie ist ein eigenes Feld - die Volltextsuche findet sie NICHT.
*
* 2. Die GLOBALE SUCHE von Paperless (/api/search/). Dieselbe
* Schnittstelle, die die Suchleiste oben in der Weboberfläche
* bedient. Sie verbindet Titelsuche und Volltext und liefert
* damit die Ergebnisse, die man aus der Oberfläche kennt.
*
* 3. Teilzeichenkette in Titel und Inhalt. Greift, wenn die globale
* Suche nichts liefert oder in der eingesetzten Version fehlt.
*
* 4. Volltextsuche über den Index, mit UND-Verknüpfung. Sie
* beherrscht Wortstämme und findet auch Beugungsformen.
*
* Zwei Fallstricke, die hier berücksichtigt sind:
*
* * Bei der Volltextsuche darf KEIN ordering mitgegeben werden.
* Paperless sortiert dort nach Relevanz; eine eigene
* Sortierangabe führte dazu, dass die Suchanfrage verworfen wurde
* und schlicht die zuletzt hinzugefügten Dokumente kamen.
*
* * Ohne UND-Verknüpfung sucht der Index mit ODER. "Anschreiben
* Markus Straub" lieferte dann alles, was irgendeines der drei
* Wörter enthält - der gesuchte Treffer ging darin unter.
*/
async search(query, limit = 50) { async search(query, limit = 50) {
const q = String(query || "").trim();
if (!q) return this.#suchlauf({ ordering: "-added" }, limit);
// 1. Seriennummer
if (/^\d+$/.test(q)) {
const asn = await this.#suchlauf({ archive_serial_number: q }, limit);
if (asn.length) return asn;
}
// 2. Globale Suche wie in der Weboberfläche
const global = await this.#globaleSuche(q, limit);
if (global.length) return global;
// 3. Titel und Inhalt
const text = await this.#suchlauf(
{ title_content: q, ordering: "-created" }, limit);
if (text.length) return text;
// 4. Volltext mit UND, ohne ordering.
//
// Nach dem Ersetzen der Sonderzeichen ERNEUT zerlegen: Aus
// "Müller-Lüdenscheidt" würde sonst ein Wort mit Leerzeichen, und
// die UND-Verknüpfung wäre wieder aufgehoben.
const worte = q.replace(/[+\-&|!(){}[\]^"~*?:\\/]/g, " ")
.split(/\s+/).filter(Boolean);
if (!worte.length) return [];
return this.#suchlauf({ query: worte.join(" AND ") }, limit);
}
/**
* Die Schnittstelle hinter der Suchleiste der Weboberfläche.
*
* Sie gibt es erst ab neueren Paperless-Ständen, und die Form der
* Antwort ist nicht überall gleich - mal ein Feld "documents", mal
* "results". Deshalb wird beides angenommen und bei jedem Fehler
* stillschweigend auf den nächsten Weg ausgewichen.
*/
async #globaleSuche(q, limit) {
let antwort;
try {
const params = new URLSearchParams({ query: q,
page_size: String(limit) });
antwort = await this.#json(`search/?${params}`);
} catch {
return [];
}
if (!antwort) return [];
for (const schluessel of ["documents", "results"]) {
const rows = antwort[schluessel];
if (Array.isArray(rows) && rows.length) {
// Die globale Suche liefert je nach Version verschiedene
// Objektarten. Nur echte Dokumente durchlassen.
return rows
.filter((r) => r && typeof r === "object"
&& r.id !== undefined && r.title !== undefined)
.slice(0, limit);
}
}
return [];
}
async #suchlauf(zusatz, limit) {
const params = new URLSearchParams({ const params = new URLSearchParams({
page_size: String(limit), page_size: String(limit),
ordering: "-added", // Ohne diese Einschränkung liefert Paperless je Treffer auch den
fields: "id,title,created,added,archive_serial_number,mime_type," + // vollständigen Inhalt - bei fünfzig Treffern mehrere Megabyte.
"original_file_name" fields: "id,title,created,added,archive_serial_number,mime_type,"
+ "original_file_name"
}); });
if (query) params.set("query", query); for (const [k, v] of Object.entries(zusatz)) params.set(k, String(v));
try {
const data = await this.#json(`documents/?${params}`); const data = await this.#json(`documents/?${params}`);
return data?.results || []; return data?.results || [];
} catch {
return [];
}
} }
document(id) { document(id) {

View File

@@ -190,6 +190,79 @@ function kopfblock(d, daten, groesse) {
d.y = oben - hoehe; d.y = oben - hoehe;
} }
/**
* Beliebigen Text als PDF setzen, mit der Ursprungsdatei als Anhang.
*
* Für Anhänge, die Text enthalten - HTML, Nur-Text, eingebettete
* Nachrichten. Formate wie DOCX oder XLSX lassen sich hier NICHT
* umwandeln; dafür bräuchte es eine Darstellungsmaschine.
*/
export function baueTextPdf(daten, opt = {}) {
const basis = begrenzen(Number(opt.schrift) || 11, 7, 16);
const papier = FORMATE[String(opt.format || "a4").toLowerCase()]
|| FORMATE.a4;
const r = opt.rand || {};
const grenze = (wert, vorgabe) =>
Math.min(papier.breite / 3, Math.max(0, Number(wert) ?? vorgabe));
const d = new PdfDokument({
titel: daten.titel || "Dokument",
breite: mm(papier.breite),
hoehe: mm(papier.hoehe),
randLinks: mm(grenze(r.links ?? 20, 20)),
randRechts: mm(grenze(r.rechts ?? 10, 10)),
randOben: mm(grenze(r.oben ?? 10, 10)),
randUnten: mm(grenze(r.unten ?? 10, 10))
});
d.neueSeite();
d.absatz(daten.titel || "Dokument",
{ groesse: basis + 2, fett: true, abstand: 1.25 });
d.abstand(6);
d.linie();
d.abstand(8);
const text = String(daten.text ?? "").replace(/\r\n?/g, "\n").trimEnd();
if (text) {
for (const zeile of text.split("\n")) {
d.absatz(zeile || " ", { groesse: basis, abstand: 1.35 });
}
} else {
d.absatz("(kein Textinhalt - siehe eingebettete Originaldatei)",
{ groesse: basis, farbe: GRAU_TEXT });
}
d.abstand(10);
d.linie();
d.absatz("Aus der Originaldatei erzeugt, die diesem PDF beiliegt.",
{ groesse: Math.max(6.5, basis - 3), farbe: GRAU_TEXT });
for (const f of daten.dateien ?? []) {
d.anhang(f.name, f.daten, {
beschreibung: f.beschreibung || "",
mime: f.mime || "application/octet-stream"
});
}
return d.bauen();
}
/**
* Lässt sich diese Datei als PDF aufbereiten?
*
* Nur was Text enthält. Bei DOCX oder XLSX müsste die Erweiterung das
* Format auswerten und setzen - dafür gibt es keine Grundlage.
*/
export function umwandelbar(mime, name = "") {
const m = String(mime || "").toLowerCase();
if (/^text\//.test(m)) return true;
if (m === "message/rfc822") return true;
if (["application/json", "application/xml", "application/xhtml+xml",
"application/rtf"].includes(m)) return true;
// Manche Server melden alles als octet-stream. Dann entscheidet die
// Endung.
return /\.(txt|htm|html|xml|json|csv|md|log|eml)$/i.test(String(name));
}
function begrenzen(wert, min, max) { function begrenzen(wert, min, max) {
return Math.min(max, Math.max(min, wert)); return Math.min(max, Math.max(min, wert));
} }

View File

@@ -2,13 +2,13 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extName__", "name": "__MSG_extName__",
"description": "__MSG_extDescription__", "description": "__MSG_extDescription__",
"version": "1.11.3", "version": "1.16.1",
"author": "Name des Herausgebers", "author": "Marco Morath",
"homepage_url": "https://example.org/paperless-thunderbird", "homepage_url": "https://gitea.cloud.mmorath.de/marco.morath/thunderbird-paperless-connector",
"default_locale": "de", "default_locale": "de",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
"id": "paperless@example.org", "id": "webmaster@mmorath.de",
"strict_min_version": "128.0" "strict_min_version": "128.0"
} }
}, },

View File

@@ -1,53 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="classifyTitle">Klassifizierung bearbeiten</title>
<link rel="stylesheet" href="gemeinsam.css">
</head>
<body>
<h1 data-i18n="classifyTitle">Klassifizierung bearbeiten</h1>
<div id="quelle" class="hinweis"></div>
<label for="titel" data-i18n="fieldTitle">Titel</label>
<input type="text" id="titel">
<label for="erstellt" data-i18n="fieldCreated">Erstellt</label>
<input type="text" id="erstellt" placeholder="JJJJ-MM-TT">
<label for="korr" data-i18n="fieldCorrespondent">Korrespondent</label>
<input type="text" id="korr" list="korrListe" autocomplete="off">
<datalist id="korrListe"></datalist>
<label for="typ" data-i18n="fieldDocType">Dokumenttyp</label>
<input type="text" id="typ" list="typListe" autocomplete="off">
<datalist id="typListe"></datalist>
<div class="wachsen">
<label for="tagSuche" data-i18n="fieldTags">Tags</label>
<input type="text" id="tagSuche"
data-i18n-placeholder="tagsFilterPlaceholder"
placeholder="Tippen zum Filtern …">
<div class="liste">
<div id="tagsGewaehlt" class="gewaehlteTags"></div>
<div id="tags" style="padding:4px 8px"></div>
<div id="tagsLeer" class="hinweis"
style="padding:6px 8px; display:none"
data-i18n="tagsNoMatch">Keine Übereinstimmung.</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>

View File

@@ -1,212 +0,0 @@
/**
* classify.js - Klassifizierung eines gerade abgelegten Dokuments.
*
* Wird unmittelbar nach dem Ablegen aufgerufen. Ohne diesen Schritt trägt
* ein neues Dokument nur den Titel, und nachträglich macht man es
* erfahrungsgemäß nicht mehr.
*/
import { kurz } from "../lib/settings.js";
import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
from "./gemeinsam.js";
const $ = (id) => document.getElementById(id);
let dokumentId = null;
let alleTags = [];
// Gewählte Tags als Menge. Bewusst getrennt vom angezeigten Zustand der
// Kontrollkästchen: Der Filter blendet Zeilen aus, die Auswahl darf davon
// nicht berührt werden.
const gewaehlt = new Set();
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) {
for (const id of gesetzt) gewaehlt.add(Number(id));
const feld = $("tagSuche");
if (feld) {
feld.addEventListener("input", zeichneTags);
// Eingabetaste soll den Dialog nicht abschicken, sondern beim
// einzigen Treffer diesen umschalten - das ist beim Tippen der
// erwartete Ablauf.
feld.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
const treffer = gefilterteTags();
if (treffer.length === 1) {
umschalten(treffer[0].id);
feld.value = "";
zeichneTags();
}
});
}
zeichneTags();
}
/** Tags, die zum aktuellen Filter passen und noch nicht gewählt sind. */
function gefilterteTags() {
const filter = ($("tagSuche")?.value || "").trim().toLowerCase();
return alleTags.filter((t) => {
if (gewaehlt.has(Number(t.id))) return false;
if (!filter) return true;
return String(t.name).toLowerCase().includes(filter);
});
}
function umschalten(id) {
const n = Number(id);
if (gewaehlt.has(n)) gewaehlt.delete(n);
else gewaehlt.add(n);
}
/**
* Tagliste zeichnen.
*
* Zwei Bereiche: oben die gewählten, darunter die übrigen. Die gewählten
* bleiben immer sichtbar, auch wenn der Filter sie ausschliesst - sonst
* verliert man beim Suchen den Überblick, was bereits angehakt ist.
*/
function zeichneTags() {
const filter = ($("tagSuche")?.value || "").trim().toLowerCase();
const oben = $("tagsGewaehlt");
oben.replaceChildren();
const gewaehlteListe = alleTags
.filter((t) => gewaehlt.has(Number(t.id)))
.sort((a, b) => String(a.name).localeCompare(String(b.name), "de"));
for (const tag of gewaehlteListe) {
oben.append(tagZeile(tag, true, ""));
}
const unten = $("tags");
unten.replaceChildren();
const rest = gefilterteTags()
.sort((a, b) => String(a.name).localeCompare(String(b.name), "de"));
for (const tag of rest) {
unten.append(tagZeile(tag, false, filter));
}
const leer = $("tagsLeer");
if (leer) leer.style.display = (rest.length === 0 && filter) ? "" : "none";
}
function tagZeile(tag, istGewaehlt, filter) {
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 = istGewaehlt;
box.addEventListener("change", () => {
umschalten(tag.id);
zeichneTags();
});
const bez = document.createElement("label");
bez.htmlFor = box.id;
beschriftungSetzen(bez, String(tag.name), filter);
zeile.append(box, bez);
return zeile;
}
/**
* Beschriftung mit hervorgehobenem Treffer.
*
* Aufbau über createElement und textContent, nicht über innerHTML - ein
* Tagname stammt aus Paperless und damit mittelbar aus fremden Quellen.
*/
function beschriftungSetzen(el, name, filter) {
el.replaceChildren();
if (!filter) {
el.textContent = name;
return;
}
const pos = name.toLowerCase().indexOf(filter);
if (pos < 0) {
el.textContent = name;
return;
}
el.append(document.createTextNode(name.slice(0, pos)));
const treffer = document.createElement("span");
treffer.className = "trefferHervor";
treffer.textContent = name.slice(pos, pos + filter.length);
el.append(treffer);
el.append(document.createTextNode(name.slice(pos + filter.length)));
}
async function speichern() {
$("speichern").disabled = true;
meldungAnzeigen(browser.i18n.getMessage("msgSaving") || "Wird gespeichert …");
try {
// Aus der Menge lesen, nicht aus den Kästchen: Gefilterte Zeilen
// sind gar nicht im Dokument, ihre Auswahl ginge sonst verloren.
const tags = [...gewaehlt];
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;
}
}

View File

@@ -1,12 +1,21 @@
/* Gemeinsames Aussehen der Dialoge. Bewusst zurückhaltend, damit sie sich /* Gemeinsames Aussehen der Dialoge.
in die Oberfläche von Thunderbird einfügen statt sich abzuheben. */ Bewusst zurückhaltend, damit sie sich in die Oberfläche von
Thunderbird einfügen statt sich abzuheben. */
:root { :root {
--gruen: #175420; --gruen: #175420;
--rand: #c8ccd0; --rand: #c8ccd0;
--hell: #f6f6f4; --hell: #f6f6f4;
color-scheme: light dark; color-scheme: light dark;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
/* Muss VOR den Anzeigearten stehen und !important tragen: Eine Regel wie
".wahl { display: flex }" überstimmt sonst das eingebaute
"[hidden] { display: none }", und ausgeblendete Elemente erscheinen. */
[hidden] { display: none !important; }
body { body {
font: message-box; font: message-box;
font-size: 13px; font-size: 13px;
@@ -15,9 +24,16 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
/* Feste Höhe UND overflow:hidden. Mit min-height wuchs die Seite über
das Fenster hinaus, und die Fußzeile lag unerreichbar darunter.
Gestaucht wird stattdessen innen: Die Listen blättern selbst. */
height: 100vh; height: 100vh;
overflow: hidden;
} }
h1 { font-size: 14px; margin: 0 0 2px; font-weight: 600; } h1 { font-size: 14px; margin: 0 0 2px; font-weight: 600; }
fieldset { fieldset {
border: 1px solid var(--rand); border: 1px solid var(--rand);
border-radius: 4px; border-radius: 4px;
@@ -26,7 +42,9 @@ fieldset {
min-width: 0; min-width: 0;
} }
legend { padding: 0 4px; font-weight: 600; } legend { padding: 0 4px; font-weight: 600; }
label { display: block; margin: 4px 0 2px; } label { display: block; margin: 4px 0 2px; }
input[type=text], input[type=password], input[type=number], select { input[type=text], input[type=password], input[type=number], select {
width: 100%; width: 100%;
padding: 4px 6px; padding: 4px 6px;
@@ -35,15 +53,30 @@ input[type=text], input[type=password], input[type=number], select {
background: Field; background: Field;
color: FieldText; color: FieldText;
} }
.zeile { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } .zeile { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.zeile > input[type=text] { flex: 1; }
/* Kompakte Zahlenfelder mit vorangestellter Beschriftung. */ /* Kompakte Zahlenfelder mit vorangestellter Beschriftung. */
.masse { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } .masse { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.mass { display: flex; gap: 5px; align-items: center; } .mass { display: flex; gap: 5px; align-items: center; }
.mass label { margin: 0; white-space: nowrap; } .mass label { margin: 0; white-space: nowrap; }
.mass input { width: 68px; } .mass input { width: 68px; }
.zeile > input[type=text] { flex: 1; }
.wahl { display: flex; gap: 6px; align-items: center; margin: 3px 0; } .wahl { display: flex; gap: 6px; align-items: center; margin: 3px 0; }
.wahl input { width: auto; margin: 0; } .wahl input { width: auto; margin: 0; }
/* Dateiliste mit Wahl der Ablageart je Datei. */
.dateizeile {
display: flex; align-items: center; gap: 10px;
padding: 3px 0; flex-wrap: wrap;
}
.dateizeile .name { flex: 1; min-width: 200px; }
.dateizeile .art { display: flex; gap: 12px; align-items: center; }
.dateizeile .art label { margin: 0; white-space: nowrap; font-size: 12px; }
.dateizeile .art input { width: auto; margin: 0; }
.dateizeile .art label.aus { color: GrayText; }
button { button {
padding: 5px 14px; padding: 5px 14px;
border: 1px solid var(--rand); border: 1px solid var(--rand);
@@ -52,18 +85,28 @@ button {
color: ButtonText; color: ButtonText;
cursor: pointer; cursor: pointer;
} }
button.haupt { background: var(--gruen); color: #fff; border-color: var(--gruen); } button.haupt {
background: var(--gruen); color: #fff; border-color: var(--gruen);
}
button:disabled { opacity: .5; cursor: default; } button:disabled { opacity: .5; cursor: default; }
.fuss { display: flex; justify-content: flex-end; gap: 8px; margin-top: auto; }
.fuss {
display: flex; justify-content: flex-end; gap: 8px; margin-top: auto;
}
.liste { .liste {
border: 1px solid var(--rand); border: 1px solid var(--rand);
border-radius: 3px; border-radius: 3px;
overflow: auto; overflow: auto;
background: Field; background: Field;
flex: 1; flex: 1;
min-height: 90px; min-height: 80px;
}
table {
width: 100%; border-collapse: collapse;
font-variant-numeric: tabular-nums;
} }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { padding: 3px 8px; text-align: left; white-space: nowrap; } th, td { padding: 3px 8px; text-align: left; white-space: nowrap; }
th { th {
position: sticky; top: 0; position: sticky; top: 0;
@@ -74,18 +117,64 @@ th {
td.zahl, th.zahl { text-align: right; } td.zahl, th.zahl { text-align: right; }
tr.gewaehlt { background: SelectedItem; color: SelectedItemText; } tr.gewaehlt { background: SelectedItem; color: SelectedItemText; }
tbody tr { cursor: default; } tbody tr { cursor: default; }
.hinweis { color: GrayText; font-size: 12px; }
/* Gewählte Tags stehen oben und bleiben sichtbar, auch wenn der Filter .hinweis { color: GrayText; font-size: 12px; }
sie ausschliesst. Sonst verliert man beim Suchen den Überblick, was
bereits angehakt ist. */
.gewaehlteTags {
padding: 4px 8px;
border-bottom: 1px solid var(--rand);
background: color-mix(in srgb, Field 94%, CanvasText);
}
.gewaehlteTags:empty { display: none; }
.gewaehlteTags .wahl label { font-weight: 600; }
.trefferHervor { background: color-mix(in srgb, Mark 60%, transparent); }
.fehler { color: #b32; } .fehler { color: #b32; }
.wachsen { flex: 1; display: flex; flex-direction: column; gap: 6px; min-height: 0; } .trefferHervor { background: color-mix(in srgb, Mark 60%, transparent); }
.wachsen {
flex: 1; display: flex; flex-direction: column; gap: 6px; min-height: 0;
}
/* --------------------------------------------------- Zweispaltig */
/* KEIN flex-wrap.
Mit Umbruch bemisst sich die Hoehe einer Flexzeile nach ihrem INHALT,
nicht nach dem Behaelter - die Spalten wachsen dann, statt sich zu
stauchen, und die Liste laeuft wieder aus dem Fenster.
Der urspruengliche Dialog hatte genau deshalb keinen Umbruch. Das
Fenster ist ohnehin 980 px breit; zwei Spalten zu 340 px passen
immer. */
.spalten {
display: flex;
flex-wrap: nowrap;
gap: 12px;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* KEIN fieldset für wachsende Bereiche.
Ein fieldset taugt nicht als Flex-Behälter: Browser behandeln seinen
inneren anonymen Kasten gesondert, height:100% löst sich darin nicht
verlässlich auf, und eine Liste darin wächst über den Rahmen hinaus.
Ein gewöhnliches Element verhält sich wie erwartet.
Der einzige sichtbare Unterschied: Die Überschrift steht innen statt
auf dem Rahmen. */
.kasten {
border: 1px solid var(--rand);
border-radius: 4px;
padding: 10px;
/* Grundbreite 0 statt 340 px: Bei nowrap wuerde eine Grundbreite die
Spalten ueber das Fenster hinausschieben. Die Aufteilung ergibt
sich aus flex-grow zu gleichen Teilen. */
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
min-width: 0;
}
.kasten > h2 {
font-size: 13px;
font-weight: 600;
margin: 0 0 4px;
}
/* Die Liste füllt, was nach den festen Elementen übrig bleibt, und
blättert selbst. Genau das war das Ziel - es scheiterte bisher am
fieldset, nicht an der Vorgehensweise. */
.kasten .liste {
flex: 1;
min-height: 80px;
}

View File

@@ -13,13 +13,33 @@
*/ */
let dauerverbindung = null; let dauerverbindung = null;
let lebenszeichen = null;
export function verbindungHalten() { export function verbindungHalten() {
if (dauerverbindung) return; if (dauerverbindung) return;
try { try {
dauerverbindung = browser.runtime.connect({ name: "dialog" }); dauerverbindung = browser.runtime.connect({ name: "dialog" });
dauerverbindung.onDisconnect.addListener(() => { dauerverbindung.onDisconnect.addListener(() => {
dauerverbindung = null; dauerverbindung = null;
if (lebenszeichen) {
clearInterval(lebenszeichen);
lebenszeichen = null;
}
}); });
// Zusätzlich regelmäßig ein Lebenszeichen senden. Die offene
// Verbindung allein hat sich als nicht ausreichend erwiesen: Bei
// mehreren Dateien hintereinander wurde die Ereignisseite trotzdem
// beendet. Ein tatsächliches Ereignis alle 15 Sekunden setzt die
// Untätigkeitsuhr zuverlässig zurück.
lebenszeichen = setInterval(() => {
try {
dauerverbindung?.postMessage({ befehl: "lebenszeichen" });
} catch {
clearInterval(lebenszeichen);
lebenszeichen = null;
}
}, 15000);
} catch (err) { } catch (err) {
console.warn("[paperless] Dauerverbindung nicht möglich:", err); console.warn("[paperless] Dauerverbindung nicht möglich:", err);
} }

View File

@@ -29,7 +29,7 @@
<div class="wachsen"> <div class="wachsen">
<label data-i18n="colVersions">Versionen</label> <label data-i18n="colVersions">Versionen</label>
<div class="liste" style="min-height:70px"> <div class="liste" >
<table> <table>
<thead><tr> <thead><tr>
<th class="zahl" data-i18n="colNo">Nr</th> <th class="zahl" data-i18n="colNo">Nr</th>
@@ -68,6 +68,7 @@
</div> </div>
<div class="hinweis" data-i18n="insertHintLink"> <div class="hinweis" data-i18n="insertHintLink">
Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt. Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt.
Leeres Tag-Feld bedeutet: keine Kennzeichnung setzen.
</div> </div>
</fieldset> </fieldset>

98
ui/schritt.html Normal file
View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title data-i18n="stepTitle">In Paperless ablegen</title>
<link rel="stylesheet" href="gemeinsam.css">
</head>
<body>
<h1 id="kopf">In Paperless ablegen</h1>
<div id="quelle" class="hinweis"></div>
<div class="spalten">
<section class="kasten">
<h2 data-i18n="storeTarget">Ziel</h2>
<div class="wahl">
<input type="radio" name="ziel" id="zielNeu" value="neu" checked>
<label for="zielNeu" data-i18n="storeTargetNew">
Neues Dokument anlegen
</label>
</div>
<div class="wahl">
<input type="radio" name="ziel" id="zielVorhanden" value="vorhanden">
<label for="zielVorhanden" data-i18n="storeTargetExisting">
Als neue Version an ein bestehendes anhängen
</label>
</div>
<div class="zeile" style="margin-top:4px">
<input type="text" id="suche" disabled>
<button id="suchen" disabled data-i18n="btnSearch">Suchen</button>
</div>
<div class="liste">
<table>
<thead><tr>
<th class="zahl" data-i18n="colAsn">ASN</th>
<th data-i18n="colCreated">Erstellt</th>
<th data-i18n="colTitle">Titel</th>
</tr></thead>
<tbody id="treffer"></tbody>
</table>
</div>
<div class="hinweis" data-i18n="stepHintExisting">
Die Klassifizierung des Ziels wird übernommen und kann angepasst
werden.
</div>
</section>
<section class="kasten">
<h2 data-i18n="classifyTitle">Klassifizierung</h2>
<label for="titel" data-i18n="fieldTitle">Titel</label>
<input type="text" id="titel">
<div class="zeile">
<span class="mass">
<label for="erstellt" data-i18n="fieldCreated">Erstellt</label>
<input type="text" id="erstellt" placeholder="JJJJ-MM-TT"
style="width:110px">
</span>
</div>
<label for="korr" data-i18n="fieldCorrespondent">Korrespondent</label>
<input type="text" id="korr" list="korrListe" autocomplete="off">
<datalist id="korrListe"></datalist>
<label for="typ" data-i18n="fieldDocType">Dokumenttyp</label>
<input type="text" id="typ" list="typListe" autocomplete="off">
<datalist id="typListe"></datalist>
<label for="tagSuche" data-i18n="fieldTags">Tags</label>
<input type="text" id="tagSuche"
data-i18n-placeholder="tagsFilterPlaceholder"
placeholder="Tippen zum Filtern …">
<div class="liste">
<div id="tags" style="padding:4px 8px"></div>
</div>
</section>
</div>
<div id="meldung" class="hinweis"></div>
<div class="fuss">
<button id="abbrechen" data-i18n="btnCancel">Abbrechen</button>
<button id="ueberspringen" data-i18n="btnSkip">Überspringen</button>
<button id="ablegen" class="haupt" data-i18n="btnStore">Ablegen</button>
</div>
<script type="module" src="schritt.js"></script>
</body>
</html>

357
ui/schritt.js Normal file
View File

@@ -0,0 +1,357 @@
/**
* schritt.js - Ziel und Klassifizierung, Datei für Datei.
*
* Der Ablauf ist bewusst in zwei Fenster geteilt:
*
* 1. Auswahl der abzulegenden Dateien (store.html)
* 2. Je Datei: Ziel und Klassifizierung (dieses Fenster)
*
* Der Grund: Ziel und Klassifizierung sind je Datei verschieden. Ein
* einzelner Dialog für alle würde die Angaben der ersten Datei auf alle
* übertragen - bei einer Rechnung samt Lastschriftmandat wäre das falsch.
*
* Beim Anhängen an ein bestehendes Dokument wird dessen Klassifizierung
* übernommen und kann angepasst werden. Sie neu einzugeben wäre Arbeit
* für nichts, und ein Tippfehler brächte die Version vom Original weg.
*/
import { kurz, groesse } from "../lib/settings.js";
import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
from "./gemeinsam.js";
const $ = (id) => document.getElementById(id);
let auftrag = null;
let liste = []; // die abzulegenden Dateien
let stelle = 0;
let stamm = { tags: [], korr: [], typen: [] };
let treffer = [];
let zielDokument = null;
let gewaehlteTags = new Set();
const abgelegt = []; // für die Abschlussmeldung
document.addEventListener("DOMContentLoaded", start);
async function start() {
uebersetzeSeite();
const nummer = Number(new URLSearchParams(location.search).get("auftrag"));
try {
auftrag = await ruf("auftrag", { nummer });
if (!auftrag) throw new Error("Der Auftrag ist nicht mehr vorhanden.");
liste = await ruf("dateiListe", { auftrag });
stamm = await ruf("stammdaten");
} catch (err) {
fehlerAnzeigen(err.message);
return;
}
if (!liste.length) {
fehlerAnzeigen(browser.i18n.getMessage("errNothingSelected")
|| "Nichts ausgewählt.");
return;
}
fuelleListe("korrListe", stamm.korr);
fuelleListe("typListe", stamm.typen);
verdrahten();
await anzeigen();
}
function verdrahten() {
for (const r of document.querySelectorAll('input[name=ziel]')) {
r.addEventListener("change", zielGewechselt);
}
$("suchen").addEventListener("click", suchen);
$("suche").addEventListener("keydown", (e) => {
if (e.key === "Enter") suchen();
});
$("tagSuche").addEventListener("input", zeichneTags);
$("abbrechen").addEventListener("click", () => window.close());
$("ueberspringen").addEventListener("click", () => weiter());
$("ablegen").addEventListener("click", ablegen);
}
// ------------------------------------------------------ Datei anzeigen
async function anzeigen() {
const eintrag = liste[stelle];
$("kopf").textContent = browser.i18n.getMessage("stepTitle")
|| "In Paperless ablegen";
const zaehler = liste.length > 1
? `${browser.i18n.getMessage("classifyProgress",
[String(stelle + 1), String(liste.length)])
|| `${stelle + 1} von ${liste.length}`} · `
: "";
const name = eintrag.art === "nachricht"
? (browser.i18n.getMessage("stepWholeMessage") || "Die ganze Nachricht")
: `${eintrag.name}${eintrag.groesse ? ` (${groesse(eintrag.groesse)})` : ""}`;
$("quelle").textContent = `${zaehler}${name}`;
// Zurücksetzen für die neue Datei
zielDokument = null;
treffer = [];
$("treffer").replaceChildren();
$("suche").value = "";
$("tagSuche").value = "";
$("zielNeu").checked = true;
gewaehlteTags = new Set();
$("titel").value = vorschlagTitel(eintrag);
$("erstellt").value = auftrag.datum || "";
$("korr").value = "";
$("typ").value = "";
zielGewechselt();
zeichneTags();
meldungAnzeigen("");
$("ablegen").disabled = false;
$("ueberspringen").hidden = liste.length < 2;
}
function vorschlagTitel(eintrag) {
if (eintrag.art === "nachricht") return auftrag.titelVorschlag || "";
// Endung weg: Sie steht schon im Dateinamen des Originals und trägt
// im Titel nichts bei.
return String(eintrag.name || "").replace(/\.[^.]{1,8}$/, "");
}
// ----------------------------------------------------------- Zielwahl
function zielGewechselt() {
const vorhanden = $("zielVorhanden").checked;
$("suche").disabled = !vorhanden;
$("suchen").disabled = !vorhanden;
// Die Trefferliste abblenden statt ausblenden: Ein Feld, das
// verschwindet und wieder auftaucht, lässt die Spalte springen.
const liste = $("treffer").closest(".liste");
if (liste) liste.style.opacity = vorhanden ? "1" : ".45";
if (vorhanden && !treffer.length) suchen();
pruefen();
}
async function suchen() {
$("treffer").replaceChildren();
zielDokument = null;
try {
treffer = await ruf("suchen", { query: $("suche").value.trim() });
} catch (err) {
fehlerAnzeigen(err.message);
return;
}
for (const [i, r] of treffer.entries()) {
const tr = document.createElement("tr");
const a = document.createElement("td");
a.className = "zahl";
a.textContent = r.archive_serial_number ?? "—";
const b = document.createElement("td");
b.textContent = (r.created || "").slice(0, 10);
const c = document.createElement("td");
c.textContent = r.title || "";
c.title = r.title || "";
tr.append(a, b, c);
tr.addEventListener("click", () => waehleZiel(i));
$("treffer").append(tr);
}
if (!treffer.length) {
meldungAnzeigen(browser.i18n.getMessage("msgNoHits") || "Keine Treffer.");
}
pruefen();
}
/**
* Zieldokument wählen und dessen Klassifizierung übernehmen.
*
* Eine neue Version gehört inhaltlich zum selben Vorgang wie das
* Original. Die Angaben neu einzutippen wäre Arbeit für nichts - und ein
* abweichender Korrespondent brächte die Version vom Original weg.
*/
async function waehleZiel(i) {
zielDokument = treffer[i] || null;
for (const [j, tr] of [...$("treffer").children].entries()) {
tr.classList.toggle("gewaehlt", j === i);
}
pruefen();
if (!zielDokument) return;
meldungAnzeigen(browser.i18n.getMessage("msgLoadingTarget")
|| "Klassifizierung des Ziels wird geladen …");
try {
const doc = await ruf("dokument", { id: zielDokument.id });
$("titel").value = doc.title || "";
$("erstellt").value = (doc.created || "").slice(0, 10);
$("korr").value = nameVon(stamm.korr, doc.correspondent);
$("typ").value = nameVon(stamm.typen, doc.document_type);
gewaehlteTags = new Set((doc.tags || []).map(Number));
zeichneTags();
meldungAnzeigen("");
} catch (err) {
fehlerAnzeigen(err.message);
}
}
function pruefen() {
const ok = $("zielNeu").checked || Boolean(zielDokument);
$("ablegen").disabled = !ok;
}
// --------------------------------------------------------------- Tags
function fuelleListe(id, eintraege) {
const dl = $(id);
dl.replaceChildren();
for (const e of eintraege) {
const o = document.createElement("option");
o.value = e.name;
dl.append(o);
}
}
function nameVon(rows, id) {
return rows.find((x) => x.id === id)?.name || "";
}
/**
* Tagliste zeichnen: Gewählte oben, dann die gefilterten übrigen.
*
* Gewählte bleiben sichtbar, auch wenn der Filter sie ausschliessen
* würde - sonst verliert man beim Suchen den Überblick, was schon
* angehakt ist.
*/
function zeichneTags() {
const ziel = $("tags");
ziel.replaceChildren();
const filter = ($("tagSuche").value || "").trim().toLowerCase();
const oben = stamm.tags.filter((t) => gewaehlteTags.has(Number(t.id)));
const unten = stamm.tags.filter(
(t) => !gewaehlteTags.has(Number(t.id))
&& (!filter || t.name.toLowerCase().includes(filter)));
for (const t of oben) ziel.append(tagZeile(t, true, ""));
if (oben.length && unten.length) {
const tr = document.createElement("hr");
tr.style.cssText = "border:0;border-top:1px solid var(--rand);margin:5px 0";
ziel.append(tr);
}
for (const t of unten) ziel.append(tagZeile(t, false, filter));
if (!oben.length && !unten.length) {
const h = document.createElement("div");
h.className = "hinweis";
h.textContent = browser.i18n.getMessage("tagsNoMatch")
|| "Keine Übereinstimmung.";
ziel.append(h);
}
}
function tagZeile(tag, gewaehlt, filter) {
const zeile = document.createElement("div");
zeile.className = "wahl";
const box = document.createElement("input");
box.type = "checkbox";
box.id = `tag_${tag.id}`;
box.checked = gewaehlt;
box.addEventListener("change", () => {
if (box.checked) gewaehlteTags.add(Number(tag.id));
else gewaehlteTags.delete(Number(tag.id));
zeichneTags();
});
const bez = document.createElement("label");
bez.htmlFor = box.id;
if (gewaehlt) bez.style.fontWeight = "600";
beschriften(bez, tag.name, filter);
zeile.append(box, bez);
return zeile;
}
/**
* Beschriftung mit hervorgehobenem Treffer.
*
* Aufbau über createElement und textContent, nicht über innerHTML: Ein
* Tagname stammt aus Paperless und damit mittelbar aus fremden Quellen -
* wer ein Dokument per Mail einliefern kann, bestimmt Tagnamen mit.
*/
function beschriften(el, name, filter) {
el.replaceChildren();
const pos = filter ? name.toLowerCase().indexOf(filter) : -1;
if (pos < 0) {
el.textContent = name;
return;
}
el.append(document.createTextNode(name.slice(0, pos)));
const mark = document.createElement("span");
mark.className = "trefferHervor";
mark.textContent = name.slice(pos, pos + filter.length);
el.append(mark, document.createTextNode(name.slice(pos + filter.length)));
}
// ------------------------------------------------------------- Ablauf
async function ablegen() {
$("ablegen").disabled = true;
$("ueberspringen").disabled = true;
meldungAnzeigen(browser.i18n.getMessage("msgUploading")
|| "Wird abgelegt …");
const eintrag = liste[stelle];
const anVorhandenes = $("zielVorhanden").checked;
try {
const ergebnis = await ruf("ablegenEine", {
auftrag,
eintrag,
ziel: anVorhandenes ? "vorhanden" : "neu",
zielId: zielDokument?.id,
titel: $("titel").value.trim(),
erstellt: $("erstellt").value.trim()
});
// Klassifizierung anwenden. Beim Anhängen gilt sie für die Wurzel,
// nicht für die neue Version.
const zielId = ergebnis.art === "version" ? ergebnis.zielId : ergebnis.id;
if (zielId) {
meldungAnzeigen(browser.i18n.getMessage("msgSaving")
|| "Klassifizierung wird gespeichert …");
await ruf("klassifizieren", {
id: zielId,
payload: {
title: $("titel").value.trim(),
tags: [...gewaehlteTags],
...($("erstellt").value.trim()
? { created: $("erstellt").value.trim() } : {})
},
korrName: $("korr").value.trim(),
typName: $("typ").value.trim()
});
}
abgelegt.push(ergebnis);
await weiter();
} catch (err) {
console.error("[paperless] Ablegen gescheitert", err);
fehlerAnzeigen(err.message);
$("ablegen").disabled = false;
$("ueberspringen").disabled = false;
}
}
async function weiter() {
$("ueberspringen").disabled = false;
stelle += 1;
if (stelle >= liste.length) {
meldungAnzeigen(browser.i18n.getMessage("msgStored",
[String(abgelegt.length)]) || `${abgelegt.length} abgelegt.`);
setTimeout(() => window.close(), 1200);
return;
}
await anzeigen();
}

View File

@@ -13,77 +13,17 @@
<fieldset> <fieldset>
<legend data-i18n="storeWhat">Was soll abgelegt werden?</legend> <legend data-i18n="storeWhat">Was soll abgelegt werden?</legend>
<div id="dateien"></div> <div id="dateien"></div>
<div class="wahl" id="pdfWahl" hidden>
<input type="checkbox" id="alsPdf" checked>
<label for="alsPdf" data-i18n="storeAsPdf">
Als PDF aufbereiten, Original und Anhänge einbetten
</label>
</div>
<div class="hinweis" data-i18n="storeHintPreselect"> <div class="hinweis" data-i18n="storeHintPreselect">
Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist Kleine Bilder und Signaturen sind vorab abgewählt — sie sind meist
Teil der Nachricht, keine eigenen Dokumente. Teil der Nachricht, keine eigenen Dokumente.
</div> </div>
</fieldset> </fieldset>
<fieldset class="wachsen">
<legend data-i18n="storeTarget">Ziel</legend>
<div class="wahl">
<input type="radio" name="ziel" id="zielNeu" value="neu" checked>
<label for="zielNeu" data-i18n="storeTargetNew">
Neues Dokument anlegen
</label>
</div>
<div id="bereichNeu">
<label for="titel" data-i18n="fieldTitle">Titel</label>
<input type="text" id="titel">
<div class="hinweis" data-i18n="storeHintTitleMulti">
Bei mehreren Dateien wird der Titel um den Dateinamen ergänzt.
</div>
<label for="erstellt" data-i18n="fieldCreated">Erstellt</label>
<input type="text" id="erstellt" placeholder="JJJJ-MM-TT">
</div>
<div class="wahl">
<input type="radio" name="ziel" id="zielVorhanden" value="vorhanden">
<label for="zielVorhanden" data-i18n="storeTargetExisting">
An bestehendes Dokument anhängen
</label>
</div>
<div id="bereichVorhanden" class="wachsen">
<div class="zeile">
<input type="text" id="suche" disabled>
<button id="suchen" disabled data-i18n="btnSearch">Suchen</button>
</div>
<div class="liste">
<table>
<thead>
<tr>
<th class="zahl" data-i18n="colAsn">ASN</th>
<th data-i18n="colCreated">Erstellt</th>
<th data-i18n="colTitle">Titel</th>
</tr>
</thead>
<tbody id="treffer"></tbody>
</table>
</div>
</div>
</fieldset>
<div class="wahl">
<input type="checkbox" id="klassifizieren" checked>
<label for="klassifizieren" data-i18n="storeClassifyAfter">
Nach dem Ablegen die Klassifizierung bearbeiten
</label>
</div>
<div id="meldung" class="hinweis"></div> <div id="meldung" class="hinweis"></div>
<div class="fuss"> <div class="fuss">
<button id="abbrechen" data-i18n="btnCancel">Abbrechen</button> <button id="abbrechen" data-i18n="btnCancel">Abbrechen</button>
<button id="ablegen" class="haupt" data-i18n="btnStore">Ablegen</button> <button id="ablegen" class="haupt" data-i18n="btnNext">Weiter</button>
</div> </div>
<script type="module" src="store.js"></script> <script type="module" src="store.js"></script>

View File

@@ -7,14 +7,14 @@
*/ */
import { groesse, kurz, titelAusBetreff } from "../lib/settings.js"; import { groesse, kurz, titelAusBetreff } from "../lib/settings.js";
import { uebersetzeSeite, ruf, fehlerAnzeigen } from "./gemeinsam.js"; import { umwandelbar } from "../lib/mailpdf.js";
import { uebersetzeSeite, ruf, fehlerAnzeigen, meldungAnzeigen }
from "./gemeinsam.js";
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
let auftrag = null; let auftrag = null;
let einstellungen = null; let einstellungen = null;
let treffer = [];
let gewaehlterTreffer = null;
document.addEventListener("DOMContentLoaded", start); document.addEventListener("DOMContentLoaded", start);
@@ -31,7 +31,6 @@ async function start() {
quelleAnzeigen(); quelleAnzeigen();
dateienAnzeigen(); dateienAnzeigen();
vorbelegen();
verdrahten(); verdrahten();
} }
@@ -50,43 +49,35 @@ function quelleAnzeigen() {
* Darstellung der Nachricht, nicht zum Inhalt - wer sie alle mit * Darstellung der Nachricht, nicht zum Inhalt - wer sie alle mit
* archiviert, hat nach einem Jahr hunderte Logos im Bestand. * archiviert, hat nach einem Jahr hunderte Logos im Bestand.
*/ */
/**
* Dateien zur Auswahl anbieten, je Zeile mit der Wahl der Ablageart.
*
* Vorbelegt wird unterschiedlich, weil die Absicht unterschiedlich ist:
*
* Eine NACHRICHT wird als PDF aufbereitet - eine .eml sieht sich im
* Archiv niemand an.
*
* Ein ANHANG wird im Original abgelegt. Eine Rechnung als PDF ist
* bereits das Dokument; sie durch eine Textfassung zu ersetzen wäre
* Unfug. Nur bei Textdateien - etwa einem HTML-Anhang - ist die
* Aufbereitung sinnvoll, und dort steht sie zur Wahl.
*/
function dateienAnzeigen() { function dateienAnzeigen() {
const ziel = $("dateien"); const ziel = $("dateien");
ziel.replaceChildren(); ziel.replaceChildren();
if (auftrag.quelle === "nachricht") { if (auftrag.quelle === "nachricht") {
// Die Wahl zwischen PDF und Rohformat gibt es nur hier. Bei einem const name = `${titelAusBetreff(auftrag.betreff)}.eml`;
// einzelnen Anhang waere sie sinnlos - dort wird die Datei so ziel.append(dateiZeile({
// abgelegt, wie sie ist. schluessel: "ganz",
const wahl = $("pdfWahl"); beschriftung: name,
if (wahl && wahl.hidden) { gewaehlt: true,
wahl.hidden = false; festGewaehlt: true,
$("alsPdf").checked = (einstellungen.mailFormat || "pdf") === "pdf"; pdfMoeglich: true,
$("alsPdf").addEventListener("change", dateienAnzeigen); alsPdf: (einstellungen.mailFormat || "pdf") === "pdf",
} hinweisPdf: browser.i18n.getMessage("artMessagePdf")
|| "als PDF aufbereiten"
// 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";
const alsPdf = $("alsPdf") ? $("alsPdf").checked
: (einstellungen.mailFormat || "pdf") === "pdf";
bez.textContent = alsPdf
? (browser.i18n.getMessage("storeWholePdf")
|| "Die Nachricht als PDF, mit eingebettetem Original")
: (browser.i18n.getMessage("storeWholeMessage")
|| "Die vollständige Nachricht als .eml");
zeile.append(box, bez);
ziel.append(zeile);
if (auftrag.dateien?.length) { if (auftrag.dateien?.length) {
const h = document.createElement("div"); const h = document.createElement("div");
@@ -96,6 +87,7 @@ function dateienAnzeigen() {
|| `Die ${auftrag.dateien.length} Anhänge bleiben darin enthalten.`; || `Die ${auftrag.dateien.length} Anhänge bleiben darin enthalten.`;
ziel.append(h); ziel.append(h);
} }
knopfPruefen();
return; return;
} }
@@ -103,128 +95,118 @@ function dateienAnzeigen() {
const uninteressant = const uninteressant =
einstellungen.ignorierteTypen.includes(d.contentType) einstellungen.ignorierteTypen.includes(d.contentType)
|| (d.size || 0) < einstellungen.ignorierteKleiner; || (d.size || 0) < einstellungen.ignorierteKleiner;
d.ausgewaehlt = !uninteressant; if (d.ausgewaehlt === undefined) d.ausgewaehlt = !uninteressant;
if (d.alsPdf === undefined) d.alsPdf = false;
ziel.append(dateiZeile({
schluessel: `d${i}`,
beschriftung: `${d.name} (${groesse(d.size)}, ${d.contentType})`,
gewaehlt: d.ausgewaehlt,
pdfMoeglich: umwandelbar(d.contentType, d.name),
alsPdf: d.alsPdf,
beiWahl: (an) => { d.ausgewaehlt = an; },
beiArt: (pdf) => { d.alsPdf = pdf; }
}));
}
knopfPruefen();
}
/**
* Eine Zeile der Dateiliste: Häkchen, Name, und rechts die Wahl zwischen
* Original und PDF.
*/
function dateiZeile(o) {
const zeile = document.createElement("div"); const zeile = document.createElement("div");
zeile.className = "wahl"; zeile.className = "dateizeile";
const box = document.createElement("input"); const box = document.createElement("input");
box.type = "checkbox"; box.type = "checkbox";
box.id = `d${i}`; box.id = o.schluessel;
box.checked = d.ausgewaehlt; box.checked = o.gewaehlt;
if (o.festGewaehlt) box.disabled = true;
box.addEventListener("change", () => { box.addEventListener("change", () => {
d.ausgewaehlt = box.checked; o.beiWahl?.(box.checked);
knopfPruefen(); knopfPruefen();
}); });
const bez = document.createElement("label");
bez.htmlFor = box.id;
bez.textContent = `${d.name} (${groesse(d.size)}, ${d.contentType})`;
zeile.append(box, bez);
ziel.append(zeile);
}
}
function vorbelegen() { const name = document.createElement("label");
$("titel").value = titelAusBetreff(auftrag.betreff); name.className = "name";
if (auftrag.datum) $("erstellt").value = auftrag.datum; name.htmlFor = box.id;
} name.textContent = o.beschriftung;
function verdrahten() { zeile.append(box, name);
for (const r of document.querySelectorAll('input[name=ziel]')) {
r.addEventListener("change", zielGewechselt); const art = document.createElement("div");
} art.className = "art";
$("suchen").addEventListener("click", suchen); const gruppe = `art_${o.schluessel}`;
$("suche").addEventListener("keydown", (e) => {
if (e.key === "Enter") suchen(); const machen = (wert, text, aktiv) => {
const r = document.createElement("input");
r.type = "radio";
r.name = gruppe;
r.id = `${gruppe}_${wert}`;
r.value = wert;
r.checked = aktiv;
if (!o.pdfMoeglich && wert === "pdf") r.disabled = true;
r.addEventListener("change", () => {
if (r.checked) o.beiArt?.(wert === "pdf");
}); });
$("abbrechen").addEventListener("click", () => window.close()); const l = document.createElement("label");
$("ablegen").addEventListener("click", ablegen); l.htmlFor = r.id;
zielGewechselt(); l.textContent = text;
} if (r.disabled) l.className = "aus";
art.append(r, l);
};
function zielGewechselt() { machen("original", browser.i18n.getMessage("artOriginal")
const neu = $("zielNeu").checked; || "Original ablegen", !o.alsPdf);
for (const id of ["titel", "erstellt"]) $(id).disabled = !neu; machen("pdf", o.hinweisPdf || browser.i18n.getMessage("artPdf")
for (const id of ["suche", "suchen"]) $(id).disabled = neu; || "PDF aufbereiten", Boolean(o.alsPdf) && o.pdfMoeglich);
if (!neu && !treffer.length) suchen();
knopfPruefen();
}
async function suchen() { if (!o.pdfMoeglich) {
const liste = $("treffer"); art.title = browser.i18n.getMessage("artPdfUnavailable")
liste.replaceChildren(); || "Für dieses Format nicht möglich - nur Textdateien lassen sich "
try { + "aufbereiten.";
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"); zeile.append(art);
const asn = document.createElement("td"); return zeile;
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; * Weiter zum Schrittdialog.
for (const [j, tr] of [...$("treffer").children].entries()) { *
tr.classList.toggle("gewaehlt", j === i); * Die Auswahl wird über einen neuen Auftrag übergeben - das Fenster
} * schliesst sich, und der Schrittdialog geht die Dateien einzeln durch.
knopfPruefen(); */
} async function weiter() {
function knopfPruefen() {
const etwasGewaehlt = auftrag.quelle === "nachricht"
|| (auftrag.dateien || []).some((d) => d.ausgewaehlt);
const zielOk = $("zielNeu").checked || Boolean(gewaehlterTreffer);
$("ablegen").disabled = !(etwasGewaehlt && zielOk);
}
async function ablegen() {
$("ablegen").disabled = true; $("ablegen").disabled = true;
$("meldung").className = "hinweis"; meldungAnzeigen(browser.i18n.getMessage("msgOpeningStep")
$("meldung").textContent = || "Wird vorbereitet …");
browser.i18n.getMessage("msgUploading") || "Wird abgelegt …";
try { try {
const ergebnisse = await ruf("ablegen", { await ruf("schrittOeffnen", {
auftrag: { ...auftrag, auftrag: {
// Nur mitgeben, wenn die Wahl überhaupt angeboten wurde. ...auftrag,
alsPdf: $("pdfWahl") && !$("pdfWahl").hidden titelVorschlag: titelAusBetreff(auftrag.betreff),
? $("alsPdf").checked : undefined }, alsPdf: auftrag.quelle === "nachricht"
ziel: $("zielNeu").checked ? "neu" : "vorhanden", ? $("art_ganz_pdf")?.checked ?? true
zielId: gewaehlterTreffer?.id, : undefined
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 = window.close();
browser.i18n.getMessage("msgStored", [String(ergebnisse.length)])
|| `${ergebnisse.length} abgelegt.`;
setTimeout(() => window.close(), 1200);
} catch (err) { } catch (err) {
fehlerAnzeigen(err.message); fehlerAnzeigen(err.message);
$("ablegen").disabled = false; $("ablegen").disabled = false;
} }
} }
function verdrahten() {
$("abbrechen").addEventListener("click", () => window.close());
$("ablegen").addEventListener("click", weiter);
}
function knopfPruefen() {
const etwasGewaehlt = auftrag.quelle === "nachricht"
|| (auftrag.dateien || []).some((d) => d.ausgewaehlt);
$("ablegen").disabled = !etwasGewaehlt;
}