10 Commits

Author SHA1 Message Date
Kendrick Bollens
8057870f8b Release v2.6.0 — updates.json (Auto-Update-Manifest)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019XcAzPQhKgwS4HNzvXEFeN
2026-07-22 18:34:43 +02:00
Kendrick Bollens
2b6e2267c2 Vorlage beim Durchswitchen ersetzen statt stapeln (v2.6.0)
HTML-Vorlagen werden jetzt in <div id="hps-vorlage"> gewickelt und
direkt im Editor-DOM eingefügt. Vor dem Einfügen wird ein evtl. schon
vorhandener hps-vorlage-Block entfernt — so ersetzt Durchswitchen die
Vorlage statt sie oben draufzustapeln. insertText bleibt als Fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019XcAzPQhKgwS4HNzvXEFeN
2026-07-22 18:34:26 +02:00
Kendrick Bollens
169fc68f18 Release v2.5.0 — updates.json (Auto-Update-Manifest)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PZHd9vRsBiH8cKYdQo1Yv
2026-07-22 15:23:04 +02:00
Kendrick Bollens
adddef75ac README: auf aktuellen Stand gebracht
Überarbeitete README (QuickMove, Schlagwörter, Web-Editor, Auto-Update,
7z-Build-Befehl) plus die neuen Features 2.4.0/2.5.0: erzwungene
about:config-Einstellungen (mit Opt-out im Sync-Tab), Client-Versions-
Übersicht im Web-Editor, _clients/-Ordner und experiments/prefs/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PZHd9vRsBiH8cKYdQo1Yv
2026-07-22 14:49:06 +02:00
Kendrick Bollens
02776311c3 Release v2.5.0 — Client-Versions-Übersicht im Web-Editor
Jeder Client meldet beim Start seinen Status (Name, Abteilung, Plugin- und
Thunderbird-Version) als _clients/<email>.json ins Gitea-Repo — aber nur, wenn
sich tatsächlich etwas geändert hat (kein Commit-Spam pro Start).

Web-Editor: neuer Admin-Unterpunkt "User & Versionen" (Route /api/clients)
mit Tabelle je User und "veraltet"-Markierung für Clients, die nicht auf der
neuesten gemeldeten Version laufen. _clients/ ist in den Ordner-Filtern
ausgenommen, damit es nicht als Abteilung erscheint.

Nebenbei: Web-Editor-Button-Label "Abteilung" -> "Vorlage" (Templates-Tab).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PZHd9vRsBiH8cKYdQo1Yv
2026-07-22 14:42:23 +02:00
Kendrick Bollens
d244273e44 Release v2.4.0 — Erzwungene Einstellungen: about:config-Prefs bei jedem Start setzen
Neuer forcedPrefs-WebExtension-Experiment (Services.prefs) setzt beim Start
globale Prefs (Startseite aus, Kartenansicht, nicht gruppiert, absteigend,
3-Spalten-Layout, nicht auto-gelesen) sowie pro Identität die Signatur-/
Antwort-Einstellungen. Pro Setting im Options-Dialog (⚙-Tab) abschaltbar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PZHd9vRsBiH8cKYdQo1Yv
2026-07-22 14:24:43 +02:00
Kendrick Bollens
83d0d52a05 Release v2.3.6 — updates.json (Auto-Update-Manifest)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNGrD9ewk1gkStw9znPqQ1
2026-07-01 12:36:04 +02:00
Kendrick Bollens
c5eebd1b06 Release v2.3.6 — Signatur-Footer: neue Compose-Fenster ziehen gesyncten Footer automatisch
Thunderbird cacht die Identitäts-Signatur pro Sitzung, daher zeigte ein nach
dem Hintergrund-Sync geöffnetes Verfassen-Fenster noch den alten Footer.
background.js hängt sich jetzt an tabs.onCreated und tauscht im Signaturblock
automatisch den Footer-Teil (ab SIG_FOOTER_START) gegen den frischen aus —
ohne Benutzeraktion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NNGrD9ewk1gkStw9znPqQ1
2026-07-01 12:35:08 +02:00
Kendrick Bollens
c7383f3a77 Release v2.3.5 — Signatur-Sync: Web-Editor-Edits werden korrekt übernommen
Pull-Lookup für Signatur-Header war case-sensitiv gegen eine lowercase
headerMap → Groß-/Leerzeichen-Namen wurden nie gefunden, Plugin fiel auf
die lokale TB-Signatur zurück und revertierte beim Push die Web-Editor-
Änderung. Jetzt: case-insensitive Lookup, kanonischer sigSlug (= Web-Editor-
Format, lowercase-bindestrich) zum Lesen+Schreiben, Legacy-Format nur noch
lesend, bei mehreren Treffern gewinnt der neueste Commit. Keine git-seitigen
Breaking Changes — alte Plugin-Versionen (Groß-Dateien) bleiben kompatibel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:29:56 +02:00
Kendrick Bollens
fa817050d8 Release v2.3.4 — QuickMove: "als gelesen" pro Aktion + dynamischer Button-Name
- QuickMove markiert Nachrichten als gelesen, pro Aktion ein-/ausschaltbar
  (Checkbox in den QuickMove-Einstellungen, Default an)
- Button-Label heißt wie die Aktion, wenn nur eine konfiguriert ist (setLabel)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:08:43 +02:00
14 changed files with 752 additions and 50 deletions

108
README.md
View File

@@ -1,19 +1,46 @@
# HPS Vorlagen & Signaturen # HPS Vorlagen & Signaturen
Thunderbird-Plugin (v2.2.0) zur zentralen Verwaltung von E-Mail-Vorlagen und Signaturen für Hotel Park Soltau. Vorlagen und Signaturen werden über ein Gitea/Forgejo-Repository synchronisiert und stehen so allen Mitarbeitern zur Verfügung. Thunderbird-MailExtension zur zentralen Verwaltung von E-Mail-Vorlagen und Signaturen für
Hotel Park Soltau. Vorlagen, Signaturen und Schlagwörter werden über ein Gitea/Forgejo-Repository
synchronisiert und stehen so allen Mitarbeitern zur Verfügung. (Aktuelle Version: siehe
`manifest.json`.)
## Features ## Features
- **E-Mail-Vorlagen** erstellen, bearbeiten und per Klick in Compose-Fenster einfügen - **E-Mail-Vorlagen** erstellen, bearbeiten und per Klick ins Compose-Fenster einfügen
- **3 Sichtbarkeitsstufen** pro Vorlage: - **3 Sichtbarkeitsstufen** pro Vorlage:
- **Persönlich** — nur für den eigenen Account, gesynct in `_benutzer/{email}/` - **Persönlich** — nur für den eigenen Account, gesynct in `_benutzer/{email}/`
- **Abteilung** — für alle in der Abteilung, gesynct in den Abteilungsordner - **Abteilung** — für alle in der Abteilung, gesynct in den Abteilungsordner
- **Alle Abteilungen** — firmenweit, gesynct in `_gemeinsam/` - **Alle Abteilungen** — firmenweit, gesynct in `_gemeinsam/`
- **Signaturen-Verwaltung** mit persönlichem Kopfbereich + gemeinsamer Fußzeile pro Abteilung - **Signaturen-Verwaltung** mit persönlichem Kopfbereich + gemeinsamer Fußzeile pro Abteilung
- **QuickMove** — Button in der Nachrichtenansicht: markiert die E-Mail mit deinem Schlagwort
und verschiebt sie in einen festgelegten Ordner. Mehrere Aktionen → Auswahlmenü.
- **Schlagwörter-Sync** — Benutzer-Tags werden zentral über `_config/schlagwoerter.json`
verwaltet, in Thunderbird angelegt und nie gelöscht (dauerhaft nachvollziehbar)
- **Git-Sync** über Gitea/Forgejo API (Pull + Push, automatisch alle 15 Min.) - **Git-Sync** über Gitea/Forgejo API (Pull + Push, automatisch alle 15 Min.)
- **Auto-Erkennung** von Abteilung und Benutzer via `_config/abteilungen.json` - **Auto-Erkennung** von Abteilung und Benutzer via `_config/abteilungen.json`
- **Abteilungsverwaltung** — neue Abteilungen direkt aus den Einstellungen anlegen
- **WYSIWYG-Editor** mit Schriftart, Farben, Listen, Bildern, Links - **WYSIWYG-Editor** mit Schriftart, Farben, Listen, Bildern, Links
- **Sichtbarkeit direkt änderbar** per klickbarem Badge in der Vorlagenliste - **Sichtbarkeit direkt änderbar** per klickbarem Badge in der Vorlagenliste
- **Auto-Update** über Gitea (siehe unten) — installierte Clients aktualisieren sich selbst
- **Toolbar-Button** öffnet direkt die Einstellungen
- **Erzwungene Thunderbird-Einstellungen** — setzt bei jedem Start bestimmte `about:config`-Prefs
(z.B. Startseite aus, Kartenansicht, „nicht automatisch als gelesen", Signatur-/Antwort-Verhalten
pro Konto); pro Einstellung im Sync-Tab abschaltbar
- **Client-Versions-Übersicht** — jeder Client meldet Plugin- und Thunderbird-Version; im
Web-Editor unter *Verwaltung → User & Versionen* einsehbar (veraltete Clients markiert)
- **[Web-Editor](web-editor/)** als optionales Web-Gegenstück (gleiche Gitea-Quelle)
## Bedienoberfläche (Einstellungsseite)
Vier Tabs:
| Tab | Funktion |
|---|---|
| **Vorlagen** | Vorlagen anlegen/bearbeiten, Sichtbarkeit, manuelles Push/Pull |
| **Signaturen** | Persönlicher Kopfbereich + Fußzeile (gemeinsam / Abteilung) |
| **QuickMove** | Aktionen konfigurieren (Schlagwort + Zielordner) |
| **Sync** | Server-Verbindung, Benutzer & Abteilung, Sync auslösen, erzwungene Einstellungen (Opt-out) |
## Repository-Struktur (Gitea) ## Repository-Struktur (Gitea)
@@ -25,14 +52,18 @@ repo/
│ ├── max@hotel-park-soltau.de/ │ ├── max@hotel-park-soltau.de/
│ └── anna@hotel-park-soltau.de/ │ └── anna@hotel-park-soltau.de/
├── _config/ ├── _config/
── abteilungen.json # E-Mail → Abteilung Mapping ── abteilungen.json # E-Mail → Abteilung Mapping
│ └── schlagwoerter.json # Benutzer-Schlagwörter (Name + Farbe)
├── _clients/ # Status-Meldungen: Plugin-/TB-Version pro User
│ └── max@hotel-park-soltau.de.json
├── Rezeption/ # Abteilungsvorlagen ├── Rezeption/ # Abteilungsvorlagen
├── IT/ ├── IT/
├── signatures/ ├── signatures/
│ ├── headers/ # Persönliche Signatur-Köpfe │ ├── headers/ # Persönliche Signatur-Köpfe
│ │ └── max@hotel.de.max-mustermann.html │ │ └── max@hotel.de.max-mustermann.html
│ └── footers/ # Gemeinsame Fußbereiche │ └── footers/ # Fußbereiche
── Rezeption.html ── _default.html # gemeinsame Standard-Fußzeile
│ └── Rezeption.html # Abteilungs-Fußzeile
``` ```
### `_config/abteilungen.json` ### `_config/abteilungen.json`
@@ -48,17 +79,36 @@ Mapping von Abteilungs-E-Mail-Adressen zu Ordnernamen. Wird vom Plugin gelesen,
} }
``` ```
### `_config/schlagwoerter.json`
Liste der Benutzer-Schlagwörter (für QuickMove/Markierung). Wird beim Sync automatisch um neue
Benutzer ergänzt und nie geleert, damit Markierungen dauerhaft nachvollziehbar bleiben:
```json
[
{ "name": "Max Mustermann", "color": "#4a7c59" },
{ "name": "Anna Beispiel", "color": "#2874a6" }
]
```
## Plugin-Aufbau ## Plugin-Aufbau
| Datei | Funktion | | Datei | Funktion |
|---|---| |---|---|
| `manifest.json` | Extension-Manifest (Thunderbird WebExtension v2) | | `manifest.json` | Extension-Manifest (Thunderbird WebExtension v2) |
| `background.js` | Template-Insertion ins Compose-Fenster | | `background.js` | Template-Insertion + QuickMove-Aktionen (Tag setzen, Nachricht verschieben) |
| `popup.html` / `popup.js` | Popup beim Klick auf "Vorlagen" im Compose | | `popup.html` / `popup.js` | Compose-Popup ("Vorlagen" beim Schreiben) |
| `lib/gitea-sync.js` | Gitea-API-Client + Sync-Manager | | `message_popup.html` / `message_popup.js` | QuickMove-Popup in der Nachrichtenansicht |
| `lib/mdi/` | Material Design Icons (Subset) | | `toolbar_popup.html` | Toolbar-Button → öffnet die Einstellungen |
| `templates_options/` | Einstellungsseite (Vorlagen, Signaturen, Verbindung) | | `templates_options/` | Einstellungsseite (Tabs: Vorlagen, Signaturen, QuickMove, Sync) |
| `lib/gitea-sync.js` | Gitea-API-Client + Sync-Manager (inkl. Schlagwörter-Sync + Client-Status-Meldung) |
| `lib/forced-prefs-list.js` | Liste der erzwungenen `about:config`-Prefs (geteilt: Background + Optionsseite) |
| `experiments/prefs/` | Privilegierte Experiment-API zum Setzen von `about:config`-Prefs |
| `lib/mdi/` | Material Design Icons (Subset) für den Editor |
| `defaults.local.json` | Optionale vorkonfigurierte Verbindungsdaten (gitignored) | | `defaults.local.json` | Optionale vorkonfigurierte Verbindungsdaten (gitignored) |
| `updates.json` | Update-Manifest für Thunderbird-Auto-Update |
| `release.sh` | Release-Skript (Build hashen, Gitea-Release anlegen, updates.json pflegen) |
| `web-editor/` | Optionaler Node/Docker-Web-Editor (eigenständig, gleiche Gitea-Quelle) |
## Installation ## Installation
@@ -71,17 +121,25 @@ Mapping von Abteilungs-E-Mail-Adressen zu Ordnernamen. Wird vom Plugin gelesen,
### XPI bauen ### XPI bauen
```bash `zip` ist nicht zwingend vorhanden, `7z` reicht. Immer **ohne** `defaults.local.json` bauen —
# Ohne vorkonfigurierte Verbindungsdaten: die Datei enthält den Gitea-Token und darf nicht in eine veröffentlichte `.xpi`:
7z a templates-reply-hotel.xpi manifest.json background.js popup.html popup.js lib/ templates_options/ icons/
# Mit vorkonfigurierten Verbindungsdaten (für Deployment): ```bash
7z a templates-reply-hotel.xpi manifest.json background.js popup.html popup.js lib/ templates_options/ icons/ defaults.local.json rm -f templates-reply-hotel.xpi
7z a -tzip templates-reply-hotel.xpi . \
-xr'!.git' -xr'!node_modules' -xr'!web-editor' -xr'!.claude' \
-xr'!defaults.local.json' -xr'!*.xpi' -xr'!release.sh' -xr'!*.md'
``` ```
> Eine **vorkonfigurierte** `.xpi` (mit `defaults.local.json`) ist nur für die interne
> Erstinstallation auf einer neuen Maschine gedacht und darf **niemals** als öffentliches
> Release hochgeladen werden — sonst wäre der Token auslesbar.
### Vorkonfigurierte Verbindungsdaten (`defaults.local.json`) ### Vorkonfigurierte Verbindungsdaten (`defaults.local.json`)
Wenn eine `defaults.local.json` im Plugin-Root existiert und in die XPI eingebaut wird, werden die Verbindungsdaten beim ersten Start automatisch gesetzt. Der User muss dann nur noch "Verbindung speichern" klicken. Wenn eine `defaults.local.json` im Plugin-Root existiert und in die XPI eingebaut wird, werden
die Verbindungsdaten **beim allerersten Start** automatisch gesetzt. Updates brauchen die Datei
nicht — bestehende Installationen behalten ihre Konfiguration in `storage.local`.
```json ```json
{ {
@@ -107,17 +165,25 @@ Installierte Add-ons aktualisieren sich automatisch über `updates.json` in dies
Neue Version veröffentlichen: Neue Version veröffentlichen:
1. `version` in `manifest.json` hochzählen, `.xpi` **ohne** `defaults.local.json` neu bauen. 1. **`version` in `manifest.json` hochzählen** (gleiche Version = Clients updaten nicht!).
2. `GITEA_TOKEN=… ./release.sh` — hasht die `.xpi`, aktualisiert `updates.json`, legt das 2. `.xpi` **ohne** `defaults.local.json` neu bauen (siehe oben).
3. `GITEA_TOKEN=… ./release.sh` — hasht die `.xpi`, aktualisiert `updates.json`, legt das
Gitea-Release an und lädt die `.xpi` als Asset hoch. Gitea-Release an und lädt die `.xpi` als Asset hoch.
3. `updates.json` + `manifest.json` committen & pushen. 4. `updates.json` + `manifest.json` committen & pushen.
Bestehende Installationen ziehen das Update dann automatisch (Prüfung ~1×/Tag, oder manuell
über Add-ons → Zahnrad → „Auf Updates prüfen").
## Einrichtung ## Einrichtung
1. **Verbindung konfigurieren**: Einstellungen-Tab (⚙) → Server-URL, Repository, Token eingeben → Verbindung speichern (entfällt bei vorkonfigurierter XPI) 1. **Verbindung konfigurieren**: Sync-Tab → Server-URL, Repository, Token eingeben →
2. **Abteilung wählen** (oder automatisch erkannt via `abteilungen.json`) Verbindung speichern (entfällt bei vorkonfigurierter XPI)
2. **Abteilung wählen** (oder automatisch erkannt via `abteilungen.json`; neue Abteilungen
lassen sich per **+** direkt anlegen)
3. **Vorlagen erstellen**: Vorlagen-Tab → Neue Vorlage → Sichtbarkeit wählen → Speichern 3. **Vorlagen erstellen**: Vorlagen-Tab → Neue Vorlage → Sichtbarkeit wählen → Speichern
4. **Signaturen einrichten**: Signaturen-Tab → Identität wählen → Kopfbereich bearbeiten → Speichern 4. **Signaturen einrichten**: Signaturen-Tab → Identität wählen → Kopfbereich bearbeiten → Speichern
5. **QuickMove einrichten** (optional): QuickMove-Tab → Aktion hinzufügen (Name + Zielordner) →
Speichern. Der Button erscheint dann in der Nachrichtenansicht.
## Voraussetzungen ## Voraussetzungen

View File

@@ -16,6 +16,8 @@ async function executeErledigtAction(tab, actionConfig) {
const storage = await browser.storage.local.get(['gitea_config', 'schlagwoerter_cache']); const storage = await browser.storage.local.get(['gitea_config', 'schlagwoerter_cache']);
const config = storage.gitea_config || {}; const config = storage.gitea_config || {};
const schlagwoerter = storage.schlagwoerter_cache; const schlagwoerter = storage.schlagwoerter_cache;
// "Als gelesen markieren" — pro Aktion konfigurierbar, standardmäßig an.
const markRead = (actionConfig || {}).markRead !== false;
// Apply user's tag // Apply user's tag
let tagKey = null; let tagKey = null;
@@ -33,6 +35,11 @@ async function executeErledigtAction(tab, actionConfig) {
} }
} }
// Mark as read (before the move, while the id is still valid in this folder)
if (markRead && !message.read) {
await messenger.messages.update(message.id, { read: true });
}
// Move to target folder // Move to target folder
if (actionConfig.targetFolder) { if (actionConfig.targetFolder) {
const folderInfo = JSON.parse(actionConfig.targetFolder); const folderInfo = JSON.parse(actionConfig.targetFolder);
@@ -42,6 +49,7 @@ async function executeErledigtAction(tab, actionConfig) {
// Feedback // Feedback
const parts = []; const parts = [];
if (tagKey) parts.push('markiert'); if (tagKey) parts.push('markiert');
if (markRead && !message.read) parts.push('gelesen');
if (actionConfig.targetFolder) parts.push('verschoben'); if (actionConfig.targetFolder) parts.push('verschoben');
const title = actionConfig.name || 'Erledigt'; const title = actionConfig.name || 'Erledigt';
browser.notifications.create({ browser.notifications.create({
@@ -68,12 +76,19 @@ messenger.messageDisplayAction.onClicked.addListener(async (tab) => {
async function updateErledigtPopup() { async function updateErledigtPopup() {
const result = await browser.storage.local.get('erledigt_config'); const result = await browser.storage.local.get('erledigt_config');
const actions = (result.erledigt_config || {}).actions || []; const actions = (result.erledigt_config || {}).actions || [];
const setLabel = (label) => {
// setLabel ist Thunderbird-spezifisch — defensiv prüfen, damit der Start nie bricht.
if (messenger.messageDisplayAction.setLabel) return messenger.messageDisplayAction.setLabel({ label });
};
if (actions.length > 1) { if (actions.length > 1) {
await messenger.messageDisplayAction.setPopup({ popup: 'message_popup.html' }); await messenger.messageDisplayAction.setPopup({ popup: 'message_popup.html' });
await messenger.messageDisplayAction.setTitle({ title: 'Aktion wählen' }); await messenger.messageDisplayAction.setTitle({ title: 'Aktion wählen' });
await setLabel('QuickMove');
} else { } else {
const name = actions[0]?.name || 'QuickMove';
await messenger.messageDisplayAction.setPopup({ popup: '' }); await messenger.messageDisplayAction.setPopup({ popup: '' });
await messenger.messageDisplayAction.setTitle({ title: actions[0]?.name || 'Erledigt' }); await messenger.messageDisplayAction.setTitle({ title: name });
await setLabel(name); // Button heißt wie die (einzige) Aktion
} }
} }
@@ -83,6 +98,134 @@ browser.storage.onChanged.addListener((changes, area) => {
}); });
updateErledigtPopup(); updateErledigtPopup();
// ── about:config-Prefs bei jedem Start erzwingen ──
//
// Manche Thunderbird-Einstellungen (Startseite, Ansicht, Layout, pro Konto das
// Signatur-/Antwort-Verhalten) „springen zurück" und müssen sonst von Hand
// nachgezogen werden. Wir setzen sie bei JEDEM Start neu über den privilegierten
// forcedPrefs-Experiment (Services.prefs). Die Liste liegt in
// lib/forced-prefs-list.js (globale FORCED_PREFS / FORCED_IDENTITY_PREFS).
// Pro Eintrag kann der Client im Options-Dialog opt-outen (forced_prefs_optout).
const FORCED_PREFS_OPTOUT_KEY = 'forced_prefs_optout';
async function enforceForcedPrefs() {
const stored = await browser.storage.local.get(FORCED_PREFS_OPTOUT_KEY);
const disabled = new Set(stored[FORCED_PREFS_OPTOUT_KEY] || []);
// Globale Prefs
for (const p of FORCED_PREFS) {
if (disabled.has(p.id)) continue;
try {
await browser.forcedPrefs.setPref(p.name, p.type, p.value);
} catch (e) {
console.error('[forcedPrefs] konnte', p.name, 'nicht setzen:', e);
}
}
// Pro-Identität-Prefs — über alle Konten/Identitäten iterieren
const accounts = await browser.accounts.list();
for (const acc of accounts) {
for (const ident of (acc.identities || [])) {
for (const p of FORCED_IDENTITY_PREFS) {
if (disabled.has(p.id)) continue;
const name = `mail.identity.${ident.id}.${p.suffix}`;
try {
await browser.forcedPrefs.setPref(name, p.type, p.value);
} catch (e) {
console.error('[forcedPrefs] konnte', name, 'nicht setzen:', e);
}
}
}
}
}
enforceForcedPrefs(); // Top-Level = läuft einmal pro TB-Start
// Wird eine Pref im Options-Dialog wieder AKTIVIERT, sofort anwenden
// (statt bis zum nächsten Neustart zu warten).
browser.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes[FORCED_PREFS_OPTOUT_KEY]) enforceForcedPrefs();
});
// ── Signatur in neuen Compose-Fenstern automatisch aktualisieren ──
//
// Thunderbird merkt sich die Signatur einer Identität pro Sitzung. Öffnet der
// Benutzer nach einem Hintergrund-Sync ein neues Verfassen-Fenster, steckt darin
// noch die ALTE Signatur (alter Footer) — `identities.update({signature})`
// schreibt zwar den Pref, aber das Compose-Fenster fügt die gecachte Fassung ein.
// Ein Event „neues Compose-Fenster" gibt es in der WebExtension-API nicht, daher
// hängen wir uns an tabs.onCreated und tauschen NUR den Footer-Teil (ab dem
// SIG_FOOTER_START-Marker) im eingefügten Signaturblock gegen den frisch
// gesyncten aus. Header, Trenner ("-- ") und Struktur bleiben unangetastet.
// Vollautomatisch, ohne jede Benutzeraktion.
const SIG_FOOTER_MARK = '<!-- SIG_FOOTER_START -->';
const sigSleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function getFreshFooter() {
const c = await browser.storage.local.get('sig_footer_cache');
return c.sig_footer_cache || '';
}
async function refreshComposeSignature(tabId, tabType) {
// Nicht-Compose-Tabs sofort ignorieren, wenn der Typ bekannt ist.
if (tabType && tabType !== 'messageCompose') return;
const footer = await getFreshFooter();
if (!footer) return;
// Läuft IM Compose-Dokument: findet den Signaturblock, ersetzt nur den Footer.
const code = `(function(){
const editor = document.getElementById('messageEditor');
const doc = editor ? editor.contentDocument : document;
const sig = doc.querySelector('.moz-signature');
if (!sig) return 'notready';
const MARK = ${JSON.stringify(SIG_FOOTER_MARK)};
const cur = sig.innerHTML;
const i = cur.indexOf(MARK);
if (i === -1) return 'unmanaged'; // keine vom Plugin verwaltete Signatur
const before = cur.slice(0, i + MARK.length); // "-- <br>" + Header + Marker
const curFooter = cur.slice(i + MARK.length).replace(/^\\s+/, '');
const NEW = ${JSON.stringify(footer)};
if (curFooter === NEW) return 'current'; // schon aktuell, nichts tun
sig.innerHTML = before + '\\n' + NEW;
return 'updated';
})();`;
// Thunderbird fügt die Signatur evtl. erst kurz nach dem Öffnen ein → kurz pollen.
let throwCount = 0;
for (let attempt = 0; attempt < 20; attempt++) {
let details;
try {
details = await browser.compose.getComposeDetails(tabId);
} catch (_) {
// Kein Compose-Tab oder noch nicht bereit. Bei bekanntem Typ weiter pollen,
// sonst nach ein paar Fehlversuchen aufgeben.
if (tabType !== 'messageCompose' && ++throwCount > 3) return;
await sigSleep(150);
continue;
}
if (!details) { await sigSleep(150); continue; }
if (details.isPlainText) return; // Footer ist HTML — Plaintext nicht anfassen
if (details.type === 'draft') return; // gespeicherte Entwürfe nicht überschreiben
let res;
try {
const out = await browser.tabs.executeScript(tabId, { code });
res = Array.isArray(out) ? out[0] : out;
} catch (_) {
return; // Fenster bereits geschlossen o. Ä.
}
if (res !== 'notready') return; // fertig: updated / current / unmanaged
await sigSleep(150);
}
}
browser.tabs.onCreated.addListener((tab) => {
refreshComposeSignature(tab.id, tab && tab.type).catch((err) =>
console.error('[Sig] Compose-Refresh fehlgeschlagen:', err));
});
// ── Template insertion ── // ── Template insertion ──
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
@@ -133,30 +276,49 @@ async function handleInsertTemplate(msg) {
const newBody = msg.text + (old ? '\n' + old : ''); const newBody = msg.text + (old ? '\n' + old : '');
await browser.compose.setComposeDetails(tab.id, { plainTextBody: newBody }); await browser.compose.setComposeDetails(tab.id, { plainTextBody: newBody });
} else { } else {
// HTML mode: use insertText API to go through the editor's rendering pipeline // HTML mode: wrap the template in a marker element (id=hps-vorlage) and
// replace any previously inserted one — so durchswitchen ersetzt statt stapelt.
const htmlContent = isHtmlTemplate ? msg.text : msg.text.replace(/\n/g, '<br>'); const htmlContent = isHtmlTemplate ? msg.text : msg.text.replace(/\n/g, '<br>');
// Move cursor to beginning of body first via script injection // Directly manipulate the editor DOM: remove the old template block (if any),
await browser.tabs.executeScript(tab.id, { // then insert the new one at the top of the body. Returns true on success.
const injected = await browser.tabs.executeScript(tab.id, {
code: ` code: `
const editor = document.getElementById('messageEditor'); (function () {
const editorDoc = editor ? editor.contentDocument : document; const editor = document.getElementById('messageEditor');
const body = editorDoc.body; const editorDoc = editor ? editor.contentDocument : document;
if (body) { const body = editorDoc && editorDoc.body;
if (!body) return false;
// Remove a previously inserted template block, so switching replaces it
const prev = editorDoc.getElementById('hps-vorlage');
if (prev) prev.remove();
// Wrap the new template in a marker element with a stable id
const wrapper = editorDoc.createElement('div');
wrapper.id = 'hps-vorlage';
wrapper.innerHTML = ${JSON.stringify(htmlContent)};
// Insert at the very top of the body (above quote/signature/typed text)
body.insertBefore(wrapper, body.firstChild);
// Place the cursor right after the inserted block
const range = editorDoc.createRange(); const range = editorDoc.createRange();
range.setStart(body, 0); range.setStartAfter(wrapper);
range.collapse(true); range.collapse(true);
const sel = editorDoc.getSelection(); const sel = editorDoc.getSelection();
sel.removeAllRanges(); sel.removeAllRanges();
sel.addRange(range); sel.addRange(range);
} return true;
})();
` `
}).catch(() => { }).then(r => (Array.isArray(r) ? r[0] : r)).catch(() => false);
// Fallback: some TB versions structure the editor differently
});
// Insert HTML at cursor position - this goes through the editor's render pipeline if (!injected) {
await browser.compose.insertText(tab.id, htmlContent + '<br>', { insertAsText: false }); // Fallback: editor DOM not reachable (TB version differences) — use the
// insertText pipeline. Note: this variant cannot replace, only prepend.
await browser.compose.insertText(tab.id, htmlContent + '<br>', { insertAsText: false });
}
} }
} catch (e) { } catch (e) {

61
experiments/prefs/api.js Normal file
View File

@@ -0,0 +1,61 @@
"use strict";
// Privilegierter Parent-Scope: schreibt/liest about:config-Prefs über Services.prefs.
// Der Modulpfad von ExtensionCommon wechselte mitten im TB-Lebenszyklus:
// TB 115+ -> ExtensionCommon.sys.mjs (ESM)
// TB 109-114 -> ExtensionCommon.jsm (JSM)
// Deshalb defensiv laden, damit dieselbe api.js über die Flotte funktioniert.
var { ExtensionCommon } = (() => {
try {
return ChromeUtils.importESModule(
"resource://gre/modules/ExtensionCommon.sys.mjs"
);
} catch (e) {
return ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
}
})();
// `Services` ist im privilegierten ExtensionAPI-Sandbox in TB 109+ global
// (stellt Services.prefs = nsIPrefBranch bereit); kein Import nötig.
this.forcedPrefs = class extends ExtensionCommon.ExtensionAPI {
getAPI(context) {
return {
forcedPrefs: {
async setPref(name, type, value) {
switch (type) {
case "bool":
Services.prefs.setBoolPref(name, !!value);
break;
case "int":
Services.prefs.setIntPref(name, value | 0);
break;
case "string":
// setStringPref ist unicode-sicher (im Gegensatz zu setCharPref).
Services.prefs.setStringPref(name, String(value));
break;
default:
throw new Error("forcedPrefs.setPref: unknown type " + type);
}
return true;
},
async getPref(name, type) {
if (Services.prefs.getPrefType(name) === Services.prefs.PREF_INVALID) {
return null;
}
switch (type) {
case "bool":
return Services.prefs.getBoolPref(name, false);
case "int":
return Services.prefs.getIntPref(name, 0);
case "string":
return Services.prefs.getStringPref(name, "");
default:
throw new Error("forcedPrefs.getPref: unknown type " + type);
}
},
},
};
}
};

View File

@@ -0,0 +1,29 @@
[
{
"namespace": "forcedPrefs",
"description": "Read/write about:config preferences from a privileged parent context.",
"functions": [
{
"name": "setPref",
"type": "function",
"async": true,
"description": "Force an about:config pref to a fixed value. type is one of 'bool','int','string'.",
"parameters": [
{ "name": "name", "type": "string" },
{ "name": "type", "type": "string", "enum": ["bool", "int", "string"] },
{ "name": "value", "type": "any" }
]
},
{
"name": "getPref",
"type": "function",
"async": true,
"description": "Read an about:config pref; returns null if unset.",
"parameters": [
{ "name": "name", "type": "string" },
{ "name": "type", "type": "string", "enum": ["bool", "int", "string"] }
]
}
]
}
]

52
lib/forced-prefs-list.js Normal file
View File

@@ -0,0 +1,52 @@
"use strict";
// Single Source of Truth für die about:config-Prefs, die bei jedem TB-Start
// erzwungen werden. Wird als klassisches Script in BEIDEN Kontexten geladen
// (Background + Options-Seite), definiert also nur globale Variablen — keine
// import/export-Mechanik.
//
// Jeder Eintrag:
// id - stabiler Opt-out-Key (NIE umbenennen/wiederverwenden; landet in storage)
// label - Text im Options-Dialog
// type - "bool" | "int" | "string"
// value - der bei jedem Start erzwungene Wert
// FORCED_PREFS zusätzlich: name - voller about:config-Pref-Name
// FORCED_IDENTITY_PREFS: suffix - voller Name = mail.identity.<id>.<suffix>
//
// WICHTIG: Werte an einem korrekt eingerichteten Referenz-Thunderbird
// (Doku-Seite „Thunderbird einrichten", Page 99) per about:config gegenprüfen —
// dieser Code läuft privilegiert auf allen Clients, ein Tippfehler trifft alle.
var FORCED_PREFS = [
{ id: "start_page_off", label: "Startseite beim Öffnen ausblenden",
name: "mailnews.start_page.enabled", type: "bool", value: false },
{ id: "listview_cards", label: "Kartenansicht (statt Tabelle)",
name: "mail.threadpane.listview", type: "int", value: 0 },
{ id: "no_grouping", label: "Nachrichten nicht gruppieren",
name: "mailnews.default_view_flags", type: "int", value: 0 },
{ id: "sort_descending", label: "Neue Nachrichten oben (absteigend)",
name: "mailnews.default_sort_order", type: "int", value: 2 },
{ id: "layout_3col", label: "3-Spalten-Ansicht (vertikales Layout)",
name: "mail.pane_config.dynamic", type: "int", value: 2 },
{ id: "no_auto_read", label: "Nachrichten nicht automatisch als gelesen markieren",
name: "mailnews.mark_message_read.auto", type: "bool", value: false },
];
// Pro Identität angewandt: voller Name = mail.identity.<id>.<suffix>
var FORCED_IDENTITY_PREFS = [
{ id: "reply_on_top", label: "Antwort über dem Zitat",
suffix: "reply_on_top", type: "int", value: 1 },
// sig_bottom=false -> Signatur direkt unter der Antwort, VOR dem Zitat.
// (true wäre ganz unten nach dem Zitat.)
{ id: "sig_bottom", label: "Signatur unter Antwort / vor dem Zitat",
suffix: "sig_bottom", type: "bool", value: false },
{ id: "sig_on_reply", label: "Signatur bei Antworten beifügen",
suffix: "sig_on_reply", type: "bool", value: true },
{ id: "sig_on_fwd", label: "Signatur beim Weiterleiten beifügen",
suffix: "sig_on_fwd", type: "bool", value: true },
];
if (typeof globalThis !== "undefined") {
globalThis.FORCED_PREFS = FORCED_PREFS;
globalThis.FORCED_IDENTITY_PREFS = FORCED_IDENTITY_PREFS;
}

View File

@@ -8,6 +8,7 @@ const FULL_SYNC_COOLDOWN_MS = 10 * 1000; // min 10s between full pulls
const SHARED_FOLDER = '_gemeinsam'; const SHARED_FOLDER = '_gemeinsam';
const USER_FOLDER = '_benutzer'; const USER_FOLDER = '_benutzer';
const CONFIG_FOLDER = '_config'; const CONFIG_FOLDER = '_config';
const CLIENTS_FOLDER = '_clients';
const SCHLAGWOERTER_CACHE_KEY = 'schlagwoerter_cache'; const SCHLAGWOERTER_CACHE_KEY = 'schlagwoerter_cache';
// ── Gitea API Client ── // ── Gitea API Client ──
@@ -190,6 +191,59 @@ class SyncManager {
return this.config?.authorEmail || ''; return this.config?.authorEmail || '';
} }
get authorName() {
return this.config?.authorName || '';
}
/**
* Meldet den eigenen Client-Status (Name, Abteilung, Plugin- + TB-Version)
* als _clients/<email>.json ins Repo — Basis für die Admin-Übersicht im
* Web-Editor. Schreibt NUR, wenn sich tatsächlich etwas geändert hat
* (Plugin-Version, TB-Version, Name oder Abteilung) — sonst kein Commit.
*/
async reportClientStatus() {
if (!this.isConfigured) return { success: false, skipped: 'not-configured' };
const email = this.authorEmail.trim().toLowerCase();
if (!email) return { success: false, skipped: 'no-email' };
let tbVersion = '';
try {
const info = await browser.runtime.getBrowserInfo();
tbVersion = [info.name, info.version].filter(Boolean).join(' ');
} catch (_) {}
const status = {
name: this.authorName,
email,
department: this.department,
pluginVersion: browser.runtime.getManifest().version,
tbVersion,
lastSeen: new Date().toISOString()
};
const filepath = `${CLIENTS_FOLDER}/${email}.json`;
const body = JSON.stringify(status, null, 2);
const commitMsg = `Client-Status: ${email} (v${status.pluginVersion})`;
const existing = await this.client.getFile(filepath);
if (existing && existing.content) {
try {
const prev = JSON.parse(GiteaClient.fromBase64(existing.content));
const unchanged = prev.name === status.name
&& prev.department === status.department
&& prev.pluginVersion === status.pluginVersion
&& prev.tbVersion === status.tbVersion;
if (unchanged) {
return { success: true, skipped: 'unchanged' };
}
} catch (_) { /* kaputter Vorgänger → einfach überschreiben */ }
await this.client.updateFile(filepath, body, existing.sha, commitMsg);
} else {
await this.client.createFile(filepath, body, commitMsg);
}
return { success: true, status };
}
async autoDetect() { async autoDetect() {
if (!this.isConfigured) return null; if (!this.isConfigured) return null;
return await this.client.getConfig(); return await this.client.getConfig();
@@ -233,7 +287,7 @@ class SyncManager {
const entries = await this.client.listDir(''); const entries = await this.client.listDir('');
const departments = []; const departments = [];
for (const entry of entries) { for (const entry of entries) {
if (entry.type === 'dir' && entry.name !== SHARED_FOLDER && entry.name !== USER_FOLDER && entry.name !== CONFIG_FOLDER && entry.name !== 'signatures' && !entry.name.startsWith('.')) { if (entry.type === 'dir' && entry.name !== SHARED_FOLDER && entry.name !== USER_FOLDER && entry.name !== CONFIG_FOLDER && entry.name !== CLIENTS_FOLDER && entry.name !== 'signatures' && !entry.name.startsWith('.')) {
departments.push(entry.name); departments.push(entry.name);
} }
} }
@@ -400,6 +454,19 @@ class SyncManager {
return { success: true, pushed }; return { success: true, pushed };
} }
// Kanonischer Signatur-Slug, identisch zum Web-Editor (slugifyHeaderName):
// toFilename(...) + lowercase + Leerzeichen→Bindestrich. So lesen/schreiben
// Web-Editor und Plugin DIESELBE Datei (z.B. ".kendrick-bollens.html").
get sigSlug() {
return SyncManager.toFilename(this.config.authorName || '')
.toLowerCase()
.replace(/\s+/g, '-');
}
// Altes Plugin-Format (Groß-/Kleinschreibung + Leerzeichen, z.B.
// ".Kendrick Bollens.html"). Nur noch für Lese-Fallback, damit Dateien von
// Clients mit alter Plugin-Version weiterhin angewandt werden. Nicht mehr
// schreiben — sonst entstehen wieder Doppel-Dateien.
get authorSlug() { get authorSlug() {
return SyncManager.toFilename(this.config.authorName || ''); return SyncManager.toFilename(this.config.authorName || '');
} }
@@ -567,11 +634,30 @@ class SyncManager {
if (source.startsWith('=')) continue; if (source.startsWith('=')) continue;
// Try personal header: email.authorslug.html // Personal-Header suchen. headerMap-Keys sind lowercase, daher
let targetFile = null; // Kandidaten ebenfalls lowercase nachschlagen (behebt den Bug,
// dass Groß-Namen NIE gefunden wurden → Plugin fiel auf die lokale
// Signatur zurück und revertierte beim Push die Web-Editor-Änderung).
// Kandidaten:
// • kanonisches Web-Editor-Format (lowercase-bindestrich)
// • altes Plugin-Format (Groß/Leerzeichen) von Clients mit alter Version
// Bei mehreren Treffern gewinnt der NEUESTE Commit — so verdeckt eine
// veraltete Datei keine frische Web-Editor-Änderung, egal welche der
// beiden Dateien zuletzt im Web-Editor bearbeitet wurde.
const candidates = [];
if (this.sigSlug) {
const f = headerMap[`${email}.${this.sigSlug}.html`.toLowerCase()];
if (f) candidates.push(f);
}
if (this.authorSlug) { if (this.authorSlug) {
const personalName = `${email}.${this.authorSlug}.html`; const f = headerMap[`${email}.${this.authorSlug}.html`.toLowerCase()];
targetFile = headerMap[personalName] || null; if (f && !candidates.includes(f)) candidates.push(f);
}
let targetFile = null;
let targetTime = -1;
for (const f of candidates) {
const t = Date.parse(f.last_author_date || f.last_committer_date || '') || 0;
if (t >= targetTime) { targetFile = f; targetTime = t; }
} }
let header = null; let header = null;
@@ -634,7 +720,7 @@ class SyncManager {
async pushSignatures() { async pushSignatures() {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
if (!this.config.authorName) throw new Error('Bitte Name eintragen (für Commit-Zuordnung)'); if (!this.config.authorName) throw new Error('Bitte Name eintragen (für Commit-Zuordnung)');
if (!this.authorSlug) throw new Error('Name fehlt für Dateiname'); if (!this.sigSlug) throw new Error('Name fehlt für Dateiname');
const sourceResult = await browser.storage.local.get('sig_source_map'); const sourceResult = await browser.storage.local.get('sig_source_map');
const sourceMap = sourceResult.sig_source_map || {}; const sourceMap = sourceResult.sig_source_map || {};
@@ -655,7 +741,12 @@ class SyncManager {
const header = SyncManager.extractHeader(identity.signature); const header = SyncManager.extractHeader(identity.signature);
if (!header.trim()) continue; if (!header.trim()) continue;
const filename = `${email}.${this.authorSlug}.html`; // Ins kanonische Web-Editor-Format schreiben (lowercase-bindestrich),
// damit Plugin und Web-Editor dieselbe Datei pflegen. Alte
// Groß-Dateien bleiben unangetastet (keine Breaking Changes für
// Clients mit alter Plugin-Version), werden aber nicht mehr neu
// geschrieben.
const filename = `${email}.${this.sigSlug}.html`;
const filepath = `signatures/headers/${filename}`; const filepath = `signatures/headers/${filename}`;
const commitMsg = `Signatur-Header ${identity.email} - von ${this.config.authorName}`; const commitMsg = `Signatur-Header ${identity.email} - von ${this.config.authorName}`;
@@ -931,6 +1022,7 @@ let lastKnownShas = null;
let lastFullSync = 0; let lastFullSync = 0;
let lastTagSync = 0; let lastTagSync = 0;
let syncInProgress = false; let syncInProgress = false;
let clientStatusReported = false;
const TAG_SYNC_INTERVAL_MS = 60 * 1000; const TAG_SYNC_INTERVAL_MS = 60 * 1000;
const HASH_STORAGE_KEY_BG = 'sync_hashes'; const HASH_STORAGE_KEY_BG = 'sync_hashes';
@@ -972,6 +1064,15 @@ async function smartSync() {
const initialized = await syncManager.init(); const initialized = await syncManager.init();
if (!initialized) return; if (!initialized) return;
// Einmal pro Session den eigenen Client-Status melden (Admin-Übersicht).
// Fire-and-forget, blockiert den Sync nicht.
if (!clientStatusReported) {
clientStatusReported = true;
syncManager.reportClientStatus()
.then((r) => { if (r?.status) console.log('[Sync] Client-Status gemeldet:', r.status.email, 'v' + r.status.pluginVersion); })
.catch((e) => console.error('[Sync] Client-Status fehlgeschlagen:', e));
}
syncInProgress = true; syncInProgress = true;
// Tag sync every 60s (schlagwoerter.json is not in SHA-checked folders) // Tag sync every 60s (schlagwoerter.json is not in SHA-checked folders)

View File

@@ -1,7 +1,7 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "HPS Vorlagen & Signaturen", "name": "HPS Vorlagen & Signaturen",
"version": "2.3.3", "version": "2.6.0",
"description": "Vorlagen- und Signaturverwaltung für Hotel Park Soltau mit Git-Sync", "description": "Vorlagen- und Signaturverwaltung für Hotel Park Soltau mit Git-Sync",
"browser_specific_settings": { "browser_specific_settings": {
"gecko": { "gecko": {
@@ -29,11 +29,22 @@
], ],
"background": { "background": {
"scripts": [ "scripts": [
"lib/forced-prefs-list.js",
"lib/gitea-sync.js", "lib/gitea-sync.js",
"background.js" "background.js"
], ],
"persistent": true "persistent": true
}, },
"experiment_apis": {
"forcedPrefs": {
"schema": "experiments/prefs/schema.json",
"parent": {
"scopes": ["addon_parent"],
"paths": [["forcedPrefs"]],
"script": "experiments/prefs/api.js"
}
}
},
"browser_action": { "browser_action": {
"default_icon": { "default_icon": {
"16": "icons/icon.png", "16": "icons/icon.png",

Binary file not shown.

View File

@@ -1049,6 +1049,13 @@
</div> </div>
</div> </div>
<!-- Erzwungene Einstellungen -->
<div class="card">
<div class="card-title">Erzwungene Einstellungen</div>
<div class="card-desc">Diese Thunderbird-Einstellungen werden bei jedem Start automatisch gesetzt (sie „springen" sonst zurück). Zum Abschalten den jeweiligen Haken entfernen — der bisherige Wert wird dabei <strong>nicht</strong> wiederhergestellt, es wird nur nicht mehr erzwungen.</div>
<div id="forced-prefs-list"></div>
</div>
<div id="sync-log"></div> <div id="sync-log"></div>
</div> </div>
@@ -1088,6 +1095,8 @@
</div> </div>
</div> </div>
<!-- geteilte Pref-Liste muss VOR templates_options.js geladen werden -->
<script src="../lib/forced-prefs-list.js"></script>
<script src="templates_options.js"></script> <script src="templates_options.js"></script>
</body> </body>
</html> </html>

View File

@@ -1879,6 +1879,12 @@ function renderErledigtAction(action, index) {
${erledigtFolderOptions} ${erledigtFolderOptions}
</select> </select>
</div> </div>
<div class="form-group" style="flex:0 0 auto;">
${index === 0 ? '<label>Gelesen</label>' : ''}
<label style="display:flex;align-items:center;gap:6px;height:34px;white-space:nowrap;font-size:13px;cursor:pointer;">
<input type="checkbox" class="erledigt-markread" ${action.markRead !== false ? 'checked' : ''}> als gelesen
</label>
</div>
<button type="button" class="btn btn-danger btn-sm erledigt-remove" title="Entfernen" style="margin-bottom:2px;">✕</button> <button type="button" class="btn btn-danger btn-sm erledigt-remove" title="Entfernen" style="margin-bottom:2px;">✕</button>
`; `;
if (action.targetFolder) { if (action.targetFolder) {
@@ -1900,10 +1906,11 @@ function getErledigtActionsFromForm() {
const actions = []; const actions = [];
const names = document.querySelectorAll('.erledigt-name'); const names = document.querySelectorAll('.erledigt-name');
const folders = document.querySelectorAll('.erledigt-folder'); const folders = document.querySelectorAll('.erledigt-folder');
const reads = document.querySelectorAll('.erledigt-markread');
for (let i = 0; i < names.length; i++) { for (let i = 0; i < names.length; i++) {
const name = names[i].value.trim(); const name = names[i].value.trim();
if (!name) continue; if (!name) continue;
actions.push({ name, targetFolder: folders[i].value || '' }); actions.push({ name, targetFolder: folders[i].value || '', markRead: reads[i] ? reads[i].checked : true });
} }
return actions; return actions;
} }
@@ -1971,3 +1978,51 @@ window.addEventListener('load', async () => {
} }
}); });
}); });
// ── Opt-out-Schalter für erzwungene Prefs ──
// FORCED_PREFS / FORCED_IDENTITY_PREFS stammen aus ../lib/forced-prefs-list.js
// (im HTML vor diesem Script geladen). Gespeichert werden die ids der
// DEAKTIVIERTEN Prefs unter 'forced_prefs_optout'; Haken = wird erzwungen.
const FORCED_PREFS_OPTOUT_KEY = 'forced_prefs_optout';
async function renderForcedPrefsToggles() {
const container = document.getElementById('forced-prefs-list');
if (!container || typeof FORCED_PREFS === 'undefined') return;
const stored = await browser.storage.local.get(FORCED_PREFS_OPTOUT_KEY);
const disabled = new Set(stored[FORCED_PREFS_OPTOUT_KEY] || []);
container.textContent = '';
const entries = [...FORCED_PREFS, ...(typeof FORCED_IDENTITY_PREFS !== 'undefined' ? FORCED_IDENTITY_PREFS : [])];
for (const p of entries) {
const row = document.createElement('label');
row.className = 'forced-pref-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '8px';
row.style.padding = '4px 0';
row.style.cursor = 'pointer';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !disabled.has(p.id); // Haken = wird erzwungen
cb.addEventListener('change', () => setForcedPrefEnabled(p.id, cb.checked));
const span = document.createElement('span');
span.textContent = p.label;
row.append(cb, span);
container.appendChild(row);
}
}
async function setForcedPrefEnabled(id, enabled) {
const stored = await browser.storage.local.get(FORCED_PREFS_OPTOUT_KEY);
const disabled = new Set(stored[FORCED_PREFS_OPTOUT_KEY] || []);
if (enabled) disabled.delete(id);
else disabled.add(id);
await browser.storage.local.set({ [FORCED_PREFS_OPTOUT_KEY]: [...disabled] });
// background.js lauscht auf storage.onChanged und wendet Aktivierungen sofort an.
}
document.addEventListener('DOMContentLoaded', renderForcedPrefsToggles);

View File

@@ -2,6 +2,56 @@
"addons": { "addons": {
"it@hotel-park-soltau.de": { "it@hotel-park-soltau.de": {
"updates": [ "updates": [
{
"version": "2.6.0",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.6.0/templates-reply-hotel.xpi",
"update_hash": "sha256:563e65f7686db7be30712fbc2be934f51c9ce8f4d35bb37f07046d8da40b562d",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.5.0",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.5.0/templates-reply-hotel.xpi",
"update_hash": "sha256:97f9006a88d565e6ec524841687b851c4f2f8f02b313b2effd5b05b68e141599",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.3.6",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.6/templates-reply-hotel.xpi",
"update_hash": "sha256:1fe5f707ae5766a58337b4270a7739f5227ac10cf4973258f4130e33fc46d1e5",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.3.5",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.5/templates-reply-hotel.xpi",
"update_hash": "sha256:41335a1b572ee38fe659d716ab7b89754fa997ec532e626930f8e470fac31281",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{
"version": "2.3.4",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.4/templates-reply-hotel.xpi",
"update_hash": "sha256:1825f81c309c7d2be4bba048b1e41d0758bb145823b765c0dbb8add75cd4ad2e",
"applications": {
"gecko": {
"strict_min_version": "109.0"
}
}
},
{ {
"version": "2.3.3", "version": "2.3.3",
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.3/templates-reply-hotel.xpi", "update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.3/templates-reply-hotel.xpi",

View File

@@ -31,6 +31,7 @@
'link2': '<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>', 'link2': '<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
'chevron': '<path d="m6 9 6 6 6-6"/>', 'chevron': '<path d="m6 9 6 6 6-6"/>',
'plug': '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>', 'plug': '<path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/>',
'users': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
}; };
function icon(name, size) { function icon(name, size) {
const s = size || 18; const s = size || 18;
@@ -213,7 +214,7 @@
const isAdmin = cat === 'admin'; const isAdmin = cat === 'admin';
el.btnListAdd.style.display = isAdmin ? 'none' : ''; el.btnListAdd.style.display = isAdmin ? 'none' : '';
el.treeSearch.parentElement.style.display = isAdmin ? 'none' : ''; el.treeSearch.parentElement.style.display = isAdmin ? 'none' : '';
if (cat === 'templates') el.btnListAddLabel.textContent = 'Abteilung'; if (cat === 'templates') el.btnListAddLabel.textContent = 'Vorlage';
else if (cat === 'footers') el.btnListAddLabel.textContent = 'Fußzeile'; else if (cat === 'footers') el.btnListAddLabel.textContent = 'Fußzeile';
else if (cat === 'headers') el.btnListAddLabel.textContent = 'Signatur'; else if (cat === 'headers') el.btnListAddLabel.textContent = 'Signatur';
renderList(); renderList();
@@ -309,6 +310,7 @@
wrap.appendChild(adminNavItem('departments', 'Abteilungen', 'building')); wrap.appendChild(adminNavItem('departments', 'Abteilungen', 'building'));
wrap.appendChild(adminNavItem('mapping', 'E-Mail-Zuordnung', 'at-sign')); wrap.appendChild(adminNavItem('mapping', 'E-Mail-Zuordnung', 'at-sign'));
wrap.appendChild(adminNavItem('tags', 'Schlagwörter', 'tag')); wrap.appendChild(adminNavItem('tags', 'Schlagwörter', 'tag'));
wrap.appendChild(adminNavItem('clients', 'User & Versionen', 'users'));
c.appendChild(wrap); c.appendChild(wrap);
return; return;
} }
@@ -528,6 +530,21 @@
const path = folder + '/' + slug + '.html'; if (existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; } const path = folder + '/' + slug + '.html'; if (existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; }
openNewFile(path, slug, 'template'); openNewFile(path, slug, 'template');
} }
// Vorlage anlegen mit Ordner-Auswahl (Haupt-„+"-Button). Das kleine „+" am
// Gruppenkopf nutzt weiterhin newTemplate(folder) direkt.
async function newTemplatePrompt() {
const t = state.tree || {};
const options = [{ value: SHARED_FOLDER, label: 'Alle Abteilungen' }]
.concat((t.departments || []).map((d) => ({ value: d, label: d })));
const res = await promptModal('Neue Vorlage', [
{ key: 'folder', label: 'Für welche Abteilung?', type: 'select', options, required: true },
{ key: 'name', label: 'Vorlagenname', placeholder: 'z. B. Angebot Doppelzimmer', required: true, live: true },
], (values, inputs, root) => { const slug = slugifyName(values.name || ''); const h = root.querySelector('[data-live="name"]'); if (h) h.innerHTML = slug ? 'Datei: <span class="preview-name">' + esc(slug) + '.html</span>' : 'Bitte einen Namen eingeben.'; });
if (!res) return; const slug = slugifyName(res.name); if (!slug) { toast('Ungültiger Name.', 'error'); return; }
const folder = res.folder || SHARED_FOLDER; const path = folder + '/' + slug + '.html';
if (existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; }
openNewFile(path, slug, 'template');
}
async function newFooter() { async function newFooter() {
const t = state.tree || {}; const t = state.tree || {};
const options = [{ value: '_default', label: 'Gemeinsam (alle Abteilungen)' }].concat((t.departments || []).map((d) => ({ value: d, label: d }))); const options = [{ value: '_default', label: 'Gemeinsam (alle Abteilungen)' }].concat((t.departments || []).map((d) => ({ value: d, label: d })));
@@ -554,7 +571,7 @@
try { const r = await api('/api/departments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + (r.name || name) + '“ angelegt.', 'success'); await loadTree(); if (state.category === 'admin') setAdminView('departments'); } try { const r = await api('/api/departments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + (r.name || name) + '“ angelegt.', 'success'); await loadTree(); if (state.category === 'admin') setAdminView('departments'); }
catch (e) { toast('Abteilung anlegen fehlgeschlagen: ' + e.message, 'error'); } catch (e) { toast('Abteilung anlegen fehlgeschlagen: ' + e.message, 'error'); }
} }
function listAddAction() { if (state.category === 'templates') newDepartment(); else if (state.category === 'footers') newFooter(); else if (state.category === 'headers') newHeader(); } function listAddAction() { if (state.category === 'templates') newTemplatePrompt(); else if (state.category === 'footers') newFooter(); else if (state.category === 'headers') newHeader(); }
// ════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════
// Verwaltung (Admin) // Verwaltung (Admin)
@@ -568,6 +585,56 @@
else if (view === 'departments') renderDepartments(); else if (view === 'departments') renderDepartments();
else if (view === 'mapping') renderMapping(); else if (view === 'mapping') renderMapping();
else if (view === 'tags') renderTags(); else if (view === 'tags') renderTags();
else if (view === 'clients') renderClients();
}
// Semver-ähnlicher Vergleich: -1 / 0 / 1 (a<b / a==b / a>b).
function cmpVersion(a, b) {
const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0);
const pb = String(b || '').split('.').map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d) return d < 0 ? -1 : 1;
}
return 0;
}
async function renderClients() {
const sub = 'Welche Plugin- und Thunderbird-Version läuft bei wem. Clients melden sich beim Start (Datei _clients/<email>.json).';
el.adminPanel.innerHTML = adminHeader('User & Versionen', sub) + '<div class="tree-empty">Lädt…</div>';
let clients = [];
try { const r = await api('/api/clients'); clients = r.clients || []; }
catch (e) { toast('Clients nicht ladbar: ' + e.message, 'error'); }
if (!clients.length) {
el.adminPanel.innerHTML = adminHeader('User & Versionen', sub) +
'<div class="tree-empty">Noch keine Client-Meldungen. Sobald Clients mit der neuen Plugin-Version starten, tauchen sie hier auf.</div>';
return;
}
// Neueste gemeldete Plugin-Version → alle anderen sind „veraltet".
const latest = clients.map((c) => c.pluginVersion).filter(Boolean).sort(cmpVersion).slice(-1)[0] || '';
const onLatest = clients.filter((c) => c.pluginVersion === latest).length;
const rows = clients.map((c) => {
const outdated = latest && c.pluginVersion && c.pluginVersion !== latest;
const ver = esc(c.pluginVersion || '—') + (outdated ? ' <span class="cl-badge cl-old">veraltet</span>' : '');
return '<tr' + (outdated ? ' class="cl-row-old"' : '') + '>' +
'<td>' + esc(c.name || '—') + '</td>' +
'<td class="cl-mail">' + esc(c.email) + '</td>' +
'<td>' + esc(c.department || '—') + '</td>' +
'<td>' + ver + '</td>' +
'<td>' + esc(c.tbVersion || '—') + '</td>' +
'</tr>';
}).join('');
const summary = clients.length + ' Client' + (clients.length === 1 ? '' : 's') +
' · ' + onLatest + ' auf neuester Version' + (latest ? ' (' + esc(latest) + ')' : '');
el.adminPanel.innerHTML = adminHeader('User & Versionen', summary) +
'<div class="cl-tablewrap"><table class="cl-table">' +
'<thead><tr><th>Name</th><th>E-Mail</th><th>Abteilung</th><th>Plugin</th><th>Thunderbird</th></tr></thead>' +
'<tbody>' + rows + '</tbody></table></div>';
} }
function adminHeader(title, subtitle) { function adminHeader(title, subtitle) {
return '<div class="admin-head"><h2>' + esc(title) + '</h2>' + (subtitle ? '<p>' + esc(subtitle) + '</p>' : '') + '</div>'; return '<div class="admin-head"><h2>' + esc(title) + '</h2>' + (subtitle ? '<p>' + esc(subtitle) + '</p>' : '') + '</div>';

View File

@@ -279,6 +279,19 @@ button { font-family: inherit; }
.list-body::-webkit-scrollbar, .html-editor::-webkit-scrollbar, .editorpane::-webkit-scrollbar { width: 10px; } .list-body::-webkit-scrollbar, .html-editor::-webkit-scrollbar, .editorpane::-webkit-scrollbar { width: 10px; }
.list-body::-webkit-scrollbar-thumb, .html-editor::-webkit-scrollbar-thumb, .editorpane::-webkit-scrollbar-thumb { background: #cdd6c9; border-radius: 10px; border: 2px solid transparent; background-clip: content-box; } .list-body::-webkit-scrollbar-thumb, .html-editor::-webkit-scrollbar-thumb, .editorpane::-webkit-scrollbar-thumb { background: #cdd6c9; border-radius: 10px; border: 2px solid transparent; background-clip: content-box; }
/* ── User & Versionen (Clients) ── */
.cl-tablewrap { margin-top: 6px; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--panel); }
.cl-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.cl-table th, .cl-table td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.cl-table thead th { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; background: var(--bg); }
.cl-table tbody tr:last-child td { border-bottom: none; }
.cl-table tbody tr:hover { background: var(--bg); }
.cl-table .cl-mail { color: var(--muted); font-family: var(--mono); font-size: 12.5px; }
.cl-row-old td { background: var(--danger-50); }
.cl-row-old:hover td { background: #fbe3e1; }
.cl-badge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; vertical-align: middle; }
.cl-badge.cl-old { background: var(--danger-50); color: var(--danger); border: 1px solid #ecc4c2; }
/* ── Responsive ── */ /* ── Responsive ── */
@media (max-width: 980px) { .workspace { grid-template-columns: 250px 1fr; } .stat-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 980px) { .workspace { grid-template-columns: 250px 1fr; } .stat-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 760px) { @media (max-width: 760px) {

View File

@@ -23,6 +23,7 @@ const {
const SHARED_FOLDER = '_gemeinsam'; const SHARED_FOLDER = '_gemeinsam';
const USER_FOLDER = '_benutzer'; const USER_FOLDER = '_benutzer';
const CONFIG_FOLDER = '_config'; const CONFIG_FOLDER = '_config';
const CLIENTS_FOLDER = '_clients';
const SIG_FOOTERS = 'signatures/footers'; const SIG_FOOTERS = 'signatures/footers';
const SIG_HEADERS = 'signatures/headers'; const SIG_HEADERS = 'signatures/headers';
@@ -421,7 +422,7 @@ app.get('/api/departments', wrap(async (_req, res) => {
const entries = await listDir(''); const entries = await listDir('');
const departments = entries const departments = entries
.filter(e => e.type === 'dir' .filter(e => e.type === 'dir'
&& ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(e.name) && ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures'].includes(e.name)
&& !e.name.startsWith('.')) && !e.name.startsWith('.'))
.map(e => e.name) .map(e => e.name)
.sort((a, b) => a.localeCompare(b, 'de')); .sort((a, b) => a.localeCompare(b, 'de'));
@@ -450,7 +451,7 @@ app.get('/api/files', wrap(async (req, res) => {
// Full inventory in ONE request (recursive tree), strukturiert aus den Pfaden. // Full inventory in ONE request (recursive tree), strukturiert aus den Pfaden.
app.get('/api/tree', wrap(async (_req, res) => { app.get('/api/tree', wrap(async (_req, res) => {
const files = await listAllFiles(); const files = await listAllFiles();
const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures']; const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures'];
const dirOf = (p) => { const i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); }; const dirOf = (p) => { const i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); };
const baseOf = (p) => p.slice(p.lastIndexOf('/') + 1); const baseOf = (p) => p.slice(p.lastIndexOf('/') + 1);
@@ -511,6 +512,31 @@ app.delete('/api/file', wrap(async (req, res) => {
res.json({ success: true }); res.json({ success: true });
})); }));
// Client-Status-Übersicht: liest alle _clients/<email>.json und liefert die
// gemeldeten Plugin-/TB-Versionen je User (Basis für den Admin-Tab).
app.get('/api/clients', wrap(async (_req, res) => {
const entries = await listDir(CLIENTS_FOLDER);
const clients = [];
for (const e of entries) {
if (e.type !== 'file' || !e.name.endsWith('.json')) continue;
const data = await getFile(e.path);
if (!data) continue;
try {
const c = JSON.parse(fromBase64(data.content));
clients.push({
name: c.name || '',
email: c.email || e.name.replace(/\.json$/, ''),
department: c.department || '',
pluginVersion: c.pluginVersion || '',
tbVersion: c.tbVersion || '',
lastSeen: c.lastSeen || '',
});
} catch (_) { /* kaputte Statusdatei überspringen */ }
}
clients.sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email, 'de'));
res.json({ clients });
}));
// Read/write the email→department mapping. // Read/write the email→department mapping.
app.get('/api/abteilungen', wrap(async (_req, res) => { app.get('/api/abteilungen', wrap(async (_req, res) => {
const data = await getFile(`${CONFIG_FOLDER}/abteilungen.json`); const data = await getFile(`${CONFIG_FOLDER}/abteilungen.json`);
@@ -548,7 +574,7 @@ app.put('/api/schlagwoerter', wrap(async (req, res) => {
app.delete('/api/departments', wrap(async (req, res) => { app.delete('/api/departments', wrap(async (req, res) => {
const name = (req.body?.name || '').trim(); const name = (req.body?.name || '').trim();
if (!name) return res.status(400).json({ error: 'Kein Name angegeben' }); if (!name) return res.status(400).json({ error: 'Kein Name angegeben' });
if ([SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(name) || name.includes('/') || name.includes('..')) { if ([SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures'].includes(name) || name.includes('/') || name.includes('..')) {
return res.status(400).json({ error: 'Geschützter oder ungültiger Ordner' }); return res.status(400).json({ error: 'Geschützter oder ungültiger Ordner' });
} }
const entries = await listDir(name); const entries = await listDir(name);