Compare commits

...

10 Commits

Author SHA1 Message Date
Kendrick Bollens
ee24caf8b7 Fix: Signaturen-Referenzen werden bei Speichern + Sync aktualisiert
- Beim Speichern von E-Mail 1 werden alle =email1 Identitäten mit-aktualisiert
- attachSignature: false auch im Second Pass (=other@ Referenzen) gesetzt
2026-05-08 17:09:01 +02:00
Kendrick Bollens
7a7815feca VERIFY.md, attachSignature-Fix, Sync-Hashes, Scope-Bug
- VERIFY.md: Regressions-Checkliste mit 30 Prüfpunkten
- Fix: attachSignature: false beim Signatur-Update (deaktiviert alte Datei-Signatur)
- Fix: Sig-Hashes werden beim Init + Background-Sync geschrieben (kein grau mehr)
- Fix: Scope-Badge Vergleich über normalisierte Scopes (folderToScope)
- Fix: storage.onChanged refreshed auch Signaturen-Indicator
2026-05-07 20:06:26 +02:00
Kendrick Bollens
bc82e33bf2 Smart-Sync, Scope-Fix, Auto-Refresh, defaults.local.json
- Background-Sync: SHA-Check alle 5s, voller Pull nur bei Änderung
- Sync-Hashes werden nach Pull im Storage geschrieben → grüne Dots
- UI refreshed automatisch bei Background-Sync (storage.onChanged)
- Scope-Badge-Bug gefixt (folderToScope normalisiert den Vergleich)
- defaults.local.json: optionale vorkonfigurierte Verbindungsdaten
- README: Doku für defaults.local.json und XPI-Build mit/ohne Defaults
2026-05-07 10:50:36 +02:00
Kendrick Bollens
533d5a34f2 Sync-Fix, Dateinamen mit Leerzeichen, E-Mail-Dropdown, Defaults-System
- Fix: Pull überschreibt lokale Vorlagen nicht mehr (Merge statt Replace)
- Fix: toFilename behält Leerzeichen und Groß-/Kleinschreibung
- E-Mail in Settings ist jetzt ein Dropdown mit TB-Identitäten
- Optionale defaults.local.js für vorkonfigurierte Verbindungsdaten (.gitignored)
2026-05-07 10:22:42 +02:00
Kendrick Bollens
1d00a06e30 Fix: Signaturen-Tab lädt erste Identität automatisch
- "Bitte wählen" Placeholder entfernt
- Erste Identität wird beim Laden direkt ausgewählt
- Signatur wird sofort in den Editor geladen (kein manuelles Umschalten nötig)
2026-05-07 02:22:55 +02:00
Kendrick Bollens
864be54646 v2.2.0: 3-Stufen-Sichtbarkeit, UX-Verbesserungen, Auto-Erkennung
- 3 Sichtbarkeitsstufen für Vorlagen: Persönlich / Abteilung / Alle
- Persönliche Vorlagen werden in _benutzer/{email}/ synchronisiert
- Sichtbarkeit direkt in der Liste per Dropdown änderbar
- Warnung beim Verringern der Sichtbarkeit (Server-Löschung)
- Auto-Erkennung von Abteilung + E-Mail via _config/abteilungen.json
- Toast-Benachrichtigungen statt unsichtbare Status-Badges
- Lade-Spinner bei Sync-Operationen
- Sync-Dots mit Symbolen (nicht nur Farbe) für Barrierefreiheit
- Custom Delete-Modal statt browser confirm()
- Collapsible-Sections visuell als klickbar erkennbar
- Token-Feld mit Show/Hide-Toggle
- Inline-Validierung für Template-Namen
- Checkbox-Klickflächen vergrößert + Label-Klick
- Offline-Erkennung mit Banner
- Font-Dropdown Viewport-Fix
- Popup: Prefix-Dropdown verständlicher
- Signaturen: erste Identität automatisch ausgewählt
- README komplett neu geschrieben
2026-05-06 22:04:46 +02:00
Kendrick Bollens
33eb87613e Umbenennung: HPS Vorlagen & Signaturen v2.0.0
- Name: Templates Reply → HPS Vorlagen & Signaturen
- ID: stmt@proton.meit@hotel-park-soltau.de
- Version: 1.0.3 → 2.0.0
2026-04-20 22:54:07 +02:00
Kendrick Bollens
78a6310424 UX-Überarbeitung, Signatur-Bausteine, QoL-Verbesserungen
- Neues Layout: Inline-Editor, aufklappbarer Import, ⚙-Tab
- Signatur Header/Footer Baustein-System (Footer pro Abteilung)
- Signatur-Quelle Dropdown (Eigene / = andere@)
- "Vorlage laden" mit Platzhalter-Ersetzung (Name, Email, Abteilung, Tel, Fax)
- "Signatur speichern" pusht automatisch zum Server
- Footer-Editor mit auto-load beim Aufklappen
- Abteilungswechsel synct Footer + Templates neu
- "Aktualisieren" Button = Pull + Push in einem Schritt
- Vorlagen: Checkbox "Für alle Abteilungen"
- Löschen vom Server für alle möglich
- Toolbar für Signaturen gleichwertig mit Vorlagen-Toolbar
- Base64 whitespace-Fix für Gitea API
- Offline-resilient (Cache-Fallback, graceful error handling)
2026-04-20 22:46:54 +02:00
Kendrick Bollens
f87258498d Aufräumen: Quill.js und doppelte XPI entfernt
Quill wird nicht mehr verwendet (ersetzt durch contenteditable).
2026-04-20 16:31:51 +02:00
Kendrick Bollens
cf051458bb Feature: WYSIWYG-Editor, Gitea-Sync, Signaturen-Verwaltung
- WYSIWYG-Editor mit contenteditable statt Textarea (MDI-Icons, System-Fonts, Farbwähler)
- Gitea-Sync: Templates per Abteilung aus Git-Repo laden/hochladen mit Commit-Author
- Abteilungsordner + _gemeinsam Ordner, einzelnes Pull/Push pro Vorlage
- Sync-Status pro Vorlage (grün/rot/grau Ampel), persistent über Neustarts
- Signaturen-Tab: Identitäten bearbeiten, aus Datei laden, Sync über signatures/ Ordner
- Persönliche Signaturen für geteilte E-Mail-Adressen (pro Mitarbeiter)
- Tab-Navigation: Vorlagen, Signaturen, Synchronisierung
- Auto-Pull beim Thunderbird-Start (Templates + Signaturen)
2026-04-20 16:30:40 +02:00
13 changed files with 3730 additions and 377 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
defaults.local.json

151
README.md
View File

@@ -1,38 +1,113 @@
# Templates Reply
## Mozilla Thunderbird Add-On
Templates Reply is a completely free and open-source Thunderbird extension that helps you create, manage, and reuse message templates directly from your compose window.
It is designed for speed, simplicity, and convenience — no sign-ups, no limits, and absolutely no data collection.
This add-on does not track, store, or transmit any personal information.
All templates and settings are saved locally on your device and never leave your computer.
No analytics, telemetry, or remote servers are involved — your privacy is 100% respected.
You can freely use, modify, and distribute the source code under the terms of the MIT License.
## Screenshots
![App Screenshot](https://addons.thunderbird.net/user-media/previews/full/207/207836.png?modified=1762427184)
![App Screenshot](https://addons.thunderbird.net/user-media/previews/full/207/207837.png?modified=1762427184)
![App Screenshot](https://addons.thunderbird.net/user-media/previews/full/207/207838.png?modified=1762427184)
## Install Locally (Temporary / Developer Mode)
```
Open Thunderbird
Go to Tools → Add-ons and Themes
Click the ⚙️ (gear icon) in the top-right corner
Select “Debug Add-ons”
Click “Load Temporary Add-on…”
Choose the manifest.json file from your add-on directory
⚠️ Note: This installation is temporary and will be removed when Thunderbird is restarted.
```
## OR via Thunderbird Add-on Manager
```
Open Thunderbird
Go toTools → Add-ons and Themes
Search for the Templates Reply add-on by name
Click Add to Thunderbird
Confirm installation
```
# 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.
## Features
- **E-Mail-Vorlagen** erstellen, bearbeiten und per Klick in Compose-Fenster einfügen
- **3 Sichtbarkeitsstufen** pro Vorlage:
- **Persönlich** — nur für den eigenen Account, gesynct in `_benutzer/{email}/`
- **Abteilung** — für alle in der Abteilung, gesynct in den Abteilungsordner
- **Alle Abteilungen** — firmenweit, gesynct in `_gemeinsam/`
- **Signaturen-Verwaltung** mit persönlichem Kopfbereich + gemeinsamer Fußzeile pro Abteilung
- **Git-Sync** über Gitea/Forgejo API (Pull + Push, automatisch alle 15 Min.)
- **Auto-Erkennung** von Abteilung und Benutzer via `_config/abteilungen.json`
- **WYSIWYG-Editor** mit Schriftart, Farben, Listen, Bildern, Links
- **Sichtbarkeit direkt änderbar** per klickbarem Badge in der Vorlagenliste
## Repository-Struktur (Gitea)
```
repo/
├── _gemeinsam/ # Vorlagen für alle Abteilungen
│ └── beispiel-vorlage.html
├── _benutzer/ # Persönliche Vorlagen pro User
│ ├── max@hotel-park-soltau.de/
│ └── anna@hotel-park-soltau.de/
├── _config/
│ └── abteilungen.json # E-Mail → Abteilung Mapping
├── Rezeption/ # Abteilungsvorlagen
├── IT/
├── signatures/
│ ├── headers/ # Persönliche Signatur-Köpfe
│ │ └── max@hotel.de.max-mustermann.html
│ └── footers/ # Gemeinsame Fußbereiche
│ └── Rezeption.html
```
### `_config/abteilungen.json`
Mapping von Abteilungs-E-Mail-Adressen zu Ordnernamen. Wird vom Plugin gelesen, um Abteilung und persönliche E-Mail automatisch zu erkennen:
```json
{
"info@hotel-park-soltau.de": "Rezeption",
"veranstaltungs@hotel-park-soltau.de": "Veranstaltungsbuero",
"it@hotel-park-soltau.de": "IT",
"haustechnik@hotel-park-soltau.de": "Haustechnik"
}
```
## Plugin-Aufbau
| Datei | Funktion |
|---|---|
| `manifest.json` | Extension-Manifest (Thunderbird WebExtension v2) |
| `background.js` | Template-Insertion ins Compose-Fenster |
| `popup.html` / `popup.js` | Popup beim Klick auf "Vorlagen" im Compose |
| `lib/gitea-sync.js` | Gitea-API-Client + Sync-Manager |
| `lib/mdi/` | Material Design Icons (Subset) |
| `templates_options/` | Einstellungsseite (Vorlagen, Signaturen, Verbindung) |
| `defaults.local.json` | Optionale vorkonfigurierte Verbindungsdaten (gitignored) |
## Installation
### Lokal (Entwicklung)
1. Thunderbird öffnen
2. Extras → Add-ons und Themes
3. Zahnrad-Icon → Add-on aus Datei installieren
4. `templates-reply-hotel.xpi` auswählen
### XPI bauen
```bash
# Ohne vorkonfigurierte Verbindungsdaten:
7z a templates-reply-hotel.xpi manifest.json background.js popup.html popup.js lib/ templates_options/ icons/
# Mit vorkonfigurierten Verbindungsdaten (für Deployment):
7z a templates-reply-hotel.xpi manifest.json background.js popup.html popup.js lib/ templates_options/ icons/ 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.
```json
{
"baseUrl": "https://git.example.com",
"owner": "organisation",
"repo": "email-vorlagen",
"branch": "main",
"token": "dein-api-token"
}
```
Die Datei ist in `.gitignore` — Tokens landen nicht im Repository.
## Einrichtung
1. **Verbindung konfigurieren**: Einstellungen-Tab (⚙) → Server-URL, Repository, Token eingeben → Verbindung speichern (entfällt bei vorkonfigurierter XPI)
2. **Abteilung wählen** (oder automatisch erkannt via `abteilungen.json`)
3. **Vorlagen erstellen**: Vorlagen-Tab → Neue Vorlage → Sichtbarkeit wählen → Speichern
4. **Signaturen einrichten**: Signaturen-Tab → Identität wählen → Kopfbereich bearbeiten → Speichern
## Voraussetzungen
- Mozilla Thunderbird >= 109.0
- Gitea/Forgejo-Server mit API-Zugang
- API-Token mit Repository-Schreibrechten
## Lizenz
MIT License

48
VERIFY.md Normal file
View File

@@ -0,0 +1,48 @@
# Verifizierungs-Checkliste
Nach Thunderbird-Updates, Plugin-Änderungen oder Deployments diese Punkte prüfen.
## Vorlagen — Sync & Anzeige
- [ ] Pull überschreibt lokale Vorlagen nicht (Merge statt Replace) — importierte Vorlagen dürfen nach Sync nicht verschwinden
- [ ] Vorlagen mit Leerzeichen im Titel werden korrekt erstellt, gesynct und angezeigt (kein Duplikat)
- [ ] Scope-Badge (Persönlich/Abteilung/Alle) zeigt den richtigen Wert — Abteilungsvorlagen dürfen nicht als "Privat" erscheinen
- [ ] Scope-Badge umschalten funktioniert (Warnung bei Downgrade, altes File wird gelöscht, neues wird gepusht)
- [ ] Sync-Dots sind grün nach Pull (nicht grau/unknown) — Hashes müssen korrekt geschrieben werden
- [ ] Background SHA-Check alle 5s läuft, voller Pull nur bei Änderung
- [ ] UI refreshed automatisch wenn Background-Sync neue Templates pullt
## Signaturen
- [ ] Signaturen-Tab lädt beim Öffnen automatisch die erste Identität + deren Signatur
- [ ] Kein "Bitte wählen" Placeholder im Identity-Dropdown
- [ ] Signatur-Sync-Status ist grün beim Öffnen (nicht grau)
- [ ] `attachSignature: false` wird gesetzt — alte Datei-basierte Signaturen werden deaktiviert
- [ ] Footer (Banner) lädt korrekt als eingebettete data-URI, nicht als Datei-Referenz
- [ ] Signatur-Quelle "= andere E-Mail" funktioniert (Kopie von anderer Identität)
## Settings / Verbindung
- [ ] E-Mail ist ein Dropdown mit TB-Identitäten (kein Freitext)
- [ ] Token-Feld hat Show/Hide Toggle
- [ ] `defaults.local.json` wird beim ersten Start geladen (wenn in XPI vorhanden)
- [ ] Ohne `defaults.local.json` funktioniert Plugin normal (manuell konfigurieren)
- [ ] Auto-Detection via `_config/abteilungen.json` erkennt Abteilung + E-Mail
- [ ] Abteilungsname wird im Scope-Badge und Editor-Dropdown angezeigt (nicht generisch "Abteilung")
## Popup (Compose-Fenster)
- [ ] Vorlagen-Popup öffnet, zeigt alle Vorlagen
- [ ] Template-Insertion funktioniert (HTML wird korrekt eingefügt)
- [ ] Prefix-Dropdown ("Textbaustein voranstellen") funktioniert
## UX-Elemente
- [ ] Toast-Benachrichtigungen erscheinen bei Sync/Push/Pull/Fehler
- [ ] Spinner bei Sync-Operationen sichtbar
- [ ] Custom Delete-Modal statt browser confirm()
- [ ] Offline-Banner erscheint bei fehlender Verbindung
- [ ] Collapsible-Sections sind visuell klickbar (Hintergrund + Hover)
- [ ] Template-Name Inline-Validierung (leer + Duplikat)
- [ ] Checkbox-Klick auf Template-Name toggelt Checkbox
## API / Gitea
- [ ] `toFilename()` behält Leerzeichen und Groß-/Kleinschreibung
- [ ] `_benutzer/`, `_config/` werden aus Department-Liste gefiltert
- [ ] Persönliche Vorlagen syncen in `_benutzer/{email}/`
- [ ] `checkRemoteShas()` inkludiert persönlichen Ordner

View File

@@ -1,5 +1,12 @@
browser.runtime.onMessage.addListener(async msg => {
if (msg.action !== 'insertTemplate') return;
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action !== 'insertTemplate') return false;
handleInsertTemplate(msg).then(() => sendResponse())
.catch(err => sendResponse({ error: err.message }));
return true; // keep channel open for async response
});
async function handleInsertTemplate(msg) {
try {
const [tab] = await browser.tabs.query({
active: true,
@@ -48,12 +55,6 @@ browser.runtime.onMessage.addListener(async msg => {
await browser.compose.insertText(tab.id, htmlContent + '<br>', { insertAsText: false });
}
await browser.notifications.create({
type: 'basic',
iconUrl: browser.runtime.getURL('icons/icon.png'),
title: 'Vorlage eingefügt',
message: 'Erfolgreich',
});
} catch (e) {
console.error('background.js error:', e);
@@ -75,13 +76,6 @@ browser.runtime.onMessage.addListener(async msg => {
newBody = htmlTpl + '<br>' + old;
}
await browser.compose.setComposeDetails(tab.id, { body: newBody });
await browser.notifications.create({
type: 'basic',
iconUrl: browser.runtime.getURL('icons/icon.png'),
title: 'Vorlage eingefügt',
message: 'Erfolgreich (Fallback)',
});
}
} catch (e2) {
console.error('background.js fallback error:', e2);
@@ -93,4 +87,4 @@ browser.runtime.onMessage.addListener(async msg => {
});
}
}
});
}

843
lib/gitea-sync.js Normal file
View File

@@ -0,0 +1,843 @@
// lib/gitea-sync.js — Gitea Sync Engine
const SYNC_CONFIG_KEY = 'gitea_config';
const SYNC_STATE_KEY = 'sync_state';
const TEMPLATE_STORAGE_KEY = 'message_templates';
const SHA_CHECK_INTERVAL_MS = 5 * 1000; // 5 seconds — lightweight SHA check
const FULL_SYNC_COOLDOWN_MS = 10 * 1000; // min 10s between full pulls
const SHARED_FOLDER = '_gemeinsam';
const USER_FOLDER = '_benutzer';
const CONFIG_FOLDER = '_config';
// ── Gitea API Client ──
class GiteaClient {
constructor(config) {
this.baseUrl = config.baseUrl;
this.owner = config.owner;
this.repo = config.repo;
this.branch = config.branch || 'main';
this.token = config.token;
this.authorName = config.authorName || '';
this.authorEmail = config.authorEmail || '';
}
get apiBase() {
return `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}`;
}
get headers() {
return {
'Authorization': `token ${this.token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
};
}
get authorInfo() {
if (!this.authorName) return {};
return {
author: {
name: this.authorName,
email: this.authorEmail || `${this.authorName.toLowerCase().replace(/\s+/g, '.')}@local`
}
};
}
static toBase64(str) {
return btoa(unescape(encodeURIComponent(str)));
}
static fromBase64(b64) {
if (!b64) return '';
return decodeURIComponent(escape(atob(b64.replace(/\s/g, ''))));
}
encodePath(p) {
return p.split('/').map(encodeURIComponent).join('/');
}
async apiError(method, filepath, res) {
let detail = '';
try { const body = await res.json(); detail = body.message || JSON.stringify(body); } catch(_) {}
throw new Error(`${method} ${filepath}: ${res.status} ${res.statusText}${detail ? ' — ' + detail : ''}`);
}
async getFile(filepath) {
const url = `${this.apiBase}/contents/${this.encodePath(filepath)}?ref=${this.branch}`;
const res = await fetch(url, { headers: this.headers });
if (res.status === 404) return null;
if (!res.ok) await this.apiError('GET', filepath, res);
return res.json();
}
async createFile(filepath, content, message) {
const url = `${this.apiBase}/contents/${this.encodePath(filepath)}`;
const res = await fetch(url, {
method: 'POST',
headers: this.headers,
body: JSON.stringify({
content: GiteaClient.toBase64(content),
message: message || `Add ${filepath}`,
branch: this.branch,
...this.authorInfo
})
});
if (!res.ok) await this.apiError('POST', filepath, res);
return res.json();
}
async updateFile(filepath, content, sha, message) {
const url = `${this.apiBase}/contents/${this.encodePath(filepath)}`;
const res = await fetch(url, {
method: 'PUT',
headers: this.headers,
body: JSON.stringify({
content: GiteaClient.toBase64(content),
sha,
message: message || `Update ${filepath}`,
branch: this.branch,
...this.authorInfo
})
});
if (!res.ok) await this.apiError('PUT', filepath, res);
return res.json();
}
async deleteFile(filepath, sha, message) {
const url = `${this.apiBase}/contents/${this.encodePath(filepath)}`;
const res = await fetch(url, {
method: 'DELETE',
headers: this.headers,
body: JSON.stringify({
sha,
message: message || `Delete ${filepath}`,
branch: this.branch,
...this.authorInfo
})
});
if (!res.ok) await this.apiError('DELETE', filepath, res);
return res.json();
}
async listDir(dirpath) {
const pathPart = dirpath ? `/${this.encodePath(dirpath)}` : '';
const url = `${this.apiBase}/contents${pathPart}?ref=${this.branch}`;
const res = await fetch(url, { headers: this.headers });
if (res.status === 404) return [];
if (!res.ok) await this.apiError('LIST', dirpath, res);
const result = await res.json();
return Array.isArray(result) ? result : [];
}
async testConnection() {
const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}`;
const res = await fetch(url, { headers: this.headers });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json();
}
async getConfig() {
const data = await this.getFile(`${CONFIG_FOLDER}/abteilungen.json`);
if (!data) return null;
try {
return JSON.parse(GiteaClient.fromBase64(data.content));
} catch (_) {
return null;
}
}
}
// ── Sync Manager ──
class SyncManager {
constructor() {
this.client = null;
this.config = null;
}
async init() {
const result = await browser.storage.local.get(SYNC_CONFIG_KEY);
this.config = result[SYNC_CONFIG_KEY];
if (!this.config || !this.config.baseUrl || !this.config.token) {
this.client = null;
return false;
}
this.client = new GiteaClient(this.config);
return true;
}
get isConfigured() {
return this.client !== null;
}
get department() {
return this.config?.department || '';
}
get authorEmail() {
return this.config?.authorEmail || '';
}
async autoDetect() {
if (!this.isConfigured) return null;
return await this.client.getConfig();
}
async getSyncState() {
const result = await browser.storage.local.get(SYNC_STATE_KEY);
return result[SYNC_STATE_KEY] || { fileShas: {} };
}
async saveSyncState(state) {
await browser.storage.local.set({ [SYNC_STATE_KEY]: state });
}
async getLocalTemplates() {
const result = await browser.storage.local.get(TEMPLATE_STORAGE_KEY);
return result[TEMPLATE_STORAGE_KEY] || [];
}
async saveLocalTemplates(templates) {
await browser.storage.local.set({ [TEMPLATE_STORAGE_KEY]: templates });
}
static toFilename(name) {
return name
.replace(/[äÄ]/g, 'ae')
.replace(/[öÖ]/g, 'oe')
.replace(/[üÜ]/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[/\\:*?"<>|]/g, '-')
.replace(/^[\s.-]+|[\s.-]+$/g, '')
.trim();
}
/**
* List all top-level directories in the repo (= available departments)
*/
async listDepartments() {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
const entries = await this.client.listDir('');
const departments = [];
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('.')) {
departments.push(entry.name);
}
}
return { success: true, departments };
}
/**
* Lightweight check: get remote file SHAs without downloading content
*/
async checkRemoteShas() {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
const remoteShas = {}; // "folder/filename" -> sha
const folders = [SHARED_FOLDER];
if (this.department) folders.push(this.department);
if (this.authorEmail) folders.push(`${USER_FOLDER}/${this.authorEmail}`);
for (const folder of folders) {
const files = await this.client.listDir(folder);
for (const file of files) {
if (file.name.endsWith('.html')) {
remoteShas[`${folder}/${file.name}`] = file.sha;
}
}
}
return { success: true, remoteShas };
}
/**
* Pull templates from repo: department folder + shared folder
*/
async pullTemplates() {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
const syncState = await this.getSyncState();
const newTemplates = [];
const newShas = {};
let updated = 0;
// Load from shared + department + personal folders
const folders = [SHARED_FOLDER];
if (this.department) folders.push(this.department);
if (this.authorEmail) folders.push(`${USER_FOLDER}/${this.authorEmail}`);
for (const folder of folders) {
const files = await this.client.listDir(folder);
for (const file of files) {
if (!file.name.endsWith('.html')) continue;
const fileData = await this.client.getFile(file.path);
if (!fileData) continue;
const content = GiteaClient.fromBase64(fileData.content);
const templateName = file.name.replace('.html', '');
newTemplates.push({
id: `${folder}/${file.name}`,
name: templateName,
content: content,
folder: folder,
remotePath: file.path
});
newShas[file.path] = fileData.sha;
updated++;
}
}
// Merge: keep local-only templates that aren't now on the server
const existingTemplates = await this.getLocalTemplates();
const pulledFilenames = new Set(newTemplates.map(t => SyncManager.toFilename(t.name).toLowerCase()));
const localOnly = existingTemplates.filter(t => !t.remotePath && !pulledFilenames.has(SyncManager.toFilename(t.name).toLowerCase()));
const merged = [...newTemplates, ...localOnly];
await this.saveLocalTemplates(merged);
syncState.fileShas = newShas;
await this.saveSyncState(syncState);
return { success: true, updated };
}
/**
* Push local templates back to repo (explicit, with commit author)
*/
async pushTemplates() {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
if (!this.config.authorName) throw new Error('Bitte Name eintragen (für Commit-Zuordnung)');
const templates = await this.getLocalTemplates();
const syncState = await this.getSyncState();
// Group templates by folder
const byFolder = {};
for (const t of templates) {
let folder;
if (t.folder === SHARED_FOLDER) folder = SHARED_FOLDER;
else if (t.folder?.startsWith(USER_FOLDER + '/')) folder = t.folder;
else folder = t.folder || this.department;
if (!folder) continue; // skip if no department and no explicit folder
if (!byFolder[folder]) byFolder[folder] = [];
byFolder[folder].push(t);
}
let pushed = 0;
// Allowed folders: shared + department + personal
const allowedFolders = [SHARED_FOLDER];
if (this.department) allowedFolders.push(this.department);
if (this.authorEmail) allowedFolders.push(`${USER_FOLDER}/${this.authorEmail}`);
for (const folder of allowedFolders) {
const localInFolder = byFolder[folder] || [];
const remoteFiles = await this.client.listDir(folder);
const remoteByName = {};
for (const rf of remoteFiles) {
if (rf.name.endsWith('.html')) {
remoteByName[rf.name] = rf;
}
}
// Push each local template
for (const template of localInFolder) {
const filename = (SyncManager.toFilename(template.name) || template.id) + '.html';
const filepath = `${folder}/${filename}`;
const commitMsg = `${template.name} - bearbeitet von ${this.config.authorName}`;
const existing = await this.client.getFile(filepath);
if (existing) {
const existingContent = GiteaClient.fromBase64(existing.content);
if (existingContent !== template.content) {
await this.client.updateFile(filepath, template.content, existing.sha, commitMsg);
pushed++;
}
} else {
await this.client.createFile(filepath, template.content, commitMsg);
pushed++;
}
}
// Delete remote files that were removed locally
const localNames = new Set(localInFolder.map(t =>
(SyncManager.toFilename(t.name) || t.id) + '.html'
));
for (const [name, rf] of Object.entries(remoteByName)) {
if (!localNames.has(name)) {
const fileData = await this.client.getFile(rf.path || `${folder}/${name}`);
if (fileData) {
const commitMsg = `${name} gelöscht von ${this.config.authorName}`;
await this.client.deleteFile(fileData.path || `${folder}/${name}`, fileData.sha, commitMsg);
pushed++;
}
}
}
}
// Re-pull to get fresh SHAs
await this.pullTemplates();
return { success: true, pushed };
}
get authorSlug() {
return SyncManager.toFilename(this.config.authorName || '');
}
static get FOOTER_SEPARATOR() {
return '<!-- SIG_FOOTER_START -->';
}
static extractHeader(fullSignature) {
const idx = fullSignature.indexOf(SyncManager.FOOTER_SEPARATOR);
if (idx === -1) return fullSignature;
return fullSignature.substring(0, idx).trim();
}
static combineSignature(header, footer) {
if (!footer) return header;
return header + '\n' + SyncManager.FOOTER_SEPARATOR + '\n' + footer;
}
/**
* Load the signature header template from signatures/headers/_vorlage.html
*/
async loadSignatureTemplate() {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
const fileData = await this.client.getFile('signatures/headers/_vorlage.html');
if (!fileData || !fileData.content) {
throw new Error('Vorlage nicht gefunden unter signatures/headers/_vorlage.html');
}
const html = GiteaClient.fromBase64(fileData.content);
return { success: true, html };
}
/**
* Pull footer for current department.
* Tries signatures/footers/{department}.html first, falls back to signatures/footers/_default.html
*/
async pullFooter() {
let fileData = null;
// Try department-specific footer first
if (this.department) {
fileData = await this.client.getFile(`signatures/footers/${this.department}.html`);
}
// Fall back to default
if (!fileData || !fileData.content) {
fileData = await this.client.getFile('signatures/footers/_default.html');
}
// Legacy fallback: old single-file location
if (!fileData || !fileData.content) {
fileData = await this.client.getFile('signatures/_footer.html');
}
if (!fileData || !fileData.content) return '';
const footer = GiteaClient.fromBase64(fileData.content);
await browser.storage.local.set({ 'sig_footer_cache': footer });
return footer;
}
/**
* Load footer for editing (returns HTML)
*/
async loadFooter() {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
const footer = await this.pullFooter();
return { success: true, html: footer };
}
/**
* Push footer for current department to signatures/footers/{department}.html
*/
async pushFooter(html) {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
if (!this.config.authorName) throw new Error('Bitte Name eintragen');
if (!this.department) throw new Error('Keine Abteilung ausgewählt');
const filepath = `signatures/footers/${this.department}.html`;
const commitMsg = `Signatur-Footer ${this.department} - von ${this.config.authorName}`;
const existing = await this.client.getFile(filepath);
if (existing && existing.content) {
const existingContent = GiteaClient.fromBase64(existing.content);
if (existingContent !== html) {
await this.client.updateFile(filepath, html, existing.sha, commitMsg);
}
} else {
await this.client.createFile(filepath, html, commitMsg);
}
await browser.storage.local.set({ 'sig_footer_cache': html });
return { success: true };
}
/**
* Pull signatures from repo using header/footer baustein model.
* - Loads shared footer from signatures/_footer.html
* - Loads personal headers from signatures/headers/email.authorslug.html
* - Combines header + footer and applies to Thunderbird identity
* - Resolves "=other@" references in second pass
*/
async pullSignatures() {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
// Pull shared footer
const footer = await this.pullFooter();
// List header files
const headerFiles = await this.client.listDir('signatures/headers');
const headerMap = {};
for (const f of headerFiles) {
if (f.name.endsWith('.html') && !f.name.startsWith('_')) {
headerMap[f.name.toLowerCase()] = f;
}
}
// Get source map
const sourceResult = await browser.storage.local.get('sig_source_map');
const sourceMap = sourceResult.sig_source_map || {};
const accounts = await browser.accounts.list();
let updated = 0;
const loadedHeaders = {}; // email → header html
const allIdentityList = [];
// First pass: load headers for "own" identities
for (const account of accounts) {
const identities = await browser.identities.list(account.id);
for (const identity of identities) {
const email = identity.email.toLowerCase();
const source = sourceMap[email] || 'own';
allIdentityList.push({ id: identity.id, email });
if (source.startsWith('=')) continue;
// Try personal header: email.authorslug.html
let targetFile = null;
if (this.authorSlug) {
const personalName = `${email}.${this.authorSlug}.html`;
targetFile = headerMap[personalName] || null;
}
if (!targetFile) continue; // No header file yet — user hasn't set up signature
const fileData = await this.client.getFile(targetFile.path);
if (!fileData) continue;
const header = GiteaClient.fromBase64(fileData.content);
loadedHeaders[email] = header;
const fullSig = SyncManager.combineSignature(header, footer);
await browser.identities.update(identity.id, {
signature: fullSig,
signatureIsPlainText: false,
attachSignature: false
});
updated++;
}
}
// Second pass: resolve "=other@" references
for (const { id, email } of allIdentityList) {
const source = sourceMap[email] || 'own';
if (!source.startsWith('=')) continue;
const srcEmail = source.substring(1).toLowerCase();
const srcHeader = loadedHeaders[srcEmail];
if (srcHeader !== undefined) {
const fullSig = SyncManager.combineSignature(srcHeader, footer);
await browser.identities.update(id, {
signature: fullSig,
signatureIsPlainText: false,
attachSignature: false
});
loadedHeaders[email] = srcHeader;
updated++;
}
}
return { success: true, updated };
}
/**
* Push signature headers to repo.
* Only the header part is pushed as signatures/headers/email.authorslug.html
* Skips "=other@" identities.
*/
async pushSignatures() {
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.authorSlug) throw new Error('Name fehlt für Dateiname');
const sourceResult = await browser.storage.local.get('sig_source_map');
const sourceMap = sourceResult.sig_source_map || {};
const accounts = await browser.accounts.list();
let pushed = 0;
for (const account of accounts) {
const identities = await browser.identities.list(account.id);
for (const identity of identities) {
if (!identity.signature) continue;
const email = identity.email.toLowerCase();
const source = sourceMap[email] || 'own';
if (source.startsWith('=')) continue;
// Extract just the header from the full signature
const header = SyncManager.extractHeader(identity.signature);
if (!header.trim()) continue;
const filename = `${email}.${this.authorSlug}.html`;
const filepath = `signatures/headers/${filename}`;
const commitMsg = `Signatur-Header ${identity.email} - von ${this.config.authorName}`;
const existing = await this.client.getFile(filepath);
if (existing) {
const existingContent = GiteaClient.fromBase64(existing.content);
if (existingContent !== header) {
await this.client.updateFile(filepath, header, existing.sha, commitMsg);
pushed++;
}
} else {
await this.client.createFile(filepath, header, commitMsg);
pushed++;
}
}
}
return { success: true, pushed };
}
/**
* Pull a single template by its remote path
*/
async pullSingleTemplate(remotePath) {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
const fileData = await this.client.getFile(remotePath);
if (!fileData) throw new Error('Datei nicht im Repository gefunden');
const content = GiteaClient.fromBase64(fileData.content);
return { success: true, content };
}
/**
* Push a single template by ID
*/
async pushSingleTemplate(templateId) {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
if (!this.config.authorName) throw new Error('Bitte Name eintragen (für Commit-Zuordnung)');
const templates = await this.getLocalTemplates();
const template = templates.find(t => t.id === templateId);
if (!template) throw new Error('Vorlage nicht gefunden');
const folder = template.folder || this.department;
if (!folder) throw new Error('Keine Abteilung ausgewählt');
const filename = (SyncManager.toFilename(template.name) || template.id) + '.html';
const filepath = `${folder}/${filename}`;
const commitMsg = `${template.name} - bearbeitet von ${this.config.authorName}`;
const existing = await this.client.getFile(filepath);
if (existing) {
const existingContent = GiteaClient.fromBase64(existing.content);
if (existingContent !== template.content) {
await this.client.updateFile(filepath, template.content, existing.sha, commitMsg);
}
} else {
await this.client.createFile(filepath, template.content, commitMsg);
}
return { success: true };
}
/**
* Delete a template from the remote repo by its remote path
*/
async deleteRemoteTemplate(remotePath) {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
if (!this.config.authorName) throw new Error('Bitte Name eintragen (für Commit-Zuordnung)');
const fileData = await this.client.getFile(remotePath);
if (!fileData) throw new Error('Datei nicht im Repository gefunden');
const commitMsg = `${remotePath.split('/').pop()} gelöscht von ${this.config.authorName}`;
await this.client.deleteFile(remotePath, fileData.sha, commitMsg);
return { success: true };
}
async testConnection() {
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
const repoInfo = await this.client.testConnection();
return { success: true, repoName: repoInfo.full_name };
}
}
// ── Global sync manager instance ──
const syncManager = new SyncManager();
// ── Background message handler for sync ──
browser.runtime.onMessage.addListener(async (msg, sender) => {
const syncActions = ['testConnection', 'pullTemplates', 'pushTemplates', 'pullSingleTemplate', 'pushSingleTemplate', 'deleteRemoteTemplate', 'listDepartments', 'checkRemoteShas', 'pullSignatures', 'pushSignatures', 'loadSignatureTemplate', 'loadFooter', 'pushFooter', 'autoDetect'];
if (!syncActions.includes(msg.action)) return;
try {
const initialized = await syncManager.init();
if (!initialized && msg.action !== 'testConnection') {
return { success: false, error: 'Sync nicht konfiguriert. Bitte Verbindung einrichten.' };
}
switch (msg.action) {
case 'testConnection':
// Init with provided config for testing before save
if (!initialized) return { success: false, error: 'Sync nicht konfiguriert.' };
return await syncManager.testConnection();
case 'listDepartments':
return await syncManager.listDepartments();
case 'checkRemoteShas':
return await syncManager.checkRemoteShas();
case 'pullTemplates':
return await syncManager.pullTemplates();
case 'pushTemplates':
return await syncManager.pushTemplates();
case 'pullSingleTemplate':
return await syncManager.pullSingleTemplate(msg.remotePath);
case 'pushSingleTemplate':
return await syncManager.pushSingleTemplate(msg.templateId);
case 'deleteRemoteTemplate':
return await syncManager.deleteRemoteTemplate(msg.remotePath);
case 'pullSignatures':
return await syncManager.pullSignatures();
case 'pushSignatures':
return await syncManager.pushSignatures();
case 'loadSignatureTemplate':
return await syncManager.loadSignatureTemplate();
case 'loadFooter':
return await syncManager.loadFooter();
case 'pushFooter':
return await syncManager.pushFooter(msg.html);
case 'autoDetect':
const config = await syncManager.autoDetect();
return { success: true, config };
default:
return { success: false, error: 'Unbekannte Aktion' };
}
} catch (err) {
console.error('Sync error:', err);
return { success: false, error: err.message };
}
});
// ── Auto-sync on startup (pull only) ──
let lastKnownShas = null;
let lastFullSync = 0;
let syncInProgress = false;
const HASH_STORAGE_KEY_BG = 'sync_hashes';
function simpleHashBg(str) {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
}
return h;
}
async function updateSyncHashes() {
const templates = await syncManager.getLocalTemplates();
const result = await browser.storage.local.get(HASH_STORAGE_KEY_BG);
const data = result[HASH_STORAGE_KEY_BG] || {};
data.tpl = {};
for (const t of templates) {
data.tpl[t.id] = simpleHashBg(t.content || '');
}
// Also update signature hashes from Thunderbird identities
try {
const accounts = await browser.accounts.list();
data.sig = data.sig || {};
for (const account of accounts) {
const identities = await browser.identities.list(account.id);
for (const identity of identities) {
data.sig[identity.email.toLowerCase()] = simpleHashBg(identity.signature || '');
}
}
} catch (_) {}
await browser.storage.local.set({ [HASH_STORAGE_KEY_BG]: data });
}
async function smartSync() {
if (syncInProgress) return;
try {
const initialized = await syncManager.init();
if (!initialized) return;
// Lightweight SHA check
const shaResult = await syncManager.checkRemoteShas();
if (!shaResult?.success) return;
const currentShas = JSON.stringify(shaResult.remoteShas);
// First run or SHAs changed → full pull
if (lastKnownShas === null || currentShas !== lastKnownShas) {
const now = Date.now();
if (now - lastFullSync < FULL_SYNC_COOLDOWN_MS) return;
syncInProgress = true;
lastFullSync = now;
console.log('[Sync] Änderung erkannt, lade Vorlagen...');
const result = await syncManager.pullTemplates();
console.log('[Sync] Vorlagen geladen:', result);
await updateSyncHashes();
const sigResult = await syncManager.pullSignatures();
console.log('[Sync] Signaturen geladen:', sigResult);
lastKnownShas = JSON.stringify((await syncManager.checkRemoteShas()).remoteShas || {});
syncInProgress = false;
}
} catch (err) {
console.error('[Sync] Check fehlgeschlagen:', err);
syncInProgress = false;
}
}
smartSync();
setInterval(smartSync, SHA_CHECK_INTERVAL_MS);

Binary file not shown.

39
lib/mdi/mdi-editor.css Normal file
View File

@@ -0,0 +1,39 @@
@font-face {
font-family: "Material Design Icons";
src: url("materialdesignicons-webfont.woff2") format("woff2");
font-weight: normal;
font-style: normal;
}
.mdi {
display: inline-block;
font: normal normal normal 18px/1 "Material Design Icons";
text-rendering: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.mdi-format-bold::before { content: "\F0264"; }
.mdi-format-italic::before { content: "\F0277"; }
.mdi-format-underline::before { content: "\F0287"; }
.mdi-format-strikethrough::before { content: "\F0280"; }
.mdi-format-color-text::before { content: "\F069E"; }
.mdi-format-color-highlight::before { content: "\F0E31"; }
.mdi-format-list-numbered::before { content: "\F027B"; }
.mdi-format-list-bulleted::before { content: "\F0279"; }
.mdi-format-align-left::before { content: "\F0262"; }
.mdi-format-align-center::before { content: "\F0260"; }
.mdi-format-align-right::before { content: "\F0263"; }
.mdi-link::before { content: "\F0337"; }
.mdi-format-clear::before { content: "\F0265"; }
.mdi-image::before { content: "\F02E9"; }
.mdi-cloud-upload::before { content: "\F0167"; }
.mdi-cloud-download::before { content: "\F0162"; }
.mdi-cloud-check::before { content: "\F0157"; }
.mdi-cloud-alert::before { content: "\F09DF"; }
.mdi-sync::before { content: "\F04E6"; }
.mdi-cog::before { content: "\F0493"; }
.mdi-eye::before { content: "\F0208"; }
.mdi-eye-off::before { content: "\F0209"; }
.mdi-account-group::before { content: "\F0849"; }
.mdi-account::before { content: "\F0004"; }

View File

@@ -1,11 +1,11 @@
{
"manifest_version": 2,
"name": "Templates Reply",
"version": "1.0.3",
"description": "Insert your reply in the body of the message",
"name": "HPS Vorlagen & Signaturen",
"version": "2.2.0",
"description": "Vorlagen- und Signaturverwaltung für Hotel Park Soltau mit Git-Sync",
"browser_specific_settings": {
"gecko": {
"id": "stmt@proton.me",
"id": "it@hotel-park-soltau.de",
"strict_min_version": "109.0"
}
},
@@ -13,10 +13,16 @@
"compose",
"storage",
"notifications",
"tabs"
"tabs",
"accountsRead",
"accountsIdentities"
],
"optional_permissions": [
"*://*/*"
],
"background": {
"scripts": ["background.js"]
"scripts": ["lib/gitea-sync.js", "background.js"],
"persistent": true
},
"compose_action": {
"default_icon": {

View File

@@ -59,6 +59,26 @@
color: #4a7c59;
text-decoration: underline;
}
.prefix-section {
padding: 8px 10px;
border-bottom: 1px solid #e0e0e0;
background: #f3f3f3;
}
.prefix-section label {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 4px;
}
.prefix-section select {
width: 100%;
padding: 5px 8px;
font-size: 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
color: #333;
}
.empty-state {
padding: 20px 14px;
text-align: center;
@@ -75,6 +95,13 @@
</head>
<body>
<div class="header">Vorlage auswählen</div>
<div id="prefix-section" class="prefix-section" style="display:none;">
<label for="prefix-select">Textbaustein voranstellen (optional)</label>
<select id="prefix-select">
<option value="">— Nichts voranstellen —</option>
</select>
<div style="font-size:10px;color:#999;margin-top:2px;">Wird VOR der gewählten Vorlage eingefügt</div>
</div>
<div id="template-list"></div>
<script src="popup.js"></script>
</body>

View File

@@ -13,9 +13,17 @@ async function getTemplates() {
}
function insertTemplateAndClose(templateText) {
// Check if a prefix template is selected
const prefixSelect = document.getElementById('prefix-select');
const prefixContent = prefixSelect ? prefixSelect.value : '';
const combinedText = prefixContent
? prefixContent + '<br>' + templateText
: templateText;
browser.runtime.sendMessage({
action: 'insertTemplate',
text: templateText
text: combinedText
}).catch(e => console.error("Error sending message to background:", e));
window.close();
}
@@ -39,6 +47,34 @@ async function renderPopupButtons() {
return;
}
// Populate prefix dropdown
const prefixSection = document.getElementById('prefix-section');
const prefixSelect = document.getElementById('prefix-select');
if (templates.length > 1) {
prefixSection.style.display = '';
templates.forEach(template => {
const option = document.createElement('option');
option.value = template.content;
option.textContent = template.name;
option.dataset.name = template.name;
prefixSelect.appendChild(option);
});
// Restore last selection
const saved = await browser.storage.local.get('last_prefix');
if (saved.last_prefix) {
const match = [...prefixSelect.options].find(o => o.dataset.name === saved.last_prefix);
if (match) prefixSelect.value = match.value;
}
// Save selection on change
prefixSelect.addEventListener('change', () => {
const selected = prefixSelect.selectedOptions[0];
const name = selected?.dataset?.name || '';
browser.storage.local.set({ last_prefix: name });
});
}
templates.forEach(template => {
const button = document.createElement('button');
button.textContent = template.name;

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff