diff --git a/README.md b/README.md index 281d351..e079821 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,92 @@ -# 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) | + +## 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 +7z a templates-reply-hotel.xpi manifest.json background.js popup.html popup.js lib/ templates_options/ icons/ +``` + +## Einrichtung + +1. **Verbindung konfigurieren**: Einstellungen-Tab (⚙) → Server-URL, Repository, Token eingeben → Verbindung speichern +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 diff --git a/lib/gitea-sync.js b/lib/gitea-sync.js index fdcc2ba..8755d91 100644 --- a/lib/gitea-sync.js +++ b/lib/gitea-sync.js @@ -5,6 +5,8 @@ const SYNC_STATE_KEY = 'sync_state'; const TEMPLATE_STORAGE_KEY = 'message_templates'; const SYNC_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes const SHARED_FOLDER = '_gemeinsam'; +const USER_FOLDER = '_benutzer'; +const CONFIG_FOLDER = '_config'; // ── Gitea API Client ── @@ -133,6 +135,16 @@ class GiteaClient { 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 ── @@ -162,6 +174,15 @@ class SyncManager { 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: {} }; @@ -200,7 +221,7 @@ class SyncManager { const entries = await this.client.listDir(''); const departments = []; for (const entry of entries) { - if (entry.type === 'dir' && entry.name !== SHARED_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 !== 'signatures' && !entry.name.startsWith('.')) { departments.push(entry.name); } } @@ -212,10 +233,11 @@ class SyncManager { */ async checkRemoteShas() { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); - if (!this.department) throw new Error('Keine Abteilung ausgewählt'); const remoteShas = {}; // "folder/filename" -> sha - const folders = [SHARED_FOLDER, this.department]; + 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); @@ -234,15 +256,16 @@ class SyncManager { */ async pullTemplates() { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); - if (!this.department) throw new Error('Keine Abteilung ausgewählt'); const syncState = await this.getSyncState(); const newTemplates = []; const newShas = {}; let updated = 0; - // Load from both folders - const folders = [SHARED_FOLDER, this.department]; + // 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); @@ -280,7 +303,6 @@ class SyncManager { */ async pushTemplates() { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); - if (!this.department) throw new Error('Keine Abteilung ausgewählt'); if (!this.config.authorName) throw new Error('Bitte Name eintragen (für Commit-Zuordnung)'); const templates = await this.getLocalTemplates(); @@ -289,15 +311,21 @@ class SyncManager { // Group templates by folder const byFolder = {}; for (const t of templates) { - const folder = t.folder || this.department; + 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; - // Only push to folders the user has templates in - const allowedFolders = [SHARED_FOLDER, this.department]; + // 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] || []; @@ -662,7 +690,7 @@ 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']; + const syncActions = ['testConnection', 'pullTemplates', 'pushTemplates', 'pullSingleTemplate', 'pushSingleTemplate', 'deleteRemoteTemplate', 'listDepartments', 'checkRemoteShas', 'pullSignatures', 'pushSignatures', 'loadSignatureTemplate', 'loadFooter', 'pushFooter', 'autoDetect']; if (!syncActions.includes(msg.action)) return; try { @@ -713,6 +741,10 @@ browser.runtime.onMessage.addListener(async (msg, sender) => { 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' }; } diff --git a/lib/mdi/mdi-editor.css b/lib/mdi/mdi-editor.css index 5bc53dd..19172ea 100644 --- a/lib/mdi/mdi-editor.css +++ b/lib/mdi/mdi-editor.css @@ -27,3 +27,13 @@ .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"; } diff --git a/manifest.json b/manifest.json index 3f09dbe..24b7bb8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "HPS Vorlagen & Signaturen", - "version": "2.0.0", + "version": "2.2.0", "description": "Vorlagen- und Signaturverwaltung für Hotel Park Soltau mit Git-Sync", "browser_specific_settings": { "gecko": { diff --git a/popup.html b/popup.html index 409fea1..01f3b81 100644 --- a/popup.html +++ b/popup.html @@ -96,10 +96,11 @@
Vorlage auswählen
diff --git a/templates-reply-hotel.xpi b/templates-reply-hotel.xpi index f0bbedd..70d48dc 100644 Binary files a/templates-reply-hotel.xpi and b/templates-reply-hotel.xpi differ diff --git a/templates_options/templates_options.html b/templates_options/templates_options.html index 050f837..16b83f6 100644 --- a/templates_options/templates_options.html +++ b/templates_options/templates_options.html @@ -327,17 +327,25 @@ .template-item:hover { background: #fafff8; } .template-item input[type="checkbox"] { - width: 16px; - height: 16px; - margin-right: 12px; + width: 18px; + height: 18px; + margin-right: 0; accent-color: #4a7c59; cursor: pointer; + flex-shrink: 0; + } + + .template-label { + display: flex; + align-items: center; + flex: 1; + cursor: pointer; + gap: 10px; } .template-name { font-weight: 500; font-size: 13.5px; - flex: 1; } .template-actions { display: flex; gap: 6px; } @@ -354,8 +362,9 @@ .template-actions .push-btn, .template-actions .pull-btn { - font-weight: bold; - font-size: 14px; + font-weight: 500; + font-size: 12px; + white-space: nowrap; } .template-actions .push-btn { color: #4a7c59; } .template-actions .push-btn:hover { background: #e8f0eb; border-color: #4a7c59; } @@ -510,6 +519,208 @@ border: 1px dashed #e0e0e0; border-radius: 6px; } + + /* Badges for shared/private */ + .badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11px; + font-weight: 500; + padding: 2px 8px; + border-radius: 10px; + margin-left: 8px; + white-space: nowrap; + } + .badge-shared { background: #e3f2fd; color: #1565c0; } + .badge-dept { background: #fff3e0; color: #e65100; } + .badge-private { background: #f3e5f5; color: #7b1fa2; } + + /* Badge as inline select */ + select.scope-select { + border: none; + font-size: 11px; + font-weight: 500; + padding: 2px 4px 2px 8px; + border-radius: 10px; + cursor: pointer; + margin-left: 8px; + outline: none; + } + select.scope-select.badge-shared { background: #e3f2fd; color: #1565c0; } + select.scope-select.badge-dept { background: #fff3e0; color: #e65100; } + select.scope-select.badge-private { background: #f3e5f5; color: #7b1fa2; } + + /* Toast notifications */ + #toast-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + } + .toast { + padding: 12px 16px; + border-radius: 8px; + font-size: 13px; + color: white; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + animation: toast-in 0.3s ease; + display: flex; + align-items: center; + gap: 8px; + } + .toast-success { background: #43a047; } + .toast-error { background: #e53935; } + .toast-info { background: #1e88e5; } + .toast-out { animation: toast-out 0.3s ease forwards; } + @keyframes toast-in { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + @keyframes toast-out { from { opacity: 1; } to { opacity: 0; transform: translateY(10px); } } + + /* Spinner */ + .spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.6s linear infinite; + vertical-align: middle; + } + .spinner-dark { + border-color: rgba(0,0,0,0.15); + border-top-color: #4a7c59; + } + @keyframes spin { to { transform: rotate(360deg); } } + + /* Improved sync dots */ + .sync-dot.in-sync::after { + content: "✓"; + font-size: 8px; + color: white; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + .sync-dot.out-of-sync::after { + content: "!"; + font-size: 8px; + font-weight: bold; + color: white; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + .sync-dot { + position: relative; + width: 14px; + height: 14px; + } + + /* Collapsible improvements */ + .collapsible-header { + background: #f9f9f9; + border: 1px solid #e8e8e8; + border-radius: 6px; + padding: 8px 12px; + margin-bottom: 4px; + } + .collapsible-header:hover { + background: #f0f5f1; + border-color: #c0d4c7; + } + .collapsible-header .arrow { font-size: 13px; } + + /* Token toggle */ + .password-wrapper { + position: relative; + } + .password-wrapper input { + padding-right: 36px; + } + .password-toggle { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + border: none; + background: none; + cursor: pointer; + color: #999; + padding: 2px; + line-height: 1; + } + .password-toggle:hover { color: #555; } + + /* Offline banner */ + .offline-banner { + display: none; + background: #ff9800; + color: white; + text-align: center; + padding: 10px; + font-size: 13px; + font-weight: 500; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9998; + } + .offline-banner.visible { display: block; } + + /* Confirm modal */ + .modal-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + z-index: 10000; + align-items: center; + justify-content: center; + } + .modal-backdrop.open { display: flex; } + .modal-card { + background: white; + border-radius: 10px; + padding: 24px; + max-width: 400px; + width: 90%; + box-shadow: 0 8px 32px rgba(0,0,0,0.2); + } + .modal-card h3 { + font-size: 16px; + margin-bottom: 12px; + color: #333; + } + .modal-card p { + font-size: 13px; + color: #666; + margin-bottom: 20px; + line-height: 1.5; + } + .modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + } + + /* Validation */ + .input-error { border-color: #e53935 !important; } + .input-valid { border-color: #43a047 !important; } + .validation-msg { + font-size: 11px; + margin-top: -6px; + margin-bottom: 8px; + } + .validation-msg.error { color: #e53935; } + .validation-msg.success { color: #43a047; } @@ -584,9 +795,13 @@ -
- - +
+ +
@@ -811,7 +1026,10 @@
- +
+ + +
@@ -825,6 +1043,24 @@
+ +
Keine Internetverbindung – Synchronisation nicht möglich
+ + +
+ + + + diff --git a/templates_options/templates_options.js b/templates_options/templates_options.js index 79238ea..e26a688 100644 --- a/templates_options/templates_options.js +++ b/templates_options/templates_options.js @@ -6,6 +6,112 @@ const SYNC_CONFIG_KEY = 'gitea_config'; const SIG_SOURCE_KEY = 'sig_source_map'; const SIG_FOOTER_KEY = 'sig_footer_cache'; +// ── Toast, Modal, Offline, Token Toggle ── + +function showToast(message, type = 'info', duration = 4000) { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + container.appendChild(toast); + setTimeout(() => { + toast.classList.add('toast-out'); + setTimeout(() => toast.remove(), 300); + }, duration); +} + +function showConfirmDialog(title, message, confirmText = 'Löschen') { + return new Promise(resolve => { + const modal = document.getElementById('confirm-modal'); + document.getElementById('modal-title').textContent = title; + document.getElementById('modal-message').textContent = message; + document.getElementById('modal-confirm').textContent = confirmText; + modal.classList.add('open'); + + function cleanup(result) { + modal.classList.remove('open'); + document.getElementById('modal-confirm').removeEventListener('click', onConfirm); + document.getElementById('modal-cancel').removeEventListener('click', onCancel); + resolve(result); + } + function onConfirm() { cleanup(true); } + function onCancel() { cleanup(false); } + document.getElementById('modal-confirm').addEventListener('click', onConfirm); + document.getElementById('modal-cancel').addEventListener('click', onCancel); + }); +} + +function checkOnline() { + if (!navigator.onLine) { + showToast('Keine Internetverbindung – Synchronisation nicht möglich.', 'error', 5000); + return false; + } + return true; +} + +window.addEventListener('online', () => { + document.getElementById('offline-banner').classList.remove('visible'); + showToast('Wieder online.', 'success', 2000); +}); +window.addEventListener('offline', () => { + document.getElementById('offline-banner').classList.add('visible'); +}); + +// Token toggle + name validation +document.addEventListener('DOMContentLoaded', () => { + const toggle = document.getElementById('token-toggle'); + if (toggle) { + toggle.addEventListener('click', () => { + const input = document.getElementById('sync-token'); + const icon = toggle.querySelector('.mdi'); + if (input.type === 'password') { + input.type = 'text'; + icon.className = 'mdi mdi-eye-off'; + } else { + input.type = 'password'; + icon.className = 'mdi mdi-eye'; + } + }); + } + + // Template name inline validation + const nameInput = document.getElementById('template-name'); + if (nameInput) { + nameInput.addEventListener('input', async () => { + const val = nameInput.value.trim(); + nameInput.classList.remove('input-error', 'input-valid'); + const msgEl = nameInput.parentElement.querySelector('.validation-msg'); + if (msgEl) msgEl.remove(); + + if (!val) { + nameInput.classList.add('input-error'); + nameInput.insertAdjacentHTML('afterend', 'Bitte Titel eingeben'); + return; + } + + const templates = await getTemplates(); + const editId = document.getElementById('template-id').value; + const duplicate = templates.find(t => t.name.toLowerCase() === val.toLowerCase() && t.id !== editId); + if (duplicate) { + nameInput.classList.add('input-error'); + nameInput.insertAdjacentHTML('afterend', 'Titel bereits vergeben'); + } else { + nameInput.classList.add('input-valid'); + } + }); + } +}); + +// ── Cached author email (for personal folder path) ── +let cachedAuthorEmail = ''; + +async function getAuthorEmail() { + if (cachedAuthorEmail) return cachedAuthorEmail; + const result = await browser.storage.local.get(SYNC_CONFIG_KEY); + cachedAuthorEmail = result[SYNC_CONFIG_KEY]?.authorEmail || ''; + return cachedAuthorEmail; +} + // ── DOM Elements ── const editorPanel = document.getElementById('tpl-editor-panel'); @@ -62,7 +168,20 @@ function renderFontDropdown(filter) { }); fontDropdown.appendChild(div); } - fontDropdown.classList.toggle('open', matches.length > 0); + const isOpen = matches.length > 0; + fontDropdown.classList.toggle('open', isOpen); + if (isOpen) { + requestAnimationFrame(() => { + const rect = fontDropdown.getBoundingClientRect(); + if (rect.bottom > window.innerHeight) { + fontDropdown.style.top = 'auto'; + fontDropdown.style.bottom = '100%'; + } else { + fontDropdown.style.top = '100%'; + fontDropdown.style.bottom = 'auto'; + } + }); + } } fontInput.addEventListener('focus', () => renderFontDropdown(fontInput.value)); @@ -158,7 +277,20 @@ function renderSigFontDropdown(filter) { }); sigFontDropdown.appendChild(div); } - sigFontDropdown.classList.toggle('open', matches.length > 0); + const isOpen = matches.length > 0; + sigFontDropdown.classList.toggle('open', isOpen); + if (isOpen) { + requestAnimationFrame(() => { + const rect = sigFontDropdown.getBoundingClientRect(); + if (rect.bottom > window.innerHeight) { + sigFontDropdown.style.top = 'auto'; + sigFontDropdown.style.bottom = '100%'; + } else { + sigFontDropdown.style.top = '100%'; + sigFontDropdown.style.bottom = 'auto'; + } + }); + } } sigFontInput.addEventListener('focus', () => renderSigFontDropdown(sigFontInput.value)); @@ -374,19 +506,24 @@ function renderTemplates(templates) { const item = document.createElement('div'); item.className = 'template-item'; const syncClass = getTplSyncClass(template); - const folderBadge = template.folder - ? `[${template.folder}]` - : ''; + const scopeValue = template.folder === '_gemeinsam' ? 'shared' + : template.folder?.startsWith('_benutzer/') ? 'private' : 'department'; + const badgeClass = scopeValue === 'shared' ? 'badge-shared' + : scopeValue === 'department' ? 'badge-dept' : 'badge-private'; + const folderBadge = ``; const pushBtn = syncClass === 'out-of-sync' - ? `` + ? `` : ''; const pullBtn = hasServerUpdate(template) - ? `` + ? `` : ''; item.innerHTML = ` - - - ${template.name}${folderBadge} + +
${pullBtn}${pushBtn} @@ -400,12 +537,17 @@ function renderTemplates(templates) { document.querySelectorAll('.delete-btn').forEach(b => b.addEventListener('click', handleDelete)); document.querySelectorAll('.push-btn').forEach(b => b.addEventListener('click', handlePushSingle)); document.querySelectorAll('.pull-btn').forEach(b => b.addEventListener('click', handlePullSingle)); + document.querySelectorAll('.scope-select').forEach(sel => sel.addEventListener('change', handleScopeChange)); } // ── Inline Editor Panel ── function openEditorPanel() { editorPanel.classList.add('open'); + // Update department name in scope dropdown + const deptName = document.getElementById('sync-department')?.value || 'Abteilung'; + const deptOption = document.querySelector('#tpl-scope-select option[value="department"]'); + if (deptOption) deptOption.textContent = deptName; editorPanel.scrollIntoView({ behavior: 'smooth', block: 'start' }); } @@ -420,7 +562,7 @@ function closeEditorPanel() { document.getElementById('new-template-button').addEventListener('click', () => { closeEditorPanel(); - document.getElementById('tpl-shared-toggle').checked = false; + document.getElementById('tpl-scope-select').value = 'private'; formLegend.textContent = 'Neue Vorlage erstellen'; saveButton.textContent = 'Speichern'; openEditorPanel(); @@ -437,30 +579,73 @@ templateForm.addEventListener('submit', async (e) => { const content = getEditorContent(); if (!content.trim()) { - alert('Bitte Inhalt eingeben.'); + showToast('Bitte Inhalt eingeben.', 'error'); return; } let templates = await getTemplates(); - const isShared = document.getElementById('tpl-shared-toggle').checked; - const folder = isShared ? '_gemeinsam' : undefined; + const scope = document.getElementById('tpl-scope-select').value; + const authorEmail = await getAuthorEmail(); + let folder; + if (scope === 'shared') folder = '_gemeinsam'; + else if (scope === 'private') folder = `_benutzer/${authorEmail}`; + else folder = undefined; // department — pushTemplates sets this.department + + if (scope === 'private' && !authorEmail) { + showToast('Bitte E-Mail in den Einstellungen eintragen für persönliche Vorlagen.', 'error'); + return; + } if (id) { const index = templates.findIndex(t => t.id === id); if (index > -1) { - const remotePath = templates[index].remotePath; - const effectiveFolder = folder !== undefined ? folder : templates[index].folder; - templates[index] = { id, name, content, folder: effectiveFolder, remotePath }; + const existingTemplate = templates[index]; + const oldFolder = existingTemplate.folder; + const scopeChanged = oldFolder !== folder; + + // Handle scope change: delete old remote file if needed + if (scopeChanged && existingTemplate.remotePath) { + const isDowngrade = (oldFolder === '_gemeinsam' && folder !== '_gemeinsam') || + (!oldFolder?.startsWith('_benutzer/') && folder?.startsWith('_benutzer/')); + + if (isDowngrade) { + const confirmed = await showConfirmDialog( + 'Sichtbarkeit verringern?', + 'Die Vorlage wird am bisherigen Ort gelöscht. Andere Nutzer verlieren ggf. den Zugriff.', + 'Trotzdem ändern' + ); + if (!confirmed) return; + } + try { + await browser.runtime.sendMessage({ + action: 'deleteRemoteTemplate', + remotePath: existingTemplate.remotePath + }); + } catch (_) {} + existingTemplate.remotePath = undefined; + } + + templates[index] = { id, name, content, folder, remotePath: existingTemplate.remotePath }; } } else { - templates.push({ id: Date.now().toString(), name, content, folder: folder || undefined }); + templates.push({ id: Date.now().toString(), name, content, folder }); } await saveTemplates(templates); renderTemplates(templates); closeEditorPanel(); updateTplSyncIndicator(); + + // Auto-push to server + try { + await browser.runtime.sendMessage({ action: 'pushTemplates' }); + const freshTemplates = await getTemplates(); + await checkForServerUpdates(); + storeTplHashes(freshTemplates); + renderTemplates(freshTemplates); + updateTplSyncIndicator(); + } catch (_) {} }); // ── Edit / Delete / Push / Pull ── @@ -473,7 +658,10 @@ async function handleEdit(e) { document.getElementById('template-id').value = template.id; document.getElementById('template-name').value = template.name; - document.getElementById('tpl-shared-toggle').checked = (template.folder === '_gemeinsam'); + const scopeSelect = document.getElementById('tpl-scope-select'); + if (template.folder === '_gemeinsam') scopeSelect.value = 'shared'; + else if (template.folder?.startsWith('_benutzer/')) scopeSelect.value = 'private'; + else scopeSelect.value = 'department'; setEditorContent(template.content); formLegend.textContent = 'Vorlage bearbeiten'; saveButton.textContent = 'Aktualisieren'; @@ -487,12 +675,12 @@ async function handleDelete(e) { if (!template) return; if (template.remotePath) { - const choice = confirm( - `"${template.name}" löschen?\n\n` + - `OK = Lokal UND vom Server löschen (für alle)\n` + - `Abbrechen = Nicht löschen` + const confirmed = await showConfirmDialog( + `"${template.name}" löschen?`, + 'Die Vorlage wird lokal und vom Server gelöscht (für alle Nutzer).', + 'Überall löschen' ); - if (!choice) return; + if (!confirmed) return; // Delete from server try { @@ -501,29 +689,37 @@ async function handleDelete(e) { remotePath: template.remotePath }); if (!result?.success) { - alert('Fehler beim Löschen vom Server: ' + (result?.error || 'Unbekannt')); + showToast('Fehler beim Löschen vom Server: ' + (result?.error || 'Unbekannt'), 'error', 6000); } } catch (err) { - alert('Fehler beim Löschen vom Server: ' + err.message); + showToast('Fehler beim Löschen vom Server: ' + err.message, 'error', 6000); } } else { - if (!confirm('Diese Vorlage wirklich löschen?')) return; + const confirmed = await showConfirmDialog( + 'Vorlage löschen?', + `"${template.name}" wirklich löschen?` + ); + if (!confirmed) return; } templates = templates.filter(t => t.id !== id); await saveTemplates(templates); renderTemplates(templates); updateTplSyncIndicator(); + showToast('Vorlage gelöscht.', 'info'); } async function handlePullSingle(e) { - const id = e.target.dataset.id; + const btn = e.target.closest('.pull-btn') || e.target; + const id = btn.dataset.id; const templates = await getTemplates(); const template = templates.find(t => t.id === id); if (!template || !template.remotePath) return; + if (!checkOnline()) return; - e.target.textContent = '...'; - e.target.disabled = true; + const origHTML = btn.innerHTML; + btn.innerHTML = ''; + btn.disabled = true; try { const result = await browser.runtime.sendMessage({ @@ -540,26 +736,30 @@ async function handlePullSingle(e) { saveSyncHashes(); renderTemplates(templates); updateTplSyncIndicator(); + showToast('Vorlage heruntergeladen.', 'success'); } else { - alert('Fehler: ' + (result?.error || 'Unbekannt')); - e.target.textContent = '\u2193'; - e.target.disabled = false; + showToast('Fehler: ' + (result?.error || 'Unbekannt'), 'error', 6000); + btn.innerHTML = origHTML; + btn.disabled = false; } } catch (err) { - alert('Fehler: ' + err.message); - e.target.textContent = '\u2193'; - e.target.disabled = false; + showToast('Fehler: ' + err.message, 'error', 6000); + btn.innerHTML = origHTML; + btn.disabled = false; } } async function handlePushSingle(e) { - const id = e.target.dataset.id; + const btn = e.target.closest('.push-btn') || e.target; + const id = btn.dataset.id; const templates = await getTemplates(); const template = templates.find(t => t.id === id); if (!template) return; + if (!checkOnline()) return; - e.target.textContent = '...'; - e.target.disabled = true; + const origHTML = btn.innerHTML; + btn.innerHTML = ''; + btn.disabled = true; try { const result = await browser.runtime.sendMessage({ @@ -571,18 +771,87 @@ async function handlePushSingle(e) { saveSyncHashes(); renderTemplates(templates); updateTplSyncIndicator(); + showToast('Vorlage hochgeladen.', 'success'); } else { - alert('Fehler: ' + (result?.error || 'Unbekannt')); - e.target.textContent = '\u2191'; - e.target.disabled = false; + showToast('Fehler: ' + (result?.error || 'Unbekannt'), 'error', 6000); + btn.innerHTML = origHTML; + btn.disabled = false; } } catch (err) { - alert('Fehler: ' + err.message); - e.target.textContent = '\u2191'; - e.target.disabled = false; + showToast('Fehler: ' + err.message, 'error', 6000); + btn.innerHTML = origHTML; + btn.disabled = false; } } +// ── Inline Scope Change ── + +async function handleScopeChange(e) { + const id = e.target.dataset.id; + const newScope = e.target.value; + let templates = await getTemplates(); + const template = templates.find(t => t.id === id); + if (!template) return; + + const authorEmail = await getAuthorEmail(); + const oldFolder = template.folder; + let newFolder; + if (newScope === 'shared') newFolder = '_gemeinsam'; + else if (newScope === 'private') newFolder = `_benutzer/${authorEmail}`; + else newFolder = undefined; + + if (oldFolder === newFolder) return; + + if (newScope === 'private' && !authorEmail) { + showToast('Bitte E-Mail in den Einstellungen eintragen.', 'error'); + // Reset + e.target.value = oldFolder === '_gemeinsam' ? 'shared' + : oldFolder?.startsWith('_benutzer/') ? 'private' : 'department'; + return; + } + + // Warn on downgrade + const isDowngrade = (oldFolder === '_gemeinsam' && newFolder !== '_gemeinsam') || + (!oldFolder?.startsWith('_benutzer/') && newFolder?.startsWith('_benutzer/')); + + if (isDowngrade && template.remotePath) { + const confirmed = await showConfirmDialog( + 'Sichtbarkeit verringern?', + 'Die Vorlage wird am bisherigen Ort gelöscht. Andere Nutzer verlieren ggf. den Zugriff.', + 'Trotzdem ändern' + ); + if (!confirmed) { + e.target.value = oldFolder === '_gemeinsam' ? 'shared' + : oldFolder?.startsWith('_benutzer/') ? 'private' : 'department'; + return; + } + } + + // Delete old remote file if exists + if (template.remotePath) { + try { + await browser.runtime.sendMessage({ + action: 'deleteRemoteTemplate', + remotePath: template.remotePath + }); + } catch (_) {} + template.remotePath = undefined; + } + + template.folder = newFolder; + await saveTemplates(templates); + + // Auto-push to new location + try { + await browser.runtime.sendMessage({ action: 'pushTemplates' }); + templates = await getTemplates(); + } catch (_) {} + + renderTemplates(templates); + updateTplSyncIndicator(); + showToast('Sichtbarkeit geändert.', 'success'); +} + // ── Bulk Actions ── document.getElementById('select-all-button').addEventListener('click', () => { @@ -594,13 +863,19 @@ document.getElementById('select-all-button').addEventListener('click', () => { document.getElementById('delete-selected-button').addEventListener('click', async () => { const checked = document.querySelectorAll('.template-checkbox:checked'); if (checked.length === 0) return; - if (!confirm(`${checked.length} Vorlage(n) wirklich löschen?`)) return; + const confirmed = await showConfirmDialog( + 'Ausgewählte löschen?', + `${checked.length} Vorlage(n) wirklich löschen?`, + `${checked.length} löschen` + ); + if (!confirmed) return; const idsToDelete = new Set(Array.from(checked).map(cb => cb.dataset.id)); let templates = await getTemplates(); templates = templates.filter(t => !idsToDelete.has(t.id)); await saveTemplates(templates); renderTemplates(templates); + showToast(`${checked.length} Vorlage(n) gelöscht.`, 'info'); }); // ── HTML File Import ── @@ -611,9 +886,7 @@ document.getElementById('import-button').addEventListener('click', async () => { const files = fileInput.files; if (files.length === 0) { - statusEl.textContent = 'Bitte Dateien auswählen!'; - statusEl.style.color = 'red'; - statusEl.style.display = 'inline'; + showToast('Bitte Dateien auswählen!', 'error'); return; } @@ -640,28 +913,26 @@ document.getElementById('import-button').addEventListener('click', async () => { renderTemplates(templates); updateTplSyncIndicator(); - statusEl.textContent = `${importCount} Vorlage(n) importiert!`; - statusEl.style.color = 'green'; - statusEl.style.display = 'inline'; + showToast(`${importCount} Vorlage(n) importiert!`, 'success'); fileInput.value = ''; - setTimeout(() => { statusEl.style.display = 'none'; }, 3000); }); // ── Sync "Aktualisieren" Button (Pull + Push) ── document.getElementById('sync-refresh-button').addEventListener('click', async () => { - const statusEl = document.getElementById('sync-sync-status'); - statusEl.textContent = 'Synchronisiere...'; - statusEl.style.color = '#777'; - statusEl.style.display = 'inline'; + if (!checkOnline()) return; + const btn = document.getElementById('sync-refresh-button'); + const origText = btn.textContent; + btn.disabled = true; + btn.innerHTML = ' Synchronisiere...'; try { // Pull first const pullResult = await browser.runtime.sendMessage({ action: 'pullTemplates' }); if (!pullResult?.success) { - statusEl.textContent = pullResult?.error || 'Fehler beim Laden'; - statusEl.style.color = 'red'; - setTimeout(() => { statusEl.style.display = 'none'; }, 4000); + showToast(pullResult?.error || 'Fehler beim Laden', 'error', 6000); + btn.disabled = false; + btn.textContent = origText; return; } @@ -674,14 +945,12 @@ document.getElementById('sync-refresh-button').addEventListener('click', async ( renderTemplates(templates); updateTplSyncIndicator(); - const msg = `${pullResult.updated || 0} geladen, ${pushResult?.pushed || 0} hochgeladen`; - statusEl.textContent = msg; - statusEl.style.color = 'green'; + showToast(`${pullResult.updated || 0} geladen, ${pushResult?.pushed || 0} hochgeladen`, 'success'); } catch (err) { - statusEl.textContent = 'Fehler: ' + err.message; - statusEl.style.color = 'red'; + showToast('Fehler: ' + err.message, 'error', 6000); } - setTimeout(() => { statusEl.style.display = 'none'; }, 4000); + btn.disabled = false; + btn.textContent = origText; }); // ── Signaturen ── @@ -756,6 +1025,12 @@ async function loadIdentities() { sigIdentitySelect.appendChild(opt); } } + + // Auto-select first identity and trigger change + if (allIdentities.length > 0 && !sigIdentitySelect.value) { + sigIdentitySelect.value = allIdentities[0].id; + sigIdentitySelect.dispatchEvent(new Event('change')); + } } // Load signature header into editor when identity is selected @@ -871,11 +1146,8 @@ document.getElementById('sig-load-template').addEventListener('click', async () }); function showSigStatus(message, color) { - const el = document.getElementById('sig-status'); - el.textContent = message; - el.style.color = color; - el.style.display = 'inline'; - setTimeout(() => { el.style.display = 'none'; }, 4000); + const type = color === 'green' ? 'success' : color === 'red' ? 'error' : 'info'; + showToast(message, type, type === 'error' ? 6000 : 4000); } // Save signature to Thunderbird identity (header + footer) @@ -979,18 +1251,19 @@ document.getElementById('sig-import-file').addEventListener('change', async (e) // Signature sync - "Aktualisieren" (pull + push) document.getElementById('sig-sync-refresh').addEventListener('click', async () => { - const statusEl = document.getElementById('sig-sync-status'); - statusEl.textContent = 'Synchronisiere...'; - statusEl.style.color = '#777'; - statusEl.style.display = 'inline'; + if (!checkOnline()) return; + const btn = document.getElementById('sig-sync-refresh'); + const origText = btn.textContent; + btn.disabled = true; + btn.innerHTML = ' Synchronisiere...'; try { // Pull first (gets footer + headers) const pullResult = await browser.runtime.sendMessage({ action: 'pullSignatures' }); if (!pullResult?.success) { - statusEl.textContent = pullResult?.error || 'Fehler'; - statusEl.style.color = 'red'; - setTimeout(() => { statusEl.style.display = 'none'; }, 4000); + showToast(pullResult?.error || 'Fehler', 'error', 6000); + btn.disabled = false; + btn.textContent = origText; return; } @@ -1005,14 +1278,12 @@ document.getElementById('sig-sync-refresh').addEventListener('click', async () = updateSigSyncIndicator(); sigIdentitySelect.dispatchEvent(new Event('change')); - const msg = `${pullResult.updated || 0} geladen, ${pushResult?.pushed || 0} hochgeladen`; - statusEl.textContent = msg; - statusEl.style.color = 'green'; + showToast(`${pullResult.updated || 0} geladen, ${pushResult?.pushed || 0} hochgeladen`, 'success'); } catch (err) { - statusEl.textContent = 'Fehler: ' + err.message; - statusEl.style.color = 'red'; + showToast('Fehler: ' + err.message, 'error', 6000); } - setTimeout(() => { statusEl.style.display = 'none'; }, 4000); + btn.disabled = false; + btn.textContent = origText; }); // ── Footer Editor ── @@ -1038,11 +1309,8 @@ document.getElementById('footer-toggle').addEventListener('click', async functio }); function showFooterStatus(message, color) { - const el = document.getElementById('footer-status'); - el.textContent = message; - el.style.color = color; - el.style.display = 'inline'; - setTimeout(() => { el.style.display = 'none'; }, 4000); + const type = color === 'green' ? 'success' : color === 'red' ? 'error' : 'info'; + showToast(message, type, type === 'error' ? 6000 : 4000); } // Load footer from server @@ -1096,6 +1364,7 @@ async function loadSyncConfig() { document.getElementById('sync-token').value = config.token || ''; document.getElementById('sync-author-name').value = config.authorName || ''; document.getElementById('sync-author-email').value = config.authorEmail || ''; + cachedAuthorEmail = config.authorEmail || ''; if (config.baseUrl && config.token) { updateSyncStatus('connected', 'Verbunden'); } @@ -1108,10 +1377,105 @@ async function loadSyncConfig() { deptSelect.appendChild(opt); } loadDepartments(); + + // Auto-detect department + author from server config + if (config.baseUrl && config.token) { + tryAutoDetect(config); + } } } catch (_) {} } +async function tryAutoDetect(currentConfig) { + try { + const result = await browser.runtime.sendMessage({ action: 'autoDetect' }); + if (!result?.success || !result.config) return; + + // result.config = { "info@hotel.de": "Rezeption", ... } + const deptMap = result.config; + + // Get all Thunderbird identities + const accounts = await browser.accounts.list(); + const tbEmails = []; + for (const account of accounts) { + for (const identity of account.identities) { + tbEmails.push(identity.email.toLowerCase()); + } + } + + // Find matching department email + let detectedDept = null; + let personalEmail = null; + const matchedEmails = []; + const unmatchedEmails = []; + + for (const email of tbEmails) { + if (deptMap[email]) { + detectedDept = deptMap[email]; + matchedEmails.push(email); + } else { + unmatchedEmails.push(email); + } + } + + // The personal email is the one NOT in the department map + if (unmatchedEmails.length > 0) { + personalEmail = unmatchedEmails[0]; + } + + let changed = false; + + // Auto-set department if not already set + if (detectedDept && !currentConfig.department) { + const deptSelect = document.getElementById('sync-department'); + // Add option if not exists + let found = false; + for (const opt of deptSelect.options) { + if (opt.value === detectedDept) { opt.selected = true; found = true; break; } + } + if (!found) { + const opt = document.createElement('option'); + opt.value = detectedDept; + opt.textContent = detectedDept; + opt.selected = true; + deptSelect.appendChild(opt); + } + currentConfig.department = detectedDept; + changed = true; + } + + // Auto-set author email if not already set + if (personalEmail && !currentConfig.authorEmail) { + document.getElementById('sync-author-email').value = personalEmail; + currentConfig.authorEmail = personalEmail; + cachedAuthorEmail = personalEmail; + changed = true; + } + + // Auto-set author name from TB identity if not already set + if (personalEmail && !currentConfig.authorName) { + for (const account of accounts) { + for (const identity of account.identities) { + if (identity.email.toLowerCase() === personalEmail && identity.name) { + document.getElementById('sync-author-name').value = identity.name; + currentConfig.authorName = identity.name; + changed = true; + break; + } + } + if (changed && currentConfig.authorName) break; + } + } + + if (changed) { + await browser.storage.local.set({ [SYNC_CONFIG_KEY]: currentConfig }); + showToast('Einstellungen automatisch erkannt.', 'info'); + } + } catch (err) { + console.log('Auto-detect failed (optional):', err.message); + } +} + function getSyncConfigFromForm() { return { baseUrl: document.getElementById('sync-url').value.replace(/\/+$/, ''), @@ -1132,11 +1496,8 @@ function updateSyncStatus(type, message) { } function showSyncActionStatus(elId, message, color) { - const el = document.getElementById(elId); - el.textContent = message; - el.style.color = color; - el.style.display = 'inline'; - setTimeout(() => { el.style.display = 'none'; }, 4000); + const type = color === 'green' ? 'success' : color === 'red' ? 'error' : 'info'; + showToast(message, type, type === 'error' ? 6000 : 4000); } function appendSyncLog(message) { @@ -1232,23 +1593,30 @@ for (const id of ['sync-author-name', 'sync-author-email']) { document.getElementById('refresh-departments').addEventListener('click', loadDepartments); document.getElementById('test-sync-connection').addEventListener('click', async () => { - showSyncActionStatus('sync-action-status', 'Teste...', '#777'); + if (!checkOnline()) return; + const btn = document.getElementById('test-sync-connection'); + const origText = btn.textContent; + btn.disabled = true; + btn.innerHTML = ' Teste...'; + try { const result = await browser.runtime.sendMessage({ action: 'testConnection' }); if (result && result.success) { updateSyncStatus('connected', 'Verbunden'); - showSyncActionStatus('sync-action-status', 'Verbindung erfolgreich!', 'green'); + showToast('Verbindung erfolgreich!', 'success'); appendSyncLog('Verbindungstest erfolgreich.'); loadDepartments(); } else { updateSyncStatus('error', 'Verbindung fehlgeschlagen'); - showSyncActionStatus('sync-action-status', result?.error || 'Fehler', 'red'); + showToast(result?.error || 'Fehler', 'error', 6000); appendSyncLog('Verbindungstest fehlgeschlagen: ' + (result?.error || 'Unbekannt')); } } catch (err) { updateSyncStatus('error', 'Verbindung fehlgeschlagen'); - showSyncActionStatus('sync-action-status', 'Sync nicht verfügbar.', 'red'); + showToast('Sync nicht verfügbar.', 'error', 6000); } + btn.disabled = false; + btn.textContent = origText; }); // ── Init ──