Compare commits
7 Commits
v2.3.6
...
8057870f8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8057870f8b | ||
|
|
2b6e2267c2 | ||
|
|
169fc68f18 | ||
|
|
adddef75ac | ||
|
|
02776311c3 | ||
|
|
d244273e44 | ||
|
|
83d0d52a05 |
108
README.md
108
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,55 @@ 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 ──
|
// ── Signatur in neuen Compose-Fenstern automatisch aktualisieren ──
|
||||||
//
|
//
|
||||||
// Thunderbird merkt sich die Signatur einer Identität pro Sitzung. Öffnet der
|
// Thunderbird merkt sich die Signatur einer Identität pro Sitzung. Öffnet der
|
||||||
@@ -227,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
61
experiments/prefs/api.js
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
29
experiments/prefs/schema.json
Normal file
29
experiments/prefs/schema.json
Normal 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
52
lib/forced-prefs-list.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -968,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';
|
||||||
|
|
||||||
@@ -1009,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)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "HPS Vorlagen & Signaturen",
|
"name": "HPS Vorlagen & Signaturen",
|
||||||
"version": "2.3.6",
|
"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.
@@ -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>
|
||||||
|
|||||||
@@ -1978,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);
|
||||||
|
|||||||
30
updates.json
30
updates.json
@@ -2,6 +2,36 @@
|
|||||||
"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",
|
"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_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.5/templates-reply-hotel.xpi",
|
||||||
|
|||||||
@@ -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>';
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user