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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {
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 = gesetzt.includes(tag.id);
box.checked = istGewaehlt;
box.addEventListener("change", () => {
umschalten(tag.id);
zeichneTags();
});
const bez = document.createElement("label");
bez.htmlFor = box.id;
bez.textContent = tag.name;
beschriftungSetzen(bez, String(tag.name), filter);
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() {
$("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;

View File

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

View File

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