Anpassung Tagsuche, Fix Fehler bei längeren Ladezeiten

This commit is contained in:
2026-09-07 17:38:06 +02:00
parent 00ca39609f
commit fd87813cbe
11 changed files with 271 additions and 51 deletions

View File

@@ -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 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. 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 ## Einrichten
1. Add-ons-Verwaltung → **Paperless-ngx** → Einstellungen 1. Add-ons-Verwaltung → **Paperless-Connector** → Einstellungen
2. Basisadresse eintragen, etwa `https://dms.example.org` — ohne 2. Basisadresse eintragen, etwa `https://dms.example.org` — ohne
abschließenden Schrägstrich abschließenden Schrägstrich
3. API-Token eintragen, erzeugt im **Benutzerprofil** von Paperless 3. API-Token eintragen, erzeugt im **Benutzerprofil** von Paperless

View File

@@ -79,7 +79,7 @@
"message": "Freigabelink im Nachrichtentext" "message": "Freigabelink im Nachrichtentext"
}, },
"insertHintLink": { "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": { "optionsTitle": {
"message": "Paperless — Verbindung" "message": "Paperless — Verbindung"
@@ -249,12 +249,19 @@
"linkValidUnlimited": { "linkValidUnlimited": {
"message": "Link ohne zeitliche Begrenzung" "message": "Link ohne zeitliche Begrenzung"
}, },
"linkHeading": { "optionsHintTag": {
"message": "Datei $1", "message": "Tag beim Teilen leer lassen, um keine Kennzeichnung zu setzen."
"placeholders": { },
"1": { "fieldShareBaseUrl": {
"content": "$1" "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."
} }
} }

View File

@@ -79,7 +79,7 @@
"message": "Share link in the message body" "message": "Share link in the message body"
}, },
"insertHintLink": { "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": { "optionsTitle": {
"message": "Paperless — Connection" "message": "Paperless — Connection"
@@ -249,12 +249,19 @@
"linkValidUnlimited": { "linkValidUnlimited": {
"message": "Link without time limit" "message": "Link without time limit"
}, },
"linkHeading": { "optionsHintTag": {
"message": "File $1", "message": "Leave the sharing tag empty to set no tag at all."
"placeholders": { },
"1": { "fieldShareBaseUrl": {
"content": "$1" "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."
} }
} }

View File

@@ -79,7 +79,7 @@
"message": "Lien de partage dans le corps du message" "message": "Lien de partage dans le corps du message"
}, },
"insertHintLink": { "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": { "optionsTitle": {
"message": "Paperless — Connexion" "message": "Paperless — Connexion"
@@ -249,12 +249,19 @@
"linkValidUnlimited": { "linkValidUnlimited": {
"message": "Lien sans limite de durée" "message": "Lien sans limite de durée"
}, },
"linkHeading": { "optionsHintTag": {
"message": "Fichier $1", "message": "Laisser l'étiquette de partage vide pour ne poser aucune étiquette."
"placeholders": { },
"1": { "fieldShareBaseUrl": {
"content": "$1" "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."
} }
} }

View File

@@ -102,7 +102,22 @@ let naechsteAuftragsnummer = 1;
// ---------------------------------------------------------------- Menüs // ---------------------------------------------------------------- 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({ browser.menus.create({
id: "paperless-anhang", id: "paperless-anhang",
title: t("menuStoreAttachment"), title: t("menuStoreAttachment"),
@@ -113,7 +128,9 @@ browser.runtime.onInstalled.addListener(() => {
title: t("menuStoreMessage"), title: t("menuStoreMessage"),
contexts: ["message_list"] contexts: ["message_list"]
}); });
}); }
menueAufbauen();
browser.menus.onClicked.addListener(async (info, tab) => { browser.menus.onClicked.addListener(async (info, tab) => {
try { try {
@@ -249,6 +266,29 @@ async function dialogOeffnen(art, daten) {
// -------------------------------------------- Vermittlung für die Dialoge // -------------------------------------------- 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) => { browser.runtime.onMessage.addListener(async (nachricht, absender) => {
// Nur Nachrichten aus dieser Erweiterung annehmen. Thunderbird lässt // Nur Nachrichten aus dieser Erweiterung annehmen. Thunderbird lässt
// fremde Absender hier zwar ohnehin nicht durch, solange kein // fremde Absender hier zwar ohnehin nicht durch, solange kein
@@ -390,14 +430,22 @@ async function ablegen(nachricht) {
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); 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", ergebnisse.push({ id, name: datei.name, art: "version",
zielId: nachricht.zielId }); zielId: nachricht.zielId });
} else { } else {
const felder = { title: nachricht.titel || datei.name }; const felder = { title: nachricht.titel || datei.name };
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); 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" }); ergebnisse.push({ id, name: datei.name, art: "neu" });
} }
} }

View File

@@ -7,7 +7,7 @@
set -eu set -eu
cd "$(dirname "$0")" cd "$(dirname "$0")"
NAME="paperless-thunderbird" 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"

View File

@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extName__", "name": "__MSG_extName__",
"description": "__MSG_extDescription__", "description": "__MSG_extDescription__",
"version": "1.6.1", "version": "1.8.1",
"author": "Name des Herausgebers", "author": "Name des Herausgebers",
"homepage_url": "https://example.org/paperless-thunderbird", "homepage_url": "https://example.org/paperless-thunderbird",
"default_locale": "de", "default_locale": "de",

View File

@@ -24,8 +24,17 @@
<datalist id="typListe"></datalist> <datalist id="typListe"></datalist>
<div class="wachsen"> <div class="wachsen">
<label data-i18n="fieldTags">Tags</label> <label for="tagSuche" data-i18n="fieldTags">Tags</label>
<div class="liste"><div id="tags" style="padding:4px 8px"></div></div> <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>
<div class="hinweis" data-i18n="classifyHintNew"> <div class="hinweis" data-i18n="classifyHintNew">

View File

@@ -14,6 +14,11 @@ const $ = (id) => document.getElementById(id);
let dokumentId = null; let dokumentId = null;
let alleTags = []; 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); document.addEventListener("DOMContentLoaded", start);
async function start() { async function start() {
@@ -66,30 +71,128 @@ function nameVon(liste, id) {
} }
function zeigeTags(gesetzt) { function zeigeTags(gesetzt) {
const ziel = $("tags"); for (const id of gesetzt) gewaehlt.add(Number(id));
ziel.replaceChildren(); const feld = $("tagSuche");
for (const tag of alleTags) { 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"); const zeile = document.createElement("div");
zeile.className = "wahl"; zeile.className = "wahl";
const box = document.createElement("input"); const box = document.createElement("input");
box.type = "checkbox"; box.type = "checkbox";
box.id = `t${tag.id}`; box.id = `t${tag.id}`;
box.value = String(tag.id); box.value = String(tag.id);
box.checked = gesetzt.includes(tag.id); box.checked = istGewaehlt;
box.addEventListener("change", () => {
umschalten(tag.id);
zeichneTags();
});
const bez = document.createElement("label"); const bez = document.createElement("label");
bez.htmlFor = box.id; bez.htmlFor = box.id;
bez.textContent = tag.name; beschriftungSetzen(bez, String(tag.name), filter);
zeile.append(box, bez); zeile.append(box, bez);
ziel.append(zeile); 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() { async function speichern() {
$("speichern").disabled = true; $("speichern").disabled = true;
meldungAnzeigen(browser.i18n.getMessage("msgSaving") || "Wird gespeichert …"); meldungAnzeigen(browser.i18n.getMessage("msgSaving") || "Wird gespeichert …");
try { try {
const tags = [...$("tags").querySelectorAll("input:checked")] // Aus der Menge lesen, nicht aus den Kästchen: Gefilterte Zeilen
.map((b) => Number(b.value)); // sind gar nicht im Dokument, ihre Auswahl ginge sonst verloren.
const tags = [...gewaehlt];
const payload = { title: $("titel").value.trim(), tags }; const payload = { title: $("titel").value.trim(), tags };
const erstellt = $("erstellt").value.trim(); const erstellt = $("erstellt").value.trim();
if (erstellt) payload.created = erstellt; if (erstellt) payload.created = erstellt;

View File

@@ -70,5 +70,17 @@ 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; } .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; } .fehler { color: #b32; }
.wachsen { flex: 1; display: flex; flex-direction: column; gap: 6px; min-height: 0; } .wachsen { flex: 1; display: flex; flex-direction: column; gap: 6px; min-height: 0; }

View File

@@ -2,6 +2,29 @@
* gemeinsam.js - Hilfsmittel für alle Dialogseiten. * 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. * 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. * im HTML hinterlegte deutsche Text stehen.
*/ */
export function uebersetzeSeite() { 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]")) { for (const el of document.querySelectorAll("[data-i18n]")) {
const s = browser.i18n.getMessage(el.dataset.i18n); const s = browser.i18n.getMessage(el.dataset.i18n);
if (s) el.textContent = s; if (s) el.textContent = s;