diff --git a/README.md b/README.md index 463f853..ca957f5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Paperless-ngx für Thunderbird / Betterbird +# Paperless-Connector für Thunderbird / Betterbird Legt Anhänge und ganze E-Mails in einem Paperless-ngx-Archiv ab und fügt Dokumente aus dem Archiv als Anhang oder Freigabelink in Entwürfe ein. @@ -85,7 +85,7 @@ für Empfänger, die ohnehin Zugriff auf das Archiv haben sollen. ## Einrichten -1. Add-ons-Verwaltung → **Paperless-ngx** → Einstellungen +1. Add-ons-Verwaltung → **Paperless-Connector** → Einstellungen 2. Basisadresse eintragen, etwa `https://dms.example.org` — ohne abschließenden Schrägstrich 3. API-Token eintragen, erzeugt im **Benutzerprofil** von Paperless diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 414ec94..49fc7de 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -79,7 +79,7 @@ "message": "Freigabelink im Nachrichtentext" }, "insertHintLink": { - "message": "Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt." + "message": "Der Link ist ohne Anmeldung abrufbar. 0 Tage bedeutet unbegrenzt. Leeres Tag-Feld bedeutet: keine Kennzeichnung setzen." }, "optionsTitle": { "message": "Paperless — Verbindung" @@ -249,12 +249,19 @@ "linkValidUnlimited": { "message": "Link ohne zeitliche Begrenzung" }, - "linkHeading": { - "message": "Datei $1", - "placeholders": { - "1": { - "content": "$1" - } - } + "optionsHintTag": { + "message": "Tag beim Teilen leer lassen, um keine Kennzeichnung zu setzen." + }, + "fieldShareBaseUrl": { + "message": "Adresse für Freigabelinks (optional)" + }, + "optionsHintShareUrl": { + "message": "Nur nötig, wenn der interne Zugang über einen abweichenden Port oder Namen läuft. Freigabelinks werden dann unter dieser Adresse gebaut, damit sie auch von außen funktionieren. Leer = Basisadresse." + }, + "tagsFilterPlaceholder": { + "message": "Tippen zum Filtern …" + }, + "tagsNoMatch": { + "message": "Keine Übereinstimmung." } } diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3cc4868..9fe90fb 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -79,7 +79,7 @@ "message": "Share link in the message body" }, "insertHintLink": { - "message": "The link works without signing in. 0 days means unlimited." + "message": "The link works without signing in. 0 days means unlimited. An empty tag field means: do not set a tag." }, "optionsTitle": { "message": "Paperless — Connection" @@ -249,12 +249,19 @@ "linkValidUnlimited": { "message": "Link without time limit" }, - "linkHeading": { - "message": "File $1", - "placeholders": { - "1": { - "content": "$1" - } - } + "optionsHintTag": { + "message": "Leave the sharing tag empty to set no tag at all." + }, + "fieldShareBaseUrl": { + "message": "Address for share links (optional)" + }, + "optionsHintShareUrl": { + "message": "Only needed if internal access uses a different port or name. Share links are then built with this address so they work from outside as well. Empty = base address." + }, + "tagsFilterPlaceholder": { + "message": "Type to filter …" + }, + "tagsNoMatch": { + "message": "No match." } } diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 0f8ef06..42ff803 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -79,7 +79,7 @@ "message": "Lien de partage dans le corps du message" }, "insertHintLink": { - "message": "Le lien est accessible sans connexion. 0 jour signifie sans limite." + "message": "Le lien est accessible sans connexion. 0 jour signifie sans limite. Un champ d'étiquette vide signifie : ne pas marquer." }, "optionsTitle": { "message": "Paperless — Connexion" @@ -249,12 +249,19 @@ "linkValidUnlimited": { "message": "Lien sans limite de durée" }, - "linkHeading": { - "message": "Fichier $1", - "placeholders": { - "1": { - "content": "$1" - } - } + "optionsHintTag": { + "message": "Laisser l'étiquette de partage vide pour ne poser aucune étiquette." + }, + "fieldShareBaseUrl": { + "message": "Adresse pour les liens de partage (facultatif)" + }, + "optionsHintShareUrl": { + "message": "Nécessaire uniquement si l'accès interne utilise un port ou un nom différent. Les liens de partage seront alors construits avec cette adresse afin de fonctionner aussi depuis l'extérieur. Vide = adresse de base." + }, + "tagsFilterPlaceholder": { + "message": "Saisir pour filtrer …" + }, + "tagsNoMatch": { + "message": "Aucune correspondance." } } diff --git a/background.js b/background.js index 6d3d83e..70621ae 100644 --- a/background.js +++ b/background.js @@ -102,7 +102,22 @@ let naechsteAuftragsnummer = 1; // ---------------------------------------------------------------- Menüs -browser.runtime.onInstalled.addListener(() => { +/** + * Menüeinträge anlegen. + * + * NICHT in onInstalled: Eine Ereignisseite wird beendet und bei Bedarf + * neu geladen, das Skript läuft dabei erneut durch. Die Menüs überleben + * das aber - ein zweiter menus.create mit derselben Kennung scheitert + * dann mit "already exists". + * + * Deshalb erst removeAll, dann neu anlegen. Das ist bei jedem Aufwachen + * richtig und kostet nichts. + */ +async function menueAufbauen() { + try { + await browser.menus.removeAll(); + } catch { /* beim ersten Lauf gibt es nichts zu entfernen */ } + browser.menus.create({ id: "paperless-anhang", title: t("menuStoreAttachment"), @@ -113,7 +128,9 @@ browser.runtime.onInstalled.addListener(() => { title: t("menuStoreMessage"), contexts: ["message_list"] }); -}); +} + +menueAufbauen(); browser.menus.onClicked.addListener(async (info, tab) => { try { @@ -249,6 +266,29 @@ async function dialogOeffnen(art, daten) { // -------------------------------------------- Vermittlung für die Dialoge +/** + * Offene Verbindungen der Dialoge. + * + * SEHR WICHTIG: Eine Ereignisseite wird beendet, sobald keine Ereignisse + * mehr anstehen. Eine laufende Wartezeit in waitForTask zählt NICHT als + * Ereignis - mitten im Ablegen verschwindet sonst der Empfänger, und der + * Dialog bekommt "Could not establish connection. Receiving end does not + * exist." + * + * Eine offene Verbindung hält die Seite dagegen am Leben. Die Dialoge + * öffnen deshalb beim Laden eine und halten sie bis zum Schliessen. + */ +const verbindungen = new Set(); + +browser.runtime.onConnect.addListener((port) => { + verbindungen.add(port); + console.debug("[paperless] Dialog verbunden, offen:", verbindungen.size); + port.onDisconnect.addListener(() => { + verbindungen.delete(port); + console.debug("[paperless] Dialog getrennt, offen:", verbindungen.size); + }); +}); + 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 @@ -390,14 +430,22 @@ async function ablegen(nachricht) { const antwort = await c.updateVersion( nachricht.zielId, datei.blob, datei.name, versionslabel(s.versionLabel)); - const id = await c.waitForTask(antwort); + const id = await c.waitForTask(antwort, (sek) => { + if (sek % 15 === 0) { + console.debug("[paperless] warte auf Aufgabe:", sek, "s"); + } + }); 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); + const id = await c.waitForTask(antwort, (sek) => { + if (sek % 15 === 0) { + console.debug("[paperless] warte auf Aufgabe:", sek, "s"); + } + }); ergebnisse.push({ id, name: datei.name, art: "neu" }); } } diff --git a/build.sh b/build.sh index 81b0c9f..e2885b8 100644 --- a/build.sh +++ b/build.sh @@ -7,7 +7,7 @@ set -eu cd "$(dirname "$0")" -NAME="paperless-thunderbird" +NAME="paperless-connector" VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' manifest.json | head -1)" OUT="${NAME}-${VERSION}.xpi" diff --git a/manifest.json b/manifest.json index 1578aed..17a24e4 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "__MSG_extName__", "description": "__MSG_extDescription__", - "version": "1.6.1", + "version": "1.8.1", "author": "Name des Herausgebers", "homepage_url": "https://example.org/paperless-thunderbird", "default_locale": "de", diff --git a/ui/classify.html b/ui/classify.html index 789374b..831cbee 100644 --- a/ui/classify.html +++ b/ui/classify.html @@ -24,8 +24,17 @@
- -
+ + +
+
+
+ +
diff --git a/ui/classify.js b/ui/classify.js index e512dce..5bbba32 100644 --- a/ui/classify.js +++ b/ui/classify.js @@ -14,6 +14,11 @@ 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() { @@ -66,30 +71,128 @@ function nameVon(liste, id) { } 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); + 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 { - const tags = [...$("tags").querySelectorAll("input:checked")] - .map((b) => Number(b.value)); + // 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; diff --git a/ui/gemeinsam.css b/ui/gemeinsam.css index 19bcfb3..0454308 100644 --- a/ui/gemeinsam.css +++ b/ui/gemeinsam.css @@ -70,5 +70,17 @@ td.zahl, th.zahl { text-align: right; } tr.gewaehlt { background: SelectedItem; color: SelectedItemText; } tbody tr { cursor: default; } .hinweis { color: GrayText; font-size: 12px; } + +/* Gewählte Tags stehen oben und bleiben sichtbar, auch wenn der Filter + 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; } .wachsen { flex: 1; display: flex; flex-direction: column; gap: 6px; min-height: 0; } diff --git a/ui/gemeinsam.js b/ui/gemeinsam.js index 2d4b240..d4358c2 100644 --- a/ui/gemeinsam.js +++ b/ui/gemeinsam.js @@ -2,6 +2,29 @@ * gemeinsam.js - Hilfsmittel für alle Dialogseiten. */ +/** + * Verbindung zur Ereignisseite, die offen bleibt, solange der Dialog + * geöffnet ist. + * + * Sie überträgt nichts - ihr einziger Zweck ist, die Ereignisseite am + * Leben zu halten. Ohne sie wird die Seite während längerer Vorgänge + * beendet, etwa beim Warten auf eine Aufgabe in Paperless, und der + * laufende Aufruf schlägt mit "Receiving end does not exist" fehl. + */ +let dauerverbindung = null; + +export function verbindungHalten() { + if (dauerverbindung) return; + try { + dauerverbindung = browser.runtime.connect({ name: "dialog" }); + dauerverbindung.onDisconnect.addListener(() => { + dauerverbindung = null; + }); + } catch (err) { + console.warn("[paperless] Dauerverbindung nicht möglich:", err); + } +} + /** * Ruft die Ereignisseite und wirft bei Fehlern eine Ausnahme. * @@ -43,6 +66,10 @@ export async function ruf(befehl, daten = {}) { * im HTML hinterlegte deutsche Text stehen. */ export function uebersetzeSeite() { + // Beim Aufbau der Seite gleich die Verbindung öffnen - jede Seite ruft + // diese Funktion, also ist es die verlässlichste Stelle. + verbindungHalten(); + for (const el of document.querySelectorAll("[data-i18n]")) { const s = browser.i18n.getMessage(el.dataset.i18n); if (s) el.textContent = s;