Compare commits
9 Commits
bc82e33bf2
...
v2.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d05f9412c | ||
|
|
fd192bb8ba | ||
|
|
113bc1bc20 | ||
|
|
8130269f8f | ||
|
|
0563146ee1 | ||
|
|
eff90e9517 | ||
|
|
edb979a1b2 | ||
|
|
ee24caf8b7 | ||
|
|
7a7815feca |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
defaults.local.json
|
defaults.local.json
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
|||||||
47
CLAUDE.md
Normal file
47
CLAUDE.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Thunderbird-MailExtension „HPS Vorlagen & Signaturen" mit Gitea-Sync.
|
||||||
|
|
||||||
|
## Workflow (WICHTIG)
|
||||||
|
|
||||||
|
1. **Bei jeder Änderung, die zu den Usern soll: `version` in `manifest.json` bumpen.**
|
||||||
|
Auto-Update vergleicht Versionsnummern — gleiche Version = Clients ziehen das Update NICHT.
|
||||||
|
Also vor dem Build erhöhen (z.B. 2.3.0 → 2.3.1 für Fixes, 2.4.0 für Features).
|
||||||
|
2. **Nach jeder Code-Änderung am Plugin immer die `.xpi` neu bauen** (siehe Build unten),
|
||||||
|
damit `templates-reply-hotel.xpi` aktuell ist.
|
||||||
|
3. **Sobald der User zufrieden ist ("happy"), committen** — Code-Änderung + neu gebaute
|
||||||
|
`.xpi` zusammen. Nicht ungefragt vorher committen; auf das OK des Users warten.
|
||||||
|
4. **Soll es ausgerollt werden: Release veröffentlichen** (siehe Auto-Update unten) —
|
||||||
|
`./release.sh`, dann `updates.json` + `manifest.json` committen & pushen.
|
||||||
|
|
||||||
|
## Build der .xpi
|
||||||
|
|
||||||
|
Immer **ohne** `defaults.local.json` bauen — die Datei enthält den Gitea-Token und darf
|
||||||
|
nicht in der (öffentlich released) `.xpi` landen. `zip` ist nicht installiert, `7z` schon:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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'
|
||||||
|
```
|
||||||
|
|
||||||
|
`defaults.local.json` wird nur beim allerersten Start gelesen (`templates_options.js`,
|
||||||
|
`if (!config)`) und ist nur zum Vorkonfigurieren frischer Installationen gedacht. Updates
|
||||||
|
brauchen sie nicht — bestehende Installs behalten ihre Config in `storage.local`.
|
||||||
|
|
||||||
|
## Auto-Update (self-hosted über Gitea)
|
||||||
|
|
||||||
|
- `manifest.json` → `browser_specific_settings.gecko.update_url` zeigt auf `updates.json`
|
||||||
|
(raw auf `main`). Repo muss öffentlich bleiben, sonst 401 für den anonymen Updater.
|
||||||
|
- Neues Release veröffentlichen: `version` in `manifest.json` bumpen, `.xpi` neu bauen, dann
|
||||||
|
`GITEA_TOKEN=… ./release.sh` (hasht die xpi, aktualisiert `updates.json`, legt das Gitea-
|
||||||
|
Release an + lädt die xpi hoch). `release.sh` bricht ab, falls der Token in der xpi steckt.
|
||||||
|
- Danach `updates.json` + `manifest.json` committen & pushen.
|
||||||
|
|
||||||
|
## Repo
|
||||||
|
|
||||||
|
- Sync-Daten-Repo (Templates/Signaturen): `hps/email-vorlagen` auf `git.hotel-park-soltau.de`.
|
||||||
|
- Plugin-Source + Release-Host: `hps/hps-thunderbird-templates`.
|
||||||
|
**Muss public bleiben** — der Thunderbird-Auto-Updater greift anonym (ohne Token) auf
|
||||||
|
`updates.json` und die Release-`.xpi` zu. Privat = 401 = Auto-Updates kaputt.
|
||||||
17
README.md
17
README.md
@@ -95,6 +95,23 @@ Wenn eine `defaults.local.json` im Plugin-Root existiert und in die XPI eingebau
|
|||||||
|
|
||||||
Die Datei ist in `.gitignore` — Tokens landen nicht im Repository.
|
Die Datei ist in `.gitignore` — Tokens landen nicht im Repository.
|
||||||
|
|
||||||
|
## Auto-Update (self-hosted über Gitea)
|
||||||
|
|
||||||
|
Installierte Add-ons aktualisieren sich automatisch über `updates.json` in diesem Repo
|
||||||
|
(`manifest.json` → `browser_specific_settings.gecko.update_url`).
|
||||||
|
|
||||||
|
> **⚠️ Dieses Repository muss public bleiben.**
|
||||||
|
> Der Thunderbird-Auto-Updater greift **anonym (ohne Token)** auf `updates.json` und die
|
||||||
|
> Release-`.xpi` zu. Ist das Repo privat, liefert Gitea `401` — die automatischen Updates
|
||||||
|
> funktionieren dann nicht mehr.
|
||||||
|
|
||||||
|
Neue Version veröffentlichen:
|
||||||
|
|
||||||
|
1. `version` in `manifest.json` hochzählen, `.xpi` **ohne** `defaults.local.json` neu bauen.
|
||||||
|
2. `GITEA_TOKEN=… ./release.sh` — hasht die `.xpi`, aktualisiert `updates.json`, legt das
|
||||||
|
Gitea-Release an und lädt die `.xpi` als Asset hoch.
|
||||||
|
3. `updates.json` + `manifest.json` committen & pushen.
|
||||||
|
|
||||||
## Einrichtung
|
## Einrichtung
|
||||||
|
|
||||||
1. **Verbindung konfigurieren**: Einstellungen-Tab (⚙) → Server-URL, Repository, Token eingeben → Verbindung speichern (entfällt bei vorkonfigurierter XPI)
|
1. **Verbindung konfigurieren**: Einstellungen-Tab (⚙) → Server-URL, Repository, Token eingeben → Verbindung speichern (entfällt bei vorkonfigurierter XPI)
|
||||||
|
|||||||
48
VERIFY.md
Normal file
48
VERIFY.md
Normal 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
|
||||||
106
background.js
106
background.js
@@ -1,5 +1,109 @@
|
|||||||
|
// ── Toolbar button: open settings page ──
|
||||||
|
|
||||||
|
browser.browserAction.onClicked.addListener(() => {
|
||||||
|
browser.runtime.openOptionsPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── "Erledigt" button in message display ──
|
||||||
|
|
||||||
|
async function executeErledigtAction(tab, actionConfig) {
|
||||||
|
const message = await messenger.messageDisplay.getDisplayedMessage(tab.id);
|
||||||
|
if (!message) {
|
||||||
|
browser.notifications.create({ type: 'basic', iconUrl: browser.runtime.getURL('icons/icon.png'), title: 'Fehler', message: 'Keine Nachricht ausgewählt.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = await browser.storage.local.get(['gitea_config', 'schlagwoerter_cache']);
|
||||||
|
const config = storage.gitea_config || {};
|
||||||
|
const schlagwoerter = storage.schlagwoerter_cache;
|
||||||
|
|
||||||
|
// Apply user's tag
|
||||||
|
let tagKey = null;
|
||||||
|
if (Array.isArray(schlagwoerter) && config.authorName) {
|
||||||
|
const match = schlagwoerter.find(u => u.name.toLowerCase() === config.authorName.toLowerCase());
|
||||||
|
if (match) {
|
||||||
|
tagKey = `$hps_${match.name.toLowerCase().replace(/\s+/g, '_')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagKey) {
|
||||||
|
const currentTags = message.tags || [];
|
||||||
|
if (!currentTags.includes(tagKey)) {
|
||||||
|
await messenger.messages.update(message.id, { tags: [...currentTags, tagKey] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to target folder
|
||||||
|
if (actionConfig.targetFolder) {
|
||||||
|
const folderInfo = JSON.parse(actionConfig.targetFolder);
|
||||||
|
await messenger.messages.move([message.id], folderInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feedback
|
||||||
|
const parts = [];
|
||||||
|
if (tagKey) parts.push('markiert');
|
||||||
|
if (actionConfig.targetFolder) parts.push('verschoben');
|
||||||
|
const title = actionConfig.name || 'Erledigt';
|
||||||
|
browser.notifications.create({
|
||||||
|
type: 'basic',
|
||||||
|
iconUrl: browser.runtime.getURL('icons/icon.png'),
|
||||||
|
title,
|
||||||
|
message: parts.length ? `Nachricht ${parts.join(' & ')}.` : 'Kein Schlagwort oder Zielordner konfiguriert.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single action: direct click without popup
|
||||||
|
messenger.messageDisplayAction.onClicked.addListener(async (tab) => {
|
||||||
|
try {
|
||||||
|
const result = await browser.storage.local.get('erledigt_config');
|
||||||
|
const actions = (result.erledigt_config || {}).actions || [];
|
||||||
|
await executeErledigtAction(tab, actions[0] || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erledigt-Button Fehler:', e);
|
||||||
|
browser.notifications.create({ type: 'basic', iconUrl: browser.runtime.getURL('icons/icon.png'), title: 'Fehler', message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle popup vs direct click based on action count
|
||||||
|
async function updateErledigtPopup() {
|
||||||
|
const result = await browser.storage.local.get('erledigt_config');
|
||||||
|
const actions = (result.erledigt_config || {}).actions || [];
|
||||||
|
if (actions.length > 1) {
|
||||||
|
await messenger.messageDisplayAction.setPopup({ popup: 'message_popup.html' });
|
||||||
|
await messenger.messageDisplayAction.setTitle({ title: 'Aktion wählen' });
|
||||||
|
} else {
|
||||||
|
await messenger.messageDisplayAction.setPopup({ popup: '' });
|
||||||
|
await messenger.messageDisplayAction.setTitle({ title: actions[0]?.name || 'Erledigt' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update on config change
|
||||||
|
browser.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === 'local' && changes.erledigt_config) updateErledigtPopup();
|
||||||
|
});
|
||||||
|
updateErledigtPopup();
|
||||||
|
|
||||||
|
// ── Template insertion ──
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
if (msg.action !== 'insertTemplate') return false;
|
if (msg.action === 'erledigtAction') {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
|
||||||
|
const result = await browser.storage.local.get('erledigt_config');
|
||||||
|
const actions = (result.erledigt_config || {}).actions || [];
|
||||||
|
const action = actions[msg.index] || {};
|
||||||
|
await executeErledigtAction(tab, action);
|
||||||
|
sendResponse({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erledigt-Action Fehler:', e);
|
||||||
|
browser.notifications.create({ type: 'basic', iconUrl: browser.runtime.getURL('icons/icon.png'), title: 'Fehler', message: e.message });
|
||||||
|
sendResponse({ success: false, error: e.message });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (msg.action !== 'insertTemplate') return;
|
||||||
|
|
||||||
handleInsertTemplate(msg).then(() => sendResponse())
|
handleInsertTemplate(msg).then(() => sendResponse())
|
||||||
.catch(err => sendResponse({ error: err.message }));
|
.catch(err => sendResponse({ error: err.message }));
|
||||||
|
|||||||
@@ -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 SCHLAGWOERTER_CACHE_KEY = 'schlagwoerter_cache';
|
||||||
|
|
||||||
// ── Gitea API Client ──
|
// ── Gitea API Client ──
|
||||||
|
|
||||||
@@ -146,6 +147,16 @@ class GiteaClient {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSchlagwoerter() {
|
||||||
|
const data = await this.getFile(`${CONFIG_FOLDER}/schlagwoerter.json`);
|
||||||
|
if (!data) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(GiteaClient.fromBase64(data.content));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync Manager ──
|
// ── Sync Manager ──
|
||||||
@@ -239,6 +250,9 @@ class SyncManager {
|
|||||||
const folders = [SHARED_FOLDER];
|
const folders = [SHARED_FOLDER];
|
||||||
if (this.department) folders.push(this.department);
|
if (this.department) folders.push(this.department);
|
||||||
if (this.authorEmail) folders.push(`${USER_FOLDER}/${this.authorEmail}`);
|
if (this.authorEmail) folders.push(`${USER_FOLDER}/${this.authorEmail}`);
|
||||||
|
// Signatur-Bausteine mitüberwachen, damit Footer-/Header-Änderungen
|
||||||
|
// auf dem Server den Hintergrund-Sync (pullSignatures) auslösen.
|
||||||
|
folders.push('signatures/footers', 'signatures/headers');
|
||||||
|
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
const files = await this.client.listDir(folder);
|
const files = await this.client.listDir(folder);
|
||||||
@@ -450,25 +464,50 @@ class SyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load footer for editing (returns HTML)
|
* Resolve the footer file path for a given scope.
|
||||||
|
* - 'shared' → signatures/footers/_default.html (gilt für alle Abteilungen)
|
||||||
|
* - 'department' → signatures/footers/{department}.html
|
||||||
*/
|
*/
|
||||||
async loadFooter() {
|
footerPathForScope(scope) {
|
||||||
|
if (scope === 'shared') return 'signatures/footers/_default.html';
|
||||||
|
if (!this.department) throw new Error('Keine Abteilung ausgewählt');
|
||||||
|
return `signatures/footers/${this.department}.html`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load footer for editing (returns HTML).
|
||||||
|
* - scope 'shared'/'department': lädt genau diese Datei (kein Fallback,
|
||||||
|
* damit der Editor den echten Inhalt der gewählten Ebene zeigt).
|
||||||
|
* - ohne scope: Fallback-Kette (Abteilung → gemeinsam) für die
|
||||||
|
* automatische Anwendung an die Signatur.
|
||||||
|
*/
|
||||||
|
async loadFooter(scope) {
|
||||||
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
|
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
|
||||||
|
|
||||||
|
if (scope === 'shared' || scope === 'department') {
|
||||||
|
const filepath = this.footerPathForScope(scope);
|
||||||
|
const fileData = await this.client.getFile(filepath);
|
||||||
|
const html = (fileData && fileData.content) ? GiteaClient.fromBase64(fileData.content) : '';
|
||||||
|
return { success: true, html, scope };
|
||||||
|
}
|
||||||
|
|
||||||
const footer = await this.pullFooter();
|
const footer = await this.pullFooter();
|
||||||
return { success: true, html: footer };
|
return { success: true, html: footer };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push footer for current department to signatures/footers/{department}.html
|
* Push footer to the chosen scope:
|
||||||
|
* - 'shared' → signatures/footers/_default.html
|
||||||
|
* - 'department' → signatures/footers/{department}.html
|
||||||
*/
|
*/
|
||||||
async pushFooter(html) {
|
async pushFooter(html, scope) {
|
||||||
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
|
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
|
||||||
if (!this.config.authorName) throw new Error('Bitte Name eintragen');
|
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 targetScope = (scope === 'shared') ? 'shared' : 'department';
|
||||||
const commitMsg = `Signatur-Footer ${this.department} - von ${this.config.authorName}`;
|
const filepath = this.footerPathForScope(targetScope);
|
||||||
|
const label = targetScope === 'shared' ? 'gemeinsam' : this.department;
|
||||||
|
const commitMsg = `Signatur-Footer ${label} - von ${this.config.authorName}`;
|
||||||
|
|
||||||
const existing = await this.client.getFile(filepath);
|
const existing = await this.client.getFile(filepath);
|
||||||
|
|
||||||
@@ -481,8 +520,10 @@ class SyncManager {
|
|||||||
await this.client.createFile(filepath, html, commitMsg);
|
await this.client.createFile(filepath, html, commitMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
await browser.storage.local.set({ 'sig_footer_cache': html });
|
// Refresh cache with the footer that actually applies (Abteilung gewinnt
|
||||||
return { success: true };
|
// vor gemeinsam) — nicht zwingend das gerade gespeicherte html.
|
||||||
|
await this.pullFooter();
|
||||||
|
return { success: true, scope: targetScope };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -533,18 +574,32 @@ class SyncManager {
|
|||||||
targetFile = headerMap[personalName] || null;
|
targetFile = headerMap[personalName] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetFile) continue; // No header file yet — user hasn't set up signature
|
let header = null;
|
||||||
|
if (targetFile) {
|
||||||
|
const fileData = await this.client.getFile(targetFile.path);
|
||||||
|
if (fileData) header = GiteaClient.fromBase64(fileData.content);
|
||||||
|
}
|
||||||
|
|
||||||
const fileData = await this.client.getFile(targetFile.path);
|
// Fallback: kein Server-Header → lokalen Header aus der aktuellen
|
||||||
if (!fileData) continue;
|
// Signatur verwenden, damit der Footer trotzdem angewandt wird.
|
||||||
|
if (header === null) {
|
||||||
|
const existing = identity.signature || '';
|
||||||
|
const localHeader = SyncManager.extractHeader(existing);
|
||||||
|
// Leere, nie eingerichtete Konten nicht mit reinem Footer versehen
|
||||||
|
if (!localHeader.trim() && !existing.includes(SyncManager.FOOTER_SEPARATOR)) continue;
|
||||||
|
header = localHeader;
|
||||||
|
}
|
||||||
|
|
||||||
const header = GiteaClient.fromBase64(fileData.content);
|
|
||||||
loadedHeaders[email] = header;
|
loadedHeaders[email] = header;
|
||||||
const fullSig = SyncManager.combineSignature(header, footer);
|
const fullSig = SyncManager.combineSignature(header, footer);
|
||||||
|
|
||||||
|
// Nur schreiben, wenn sich etwas ändert (vermeidet unnötige Writes
|
||||||
|
// bei jedem Hintergrund-Sync)
|
||||||
|
if (fullSig === identity.signature) continue;
|
||||||
|
|
||||||
await browser.identities.update(identity.id, {
|
await browser.identities.update(identity.id, {
|
||||||
signature: fullSig,
|
signature: fullSig,
|
||||||
signatureIsPlainText: false
|
signatureIsPlainText: false,
|
||||||
});
|
});
|
||||||
updated++;
|
updated++;
|
||||||
}
|
}
|
||||||
@@ -561,7 +616,7 @@ class SyncManager {
|
|||||||
const fullSig = SyncManager.combineSignature(srcHeader, footer);
|
const fullSig = SyncManager.combineSignature(srcHeader, footer);
|
||||||
await browser.identities.update(id, {
|
await browser.identities.update(id, {
|
||||||
signature: fullSig,
|
signature: fullSig,
|
||||||
signatureIsPlainText: false
|
signatureIsPlainText: false,
|
||||||
});
|
});
|
||||||
loadedHeaders[email] = srcHeader;
|
loadedHeaders[email] = srcHeader;
|
||||||
updated++;
|
updated++;
|
||||||
@@ -683,6 +738,98 @@ class SyncManager {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static hslToHex(h, s, l) {
|
||||||
|
s /= 100; l /= 100;
|
||||||
|
const a = s * Math.min(l, 1 - l);
|
||||||
|
const f = n => { const k = (n + h / 30) % 12; return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); };
|
||||||
|
const toHex = x => Math.round(x * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync Schlagwörter (tags) from Gitea config to Thunderbird.
|
||||||
|
* Auto-registers the current user if not yet listed.
|
||||||
|
* Tags are never removed so they remain traceable forever.
|
||||||
|
* Format: [ {"name": "Kenny", "color": "#e74c3c"}, ... ]
|
||||||
|
*/
|
||||||
|
async syncTags() {
|
||||||
|
if (!this.isConfigured) return { success: true, created: 0, updated: 0 };
|
||||||
|
|
||||||
|
const userName = this.config.authorName;
|
||||||
|
|
||||||
|
// Pull current file (may be null if it doesn't exist yet)
|
||||||
|
const fileData = await this.client.getFile(`${CONFIG_FOLDER}/schlagwoerter.json`);
|
||||||
|
let schlagwoerter = [];
|
||||||
|
let fileSha = null;
|
||||||
|
if (fileData) {
|
||||||
|
fileSha = fileData.sha;
|
||||||
|
try { schlagwoerter = JSON.parse(GiteaClient.fromBase64(fileData.content)); } catch (_) {}
|
||||||
|
}
|
||||||
|
if (!Array.isArray(schlagwoerter)) schlagwoerter = [];
|
||||||
|
|
||||||
|
// Auto-register current user if not yet listed
|
||||||
|
let pushed = false;
|
||||||
|
if (userName) {
|
||||||
|
const exists = schlagwoerter.some(u => u.name.toLowerCase() === userName.toLowerCase());
|
||||||
|
if (!exists) {
|
||||||
|
const hue = Math.abs([...userName].reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0)) % 360;
|
||||||
|
const color = SyncManager.hslToHex(hue, 65, 45);
|
||||||
|
schlagwoerter.push({ name: userName, color });
|
||||||
|
|
||||||
|
const json = JSON.stringify(schlagwoerter, null, 2);
|
||||||
|
const commitMsg = `Schlagwort für ${userName} hinzugefügt`;
|
||||||
|
if (fileSha) {
|
||||||
|
await this.client.updateFile(`${CONFIG_FOLDER}/schlagwoerter.json`, json, fileSha, commitMsg);
|
||||||
|
} else {
|
||||||
|
await this.client.createFile(`${CONFIG_FOLDER}/schlagwoerter.json`, json, commitMsg);
|
||||||
|
}
|
||||||
|
pushed = true;
|
||||||
|
console.log(`[Sync] Benutzer "${userName}" als Schlagwort registriert`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache for background.js access
|
||||||
|
await browser.storage.local.set({ [SCHLAGWOERTER_CACHE_KEY]: schlagwoerter });
|
||||||
|
|
||||||
|
// Create/update Thunderbird tags
|
||||||
|
const existingTags = await messenger.messages.tags.list();
|
||||||
|
const existingByKey = {};
|
||||||
|
for (const t of existingTags) existingByKey[t.key] = t;
|
||||||
|
|
||||||
|
let created = 0, updated = 0;
|
||||||
|
|
||||||
|
for (const user of schlagwoerter) {
|
||||||
|
const key = `$hps_${user.name.toLowerCase().replace(/\s+/g, '_')}`;
|
||||||
|
const color = user.color || '#999999';
|
||||||
|
|
||||||
|
if (existingByKey[key]) {
|
||||||
|
if (existingByKey[key].color !== color) {
|
||||||
|
await messenger.messages.tags.update(key, { color });
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await messenger.messages.tags.create(key, user.name, color);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, created, updated, pushed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current user's tag key based on config
|
||||||
|
*/
|
||||||
|
getMyTagKey(schlagwoerter) {
|
||||||
|
if (!this.config || !Array.isArray(schlagwoerter)) return null;
|
||||||
|
const name = this.config.authorName;
|
||||||
|
if (!name) return null;
|
||||||
|
|
||||||
|
const match = schlagwoerter.find(u => u.name.toLowerCase() === name.toLowerCase());
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return `$hps_${match.name.toLowerCase().replace(/\s+/g, '_')}`;
|
||||||
|
}
|
||||||
|
|
||||||
async testConnection() {
|
async testConnection() {
|
||||||
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
|
if (!this.isConfigured) throw new Error('Sync nicht konfiguriert');
|
||||||
const repoInfo = await this.client.testConnection();
|
const repoInfo = await this.client.testConnection();
|
||||||
@@ -697,7 +844,7 @@ const syncManager = new SyncManager();
|
|||||||
// ── Background message handler for sync ──
|
// ── Background message handler for sync ──
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener(async (msg, sender) => {
|
browser.runtime.onMessage.addListener(async (msg, sender) => {
|
||||||
const syncActions = ['testConnection', 'pullTemplates', 'pushTemplates', 'pullSingleTemplate', 'pushSingleTemplate', 'deleteRemoteTemplate', 'listDepartments', 'checkRemoteShas', 'pullSignatures', 'pushSignatures', 'loadSignatureTemplate', 'loadFooter', 'pushFooter', 'autoDetect'];
|
const syncActions = ['testConnection', 'pullTemplates', 'pushTemplates', 'pullSingleTemplate', 'pushSingleTemplate', 'deleteRemoteTemplate', 'listDepartments', 'addDepartment', 'checkRemoteShas', 'pullSignatures', 'pushSignatures', 'loadSignatureTemplate', 'loadFooter', 'pushFooter', 'autoDetect', 'syncTags', 'getMyTagKey'];
|
||||||
if (!syncActions.includes(msg.action)) return;
|
if (!syncActions.includes(msg.action)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -715,6 +862,14 @@ browser.runtime.onMessage.addListener(async (msg, sender) => {
|
|||||||
case 'listDepartments':
|
case 'listDepartments':
|
||||||
return await syncManager.listDepartments();
|
return await syncManager.listDepartments();
|
||||||
|
|
||||||
|
case 'addDepartment': {
|
||||||
|
if (!syncManager.isConfigured) throw new Error('Sync nicht konfiguriert');
|
||||||
|
const name = msg.name?.trim();
|
||||||
|
if (!name) throw new Error('Kein Name angegeben');
|
||||||
|
await syncManager.client.createFile(`${name}/.gitkeep`, '', `Abteilung "${name}" angelegt`);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
case 'checkRemoteShas':
|
case 'checkRemoteShas':
|
||||||
return await syncManager.checkRemoteShas();
|
return await syncManager.checkRemoteShas();
|
||||||
|
|
||||||
@@ -743,15 +898,24 @@ browser.runtime.onMessage.addListener(async (msg, sender) => {
|
|||||||
return await syncManager.loadSignatureTemplate();
|
return await syncManager.loadSignatureTemplate();
|
||||||
|
|
||||||
case 'loadFooter':
|
case 'loadFooter':
|
||||||
return await syncManager.loadFooter();
|
return await syncManager.loadFooter(msg.scope);
|
||||||
|
|
||||||
case 'pushFooter':
|
case 'pushFooter':
|
||||||
return await syncManager.pushFooter(msg.html);
|
return await syncManager.pushFooter(msg.html, msg.scope);
|
||||||
|
|
||||||
case 'autoDetect':
|
case 'autoDetect':
|
||||||
const config = await syncManager.autoDetect();
|
const config = await syncManager.autoDetect();
|
||||||
return { success: true, config };
|
return { success: true, config };
|
||||||
|
|
||||||
|
case 'syncTags':
|
||||||
|
return await syncManager.syncTags();
|
||||||
|
|
||||||
|
case 'getMyTagKey': {
|
||||||
|
const cached = (await browser.storage.local.get(SCHLAGWOERTER_CACHE_KEY))[SCHLAGWOERTER_CACHE_KEY];
|
||||||
|
const tagKey = syncManager.getMyTagKey(cached);
|
||||||
|
return { success: !!tagKey, tagKey };
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { success: false, error: 'Unbekannte Aktion' };
|
return { success: false, error: 'Unbekannte Aktion' };
|
||||||
}
|
}
|
||||||
@@ -765,7 +929,9 @@ browser.runtime.onMessage.addListener(async (msg, sender) => {
|
|||||||
|
|
||||||
let lastKnownShas = null;
|
let lastKnownShas = null;
|
||||||
let lastFullSync = 0;
|
let lastFullSync = 0;
|
||||||
|
let lastTagSync = 0;
|
||||||
let syncInProgress = false;
|
let syncInProgress = false;
|
||||||
|
const TAG_SYNC_INTERVAL_MS = 60 * 1000;
|
||||||
const HASH_STORAGE_KEY_BG = 'sync_hashes';
|
const HASH_STORAGE_KEY_BG = 'sync_hashes';
|
||||||
|
|
||||||
function simpleHashBg(str) {
|
function simpleHashBg(str) {
|
||||||
@@ -784,6 +950,19 @@ async function updateSyncHashes() {
|
|||||||
for (const t of templates) {
|
for (const t of templates) {
|
||||||
data.tpl[t.id] = simpleHashBg(t.content || '');
|
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 });
|
await browser.storage.local.set({ [HASH_STORAGE_KEY_BG]: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,18 +972,30 @@ async function smartSync() {
|
|||||||
const initialized = await syncManager.init();
|
const initialized = await syncManager.init();
|
||||||
if (!initialized) return;
|
if (!initialized) return;
|
||||||
|
|
||||||
// Lightweight SHA check
|
syncInProgress = true;
|
||||||
|
|
||||||
|
// Tag sync every 60s (schlagwoerter.json is not in SHA-checked folders)
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTagSync >= TAG_SYNC_INTERVAL_MS) {
|
||||||
|
lastTagSync = now;
|
||||||
|
try {
|
||||||
|
const tagResult = await syncManager.syncTags();
|
||||||
|
if (tagResult.created || tagResult.pushed) console.log('[Sync] Tags synchronisiert:', tagResult);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Sync] Tag-Sync fehlgeschlagen:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight SHA check for templates + signatures
|
||||||
const shaResult = await syncManager.checkRemoteShas();
|
const shaResult = await syncManager.checkRemoteShas();
|
||||||
if (!shaResult?.success) return;
|
if (!shaResult?.success) { syncInProgress = false; return; }
|
||||||
|
|
||||||
const currentShas = JSON.stringify(shaResult.remoteShas);
|
const currentShas = JSON.stringify(shaResult.remoteShas);
|
||||||
|
|
||||||
// First run or SHAs changed → full pull
|
// First run or SHAs changed → full pull
|
||||||
if (lastKnownShas === null || currentShas !== lastKnownShas) {
|
if (lastKnownShas === null || currentShas !== lastKnownShas) {
|
||||||
const now = Date.now();
|
if (now - lastFullSync < FULL_SYNC_COOLDOWN_MS) { syncInProgress = false; return; }
|
||||||
if (now - lastFullSync < FULL_SYNC_COOLDOWN_MS) return;
|
|
||||||
|
|
||||||
syncInProgress = true;
|
|
||||||
lastFullSync = now;
|
lastFullSync = now;
|
||||||
console.log('[Sync] Änderung erkannt, lade Vorlagen...');
|
console.log('[Sync] Änderung erkannt, lade Vorlagen...');
|
||||||
|
|
||||||
@@ -816,8 +1007,8 @@ async function smartSync() {
|
|||||||
console.log('[Sync] Signaturen geladen:', sigResult);
|
console.log('[Sync] Signaturen geladen:', sigResult);
|
||||||
|
|
||||||
lastKnownShas = JSON.stringify((await syncManager.checkRemoteShas()).remoteShas || {});
|
lastKnownShas = JSON.stringify((await syncManager.checkRemoteShas()).remoteShas || {});
|
||||||
syncInProgress = false;
|
|
||||||
}
|
}
|
||||||
|
syncInProgress = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Sync] Check fehlgeschlagen:', err);
|
console.error('[Sync] Check fehlgeschlagen:', err);
|
||||||
syncInProgress = false;
|
syncInProgress = false;
|
||||||
@@ -826,3 +1017,4 @@ async function smartSync() {
|
|||||||
|
|
||||||
smartSync();
|
smartSync();
|
||||||
setInterval(smartSync, SHA_CHECK_INTERVAL_MS);
|
setInterval(smartSync, SHA_CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "HPS Vorlagen & Signaturen",
|
"name": "HPS Vorlagen & Signaturen",
|
||||||
"version": "2.2.0",
|
"version": "2.3.2",
|
||||||
"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": {
|
||||||
"id": "it@hotel-park-soltau.de",
|
"id": "it@hotel-park-soltau.de",
|
||||||
"strict_min_version": "109.0"
|
"strict_min_version": "109.0",
|
||||||
|
"update_url": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/raw/branch/main/updates.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -15,15 +16,32 @@
|
|||||||
"notifications",
|
"notifications",
|
||||||
"tabs",
|
"tabs",
|
||||||
"accountsRead",
|
"accountsRead",
|
||||||
"accountsIdentities"
|
"accountsIdentities",
|
||||||
|
"messagesTagsList",
|
||||||
|
"messagesTags",
|
||||||
|
"messagesRead",
|
||||||
|
"messagesUpdate",
|
||||||
|
"messagesMove",
|
||||||
|
"accountsFolders"
|
||||||
],
|
],
|
||||||
"optional_permissions": [
|
"optional_permissions": [
|
||||||
"*://*/*"
|
"*://*/*"
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["lib/gitea-sync.js", "background.js"],
|
"scripts": [
|
||||||
|
"lib/gitea-sync.js",
|
||||||
|
"background.js"
|
||||||
|
],
|
||||||
"persistent": true
|
"persistent": true
|
||||||
},
|
},
|
||||||
|
"browser_action": {
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon.png",
|
||||||
|
"32": "icons/icon.png"
|
||||||
|
},
|
||||||
|
"default_title": "Vorlagen & Signaturen verwalten",
|
||||||
|
"default_label": "Vorlagen & Signaturen"
|
||||||
|
},
|
||||||
"compose_action": {
|
"compose_action": {
|
||||||
"default_icon": {
|
"default_icon": {
|
||||||
"16": "icons/icon.png",
|
"16": "icons/icon.png",
|
||||||
@@ -32,6 +50,13 @@
|
|||||||
"default_popup": "popup.html",
|
"default_popup": "popup.html",
|
||||||
"default_label": "Vorlagen"
|
"default_label": "Vorlagen"
|
||||||
},
|
},
|
||||||
|
"message_display_action": {
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon.png",
|
||||||
|
"32": "icons/icon.png"
|
||||||
|
},
|
||||||
|
"default_label": "QuickMove"
|
||||||
|
},
|
||||||
"options_ui": {
|
"options_ui": {
|
||||||
"page": "templates_options/templates_options.html",
|
"page": "templates_options/templates_options.html",
|
||||||
"browser_style": true
|
"browser_style": true
|
||||||
|
|||||||
50
message_popup.html
Normal file
50
message_popup.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Aktion wählen</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", -apple-system, sans-serif;
|
||||||
|
width: 220px;
|
||||||
|
background: #fafafa;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
#action-list {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
#action-list button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
#action-list button:hover {
|
||||||
|
background: #e8f0eb;
|
||||||
|
border-color: #4a7c59;
|
||||||
|
}
|
||||||
|
#action-list button:active {
|
||||||
|
background: #d0e2d5;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
padding: 16px 14px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="action-list"></div>
|
||||||
|
<script src="message_popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
message_popup.js
Normal file
21
message_popup.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
(async () => {
|
||||||
|
const result = await browser.storage.local.get('erledigt_config');
|
||||||
|
const actions = (result.erledigt_config || {}).actions || [];
|
||||||
|
const list = document.getElementById('action-list');
|
||||||
|
|
||||||
|
if (actions.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-state">Keine Aktionen konfiguriert.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < actions.length; i++) {
|
||||||
|
const action = actions[i];
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.textContent = action.name || `Aktion ${i + 1}`;
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await browser.runtime.sendMessage({ action: 'erledigtAction', index: i });
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
list.appendChild(btn);
|
||||||
|
}
|
||||||
|
})();
|
||||||
70
release.sh
Executable file
70
release.sh
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# release.sh — publish a new version of the Thunderbird add-on to Gitea
|
||||||
|
# and update updates.json so installed clients auto-update.
|
||||||
|
#
|
||||||
|
# Prereqs:
|
||||||
|
# - manifest.json "version" already bumped to the new version
|
||||||
|
# - templates-reply-hotel.xpi rebuilt for that version, WITHOUT defaults.local.json
|
||||||
|
# (the token must not ship in a public release; updates don't need defaults anyway)
|
||||||
|
# - GITEA_TOKEN exported (a Gitea token with repo write access)
|
||||||
|
#
|
||||||
|
# Usage: GITEA_TOKEN=xxxx ./release.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
OWNER="hps"
|
||||||
|
REPO="hps-thunderbird-templates"
|
||||||
|
BASE="https://git.hotel-park-soltau.de"
|
||||||
|
XPI="templates-reply-hotel.xpi"
|
||||||
|
ID="it@hotel-park-soltau.de"
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# --- 0. Safety: never publish a build that bundles the local defaults/token ---
|
||||||
|
# Check the archive's file list (not raw bytes — the source references the
|
||||||
|
# filename as a string, which would be a false positive).
|
||||||
|
if 7z l "$XPI" | grep -q "defaults.local.json"; then
|
||||||
|
echo "ABORT: $XPI contains defaults.local.json (your Gitea token!)." >&2
|
||||||
|
echo " Rebuild the .xpi without it before releasing." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION=$(jq -r '.version' manifest.json)
|
||||||
|
MINVER=$(jq -r '.browser_specific_settings.gecko.strict_min_version' manifest.json)
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
HASH=$(sha256sum "$XPI" | awk '{print $1}')
|
||||||
|
LINK="${BASE}/${OWNER}/${REPO}/releases/download/${TAG}/${XPI}"
|
||||||
|
|
||||||
|
echo "Version : $VERSION"
|
||||||
|
echo "Tag : $TAG"
|
||||||
|
echo "SHA-256 : $HASH"
|
||||||
|
|
||||||
|
# --- 1. Rewrite updates.json: prepend this version (idempotent) ---
|
||||||
|
[ -f updates.json ] || echo "{\"addons\":{\"${ID}\":{\"updates\":[]}}}" > updates.json
|
||||||
|
TMP=$(mktemp)
|
||||||
|
jq --arg id "$ID" --arg v "$VERSION" --arg link "$LINK" \
|
||||||
|
--arg hash "sha256:$HASH" --arg min "$MINVER" '
|
||||||
|
.addons[$id].updates =
|
||||||
|
([{version:$v, update_link:$link, update_hash:$hash,
|
||||||
|
applications:{gecko:{strict_min_version:$min}}}]
|
||||||
|
+ [ .addons[$id].updates[]? | select(.version != $v) ])
|
||||||
|
' updates.json > "$TMP" && mv "$TMP" updates.json
|
||||||
|
echo "updates.json updated"
|
||||||
|
|
||||||
|
# --- 2. Create the Gitea release + upload the .xpi asset ---
|
||||||
|
: "${GITEA_TOKEN:?Set GITEA_TOKEN to a Gitea token with repo write access}"
|
||||||
|
API="${BASE}/api/v1/repos/${OWNER}/${REPO}"
|
||||||
|
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||||
|
|
||||||
|
echo "Creating release $TAG ..."
|
||||||
|
REL_ID=$(curl -fsS -X POST "${API}/releases" \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"main\",\"name\":\"${TAG}\"}" \
|
||||||
|
| jq -r '.id')
|
||||||
|
|
||||||
|
echo "Uploading $XPI to release $REL_ID ..."
|
||||||
|
curl -fsS -X POST "${API}/releases/${REL_ID}/assets?name=${XPI}" \
|
||||||
|
-H "$AUTH" -F "attachment=@${XPI};type=application/x-xpinstall" >/dev/null
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done. Now commit & push the manifest:"
|
||||||
|
echo " git add updates.json manifest.json && git commit -m \"Release ${TAG}\" && git push"
|
||||||
Binary file not shown.
@@ -731,6 +731,7 @@
|
|||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button class="tab-btn active" data-tab="templates">Vorlagen</button>
|
<button class="tab-btn active" data-tab="templates">Vorlagen</button>
|
||||||
<button class="tab-btn" data-tab="signatures">Signaturen</button>
|
<button class="tab-btn" data-tab="signatures">Signaturen</button>
|
||||||
|
<button class="tab-btn" data-tab="erledigt">QuickMove</button>
|
||||||
<button class="tab-btn tab-btn-settings" data-tab="sync" title="Einstellungen">⚙</button>
|
<button class="tab-btn tab-btn-settings" data-tab="sync" title="Einstellungen">⚙</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -932,7 +933,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="collapsible-body" id="footer-body">
|
<div class="collapsible-body" id="footer-body">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-desc">Der Fußbereich wird automatisch an alle Signaturen deiner Abteilung angefügt (Banner, Links, rechtliche Angaben). Änderungen gelten für alle Mitarbeiter der Abteilung.</div>
|
<div class="card-desc">Der Fußbereich wird automatisch an die Signaturen angefügt (Banner, Links, rechtliche Angaben). Wähle, ob du den <strong>gemeinsamen</strong> Fußbereich (gilt für alle Abteilungen) oder den deiner <strong>Abteilung</strong> bearbeitest. Hinweis: Eine vorhandene Abteilungs-Version überschreibt beim Anwenden den gemeinsamen Fußbereich.</div>
|
||||||
|
|
||||||
|
<label for="footer-scope">Geltungsbereich</label>
|
||||||
|
<select id="footer-scope" style="margin-bottom:10px;max-width:320px;">
|
||||||
|
<option value="shared">Gemeinsam (alle Abteilungen)</option>
|
||||||
|
<option value="department">Abteilung</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<label>Fußbereich</label>
|
<label>Fußbereich</label>
|
||||||
<div class="editor-wrapper">
|
<div class="editor-wrapper">
|
||||||
@@ -990,6 +997,7 @@
|
|||||||
<option value="">— Bitte wählen —</option>
|
<option value="">— Bitte wählen —</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" class="btn btn-secondary btn-sm" id="refresh-departments" title="Abteilungen neu laden">Aktualisieren</button>
|
<button type="button" class="btn btn-secondary btn-sm" id="refresh-departments" title="Abteilungen neu laden">Aktualisieren</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="add-department" title="Neue Abteilung anlegen">+</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-desc" style="margin-top:6px;">Du erhältst Vorlagen aus deiner Abteilung + dem gemeinsamen Ordner (<code>_gemeinsam</code>).</div>
|
<div class="card-desc" style="margin-top:6px;">Du erhältst Vorlagen aus deiner Abteilung + dem gemeinsamen Ordner (<code>_gemeinsam</code>).</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1044,6 +1052,24 @@
|
|||||||
<div id="sync-log"></div>
|
<div id="sync-log"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ Tab: Erledigt ═══════════ -->
|
||||||
|
<div id="tab-erledigt" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">Aktionen</div>
|
||||||
|
<div class="card-desc">Der Button erscheint in der Nachrichtenansicht. Ein Klick markiert die E-Mail mit deinem Schlagwort und verschiebt sie in den gewählten Ordner. Bei mehreren Aktionen öffnet sich ein Auswahlmenü.</div>
|
||||||
|
|
||||||
|
<div id="erledigt-actions-list"></div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<button type="button" class="btn btn-secondary" id="add-erledigt-action">+ Aktion hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sync-actions" style="margin-top:16px;">
|
||||||
|
<button type="button" class="btn btn-primary" id="save-erledigt-config">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Offline Banner -->
|
<!-- Offline Banner -->
|
||||||
<div id="offline-banner" class="offline-banner">Keine Internetverbindung – Synchronisation nicht möglich</div>
|
<div id="offline-banner" class="offline-banner">Keine Internetverbindung – Synchronisation nicht möglich</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1119,6 +1119,42 @@ function combineSignature(header, footer) {
|
|||||||
return header + '\n' + FOOTER_SEPARATOR + '\n' + footer;
|
return header + '\n' + FOOTER_SEPARATOR + '\n' + footer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wendet den Footer lokal auf alle eingerichteten Identitäten an,
|
||||||
|
// indem der vorhandene Header (lokal) mit dem neuen Footer neu kombiniert wird.
|
||||||
|
// Funktioniert auch ohne hochgeladenen Server-Header.
|
||||||
|
async function applyFooterLocally(footer) {
|
||||||
|
const sourceMap = await getSigSourceMap();
|
||||||
|
let applied = 0;
|
||||||
|
for (const identity of allIdentities) {
|
||||||
|
const source = sourceMap[identity.email.toLowerCase()] || 'own';
|
||||||
|
|
||||||
|
let header;
|
||||||
|
if (source.startsWith('=')) {
|
||||||
|
const srcEmail = source.substring(1);
|
||||||
|
const srcIdentity = allIdentities.find(i => i.email.toLowerCase() === srcEmail);
|
||||||
|
if (!srcIdentity) continue;
|
||||||
|
header = extractHeader(srcIdentity.signature || '');
|
||||||
|
} else {
|
||||||
|
header = extractHeader(identity.signature || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unangetastete, leere Konten nicht mit einem reinen Footer versehen
|
||||||
|
const alreadyManaged = (identity.signature || '').includes(FOOTER_SEPARATOR);
|
||||||
|
if (!header.trim() && !alreadyManaged) continue;
|
||||||
|
|
||||||
|
const fullSig = combineSignature(header, footer);
|
||||||
|
if (fullSig === identity.signature) { applied++; continue; }
|
||||||
|
|
||||||
|
await browser.identities.update(identity.id, {
|
||||||
|
signature: fullSig,
|
||||||
|
signatureIsPlainText: false,
|
||||||
|
});
|
||||||
|
identity.signature = fullSig;
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
// ── "Vorlage laden" Button ──
|
// ── "Vorlage laden" Button ──
|
||||||
|
|
||||||
document.getElementById('sig-load-template').addEventListener('click', async () => {
|
document.getElementById('sig-load-template').addEventListener('click', async () => {
|
||||||
@@ -1167,6 +1203,7 @@ function showSigStatus(message, color) {
|
|||||||
|
|
||||||
// Save signature to Thunderbird identity (header + footer)
|
// Save signature to Thunderbird identity (header + footer)
|
||||||
document.getElementById('sig-save-button').addEventListener('click', async () => {
|
document.getElementById('sig-save-button').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
const identityId = sigIdentitySelect.value;
|
const identityId = sigIdentitySelect.value;
|
||||||
if (!identityId) {
|
if (!identityId) {
|
||||||
showSigStatus('Bitte Identität auswählen.', 'red');
|
showSigStatus('Bitte Identität auswählen.', 'red');
|
||||||
@@ -1193,26 +1230,49 @@ document.getElementById('sig-save-button').addEventListener('click', async () =>
|
|||||||
|
|
||||||
await browser.identities.update(identityId, {
|
await browser.identities.update(identityId, {
|
||||||
signature: fullSignature,
|
signature: fullSignature,
|
||||||
signatureIsPlainText: false
|
signatureIsPlainText: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (identity) identity.signature = fullSignature;
|
if (identity) identity.signature = fullSignature;
|
||||||
|
|
||||||
|
// Update all identities that reference this email as source
|
||||||
|
for (const otherId of allIdentities) {
|
||||||
|
const otherSource = sourceMap[otherId.email.toLowerCase()] || 'own';
|
||||||
|
if (otherSource === `=${identity.email.toLowerCase()}`) {
|
||||||
|
await browser.identities.update(otherId.id, {
|
||||||
|
signature: fullSignature,
|
||||||
|
signatureIsPlainText: false,
|
||||||
|
});
|
||||||
|
otherId.signature = fullSignature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateSigSyncIndicator();
|
updateSigSyncIndicator();
|
||||||
|
|
||||||
// Auto-push to server
|
// Auto-push to server, then pull to resolve reference chains
|
||||||
try {
|
try {
|
||||||
const pushResult = await browser.runtime.sendMessage({ action: 'pushSignatures' });
|
await browser.runtime.sendMessage({ action: 'pushSignatures' });
|
||||||
await loadIdentities();
|
|
||||||
for (const id of allIdentities) {
|
|
||||||
sigSyncedHashes[id.email.toLowerCase()] = simpleHash(id.signature || '');
|
|
||||||
}
|
|
||||||
saveSyncHashes();
|
|
||||||
updateSigSyncIndicator();
|
|
||||||
const label = source.startsWith('=') ? ` (von ${source.substring(1)})` : '';
|
|
||||||
showSigStatus(`Signatur gespeichert & hochgeladen!${label}`, 'green');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showSigStatus('Gespeichert, aber Upload fehlgeschlagen: ' + err.message, '#e65100');
|
showSigStatus('Gespeichert, aber Upload fehlgeschlagen: ' + err.message, '#e65100');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.runtime.sendMessage({ action: 'pullSignatures' });
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
await loadIdentities();
|
||||||
|
for (const id of allIdentities) {
|
||||||
|
sigSyncedHashes[id.email.toLowerCase()] = simpleHash(id.signature || '');
|
||||||
|
}
|
||||||
|
saveSyncHashes();
|
||||||
|
updateSigSyncIndicator();
|
||||||
|
const label = source.startsWith('=') ? ` (von ${source.substring(1)})` : '';
|
||||||
|
showSigStatus(`Signatur gespeichert & hochgeladen!${label}`, 'green');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Signatur-Speichern Fehler:', err);
|
||||||
|
showSigStatus('Fehler: ' + err.message, 'red');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Import signature from HTML file
|
// Import signature from HTML file
|
||||||
@@ -1304,45 +1364,94 @@ document.getElementById('sig-sync-refresh').addEventListener('click', async () =
|
|||||||
// ── Footer Editor ──
|
// ── Footer Editor ──
|
||||||
|
|
||||||
const footerEditorArea = document.getElementById('footer-editor-area');
|
const footerEditorArea = document.getElementById('footer-editor-area');
|
||||||
|
const footerScopeSelect = document.getElementById('footer-scope');
|
||||||
|
|
||||||
setupToolbarCommands('footer-toolbar', footerEditorArea);
|
setupToolbarCommands('footer-toolbar', footerEditorArea);
|
||||||
setupImageInsert('footer-insert-image', 'footer-image-file', footerEditorArea);
|
setupImageInsert('footer-insert-image', 'footer-image-file', footerEditorArea);
|
||||||
|
|
||||||
|
// Keep the "Abteilung" option label in sync with the selected department
|
||||||
|
function updateFooterScopeLabel() {
|
||||||
|
const dept = document.getElementById('sync-department')?.value || '';
|
||||||
|
const deptOption = footerScopeSelect?.querySelector('option[value="department"]');
|
||||||
|
if (deptOption) {
|
||||||
|
deptOption.textContent = dept ? `Abteilung: ${dept}` : 'Abteilung';
|
||||||
|
deptOption.disabled = !dept;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFooterScope() {
|
||||||
|
return footerScopeSelect?.value === 'department' ? 'department' : 'shared';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default-Auswahl: Abteilungs-Footer bevorzugen, wenn einer existiert.
|
||||||
|
// Sonst gemeinsam. Wird nur für die Vorbelegung genutzt — manuelles
|
||||||
|
// Umschalten bleibt erhalten.
|
||||||
|
async function pickDefaultFooterScope() {
|
||||||
|
const dept = document.getElementById('sync-department')?.value || '';
|
||||||
|
if (!dept) return 'shared';
|
||||||
|
try {
|
||||||
|
const res = await browser.runtime.sendMessage({ action: 'loadFooter', scope: 'department' });
|
||||||
|
if (res && res.success && res.html) return 'department';
|
||||||
|
} catch (_) {}
|
||||||
|
return 'shared';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Race-Schutz: nur das Ergebnis des zuletzt gestarteten Loads anwenden
|
||||||
|
let footerLoadSeq = 0;
|
||||||
|
|
||||||
|
// Load footer for the currently selected scope into the editor
|
||||||
|
async function loadFooterForScope(showStatus) {
|
||||||
|
const scope = currentFooterScope();
|
||||||
|
// Gleiche Offline-Prüfung wie bei den anderen Netzwerk-Aktionen
|
||||||
|
if (showStatus && !checkOnline()) return;
|
||||||
|
|
||||||
|
const seq = ++footerLoadSeq;
|
||||||
|
const loadBtn = document.getElementById('footer-load-button');
|
||||||
|
if (showStatus && loadBtn) loadBtn.disabled = true;
|
||||||
|
if (showStatus) showFooterStatus('Lade...', '#777');
|
||||||
|
try {
|
||||||
|
const result = await browser.runtime.sendMessage({ action: 'loadFooter', scope });
|
||||||
|
// Veralteter Lauf: ein neuerer Load wurde gestartet → Ergebnis verwerfen
|
||||||
|
if (seq !== footerLoadSeq) return;
|
||||||
|
if (result && result.success) {
|
||||||
|
footerEditorArea.innerHTML = result.html || '';
|
||||||
|
if (showStatus) {
|
||||||
|
const where = scope === 'shared' ? 'gemeinsamer' : 'Abteilungs-';
|
||||||
|
showFooterStatus(result.html ? `${where}Fußbereich geladen.` : `Noch kein ${where}Fußbereich vorhanden — du kannst einen anlegen.`, result.html ? 'green' : '#e65100');
|
||||||
|
}
|
||||||
|
} else if (showStatus) {
|
||||||
|
showFooterStatus(result?.error || 'Fehler', 'red');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (seq === footerLoadSeq && showStatus) showFooterStatus('Fehler: ' + err.message, 'red');
|
||||||
|
} finally {
|
||||||
|
if (seq === footerLoadSeq && showStatus && loadBtn) loadBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('footer-toggle').addEventListener('click', async function() {
|
document.getElementById('footer-toggle').addEventListener('click', async function() {
|
||||||
this.classList.toggle('open');
|
this.classList.toggle('open');
|
||||||
document.getElementById('footer-body').classList.toggle('open');
|
document.getElementById('footer-body').classList.toggle('open');
|
||||||
|
|
||||||
// Auto-load footer when opening and editor is empty
|
// Auto-load footer when opening and editor is empty
|
||||||
if (this.classList.contains('open') && (!footerEditorArea.innerHTML || footerEditorArea.innerHTML === '<br>')) {
|
if (this.classList.contains('open') && (!footerEditorArea.innerHTML || footerEditorArea.innerHTML === '<br>')) {
|
||||||
try {
|
updateFooterScopeLabel();
|
||||||
const result = await browser.runtime.sendMessage({ action: 'loadFooter' });
|
// Standardmäßig den Abteilungs-Footer vorbelegen, falls vorhanden
|
||||||
if (result && result.success && result.html) {
|
if (footerScopeSelect) footerScopeSelect.value = await pickDefaultFooterScope();
|
||||||
footerEditorArea.innerHTML = result.html;
|
await loadFooterForScope(false);
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reload editor content when switching scope
|
||||||
|
footerScopeSelect?.addEventListener('change', () => loadFooterForScope(true));
|
||||||
|
|
||||||
function showFooterStatus(message, color) {
|
function showFooterStatus(message, color) {
|
||||||
const type = color === 'green' ? 'success' : color === 'red' ? 'error' : 'info';
|
const type = color === 'green' ? 'success' : color === 'red' ? 'error' : 'info';
|
||||||
showToast(message, type, type === 'error' ? 6000 : 4000);
|
showToast(message, type, type === 'error' ? 6000 : 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load footer from server
|
// Load footer from server
|
||||||
document.getElementById('footer-load-button').addEventListener('click', async () => {
|
document.getElementById('footer-load-button').addEventListener('click', () => loadFooterForScope(true));
|
||||||
showFooterStatus('Lade...', '#777');
|
|
||||||
try {
|
|
||||||
const result = await browser.runtime.sendMessage({ action: 'loadFooter' });
|
|
||||||
if (result && result.success) {
|
|
||||||
footerEditorArea.innerHTML = result.html || '';
|
|
||||||
showFooterStatus(result.html ? 'Fußbereich geladen.' : 'Kein Fußbereich für diese Abteilung gefunden.', result.html ? 'green' : '#e65100');
|
|
||||||
} else {
|
|
||||||
showFooterStatus(result?.error || 'Fehler', 'red');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showFooterStatus('Fehler: ' + err.message, 'red');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save & push footer
|
// Save & push footer
|
||||||
document.getElementById('footer-save-button').addEventListener('click', async () => {
|
document.getElementById('footer-save-button').addEventListener('click', async () => {
|
||||||
@@ -1352,16 +1461,57 @@ document.getElementById('footer-save-button').addEventListener('click', async ()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!checkOnline()) return;
|
||||||
|
const scope = currentFooterScope();
|
||||||
|
const saveBtn = document.getElementById('footer-save-button');
|
||||||
|
saveBtn.disabled = true;
|
||||||
showFooterStatus('Speichere...', '#777');
|
showFooterStatus('Speichere...', '#777');
|
||||||
try {
|
try {
|
||||||
const result = await browser.runtime.sendMessage({ action: 'pushFooter', html });
|
const result = await browser.runtime.sendMessage({ action: 'pushFooter', html, scope });
|
||||||
if (result && result.success) {
|
if (!result || !result.success) {
|
||||||
showFooterStatus('Fußbereich gespeichert & hochgeladen!', 'green');
|
|
||||||
} else {
|
|
||||||
showFooterStatus(result?.error || 'Fehler', 'red');
|
showFooterStatus(result?.error || 'Fehler', 'red');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuen Footer direkt (lokal) auf die Thunderbird-Identitäten anwenden —
|
||||||
|
// funktioniert auch ohne hochgeladenen Server-Header.
|
||||||
|
const footer = await getFooter();
|
||||||
|
const applied = await applyFooterLocally(footer);
|
||||||
|
|
||||||
|
// Geänderte Header zusätzlich hochladen (best effort), damit andere
|
||||||
|
// Geräte beim nächsten Sync den kombinierten Stand erhalten.
|
||||||
|
try { await browser.runtime.sendMessage({ action: 'pushSignatures' }); } catch (_) {}
|
||||||
|
|
||||||
|
for (const id of allIdentities) {
|
||||||
|
sigSyncedHashes[id.email.toLowerCase()] = simpleHash(id.signature || '');
|
||||||
|
}
|
||||||
|
saveSyncHashes();
|
||||||
|
updateSigSyncIndicator();
|
||||||
|
// Aktuell geöffneten Signatur-Editor neu rendern
|
||||||
|
sigIdentitySelect.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
// Wenn "gemeinsam" gespeichert wurde, aber ein Abteilungs-Footer
|
||||||
|
// existiert, verdeckt dieser den gemeinsamen beim Anwenden.
|
||||||
|
let shadowed = false;
|
||||||
|
if (scope === 'shared') {
|
||||||
|
try {
|
||||||
|
const dept = await browser.runtime.sendMessage({ action: 'loadFooter', scope: 'department' });
|
||||||
|
shadowed = !!(dept && dept.success && dept.html);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = scope === 'shared' ? 'Gemeinsamer' : 'Abteilungs-';
|
||||||
|
if (shadowed) {
|
||||||
|
showFooterStatus('Gemeinsamer Fußbereich gespeichert — aber deine Abteilung hat einen eigenen Footer, der ihn überschreibt. Zum Anwenden den Abteilungs-Footer bearbeiten/löschen.', '#e65100');
|
||||||
|
} else if (applied > 0) {
|
||||||
|
showFooterStatus(`${where} Fußbereich gespeichert & in ${applied} Signatur(en) übernommen!`, 'green');
|
||||||
|
} else {
|
||||||
|
showFooterStatus(`${where} Fußbereich gespeichert. Hinweis: keine Signatur aktualisiert — erst eine persönliche Signatur speichern/hochladen.`, '#e65100');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showFooterStatus('Fehler: ' + err.message, 'red');
|
showFooterStatus('Fehler: ' + err.message, 'red');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1384,6 +1534,30 @@ async function populateEmailDropdown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function populateFolderDropdown() {
|
||||||
|
try {
|
||||||
|
let options = '';
|
||||||
|
const accounts = await browser.accounts.list();
|
||||||
|
for (const account of accounts) {
|
||||||
|
const folders = await browser.folders.getSubFolders(account);
|
||||||
|
const addFolders = (list, prefix) => {
|
||||||
|
for (const folder of list) {
|
||||||
|
const label = prefix ? `${prefix} / ${folder.name}` : `${account.name} / ${folder.name}`;
|
||||||
|
const val = JSON.stringify({ accountId: account.id, path: folder.path });
|
||||||
|
options += `<option value='${val.replace(/'/g, ''')}'>${label}</option>`;
|
||||||
|
if (folder.subFolders && folder.subFolders.length) {
|
||||||
|
addFolders(folder.subFolders, label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
addFolders(folders, '');
|
||||||
|
}
|
||||||
|
erledigtFolderOptions = options;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ordner laden fehlgeschlagen:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSyncConfig() {
|
async function loadSyncConfig() {
|
||||||
try {
|
try {
|
||||||
const result = await browser.storage.local.get(SYNC_CONFIG_KEY);
|
const result = await browser.storage.local.get(SYNC_CONFIG_KEY);
|
||||||
@@ -1568,6 +1742,7 @@ async function loadDepartments() {
|
|||||||
if (dept === savedDept) opt.selected = true;
|
if (dept === savedDept) opt.selected = true;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
}
|
}
|
||||||
|
updateFooterScopeLabel();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -1604,6 +1779,8 @@ document.getElementById('sync-department').addEventListener('change', async () =
|
|||||||
config.department = document.getElementById('sync-department').value;
|
config.department = document.getElementById('sync-department').value;
|
||||||
await browser.storage.local.set({ [SYNC_CONFIG_KEY]: config });
|
await browser.storage.local.set({ [SYNC_CONFIG_KEY]: config });
|
||||||
|
|
||||||
|
updateFooterScopeLabel();
|
||||||
|
|
||||||
if (config.department) {
|
if (config.department) {
|
||||||
try {
|
try {
|
||||||
// Pull templates for new department
|
// Pull templates for new department
|
||||||
@@ -1636,6 +1813,23 @@ for (const id of ['sync-author-name', 'sync-author-email']) {
|
|||||||
|
|
||||||
document.getElementById('refresh-departments').addEventListener('click', loadDepartments);
|
document.getElementById('refresh-departments').addEventListener('click', loadDepartments);
|
||||||
|
|
||||||
|
document.getElementById('add-department').addEventListener('click', async () => {
|
||||||
|
const name = prompt('Name der neuen Abteilung:');
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
try {
|
||||||
|
const result = await browser.runtime.sendMessage({ action: 'addDepartment', name: name.trim() });
|
||||||
|
if (result?.success) {
|
||||||
|
showToast(`Abteilung "${name.trim()}" angelegt!`, 'success');
|
||||||
|
await loadDepartments();
|
||||||
|
document.getElementById('sync-department').value = name.trim();
|
||||||
|
} else {
|
||||||
|
showToast(result?.error || 'Fehler beim Anlegen', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Fehler: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('test-sync-connection').addEventListener('click', async () => {
|
document.getElementById('test-sync-connection').addEventListener('click', async () => {
|
||||||
if (!checkOnline()) return;
|
if (!checkOnline()) return;
|
||||||
const btn = document.getElementById('test-sync-connection');
|
const btn = document.getElementById('test-sync-connection');
|
||||||
@@ -1663,6 +1857,80 @@ document.getElementById('test-sync-connection').addEventListener('click', async
|
|||||||
btn.textContent = origText;
|
btn.textContent = origText;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Erledigt-Tab ──
|
||||||
|
|
||||||
|
const ERLEDIGT_CONFIG_KEY = 'erledigt_config';
|
||||||
|
let erledigtFolderOptions = '';
|
||||||
|
|
||||||
|
function renderErledigtAction(action, index) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'form-row';
|
||||||
|
div.style.alignItems = 'end';
|
||||||
|
div.style.marginBottom = '8px';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="form-group" style="flex:1;">
|
||||||
|
${index === 0 ? '<label>Name</label>' : ''}
|
||||||
|
<input type="text" class="erledigt-name" placeholder="z.B. Erledigt" value="${(action.name || '').replace(/"/g, '"')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:2;">
|
||||||
|
${index === 0 ? '<label>Zielordner</label>' : ''}
|
||||||
|
<select class="erledigt-folder" style="width:100%;">
|
||||||
|
<option value="">— Kein Ordner (nur markieren) —</option>
|
||||||
|
${erledigtFolderOptions}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm erledigt-remove" title="Entfernen" style="margin-bottom:2px;">✕</button>
|
||||||
|
`;
|
||||||
|
if (action.targetFolder) {
|
||||||
|
div.querySelector('.erledigt-folder').value = action.targetFolder;
|
||||||
|
}
|
||||||
|
div.querySelector('.erledigt-remove').addEventListener('click', () => {
|
||||||
|
div.remove();
|
||||||
|
});
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderErledigtActions(actions) {
|
||||||
|
const list = document.getElementById('erledigt-actions-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
actions.forEach((a, i) => list.appendChild(renderErledigtAction(a, i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErledigtActionsFromForm() {
|
||||||
|
const actions = [];
|
||||||
|
const names = document.querySelectorAll('.erledigt-name');
|
||||||
|
const folders = document.querySelectorAll('.erledigt-folder');
|
||||||
|
for (let i = 0; i < names.length; i++) {
|
||||||
|
const name = names[i].value.trim();
|
||||||
|
if (!name) continue;
|
||||||
|
actions.push({ name, targetFolder: folders[i].value || '' });
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadErledigtConfig() {
|
||||||
|
const result = await browser.storage.local.get(ERLEDIGT_CONFIG_KEY);
|
||||||
|
const config = result[ERLEDIGT_CONFIG_KEY] || {};
|
||||||
|
const actions = config.actions || [];
|
||||||
|
if (actions.length === 0) actions.push({ name: 'Erledigt', targetFolder: '' });
|
||||||
|
renderErledigtActions(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('add-erledigt-action').addEventListener('click', () => {
|
||||||
|
const list = document.getElementById('erledigt-actions-list');
|
||||||
|
list.appendChild(renderErledigtAction({ name: '', targetFolder: '' }, list.children.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('save-erledigt-config').addEventListener('click', async () => {
|
||||||
|
const actions = getErledigtActionsFromForm();
|
||||||
|
if (actions.length === 0) {
|
||||||
|
showToast('Mindestens eine Aktion mit Namen anlegen.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await browser.storage.local.set({ [ERLEDIGT_CONFIG_KEY]: { actions } });
|
||||||
|
showToast('Aktionen gespeichert!', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
// ── Init ──
|
// ── Init ──
|
||||||
|
|
||||||
window.addEventListener('load', async () => {
|
window.addEventListener('load', async () => {
|
||||||
@@ -1671,20 +1939,35 @@ window.addEventListener('load', async () => {
|
|||||||
renderTemplates(templates);
|
renderTemplates(templates);
|
||||||
updateTplSyncIndicator();
|
updateTplSyncIndicator();
|
||||||
await populateEmailDropdown();
|
await populateEmailDropdown();
|
||||||
|
await populateFolderDropdown();
|
||||||
|
await loadErledigtConfig();
|
||||||
loadSyncConfig();
|
loadSyncConfig();
|
||||||
await loadIdentities();
|
await loadIdentities();
|
||||||
|
|
||||||
|
// Always init sig hashes from current identities
|
||||||
|
if (allIdentities.length > 0) {
|
||||||
|
for (const id of allIdentities) {
|
||||||
|
sigSyncedHashes[id.email.toLowerCase()] = simpleHash(id.signature || '');
|
||||||
|
}
|
||||||
|
saveSyncHashes();
|
||||||
|
}
|
||||||
|
updateSigSyncIndicator();
|
||||||
|
|
||||||
checkForServerUpdates();
|
checkForServerUpdates();
|
||||||
setInterval(checkForServerUpdates, 30000);
|
setInterval(checkForServerUpdates, 30000);
|
||||||
|
|
||||||
// Auto-refresh UI when background sync updates hashes (fires after templates + hashes are both written)
|
// Auto-refresh UI when background sync updates storage
|
||||||
|
let refreshing = false;
|
||||||
browser.storage.onChanged.addListener(async (changes, area) => {
|
browser.storage.onChanged.addListener(async (changes, area) => {
|
||||||
if (area !== 'local') return;
|
if (area !== 'local' || refreshing) return;
|
||||||
if (changes[HASH_STORAGE_KEY]) {
|
if (changes[HASH_STORAGE_KEY] || changes[TEMPLATE_STORAGE_KEY]) {
|
||||||
|
refreshing = true;
|
||||||
await loadSyncHashes();
|
await loadSyncHashes();
|
||||||
const templates = await getTemplates();
|
const templates = await getTemplates();
|
||||||
renderTemplates(templates);
|
renderTemplates(templates);
|
||||||
updateTplSyncIndicator();
|
updateTplSyncIndicator();
|
||||||
|
updateSigSyncIndicator();
|
||||||
|
refreshing = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
9
toolbar_popup.html
Normal file
9
toolbar_popup.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="UTF-8"></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
browser.runtime.openOptionsPage();
|
||||||
|
window.close();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
updates.json
Normal file
38
updates.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"addons": {
|
||||||
|
"it@hotel-park-soltau.de": {
|
||||||
|
"updates": [
|
||||||
|
{
|
||||||
|
"version": "2.3.2",
|
||||||
|
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.2/templates-reply-hotel.xpi",
|
||||||
|
"update_hash": "sha256:8466d174aeda6c15577a45ed1cd442bc592d35a1da602d79b1f169ae3d012bd7",
|
||||||
|
"applications": {
|
||||||
|
"gecko": {
|
||||||
|
"strict_min_version": "109.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.3.1",
|
||||||
|
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.1/templates-reply-hotel.xpi",
|
||||||
|
"update_hash": "sha256:bcfb4feade849d1dabaccaa8b932ea6d57846c82f6e9796e2c39d577ffc09744",
|
||||||
|
"applications": {
|
||||||
|
"gecko": {
|
||||||
|
"strict_min_version": "109.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.3.0",
|
||||||
|
"update_link": "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates/releases/download/v2.3.0/templates-reply-hotel.xpi",
|
||||||
|
"update_hash": "sha256:94ca10bb1e35cc8183c4ed2cba640ad06b8cb25273a85d643c8920cfe11158ef",
|
||||||
|
"applications": {
|
||||||
|
"gecko": {
|
||||||
|
"strict_min_version": "109.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web-editor/.dockerignore
Normal file
6
web-editor/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
23
web-editor/.env.example
Normal file
23
web-editor/.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# ── Gitea/Forgejo-Verbindung (Pflicht) ──
|
||||||
|
# Basis-URL des Servers, ohne abschließenden Slash.
|
||||||
|
GITEA_URL=https://git.example.com
|
||||||
|
# Besitzer (Organisation oder Benutzer) und Repository-Name.
|
||||||
|
GITEA_OWNER=organisation
|
||||||
|
GITEA_REPO=email-vorlagen
|
||||||
|
GITEA_BRANCH=main
|
||||||
|
# API-Token mit Schreibrechten auf das Repository.
|
||||||
|
GITEA_TOKEN=dein-api-token
|
||||||
|
|
||||||
|
# ── Commit-Autor (optional) ──
|
||||||
|
# Name/E-Mail, mit dem Änderungen aus dem Web-Editor committet werden.
|
||||||
|
COMMIT_AUTHOR_NAME=Web-Editor
|
||||||
|
COMMIT_AUTHOR_EMAIL=
|
||||||
|
|
||||||
|
# ── Zugriffsschutz (optional, aber empfohlen) ──
|
||||||
|
# Wenn beide gesetzt sind, ist der Editor per HTTP-Basic-Auth geschützt.
|
||||||
|
BASIC_AUTH_USER=
|
||||||
|
BASIC_AUTH_PASS=
|
||||||
|
|
||||||
|
# ── Host-Port (nur docker-compose) ──
|
||||||
|
# Auf welchem Port der Editor am Host erreichbar ist (Standard 8080).
|
||||||
|
HOST_PORT=8080
|
||||||
3
web-editor/.gitignore
vendored
Normal file
3
web-editor/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
npm-debug.log
|
||||||
20
web-editor/Dockerfile
Normal file
20
web-editor/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first for better layer caching.
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
# App source.
|
||||||
|
COPY server.js ./
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Lightweight container healthcheck against the API.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:3000/api/config > /dev/null || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
152
web-editor/README.md
Normal file
152
web-editor/README.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# HPS Vorlagen & Signaturen – Web-Editor
|
||||||
|
|
||||||
|
Ein kleiner, in Docker laufender Web-Editor zum Pflegen der **E-Mail-Vorlagen** und
|
||||||
|
**Signaturen** für das Hotel Park Soltau. Er ist das Web-Gegenstück zum Thunderbird-Plugin
|
||||||
|
**„HPS Vorlagen & Signaturen“** und teilt sich mit ihm dieselbe **Gitea/Forgejo-Repository**
|
||||||
|
als gemeinsame Quelle der Wahrheit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was kann der Editor?
|
||||||
|
|
||||||
|
- **Vorlagen für alle Abteilungen** verwalten – gemeinsame Vorlagen (`_gemeinsam/`),
|
||||||
|
Abteilungsvorlagen (z. B. `Rezeption/`, `IT/`) und persönliche Vorlagen pro Benutzer
|
||||||
|
(`_benutzer/<email>/`).
|
||||||
|
- **Fußzeilen** pflegen – pro Abteilung (`signatures/footers/<Abteilung>.html`) sowie eine
|
||||||
|
gemeinsame Standard-Fußzeile (`signatures/footers/_default.html`).
|
||||||
|
- **Signatur-Köpfe** bearbeiten (`signatures/headers/<email>.<name-slug>.html`).
|
||||||
|
- **WYSIWYG-Bearbeitung**, Umschalten auf den **HTML-Quelltext** und eine **Live-Vorschau**.
|
||||||
|
- Jede Änderung wird **direkt als Commit ins Gitea-Repo** geschrieben.
|
||||||
|
|
||||||
|
Der Editor ist eine kleine **Node.js/Express-App** (`server.js`), die ein statisches Frontend
|
||||||
|
aus `public/` ausliefert und die **Gitea Contents API** weiterreicht. Der **Gitea-Token bleibt
|
||||||
|
serverseitig** und ist für den Browser nicht sichtbar. Konfiguriert wird ausschließlich über
|
||||||
|
**Umgebungsvariablen**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- **Docker** mit **docker compose** – *oder* **Node.js ≥ 18**, falls ohne Docker betrieben.
|
||||||
|
- Ein **Gitea/Forgejo-API-Token mit Schreibrechten** auf das Vorlagen-Repository
|
||||||
|
(siehe [Gitea-API-Token erstellen](#gitea-api-token-erstellen)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart mit docker compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Vorlage kopieren und ausfüllen
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # GITEA_URL, GITEA_OWNER, GITEA_REPO, GITEA_TOKEN eintragen
|
||||||
|
|
||||||
|
# 2. Bauen und starten
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Anschließend den Editor im Browser öffnen:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Den Host-Port kannst du über `HOST_PORT` in der `.env` ändern (Standard `8080`).
|
||||||
|
docker compose mappt `${HOST_PORT:-8080}:3000`.
|
||||||
|
|
||||||
|
Logs ansehen bzw. Editor stoppen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative: plain `docker build` / `docker run`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Image bauen
|
||||||
|
docker build -t hps-vorlagen-web-editor .
|
||||||
|
|
||||||
|
# Container starten (Umgebung aus .env, Port 8080 → 3000)
|
||||||
|
docker run -d \
|
||||||
|
--name hps-web-editor \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 8080:3000 \
|
||||||
|
--env-file .env \
|
||||||
|
hps-vorlagen-web-editor
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative: lokal ohne Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Abhängigkeiten installieren
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Umgebungsvariablen setzen (Beispiel)
|
||||||
|
export GITEA_URL="https://git.example.com"
|
||||||
|
export GITEA_OWNER="organisation"
|
||||||
|
export GITEA_REPO="email-vorlagen"
|
||||||
|
export GITEA_BRANCH="main"
|
||||||
|
export GITEA_TOKEN="dein-api-token"
|
||||||
|
|
||||||
|
# Starten
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Editor läuft dann auf dem in `PORT` eingestellten Port (Standard `3000`),
|
||||||
|
also unter `http://localhost:3000`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
| Name | Pflicht | Standard | Beschreibung |
|
||||||
|
| --------------------- | :-----: | -------------- | --------------------------------------------------------------------------- |
|
||||||
|
| `GITEA_URL` | **ja** | – | Basis-URL des Gitea/Forgejo-Servers, ohne abschließenden Slash. |
|
||||||
|
| `GITEA_OWNER` | **ja** | – | Besitzer des Repos (Organisation oder Benutzer). |
|
||||||
|
| `GITEA_REPO` | **ja** | – | Name des Vorlagen-Repositories. |
|
||||||
|
| `GITEA_BRANCH` | nein | `main` | Branch, in den committet wird. |
|
||||||
|
| `GITEA_TOKEN` | **ja** | – | API-Token mit Schreibrechten auf das Repository. Bleibt serverseitig. |
|
||||||
|
| `COMMIT_AUTHOR_NAME` | nein | `Web-Editor` | Name, mit dem Änderungen committet werden. |
|
||||||
|
| `COMMIT_AUTHOR_EMAIL` | nein | – | E-Mail-Adresse des Commit-Autors. |
|
||||||
|
| `BASIC_AUTH_USER` | nein | – | Benutzername für HTTP-Basic-Auth (siehe [Sicherheit](#sicherheit)). |
|
||||||
|
| `BASIC_AUTH_PASS` | nein | – | Passwort für HTTP-Basic-Auth. Schutz aktiv, wenn beide Werte gesetzt sind. |
|
||||||
|
| `PORT` | nein | `3000` | Port, auf dem die App im Container/Prozess lauscht. |
|
||||||
|
| `HOST_PORT` | nein | `8080` | Nur für docker compose: Port, unter dem der Editor am Host erreichbar ist. |
|
||||||
|
|
||||||
|
Eine fertige Vorlage findest du in [`.env.example`](.env.example) – einfach kopieren
|
||||||
|
und ausfüllen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gitea-API-Token erstellen
|
||||||
|
|
||||||
|
1. In Gitea/Forgejo oben rechts auf das Profilbild → **Einstellungen**.
|
||||||
|
2. Reiter **Anwendungen** öffnen.
|
||||||
|
3. Unter **Token verwalten** einen neuen Token **generieren**.
|
||||||
|
4. Als **Scope** mindestens **Schreibrechte auf Repositories** (`repo` bzw. `write:repository`)
|
||||||
|
vergeben.
|
||||||
|
5. Den angezeigten Token **sofort kopieren** (er wird nur einmal angezeigt) und als
|
||||||
|
`GITEA_TOKEN` in die `.env` eintragen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- **Wenn der Editor öffentlich erreichbar ist, unbedingt `BASIC_AUTH_USER` und
|
||||||
|
`BASIC_AUTH_PASS` setzen.** Sind beide gesetzt, ist der gesamte Editor per
|
||||||
|
HTTP-Basic-Auth geschützt.
|
||||||
|
- Die **`.env` niemals committen** – sie enthält den Gitea-Token. Sie steht bereits in der
|
||||||
|
`.gitignore` und bleibt damit außerhalb der Versionskontrolle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hinweis zum Zusammenspiel mit dem Thunderbird-Plugin
|
||||||
|
|
||||||
|
Jede im Editor gespeicherte Änderung landet **sofort als Commit im Gitea-Repo**. Das
|
||||||
|
Thunderbird-Plugin **„HPS Vorlagen & Signaturen“** nutzt dasselbe Repo als Quelle der Wahrheit
|
||||||
|
und übernimmt die Änderungen automatisch beim nächsten **Sync** (alle paar Sekunden bis
|
||||||
|
spätestens alle 15 Minuten). Ein manueller Export ist nicht nötig.
|
||||||
31
web-editor/docker-compose.yml
Normal file
31
web-editor/docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
web-editor:
|
||||||
|
# Quellcode direkt aus dem Git-Repo bauen (kein Kopieren nötig).
|
||||||
|
# Für lokale Entwicklung stattdessen `build: .` verwenden (siehe unten).
|
||||||
|
build:
|
||||||
|
context: "https://git.hotel-park-soltau.de/hps/hps-thunderbird-templates.git#main:web-editor"
|
||||||
|
secrets:
|
||||||
|
- GIT_AUTH_TOKEN
|
||||||
|
# build: . # ← lokale Variante: baut aus diesem Ordner statt aus Git
|
||||||
|
image: hps-vorlagen-web-editor
|
||||||
|
container_name: hps-web-editor
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-8080}:3000"
|
||||||
|
environment:
|
||||||
|
GITEA_URL: ${GITEA_URL}
|
||||||
|
GITEA_OWNER: ${GITEA_OWNER}
|
||||||
|
GITEA_REPO: ${GITEA_REPO}
|
||||||
|
GITEA_BRANCH: ${GITEA_BRANCH:-main}
|
||||||
|
GITEA_TOKEN: ${GITEA_TOKEN}
|
||||||
|
COMMIT_AUTHOR_NAME: ${COMMIT_AUTHOR_NAME:-Web-Editor}
|
||||||
|
COMMIT_AUTHOR_EMAIL: ${COMMIT_AUTHOR_EMAIL:-}
|
||||||
|
BASIC_AUTH_USER: ${BASIC_AUTH_USER:-}
|
||||||
|
BASIC_AUTH_PASS: ${BASIC_AUTH_PASS:-}
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
# BuildKit nutzt dieses Secret, um das (private) Repo beim Git-Build zu klonen.
|
||||||
|
secrets:
|
||||||
|
GIT_AUTH_TOKEN:
|
||||||
|
environment: GITEA_TOKEN
|
||||||
838
web-editor/package-lock.json
generated
Normal file
838
web-editor/package-lock.json
generated
Normal file
@@ -0,0 +1,838 @@
|
|||||||
|
{
|
||||||
|
"name": "hps-vorlagen-web-editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "hps-vorlagen-web-editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"tinymce": "^7.9.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/array-flatten": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/body-parser": {
|
||||||
|
"version": "1.20.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||||
|
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"content-type": "~1.0.5",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "~1.2.0",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"qs": "~6.15.1",
|
||||||
|
"raw-body": "~2.5.3",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bytes": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-disposition": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/content-type": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/depd": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/destroy": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8",
|
||||||
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ee-first": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/encodeurl": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/escape-html": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/etag": {
|
||||||
|
"version": "1.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
|
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express": {
|
||||||
|
"version": "4.22.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.8",
|
||||||
|
"array-flatten": "1.1.1",
|
||||||
|
"body-parser": "~1.20.5",
|
||||||
|
"content-disposition": "~0.5.4",
|
||||||
|
"content-type": "~1.0.4",
|
||||||
|
"cookie": "~0.7.1",
|
||||||
|
"cookie-signature": "~1.0.6",
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"finalhandler": "~1.3.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.0",
|
||||||
|
"merge-descriptors": "1.0.3",
|
||||||
|
"methods": "~1.1.2",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"path-to-regexp": "~0.1.12",
|
||||||
|
"proxy-addr": "~2.0.7",
|
||||||
|
"qs": "~6.15.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"safe-buffer": "5.2.1",
|
||||||
|
"send": "~0.19.0",
|
||||||
|
"serve-static": "~1.16.2",
|
||||||
|
"setprototypeof": "1.2.0",
|
||||||
|
"statuses": "~2.0.1",
|
||||||
|
"type-is": "~1.6.18",
|
||||||
|
"utils-merge": "1.0.1",
|
||||||
|
"vary": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/finalhandler": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/forwarded": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fresh": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/media-typer": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/merge-descriptors": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/methods": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-inspect": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/on-finished": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ee-first": "1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseurl": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-to-regexp": {
|
||||||
|
"version": "0.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/proxy-addr": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"forwarded": "0.2.0",
|
||||||
|
"ipaddr.js": "1.9.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qs": {
|
||||||
|
"version": "6.15.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"side-channel": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/range-parser": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body": {
|
||||||
|
"version": "2.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||||
|
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bytes": "~3.1.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"iconv-lite": "~0.4.24",
|
||||||
|
"unpipe": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/send": {
|
||||||
|
"version": "0.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
|
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "2.6.9",
|
||||||
|
"depd": "2.0.0",
|
||||||
|
"destroy": "1.2.0",
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"http-errors": "~2.0.1",
|
||||||
|
"mime": "1.6.0",
|
||||||
|
"ms": "2.1.3",
|
||||||
|
"on-finished": "~2.4.1",
|
||||||
|
"range-parser": "~1.2.1",
|
||||||
|
"statuses": "~2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/send/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/serve-static": {
|
||||||
|
"version": "1.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||||
|
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encodeurl": "~2.0.0",
|
||||||
|
"escape-html": "~1.0.3",
|
||||||
|
"parseurl": "~1.3.3",
|
||||||
|
"send": "~0.19.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/setprototypeof": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/side-channel": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4",
|
||||||
|
"side-channel-list": "^1.0.1",
|
||||||
|
"side-channel-map": "^1.0.1",
|
||||||
|
"side-channel-weakmap": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-list": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"object-inspect": "^1.13.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/side-channel-weakmap": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.5",
|
||||||
|
"object-inspect": "^1.13.3",
|
||||||
|
"side-channel-map": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinymce": {
|
||||||
|
"version": "7.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.9.3.tgz",
|
||||||
|
"integrity": "sha512-Mtm54U5YJ6Pyo/GaAx+JSHXTGEuxrg2AowVWCD9zy1eBolp5Ub7S1rTtsyQdxhPegfhLuR3VLiTKGw1tacv09g==",
|
||||||
|
"license": "GPL-2.0-or-later"
|
||||||
|
},
|
||||||
|
"node_modules/toidentifier": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/type-is": {
|
||||||
|
"version": "1.6.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"media-typer": "0.3.0",
|
||||||
|
"mime-types": "~2.1.24"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unpipe": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/utils-merge": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
web-editor/package.json
Normal file
18
web-editor/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "hps-vorlagen-web-editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Web-Editor für HPS E-Mail-Vorlagen, Signaturen, Kopf- und Fußzeilen (Gitea/Forgejo)",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"demo": "DEMO=1 node server.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"tinymce": "^7.9.3"
|
||||||
|
},
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
726
web-editor/public/app.js
Normal file
726
web-editor/public/app.js
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
/* HPS Vorlagen & Signaturen — Web-Editor Frontend
|
||||||
|
* Vanilla JS + TinyMCE (selbst gehostet). Spricht ausschließlich mit dem
|
||||||
|
* Express-Backend (same origin) über die /api-Endpunkte.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Backend-Konstanten ──
|
||||||
|
const SHARED_FOLDER = '_gemeinsam';
|
||||||
|
const USER_FOLDER = '_benutzer';
|
||||||
|
const SIG_FOOTERS = 'signatures/footers';
|
||||||
|
const SIG_HEADERS = 'signatures/headers';
|
||||||
|
|
||||||
|
// ── Icon-Set (Lucide-Stil, currentColor) ──
|
||||||
|
const ICONS = {
|
||||||
|
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
|
||||||
|
'panel-bottom': '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 15h18"/>',
|
||||||
|
'pen-line': '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||||
|
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||||
|
'globe': '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||||
|
'building': '<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4"/><path d="M8 6h.01M16 6h.01M12 6h.01M12 10h.01M12 14h.01M16 10h.01M16 14h.01M8 10h.01M8 14h.01"/>',
|
||||||
|
'at-sign': '<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/>',
|
||||||
|
'plus': '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||||
|
'refresh': '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
|
||||||
|
'reload': '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>',
|
||||||
|
'save': '<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/>',
|
||||||
|
'trash': '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
|
||||||
|
'search': '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||||
|
'dashboard': '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
|
||||||
|
'tag': '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
|
||||||
|
'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"/>',
|
||||||
|
'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"/>',
|
||||||
|
};
|
||||||
|
function icon(name, size) {
|
||||||
|
const s = size || 18;
|
||||||
|
return '<svg viewBox="0 0 24 24" width="' + s + '" height="' + s + '" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + (ICONS[name] || '') + '</svg>';
|
||||||
|
}
|
||||||
|
function hydrateIcons(root) {
|
||||||
|
(root || document).querySelectorAll('[data-icon]').forEach((e) => { e.innerHTML = icon(e.dataset.icon); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zustand ──
|
||||||
|
const state = {
|
||||||
|
config: null, tree: null,
|
||||||
|
category: 'templates', // templates | footers | headers | admin
|
||||||
|
adminView: 'overview', // overview | departments | mapping | tags
|
||||||
|
current: null, dirty: false,
|
||||||
|
view: 'visual', html: '',
|
||||||
|
groupsCollapsed: {},
|
||||||
|
};
|
||||||
|
let ed = null, edReady = false, suppressDirty = false, pendingNetwork = 0;
|
||||||
|
|
||||||
|
// ── DOM ──
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const el = {
|
||||||
|
statusPill: $('status-pill'), statusText: $('status-text'), configBanner: $('config-banner'),
|
||||||
|
catTabs: document.querySelectorAll('.cat-tab'),
|
||||||
|
btnRefresh: $('btn-refresh'), btnListAdd: $('btn-list-add'), btnListAddLabel: $('btn-list-add-label'),
|
||||||
|
treeSearch: $('tree-search'), listBody: $('list-body'),
|
||||||
|
emptyState: $('empty-state'), editorPanel: $('editor-panel'), adminPanel: $('admin-panel'),
|
||||||
|
fileFriendly: $('file-friendly'), filePath: $('file-path'), dirtyBadge: $('dirty-badge'),
|
||||||
|
btnSave: $('btn-save'), btnReload: $('btn-reload'), btnDelete: $('btn-delete'),
|
||||||
|
tabVisual: $('tab-visual'), tabHtml: $('tab-html'), tabPreview: $('tab-preview'),
|
||||||
|
paneVisual: $('pane-visual'), paneHtml: $('pane-html'), panePreview: $('pane-preview'),
|
||||||
|
htmlEditor: $('html-editor'), previewFrame: $('preview-frame'),
|
||||||
|
toastStack: $('toast-stack'), loading: $('loading-overlay'),
|
||||||
|
confirmBackdrop: $('confirm-backdrop'), confirmTitle: $('confirm-title'), confirmMessage: $('confirm-message'),
|
||||||
|
confirmOk: $('confirm-ok'), confirmCancel: $('confirm-cancel'),
|
||||||
|
promptBackdrop: $('prompt-backdrop'), promptForm: $('prompt-form'), promptTitle: $('prompt-title'),
|
||||||
|
promptFields: $('prompt-fields'), promptOk: $('prompt-ok'), promptCancel: $('prompt-cancel'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helfer ──
|
||||||
|
function esc(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
||||||
|
function slugifyName(name) {
|
||||||
|
return name.replace(/[äÄ]/g, 'ae').replace(/[öÖ]/g, 'oe').replace(/[üÜ]/g, 'ue').replace(/ß/g, 'ss')
|
||||||
|
.replace(/[/\\:*?"<>|]/g, '-').replace(/^[\s.-]+|[\s.-]+$/g, '').trim();
|
||||||
|
}
|
||||||
|
function slugifyHeaderName(name) { return slugifyName(name).toLowerCase().replace(/\s+/g, '-'); }
|
||||||
|
|
||||||
|
// ── Toasts ──
|
||||||
|
function toast(message, type) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'toast' + (type ? ' toast-' + type : '');
|
||||||
|
const ic = type === 'success' ? '✓' : type === 'error' ? '⚠' : 'ℹ';
|
||||||
|
t.innerHTML = '<span class="toast-icon">' + ic + '</span><span class="toast-msg"></span>';
|
||||||
|
t.querySelector('.toast-msg').textContent = message;
|
||||||
|
el.toastStack.appendChild(t);
|
||||||
|
setTimeout(() => { t.classList.add('fade-out'); t.addEventListener('animationend', () => t.remove(), { once: true }); }, 4200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading + API ──
|
||||||
|
function startLoading() { pendingNetwork++; el.loading.hidden = false; }
|
||||||
|
function stopLoading() { pendingNetwork = Math.max(0, pendingNetwork - 1); if (pendingNetwork === 0) el.loading.hidden = true; }
|
||||||
|
async function api(path, options) {
|
||||||
|
startLoading();
|
||||||
|
try {
|
||||||
|
const res = await fetch(path, options);
|
||||||
|
let data = null; const text = await res.text();
|
||||||
|
if (text) { try { data = JSON.parse(text); } catch (_) { data = { error: text }; } }
|
||||||
|
if (!res.ok) throw new Error((data && data.error) ? data.error : ('HTTP ' + res.status + ' ' + res.statusText));
|
||||||
|
return data || {};
|
||||||
|
} finally { stopLoading(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modals ──
|
||||||
|
function confirmModal(message, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
el.confirmTitle.textContent = opts.title || 'Bestätigen';
|
||||||
|
el.confirmMessage.textContent = message;
|
||||||
|
el.confirmOk.textContent = opts.okLabel || 'Bestätigen';
|
||||||
|
el.confirmOk.className = 'btn ' + (opts.danger ? 'btn-danger' : 'btn-primary');
|
||||||
|
el.confirmBackdrop.hidden = false;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
function cleanup(r) {
|
||||||
|
el.confirmBackdrop.hidden = true;
|
||||||
|
el.confirmOk.removeEventListener('click', onOk); el.confirmCancel.removeEventListener('click', onCancel); el.confirmBackdrop.removeEventListener('click', onBackdrop);
|
||||||
|
resolve(r);
|
||||||
|
}
|
||||||
|
const onOk = () => cleanup(true), onCancel = () => cleanup(false);
|
||||||
|
const onBackdrop = (e) => { if (e.target === el.confirmBackdrop) cleanup(false); };
|
||||||
|
el.confirmOk.addEventListener('click', onOk); el.confirmCancel.addEventListener('click', onCancel); el.confirmBackdrop.addEventListener('click', onBackdrop);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function promptModal(title, fields, onChange) {
|
||||||
|
el.promptTitle.textContent = title; el.promptFields.innerHTML = ''; const inputs = {};
|
||||||
|
fields.forEach((f) => {
|
||||||
|
const wrap = document.createElement('div'); wrap.className = 'field';
|
||||||
|
const label = document.createElement('label'); label.textContent = f.label; wrap.appendChild(label);
|
||||||
|
let input;
|
||||||
|
if (f.type === 'select') {
|
||||||
|
input = document.createElement('select');
|
||||||
|
(f.options || []).forEach((o) => { const opt = document.createElement('option'); opt.value = o.value; opt.textContent = o.label; input.appendChild(opt); });
|
||||||
|
if (f.value != null) input.value = f.value;
|
||||||
|
} else {
|
||||||
|
input = document.createElement('input'); input.type = f.type || 'text';
|
||||||
|
if (f.placeholder) input.placeholder = f.placeholder; if (f.value != null) input.value = f.value;
|
||||||
|
}
|
||||||
|
input.dataset.key = f.key; wrap.appendChild(input);
|
||||||
|
if (f.hint || f.live) { const h = document.createElement('div'); h.className = 'hint'; if (f.live) h.dataset.live = f.key; if (f.hint) h.textContent = f.hint; wrap.appendChild(h); }
|
||||||
|
el.promptFields.appendChild(wrap); inputs[f.key] = input;
|
||||||
|
});
|
||||||
|
const readValues = () => { const v = {}; Object.keys(inputs).forEach((k) => { v[k] = inputs[k].value; }); return v; };
|
||||||
|
el.promptBackdrop.hidden = false;
|
||||||
|
const first = el.promptFields.querySelector('input, select'); if (first) setTimeout(() => first.focus(), 30);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
function cleanup(r) {
|
||||||
|
el.promptBackdrop.hidden = true;
|
||||||
|
el.promptForm.removeEventListener('submit', onSubmit); el.promptCancel.removeEventListener('click', onCancel);
|
||||||
|
el.promptBackdrop.removeEventListener('click', onBackdrop); el.promptForm.removeEventListener('input', onInput);
|
||||||
|
resolve(r);
|
||||||
|
}
|
||||||
|
function onSubmit(e) {
|
||||||
|
e.preventDefault(); const values = readValues();
|
||||||
|
for (const f of fields) { if (f.required && !String(values[f.key] || '').trim()) { toast('Bitte „' + f.label + '“ ausfüllen.', 'error'); inputs[f.key].focus(); return; } }
|
||||||
|
cleanup(values);
|
||||||
|
}
|
||||||
|
const onCancel = () => cleanup(null);
|
||||||
|
const onBackdrop = (e) => { if (e.target === el.promptBackdrop) cleanup(null); };
|
||||||
|
const onInput = () => { if (onChange) onChange(readValues(), inputs, el.promptFields); };
|
||||||
|
el.promptForm.addEventListener('submit', onSubmit); el.promptCancel.addEventListener('click', onCancel);
|
||||||
|
el.promptBackdrop.addEventListener('click', onBackdrop); el.promptForm.addEventListener('input', onInput);
|
||||||
|
if (onChange) onChange(readValues(), inputs, el.promptFields);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anzeige-Namen ──
|
||||||
|
function footerLabel(name) { return name === '_default.html' ? 'Gemeinsam (alle Abteilungen)' : name.replace(/\.html$/i, ''); }
|
||||||
|
function headerLabel(name) {
|
||||||
|
if (name === '_vorlage.html') return 'Vorlage (Standard-Kopf)';
|
||||||
|
const base = name.replace(/\.html$/i, ''); const at = base.indexOf('@');
|
||||||
|
if (at >= 0) { const dot = base.indexOf('.', at); if (dot > -1 && dot < base.length - 1) return base.slice(0, dot) + ' — ' + base.slice(dot + 1); }
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
function templateLabel(name) { return name.replace(/\.html$/i, ''); }
|
||||||
|
function friendlyFor(category, name) {
|
||||||
|
if (category === 'footer') return 'Fußzeile: ' + footerLabel(name);
|
||||||
|
if (category === 'header') return 'Signatur: ' + headerLabel(name);
|
||||||
|
return templateLabel(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verbindungsstatus ──
|
||||||
|
async function loadConfigAndHealth() {
|
||||||
|
try {
|
||||||
|
const cfg = await api('/api/config'); state.config = cfg;
|
||||||
|
if (!cfg.configured) { el.configBanner.hidden = false; setStatus('error', 'Nicht konfiguriert'); return false; }
|
||||||
|
el.configBanner.hidden = true;
|
||||||
|
} catch (e) { setStatus('error', 'Nicht verbunden'); toast('Konfiguration nicht ladbar: ' + e.message, 'error'); return false; }
|
||||||
|
try {
|
||||||
|
const health = await api('/api/health');
|
||||||
|
if (health.ok) { const c = state.config; setStatus('ok', c.owner + '/' + c.repo + '@' + c.branch); }
|
||||||
|
else setStatus('error', 'Nicht verbunden: ' + (health.error || 'unbekannt'));
|
||||||
|
} catch (e) { setStatus('error', 'Nicht verbunden: ' + e.message); toast('Verbindung fehlgeschlagen: ' + e.message, 'error'); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function setStatus(kind, text) {
|
||||||
|
el.statusPill.className = 'status-pill status-' + (kind === 'ok' ? 'ok' : kind === 'error' ? 'error' : 'unknown');
|
||||||
|
el.statusText.textContent = text; el.statusPill.title = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hauptbereich umschalten ──
|
||||||
|
function showMain(kind) {
|
||||||
|
el.emptyState.hidden = kind !== 'empty';
|
||||||
|
el.editorPanel.hidden = kind !== 'editor';
|
||||||
|
el.adminPanel.hidden = kind !== 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kategorie ──
|
||||||
|
function setCategory(cat) {
|
||||||
|
state.category = cat;
|
||||||
|
el.catTabs.forEach((t) => t.classList.toggle('is-active', t.dataset.cat === cat));
|
||||||
|
const isAdmin = cat === 'admin';
|
||||||
|
el.btnListAdd.style.display = isAdmin ? 'none' : '';
|
||||||
|
el.treeSearch.parentElement.style.display = isAdmin ? 'none' : '';
|
||||||
|
if (cat === 'templates') el.btnListAddLabel.textContent = 'Abteilung';
|
||||||
|
else if (cat === 'footers') el.btnListAddLabel.textContent = 'Fußzeile';
|
||||||
|
else if (cat === 'headers') el.btnListAddLabel.textContent = 'Signatur';
|
||||||
|
renderList();
|
||||||
|
if (isAdmin) setAdminView(state.adminView);
|
||||||
|
else showMain(state.current ? 'editor' : 'empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tree laden ──
|
||||||
|
async function loadTree() { try { state.tree = await api('/api/tree'); renderList(); } catch (e) { toast('Liste nicht ladbar: ' + e.message, 'error'); } }
|
||||||
|
|
||||||
|
// ── Listen-Spalte ──
|
||||||
|
function fileItem(file, category) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'tree-item'; div.dataset.path = file.path;
|
||||||
|
const label = category === 'footer' ? footerLabel(file.name) : category === 'header' ? headerLabel(file.name) : templateLabel(file.name);
|
||||||
|
const ic = category === 'footer' ? 'panel-bottom' : category === 'header' ? 'pen-line' : 'file-text';
|
||||||
|
div.innerHTML = '<span class="ti-icon">' + icon(ic, 16) + '</span><span class="ti-label"></span>';
|
||||||
|
div.querySelector('.ti-label').textContent = label; div.title = file.path;
|
||||||
|
div.addEventListener('click', () => openFile(file.path, { friendly: friendlyFor(category, file.name), sha: file.sha, category, exists: true }));
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
// Gedämpfte, aber unterscheidbare Farbpalette für Abteilungs-Badges.
|
||||||
|
const DEPT_PALETTE = [
|
||||||
|
{ fg: '#647219', bg: '#eef2da' }, { fg: '#2f7d83', bg: '#dff0f0' },
|
||||||
|
{ fg: '#b5683f', bg: '#f7e8df' }, { fg: '#6c5a90', bg: '#ece7f3' },
|
||||||
|
{ fg: '#4a6488', bg: '#e5ecf5' }, { fg: '#9a7d1e', bg: '#f5efd6' },
|
||||||
|
{ fg: '#a8527a', bg: '#f6e5ee' }, { fg: '#3f7d5a', bg: '#e2f0e8' },
|
||||||
|
];
|
||||||
|
// Kürzeste eindeutige Abkürzung je Abteilung (min. 2 Zeichen):
|
||||||
|
// Rezeption/Restaurant → REZ/RES, Buchhaltung → BU, IT → IT.
|
||||||
|
function computeAbbrevs(names) {
|
||||||
|
const clean = (n) => (n.replace(/[^a-z0-9äöüß]/gi, '') || n);
|
||||||
|
const cleaned = names.map(clean);
|
||||||
|
const map = {};
|
||||||
|
names.forEach((n, i) => {
|
||||||
|
const cn = cleaned[i];
|
||||||
|
const maxLen = Math.min(3, cn.length); // Badge bleibt kurz; Rest unterscheidet die Farbe
|
||||||
|
let len = Math.min(2, cn.length);
|
||||||
|
while (len < maxLen) {
|
||||||
|
const pre = cn.slice(0, len).toLowerCase();
|
||||||
|
const collide = cleaned.some((o, j) => j !== i && o.slice(0, len).toLowerCase() === pre);
|
||||||
|
if (!collide) break;
|
||||||
|
len++;
|
||||||
|
}
|
||||||
|
map[n] = cn.slice(0, len).toUpperCase();
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
function deptBadge(name, label) {
|
||||||
|
const i = Math.abs([...name].reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0)) % DEPT_PALETTE.length;
|
||||||
|
return { type: 'initial', value: label || (name.trim()[0] || '?').toUpperCase(), fg: DEPT_PALETTE[i].fg, bg: DEPT_PALETTE[i].bg };
|
||||||
|
}
|
||||||
|
function neutralBadge(iconName) { return { type: 'icon', value: iconName, fg: 'var(--muted)', bg: 'var(--bg)' }; }
|
||||||
|
|
||||||
|
function makeGroup(key, title, badge, files, onAdd) {
|
||||||
|
const group = document.createElement('div'); group.className = 'tree-group';
|
||||||
|
const collapsed = !!state.groupsCollapsed[key];
|
||||||
|
const head = document.createElement('div'); head.className = 'group-head' + (collapsed ? ' collapsed' : '');
|
||||||
|
const badgeInner = badge.type === 'icon' ? icon(badge.value, 15) : esc(badge.value);
|
||||||
|
const fs = badge.type === 'icon' ? '' : (badge.value.length >= 3 ? ';font-size:9.5px' : badge.value.length === 2 ? ';font-size:11px' : '');
|
||||||
|
head.innerHTML =
|
||||||
|
'<span class="g-caret">' + icon('chevron', 14) + '</span>' +
|
||||||
|
'<span class="g-badge" style="background:' + badge.bg + ';color:' + badge.fg + fs + '">' + badgeInner + '</span>' +
|
||||||
|
'<span class="g-label"></span>' +
|
||||||
|
'<span class="g-count">' + files.length + '</span>' +
|
||||||
|
(onAdd ? '<button class="g-add" title="Neue Vorlage">' + icon('plus', 15) + '</button>' : '');
|
||||||
|
head.querySelector('.g-label').textContent = title;
|
||||||
|
const body = document.createElement('div'); body.className = 'group-files' + (collapsed ? ' collapsed' : '');
|
||||||
|
if (files.length === 0) { const e = document.createElement('div'); e.className = 'tree-empty'; e.textContent = 'Noch leer'; body.appendChild(e); }
|
||||||
|
else files.forEach((f) => body.appendChild(fileItem(f, 'template')));
|
||||||
|
head.addEventListener('click', (ev) => {
|
||||||
|
if (ev.target.closest('.g-add')) return;
|
||||||
|
state.groupsCollapsed[key] = !state.groupsCollapsed[key];
|
||||||
|
head.classList.toggle('collapsed'); body.classList.toggle('collapsed');
|
||||||
|
});
|
||||||
|
if (onAdd) head.querySelector('.g-add').addEventListener('click', (ev) => { ev.stopPropagation(); onAdd(); });
|
||||||
|
group.appendChild(head); group.appendChild(body); return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminNavItem(view, label, iconName) {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.className = 'nav-item' + (state.adminView === view ? ' is-active' : '');
|
||||||
|
b.innerHTML = '<span class="ni-icon">' + icon(iconName, 17) + '</span><span>' + esc(label) + '</span>';
|
||||||
|
b.addEventListener('click', () => setAdminView(view));
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
const c = el.listBody; c.innerHTML = ''; const t = state.tree;
|
||||||
|
if (state.category === 'admin') {
|
||||||
|
const wrap = document.createElement('div'); wrap.className = 'nav-list';
|
||||||
|
wrap.appendChild(adminNavItem('overview', 'Übersicht', 'dashboard'));
|
||||||
|
wrap.appendChild(adminNavItem('departments', 'Abteilungen', 'building'));
|
||||||
|
wrap.appendChild(adminNavItem('mapping', 'E-Mail-Zuordnung', 'at-sign'));
|
||||||
|
wrap.appendChild(adminNavItem('tags', 'Schlagwörter', 'tag'));
|
||||||
|
c.appendChild(wrap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!t) return;
|
||||||
|
if (state.category === 'templates') {
|
||||||
|
c.appendChild(makeGroup('tmpl:' + SHARED_FOLDER, 'Alle Abteilungen', neutralBadge('globe'), t.templates[SHARED_FOLDER] || [], () => newTemplate(SHARED_FOLDER)));
|
||||||
|
const abbr = computeAbbrevs(t.departments || []);
|
||||||
|
(t.departments || []).forEach((d) => c.appendChild(makeGroup('tmpl:' + d, d, deptBadge(d, abbr[d]), t.templates[d] || [], () => newTemplate(d))));
|
||||||
|
const users = t.users || {}; Object.keys(users).sort((a, b) => a.localeCompare(b, 'de')).forEach((email) =>
|
||||||
|
c.appendChild(makeGroup('tmpl:user:' + email, email, neutralBadge('at-sign'), users[email] || [], () => newTemplate(USER_FOLDER + '/' + email))));
|
||||||
|
} else if (state.category === 'footers') {
|
||||||
|
const footers = t.footers || [];
|
||||||
|
if (!footers.length) c.appendChild(emptyHint('Keine Fußzeilen')); else footers.forEach((f) => c.appendChild(fileItem(f, 'footer')));
|
||||||
|
} else {
|
||||||
|
const headers = t.headers || [];
|
||||||
|
if (!headers.length) c.appendChild(emptyHint('Keine Signaturen')); else headers.forEach((f) => c.appendChild(fileItem(f, 'header')));
|
||||||
|
}
|
||||||
|
highlightActive();
|
||||||
|
if (el.treeSearch.value.trim()) applyFilter();
|
||||||
|
}
|
||||||
|
function emptyHint(text) { const e = document.createElement('div'); e.className = 'tree-empty'; e.textContent = text; return e; }
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
const q = el.treeSearch.value.trim().toLowerCase();
|
||||||
|
document.querySelectorAll('.list-body .tree-empty').forEach((n) => { n.style.display = q ? 'none' : ''; });
|
||||||
|
document.querySelectorAll('.list-body .group-files').forEach((b) => { if (q) b.classList.remove('collapsed'); });
|
||||||
|
document.querySelectorAll('.list-body .group-head').forEach((tg) => { if (q) tg.classList.remove('collapsed'); });
|
||||||
|
document.querySelectorAll('.list-body .tree-item').forEach((it) => {
|
||||||
|
const label = (it.querySelector('.ti-label')?.textContent || '').toLowerCase();
|
||||||
|
it.style.display = (!q || label.includes(q) || (it.dataset.path || '').toLowerCase().includes(q)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.list-body .tree-group').forEach((g) => {
|
||||||
|
const items = g.querySelectorAll('.tree-item');
|
||||||
|
const any = Array.from(items).some((i) => i.style.display !== 'none');
|
||||||
|
g.style.display = (q && items.length && !any) ? 'none' : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function highlightActive() {
|
||||||
|
document.querySelectorAll('.tree-item').forEach((it) => it.classList.toggle('is-active', state.current && it.dataset.path === state.current.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TinyMCE ──
|
||||||
|
function ensureEditor() {
|
||||||
|
if (edReady) return Promise.resolve();
|
||||||
|
return tinymce.init({
|
||||||
|
target: $('visual-editor'), base_url: '/vendor/tinymce', license_key: 'gpl',
|
||||||
|
menubar: false, branding: false, statusbar: false, height: '100%',
|
||||||
|
plugins: 'link image lists table code autolink searchreplace visualblocks',
|
||||||
|
toolbar: 'undo redo | blocks fontfamily fontsize | bold italic underline forecolor backcolor | alignleft aligncenter alignright | bullist numlist | link image table | removeformat | code',
|
||||||
|
toolbar_mode: 'wrap',
|
||||||
|
valid_elements: '*[*]', extended_valid_elements: '*[*]', valid_children: '+body[style]',
|
||||||
|
verify_html: false, convert_urls: false,
|
||||||
|
content_style: 'body{font-family:Arial,Helvetica,sans-serif;font-size:14px;color:#1f2a30;line-height:1.6;padding:10px 12px;} img{max-width:100%;height:auto;} table{border-collapse:collapse;}',
|
||||||
|
paste_data_images: true, automatic_uploads: false, file_picker_types: 'image',
|
||||||
|
file_picker_callback: function (cb) {
|
||||||
|
const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*';
|
||||||
|
input.onchange = function () { const file = input.files[0]; if (!file) return; const r = new FileReader(); r.onload = function () { cb(r.result, { title: file.name }); }; r.readAsDataURL(file); };
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
setup: function (editor) {
|
||||||
|
ed = editor;
|
||||||
|
editor.on('init', function () { edReady = true; });
|
||||||
|
editor.on('input ExecCommand Undo Redo SetContent paste', function () { if (!suppressDirty) markDirty(); });
|
||||||
|
},
|
||||||
|
}).then(() => { edReady = true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Platzhalter (nur sinnvoll in der Signatur-Vorlage _vorlage.html) ──
|
||||||
|
const VORLAGE_PATH = SIG_HEADERS + '/_vorlage.html';
|
||||||
|
const PLACEHOLDERS = [
|
||||||
|
{ t: '{{NAME}}', d: 'Name des Mitarbeiters', s: 'aus dem Feld „Name" in den Plugin-Einstellungen' },
|
||||||
|
{ t: '{{EMAIL}}', d: 'E-Mail-Adresse', s: 'aus der gewählten Thunderbird-Identität' },
|
||||||
|
{ t: '{{ABTEILUNG}}', d: 'Abteilung', s: 'automatisch erkannt über die E-Mail-Zuordnung', link: true },
|
||||||
|
{ t: '{{TELEFON}}', d: '+49 (0) 5191 - 605-0', s: 'fest im Plugin-Code hinterlegt' },
|
||||||
|
{ t: '{{FAX}}', d: '+49 (0) 5191 - 605-185', s: 'fest im Plugin-Code hinterlegt' },
|
||||||
|
];
|
||||||
|
function buildPlaceholderBar() {
|
||||||
|
const bar = $('ph-bar'), details = $('ph-details');
|
||||||
|
bar.innerHTML =
|
||||||
|
'<span class="ph-lead">' + icon('tag', 15) + ' Platzhalter – klick zum Einfügen:</span>' +
|
||||||
|
'<span class="ph-chips">' + PLACEHOLDERS.map((p) => '<button class="ph-chip" data-token="' + esc(p.t) + '" title="wird zu: ' + esc(p.d) + '">' + esc(p.t) + '</button>').join('') + '</span>' +
|
||||||
|
'<button class="ph-more" id="ph-more">Was ist das?</button>';
|
||||||
|
details.innerHTML =
|
||||||
|
'<p>Dies ist die zentrale <strong>Signatur-Vorlage</strong>. Klickt ein Mitarbeiter im Thunderbird-Plugin (Tab „Signaturen") auf <strong>„Vorlage laden"</strong>, ersetzt das Plugin diese Platzhalter <strong>einmalig</strong> durch seine eigenen Daten:</p>' +
|
||||||
|
'<table class="ph-table"><tbody>' + PLACEHOLDERS.map((p) =>
|
||||||
|
'<tr><td><code>' + esc(p.t) + '</code></td><td>→ ' + (p.link ? '<a href="#" id="ph-link">' + esc(p.d) + '</a>' : esc(p.d)) + '</td><td class="ph-src">' + esc(p.s) + '</td></tr>').join('') + '</tbody></table>' +
|
||||||
|
'<p class="ph-foot">Telefon & Fax sind <strong>fest im Plugin-Code</strong> hinterlegt – zum Ändern muss das Plugin angepasst werden, nicht diese Oberfläche. In normalen Vorlagen, Fußzeilen und bereits gespeicherten Signaturen werden Platzhalter <strong>nicht</strong> ersetzt.</p>';
|
||||||
|
|
||||||
|
bar.querySelectorAll('.ph-chip').forEach((b) => b.addEventListener('click', () => insertPlaceholder(b.dataset.token)));
|
||||||
|
$('ph-more').addEventListener('click', () => { details.hidden = !details.hidden; $('ph-more').classList.toggle('is-open', !details.hidden); });
|
||||||
|
const link = $('ph-link');
|
||||||
|
if (link) link.addEventListener('click', (e) => { e.preventDefault(); setCategory('admin'); setAdminView('mapping'); });
|
||||||
|
}
|
||||||
|
// Leiste nur in der Signatur-Vorlage zeigen – sonst sind Platzhalter wirkungslos.
|
||||||
|
function updatePlaceholderBar() {
|
||||||
|
const show = !!state.current && state.current.path === VORLAGE_PATH;
|
||||||
|
$('ph-bar').hidden = !show;
|
||||||
|
if (!show) { $('ph-details').hidden = true; const m = $('ph-more'); if (m) m.classList.remove('is-open'); }
|
||||||
|
}
|
||||||
|
function insertPlaceholder(token) {
|
||||||
|
if (!state.current) return;
|
||||||
|
if (state.view === 'preview') setView('visual');
|
||||||
|
if (state.view === 'visual' && edReady && ed) { ed.insertContent(token); markDirty(); }
|
||||||
|
else if (state.view === 'html') {
|
||||||
|
const ta = el.htmlEditor, s = ta.selectionStart, e = ta.selectionEnd;
|
||||||
|
ta.value = ta.value.slice(0, s) + token + ta.value.slice(e);
|
||||||
|
ta.selectionStart = ta.selectionEnd = s + token.length; ta.focus(); markDirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Datei öffnen ──
|
||||||
|
async function openFile(path, meta) {
|
||||||
|
if (!(await guardUnsaved())) return;
|
||||||
|
try {
|
||||||
|
const data = await api('/api/file?path=' + encodeURIComponent(path));
|
||||||
|
state.current = { path: data.path, friendly: meta.friendly, sha: data.sha, exists: data.exists, isNew: false, category: meta.category };
|
||||||
|
await showEditor(data.content || ''); setDirty(false); highlightActive();
|
||||||
|
} catch (e) { toast('Datei nicht ladbar: ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
async function openNewFile(path, friendly, category) {
|
||||||
|
if (!(await guardUnsaved())) return;
|
||||||
|
state.current = { path, friendly, sha: null, exists: false, isNew: true, category };
|
||||||
|
await showEditor(''); setDirty(true); highlightActive();
|
||||||
|
toast('Neuer Eintrag „' + friendly + '“ – jetzt bearbeiten und speichern.', 'success');
|
||||||
|
}
|
||||||
|
async function guardUnsaved() {
|
||||||
|
if (!state.dirty) return true;
|
||||||
|
return await confirmModal('Es gibt ungespeicherte Änderungen. Trotzdem fortfahren? Die Änderungen gehen verloren.', { title: 'Ungespeicherte Änderungen', okLabel: 'Verwerfen', danger: true });
|
||||||
|
}
|
||||||
|
async function showEditor(html) {
|
||||||
|
state.html = html; showMain('editor');
|
||||||
|
el.fileFriendly.textContent = state.current.friendly; el.filePath.textContent = state.current.path;
|
||||||
|
updatePlaceholderBar();
|
||||||
|
await ensureEditor(); setView('visual', true);
|
||||||
|
}
|
||||||
|
function hideEditor() { state.current = null; showMain('empty'); highlightActive(); }
|
||||||
|
|
||||||
|
function syncFromActive() {
|
||||||
|
if (state.view === 'visual' && edReady && ed) state.html = ed.getContent();
|
||||||
|
else if (state.view === 'html') state.html = el.htmlEditor.value;
|
||||||
|
return state.html;
|
||||||
|
}
|
||||||
|
function setView(view, skipSync) {
|
||||||
|
const html = skipSync ? state.html : syncFromActive();
|
||||||
|
state.html = html; state.view = view;
|
||||||
|
el.paneVisual.hidden = view !== 'visual'; el.paneHtml.hidden = view !== 'html'; el.panePreview.hidden = view !== 'preview';
|
||||||
|
el.tabVisual.classList.toggle('is-active', view === 'visual');
|
||||||
|
el.tabHtml.classList.toggle('is-active', view === 'html');
|
||||||
|
el.tabPreview.classList.toggle('is-active', view === 'preview');
|
||||||
|
if (view === 'visual' && edReady && ed) { suppressDirty = true; ed.setContent(html); setTimeout(() => { suppressDirty = false; }, 0); }
|
||||||
|
else if (view === 'html') el.htmlEditor.value = html;
|
||||||
|
else if (view === 'preview') renderPreview(html);
|
||||||
|
}
|
||||||
|
function renderPreview(html) {
|
||||||
|
const doc = '<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><style>html,body{margin:0;padding:0;}' +
|
||||||
|
'body{font-family:Arial,Helvetica,sans-serif;color:#1f2a30;line-height:1.5;background:#e7ebee;padding:22px;}' +
|
||||||
|
'.email-card{max-width:640px;margin:0 auto;background:#fff;padding:26px 30px;border-radius:10px;box-shadow:0 1px 5px rgba(0,0,0,.12);}' +
|
||||||
|
'img{max-width:100%;height:auto;}table{border-collapse:collapse;}</style></head><body><div class="email-card">' + html + '</div></body></html>';
|
||||||
|
el.previewFrame.srcdoc = doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dirty ──
|
||||||
|
function setDirty(d) {
|
||||||
|
state.dirty = d; el.dirtyBadge.hidden = !d;
|
||||||
|
if (!state.current) el.btnSave.disabled = true;
|
||||||
|
else if (!state.current.exists) el.btnSave.disabled = false;
|
||||||
|
else el.btnSave.disabled = !d;
|
||||||
|
}
|
||||||
|
function markDirty() { if (!state.dirty) setDirty(true); }
|
||||||
|
|
||||||
|
// ── Speichern / Neu laden / Löschen ──
|
||||||
|
async function saveCurrent() {
|
||||||
|
if (!state.current) return;
|
||||||
|
const content = syncFromActive(); const friendly = state.current.friendly;
|
||||||
|
const wasNew = state.current.isNew;
|
||||||
|
try {
|
||||||
|
const res = await api('/api/file', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.current.path, content, message: friendly + ' bearbeitet (Web-Editor)' }) });
|
||||||
|
state.current.exists = true; state.current.isNew = false; if (res.sha) state.current.sha = res.sha;
|
||||||
|
setDirty(false);
|
||||||
|
toast(res.unchanged ? 'Keine Änderungen – nichts zu speichern.' : '„' + friendly + '“ gespeichert.', 'success');
|
||||||
|
// Baum nur neu laden, wenn eine NEUE Datei dazukam (sonst ändert sich die Liste nicht).
|
||||||
|
if (wasNew) { await loadTree(); highlightActive(); }
|
||||||
|
} catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
async function reloadCurrent() {
|
||||||
|
if (!state.current) return;
|
||||||
|
if (state.current.isNew) { toast('Dieser Eintrag wurde noch nicht gespeichert.', 'error'); return; }
|
||||||
|
if (state.dirty && !(await confirmModal('Ungespeicherte Änderungen verwerfen und neu laden?', { title: 'Neu laden', okLabel: 'Verwerfen', danger: true }))) return;
|
||||||
|
try {
|
||||||
|
const data = await api('/api/file?path=' + encodeURIComponent(state.current.path));
|
||||||
|
state.current.sha = data.sha; state.current.exists = data.exists; state.html = data.content || '';
|
||||||
|
setView('visual', true); setDirty(false); toast('Neu geladen.', 'success');
|
||||||
|
} catch (e) { toast('Neu laden fehlgeschlagen: ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
async function deleteCurrent() {
|
||||||
|
if (!state.current) return; const friendly = state.current.friendly;
|
||||||
|
if (state.current.isNew) { if (await confirmModal('Diesen neuen, noch nicht gespeicherten Eintrag verwerfen?', { title: 'Verwerfen', okLabel: 'Verwerfen', danger: true })) { setDirty(false); hideEditor(); } return; }
|
||||||
|
if (!(await confirmModal('„' + friendly + '“ wirklich löschen? Das kann nicht rückgängig gemacht werden.', { title: 'Löschen', okLabel: 'Löschen', danger: true }))) return;
|
||||||
|
try {
|
||||||
|
await api('/api/file', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.current.path, message: friendly + ' gelöscht (Web-Editor)' }) });
|
||||||
|
toast('„' + friendly + '“ gelöscht.', 'success'); setDirty(false); hideEditor(); await loadTree();
|
||||||
|
} catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Neue Einträge ──
|
||||||
|
function existsInTree(path) {
|
||||||
|
const t = state.tree; if (!t) return false; const lists = [];
|
||||||
|
Object.keys(t.templates || {}).forEach((k) => lists.push(t.templates[k]));
|
||||||
|
Object.keys(t.users || {}).forEach((k) => lists.push(t.users[k]));
|
||||||
|
lists.push(t.footers || [], t.headers || []);
|
||||||
|
return lists.some((arr) => (arr || []).some((f) => f.path === path));
|
||||||
|
}
|
||||||
|
async function newTemplate(folder) {
|
||||||
|
const res = await promptModal('Neue Vorlage in „' + folder + '“', [{ 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 path = folder + '/' + slug + '.html'; if (existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; }
|
||||||
|
openNewFile(path, slug, 'template');
|
||||||
|
}
|
||||||
|
async function newFooter() {
|
||||||
|
const t = state.tree || {};
|
||||||
|
const options = [{ value: '_default', label: 'Gemeinsam (alle Abteilungen)' }].concat((t.departments || []).map((d) => ({ value: d, label: d })));
|
||||||
|
const res = await promptModal('Neue Fußzeile', [{ key: 'dept', label: 'Für welche Abteilung?', type: 'select', options, required: true }]);
|
||||||
|
if (!res) return; const file = (res.dept === '_default' ? '_default' : res.dept) + '.html'; const path = SIG_FOOTERS + '/' + file;
|
||||||
|
if (existsInTree(path)) { toast('Diese Fußzeile existiert bereits.', 'error'); return; }
|
||||||
|
openNewFile(path, 'Fußzeile: ' + footerLabel(file), 'footer');
|
||||||
|
}
|
||||||
|
async function newHeader() {
|
||||||
|
const res = await promptModal('Neue Signatur', [
|
||||||
|
{ key: 'email', label: 'E-Mail-Adresse', placeholder: 'name@hotel-park-soltau.de', required: true, live: true },
|
||||||
|
{ key: 'name', label: 'Name', placeholder: 'Max Mustermann', required: true, live: true },
|
||||||
|
], (values, inputs, root) => { const email = (values.email || '').trim(); const slug = slugifyHeaderName(values.name || ''); const h = root.querySelector('[data-live="name"]'); const file = (email && slug) ? (email + '.' + slug + '.html') : ''; if (h) h.innerHTML = file ? 'Datei: <span class="preview-name">' + esc(file) + '</span>' : 'E-Mail und Name eingeben.'; });
|
||||||
|
if (!res) return; const email = res.email.trim(); const slug = slugifyHeaderName(res.name);
|
||||||
|
if (!email || !slug) { toast('E-Mail und Name erforderlich.', 'error'); return; }
|
||||||
|
const file = email + '.' + slug + '.html'; const path = SIG_HEADERS + '/' + file;
|
||||||
|
if (existsInTree(path)) { toast('Diese Signatur existiert bereits.', 'error'); return; }
|
||||||
|
openNewFile(path, 'Signatur: ' + headerLabel(file), 'header');
|
||||||
|
}
|
||||||
|
async function newDepartment() {
|
||||||
|
const res = await promptModal('Neue Abteilung', [{ key: 'name', label: 'Abteilungsname', placeholder: 'z. B. Rezeption', required: true, hint: 'Wird als Ordner im Repository angelegt.' }]);
|
||||||
|
if (!res) return; const name = res.name.trim();
|
||||||
|
if (/[\/\\:*?"<>|]/.test(name)) { toast('Ungültiger Abteilungsname.', 'error'); return; }
|
||||||
|
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'); }
|
||||||
|
}
|
||||||
|
function listAddAction() { if (state.category === 'templates') newDepartment(); else if (state.category === 'footers') newFooter(); else if (state.category === 'headers') newHeader(); }
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// Verwaltung (Admin)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
function setAdminView(view) {
|
||||||
|
state.adminView = view;
|
||||||
|
document.querySelectorAll('.nav-item').forEach((n) => {});
|
||||||
|
renderList(); // aktualisiert aktive Markierung in der Nav
|
||||||
|
showMain('admin');
|
||||||
|
if (view === 'overview') renderOverview();
|
||||||
|
else if (view === 'departments') renderDepartments();
|
||||||
|
else if (view === 'mapping') renderMapping();
|
||||||
|
else if (view === 'tags') renderTags();
|
||||||
|
}
|
||||||
|
function adminHeader(title, subtitle) {
|
||||||
|
return '<div class="admin-head"><h2>' + esc(title) + '</h2>' + (subtitle ? '<p>' + esc(subtitle) + '</p>' : '') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverview() {
|
||||||
|
const t = state.tree || {}; const c = state.config || {};
|
||||||
|
const tmpl = Object.values(t.templates || {}).reduce((n, a) => n + a.length, 0) + Object.values(t.users || {}).reduce((n, a) => n + a.length, 0);
|
||||||
|
const cards = [
|
||||||
|
{ icon: 'file-text', n: tmpl, label: 'Vorlagen' },
|
||||||
|
{ icon: 'building', n: (t.departments || []).length, label: 'Abteilungen' },
|
||||||
|
{ icon: 'panel-bottom', n: (t.footers || []).length, label: 'Fußzeilen' },
|
||||||
|
{ icon: 'pen-line', n: (t.headers || []).length, label: 'Signaturen' },
|
||||||
|
];
|
||||||
|
const mode = c.demo ? 'Demo-Modus' : c.local ? 'Lokaler Ordner' : 'Gitea/Forgejo';
|
||||||
|
el.adminPanel.innerHTML =
|
||||||
|
adminHeader('Übersicht', 'Auf einen Blick: was im Repository liegt und wie der Editor verbunden ist.') +
|
||||||
|
'<div class="stat-grid">' + cards.map((k) =>
|
||||||
|
'<div class="stat-card"><span class="stat-ic">' + icon(k.icon, 22) + '</span><div class="stat-n">' + k.n + '</div><div class="stat-l">' + k.label + '</div></div>').join('') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="info-card"><div class="info-row"><span class="info-ic">' + icon('plug', 18) + '</span><div><div class="info-k">Verbindung</div>' +
|
||||||
|
'<div class="info-v">' + esc(mode) + ' · ' + esc((c.owner || '?') + '/' + (c.repo || '?') + '@' + (c.branch || 'main')) + '</div></div></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDepartments() {
|
||||||
|
const t = state.tree || {}; const depts = t.departments || [];
|
||||||
|
let html = adminHeader('Abteilungen', 'Ordner im Repository. Jede Abteilung kann eigene Vorlagen und eine Fußzeile haben.');
|
||||||
|
html += '<div class="adm-add"><input id="adm-dept-name" class="inp" placeholder="Neue Abteilung – z. B. Spa" /><button id="adm-dept-add" class="btn btn-primary"><span class="ic">' + icon('plus', 16) + '</span><span>Anlegen</span></button></div>';
|
||||||
|
html += '<div class="adm-list">';
|
||||||
|
if (!depts.length) html += '<div class="tree-empty">Noch keine Abteilungen.</div>';
|
||||||
|
depts.forEach((d) => {
|
||||||
|
const count = (t.templates[d] || []).length;
|
||||||
|
html += '<div class="adm-row"><span class="adm-ic">' + icon('building', 18) + '</span><span class="adm-name">' + esc(d) + '</span>' +
|
||||||
|
'<span class="adm-meta">' + count + ' Vorlage' + (count === 1 ? '' : 'n') + '</span>' +
|
||||||
|
'<button class="icon-btn danger" data-del="' + esc(d) + '" title="Abteilung löschen">' + icon('trash', 16) + '</button></div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
el.adminPanel.innerHTML = html;
|
||||||
|
$('adm-dept-add').addEventListener('click', newDepartment);
|
||||||
|
$('adm-dept-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addDeptInline(); } });
|
||||||
|
el.adminPanel.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', () => deleteDepartment(b.dataset.del)));
|
||||||
|
}
|
||||||
|
async function addDeptInline() {
|
||||||
|
const name = ($('adm-dept-name').value || '').trim(); if (!name) return;
|
||||||
|
if (/[\/\\:*?"<>|]/.test(name)) { toast('Ungültiger Abteilungsname.', 'error'); return; }
|
||||||
|
try { await api('/api/departments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + name + '“ angelegt.', 'success'); await loadTree(); renderDepartments(); }
|
||||||
|
catch (e) { toast('Anlegen fehlgeschlagen: ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
async function deleteDepartment(name) {
|
||||||
|
const count = ((state.tree && state.tree.templates[name]) || []).length;
|
||||||
|
const msg = count ? 'Abteilung „' + name + '“ und alle ' + count + ' enthaltenen Vorlagen löschen?' : 'Leere Abteilung „' + name + '“ löschen?';
|
||||||
|
if (!(await confirmModal(msg, { title: 'Abteilung löschen', okLabel: 'Löschen', danger: true }))) return;
|
||||||
|
try { const r = await api('/api/departments', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + name + '“ gelöscht (' + (r.deleted || 0) + ' Dateien).', 'success'); await loadTree(); renderDepartments(); }
|
||||||
|
catch (e) { toast('Löschen fehlgeschlagen: ' + e.message, 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMapping() {
|
||||||
|
el.adminPanel.innerHTML = adminHeader('E-Mail-Zuordnung', 'Welche Absender-Adresse gehört zu welcher Abteilung? (Datei _config/abteilungen.json)') + '<div class="tree-empty">Lädt…</div>';
|
||||||
|
let mapping = {};
|
||||||
|
try { const r = await api('/api/abteilungen'); mapping = r.mapping || {}; } catch (e) { toast('Zuordnung nicht ladbar: ' + e.message, 'error'); }
|
||||||
|
const rows = Object.keys(mapping).map((email) => ({ email, dept: mapping[email] }));
|
||||||
|
const depts = (state.tree && state.tree.departments) || [];
|
||||||
|
|
||||||
|
function deptSelect(val) {
|
||||||
|
return '<select class="inp dept-sel">' + ['<option value="">— wählen —</option>'].concat(depts.map((d) => '<option' + (d === val ? ' selected' : '') + '>' + esc(d) + '</option>')).join('') +
|
||||||
|
(val && !depts.includes(val) ? '<option selected>' + esc(val) + '</option>' : '') + '</select>';
|
||||||
|
}
|
||||||
|
function rowHtml(r) {
|
||||||
|
return '<div class="map-row"><input class="inp map-email" value="' + esc(r.email) + '" placeholder="name@hotel-park-soltau.de" />' +
|
||||||
|
deptSelect(r.dept) + '<button class="icon-btn danger map-del" title="Zeile entfernen">' + icon('trash', 16) + '</button></div>';
|
||||||
|
}
|
||||||
|
el.adminPanel.innerHTML = adminHeader('E-Mail-Zuordnung', 'Welche Absender-Adresse gehört zu welcher Abteilung? (Datei _config/abteilungen.json)') +
|
||||||
|
'<div class="map-head"><span>E-Mail-Adresse</span><span>Abteilung</span><span></span></div>' +
|
||||||
|
'<div id="map-rows" class="map-rows">' + (rows.length ? rows.map(rowHtml).join('') : '') + '</div>' +
|
||||||
|
'<div class="adm-actions"><button id="map-addrow" class="btn btn-ghost"><span class="ic">' + icon('plus', 16) + '</span><span>Zeile hinzufügen</span></button>' +
|
||||||
|
'<button id="map-save" class="btn btn-primary"><span class="ic">' + icon('save', 16) + '</span><span>Speichern</span></button></div>';
|
||||||
|
|
||||||
|
const rowsEl = $('map-rows');
|
||||||
|
function bindRow(row) { row.querySelector('.map-del').addEventListener('click', () => row.remove()); }
|
||||||
|
rowsEl.querySelectorAll('.map-row').forEach(bindRow);
|
||||||
|
$('map-addrow').addEventListener('click', () => { const div = document.createElement('div'); div.innerHTML = rowHtml({ email: '', dept: '' }); const row = div.firstChild; rowsEl.appendChild(row); bindRow(row); row.querySelector('.map-email').focus(); });
|
||||||
|
$('map-save').addEventListener('click', async () => {
|
||||||
|
const out = {};
|
||||||
|
let bad = false;
|
||||||
|
rowsEl.querySelectorAll('.map-row').forEach((row) => {
|
||||||
|
const email = row.querySelector('.map-email').value.trim(); const dept = row.querySelector('.dept-sel').value.trim();
|
||||||
|
if (!email && !dept) return; if (!email || !dept) { bad = true; return; }
|
||||||
|
out[email] = dept;
|
||||||
|
});
|
||||||
|
if (bad) { toast('Bitte jede Zeile vollständig ausfüllen (E-Mail + Abteilung) oder leer lassen.', 'error'); return; }
|
||||||
|
try { await api('/api/abteilungen', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mapping: out }) }); toast('Zuordnung gespeichert.', 'success'); }
|
||||||
|
catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderTags() {
|
||||||
|
el.adminPanel.innerHTML = adminHeader('Schlagwörter', 'Farbige Tags für Thunderbird (Datei _config/schlagwoerter.json).') + '<div class="tree-empty">Lädt…</div>';
|
||||||
|
let tags = [];
|
||||||
|
try { const r = await api('/api/schlagwoerter'); tags = r.tags || []; } catch (e) { toast('Schlagwörter nicht ladbar: ' + e.message, 'error'); }
|
||||||
|
function rowHtml(t) {
|
||||||
|
const color = (t && t.color) || '#95a322'; const name = (t && t.name) || '';
|
||||||
|
return '<div class="tag-row"><span class="tag-swatch" style="background:' + esc(color) + '"></span>' +
|
||||||
|
'<input class="inp tag-name" value="' + esc(name) + '" placeholder="Name / Schlagwort" />' +
|
||||||
|
'<input type="color" class="tag-color" value="' + esc(color) + '" />' +
|
||||||
|
'<button class="icon-btn danger tag-del" title="Entfernen">' + icon('trash', 16) + '</button></div>';
|
||||||
|
}
|
||||||
|
el.adminPanel.innerHTML = adminHeader('Schlagwörter', 'Farbige Tags für Thunderbird (Datei _config/schlagwoerter.json).') +
|
||||||
|
'<div id="tag-rows" class="tag-rows">' + (tags.length ? tags.map(rowHtml).join('') : '') + '</div>' +
|
||||||
|
'<div class="adm-actions"><button id="tag-addrow" class="btn btn-ghost"><span class="ic">' + icon('plus', 16) + '</span><span>Schlagwort hinzufügen</span></button>' +
|
||||||
|
'<button id="tag-save" class="btn btn-primary"><span class="ic">' + icon('save', 16) + '</span><span>Speichern</span></button></div>';
|
||||||
|
const rowsEl = $('tag-rows');
|
||||||
|
function bindRow(row) {
|
||||||
|
row.querySelector('.tag-del').addEventListener('click', () => row.remove());
|
||||||
|
const color = row.querySelector('.tag-color'), sw = row.querySelector('.tag-swatch');
|
||||||
|
color.addEventListener('input', () => { sw.style.background = color.value; });
|
||||||
|
}
|
||||||
|
rowsEl.querySelectorAll('.tag-row').forEach(bindRow);
|
||||||
|
$('tag-addrow').addEventListener('click', () => { const div = document.createElement('div'); div.innerHTML = rowHtml({}); const row = div.firstChild; rowsEl.appendChild(row); bindRow(row); row.querySelector('.tag-name').focus(); });
|
||||||
|
$('tag-save').addEventListener('click', async () => {
|
||||||
|
const out = []; let bad = false;
|
||||||
|
rowsEl.querySelectorAll('.tag-row').forEach((row) => { const name = row.querySelector('.tag-name').value.trim(); const color = row.querySelector('.tag-color').value; if (!name) { if (row.querySelector('.tag-name').value !== '') bad = true; return; } out.push({ name, color }); });
|
||||||
|
try { await api('/api/schlagwoerter', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: out }) }); toast('Schlagwörter gespeichert.', 'success'); }
|
||||||
|
catch (e) { toast('Speichern fehlgeschlagen: ' + e.message, 'error'); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Events ──
|
||||||
|
function bindEvents() {
|
||||||
|
el.catTabs.forEach((t) => t.addEventListener('click', () => setCategory(t.dataset.cat)));
|
||||||
|
el.btnRefresh.addEventListener('click', () => { loadTree(); if (state.category === 'admin') setAdminView(state.adminView); });
|
||||||
|
el.btnListAdd.addEventListener('click', listAddAction);
|
||||||
|
el.treeSearch.addEventListener('input', applyFilter);
|
||||||
|
el.btnSave.addEventListener('click', saveCurrent);
|
||||||
|
el.btnReload.addEventListener('click', reloadCurrent);
|
||||||
|
el.btnDelete.addEventListener('click', deleteCurrent);
|
||||||
|
el.tabVisual.addEventListener('click', () => setView('visual'));
|
||||||
|
el.tabHtml.addEventListener('click', () => setView('html'));
|
||||||
|
el.tabPreview.addEventListener('click', () => setView('preview'));
|
||||||
|
el.htmlEditor.addEventListener('input', () => { markDirty(); });
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); if (state.current && !el.btnSave.disabled && !el.editorPanel.hidden) saveCurrent(); }
|
||||||
|
if (e.key === 'Escape') { if (!el.promptBackdrop.hidden) el.promptCancel.click(); else if (!el.confirmBackdrop.hidden) el.confirmCancel.click(); }
|
||||||
|
});
|
||||||
|
window.addEventListener('beforeunload', (e) => { if (state.dirty) { e.preventDefault(); e.returnValue = ''; return ''; } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start ──
|
||||||
|
async function init() {
|
||||||
|
hydrateIcons(document);
|
||||||
|
buildPlaceholderBar();
|
||||||
|
bindEvents(); setDirty(false); setCategory('templates');
|
||||||
|
const ok = await loadConfigAndHealth();
|
||||||
|
if (ok) await loadTree();
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
})();
|
||||||
BIN
web-editor/public/fonts/pjs-400.ttf
Normal file
BIN
web-editor/public/fonts/pjs-400.ttf
Normal file
Binary file not shown.
BIN
web-editor/public/fonts/pjs-500.ttf
Normal file
BIN
web-editor/public/fonts/pjs-500.ttf
Normal file
Binary file not shown.
BIN
web-editor/public/fonts/pjs-600.ttf
Normal file
BIN
web-editor/public/fonts/pjs-600.ttf
Normal file
Binary file not shown.
BIN
web-editor/public/fonts/pjs-700.ttf
Normal file
BIN
web-editor/public/fonts/pjs-700.ttf
Normal file
Binary file not shown.
BIN
web-editor/public/fonts/pjs-800.ttf
Normal file
BIN
web-editor/public/fonts/pjs-800.ttf
Normal file
Binary file not shown.
146
web-editor/public/index.html
Normal file
146
web-editor/public/index.html
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>HPS Vorlagen & Signaturen</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<link rel="icon" href="logo.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- ── Topbar ── -->
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-logo"><img src="logo.svg" alt="Hotel Park Soltau" /></span>
|
||||||
|
<span class="brand-divider" aria-hidden="true"></span>
|
||||||
|
<span class="brand-title">Vorlagen & Signaturen</span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<span id="status-pill" class="status-pill status-unknown" title="Verbindungsstatus">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span id="status-text">Verbinde…</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── Config-Banner ── -->
|
||||||
|
<div id="config-banner" class="config-banner" hidden>
|
||||||
|
<strong>Verbindung nicht konfiguriert.</strong>
|
||||||
|
<span>
|
||||||
|
Bitte die Umgebungsvariablen <code>GITEA_URL</code>, <code>GITEA_OWNER</code>,
|
||||||
|
<code>GITEA_REPO</code> und <code>GITEA_TOKEN</code> setzen (siehe <code>.env.example</code>)
|
||||||
|
und den Dienst neu starten.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── App ── -->
|
||||||
|
<div class="app">
|
||||||
|
<!-- Kategorie-Navigation -->
|
||||||
|
<nav class="cat-tabs" role="tablist">
|
||||||
|
<button class="cat-tab is-active" data-cat="templates" role="tab"><span class="ic" data-icon="file-text"></span><span>Vorlagen</span></button>
|
||||||
|
<button class="cat-tab" data-cat="footers" role="tab"><span class="ic" data-icon="panel-bottom"></span><span>Fußzeilen</span></button>
|
||||||
|
<button class="cat-tab" data-cat="headers" role="tab"><span class="ic" data-icon="pen-line"></span><span>Signaturen</span></button>
|
||||||
|
<button class="cat-tab" data-cat="admin" role="tab"><span class="ic" data-icon="settings"></span><span>Verwaltung</span></button>
|
||||||
|
<span class="cat-spacer"></span>
|
||||||
|
<button id="btn-refresh" class="icon-btn" title="Liste neu laden"><span class="ic" data-icon="refresh"></span></button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<!-- Listen-Spalte -->
|
||||||
|
<aside class="listpane">
|
||||||
|
<div class="listpane-head">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<span class="ic search-ic" data-icon="search"></span>
|
||||||
|
<input type="search" id="tree-search" class="tree-search" placeholder="Suchen…" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<button id="btn-list-add" class="btn btn-primary btn-sm"><span class="ic" data-icon="plus"></span><span id="btn-list-add-label">Neu</span></button>
|
||||||
|
</div>
|
||||||
|
<div id="list-body" class="list-body"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Inhalts-Spalte -->
|
||||||
|
<main class="editorpane">
|
||||||
|
<!-- Leerzustand -->
|
||||||
|
<div class="empty-state" id="empty-state">
|
||||||
|
<div class="empty-illu" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="60" height="60" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M9 13h6"/><path d="M9 17h4"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2>Wähle links einen Eintrag</h2>
|
||||||
|
<p>Vorlage, Fußzeile oder Signatur anklicken zum Bearbeiten – oder über <strong>+ Neu</strong> einen neuen Eintrag anlegen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Datei-Editor -->
|
||||||
|
<div class="editor-panel" id="editor-panel" hidden>
|
||||||
|
<div class="editor-head">
|
||||||
|
<div class="editor-titles">
|
||||||
|
<h2 id="file-friendly">—</h2>
|
||||||
|
<code id="file-path" class="file-path">—</code>
|
||||||
|
</div>
|
||||||
|
<div class="editor-head-actions">
|
||||||
|
<span id="dirty-badge" class="dirty-badge" hidden>Nicht gespeichert</span>
|
||||||
|
<button class="btn btn-ghost" id="btn-reload" title="Vom Server neu laden"><span class="ic" data-icon="reload"></span><span>Neu laden</span></button>
|
||||||
|
<button class="btn btn-danger-ghost" id="btn-delete"><span class="ic" data-icon="trash"></span><span>Löschen</span></button>
|
||||||
|
<button class="btn btn-primary" id="btn-save"><span class="ic" data-icon="save"></span><span>Speichern</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ph-bar" id="ph-bar" hidden></div>
|
||||||
|
<div class="ph-details" id="ph-details" hidden></div>
|
||||||
|
|
||||||
|
<div class="editor-tabs" role="tablist">
|
||||||
|
<button class="editor-tab is-active" id="tab-visual" data-view="visual" role="tab">Bearbeiten</button>
|
||||||
|
<button class="editor-tab" id="tab-html" data-view="html" role="tab">HTML</button>
|
||||||
|
<button class="editor-tab" id="tab-preview" data-view="preview" role="tab">Vorschau</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-body">
|
||||||
|
<div class="epane" id="pane-visual"><textarea id="visual-editor"></textarea></div>
|
||||||
|
<div class="epane" id="pane-html" hidden><textarea class="html-editor" id="html-editor" spellcheck="false" wrap="soft"></textarea></div>
|
||||||
|
<div class="epane" id="pane-preview" hidden>
|
||||||
|
<div class="preview-frame-wrap"><iframe class="preview-frame" id="preview-frame" title="Vorschau" sandbox=""></iframe></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verwaltung -->
|
||||||
|
<div class="admin-panel" id="admin-panel" hidden></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Toasts ── -->
|
||||||
|
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<!-- ── Lade-Overlay ── -->
|
||||||
|
<div class="loading-overlay" id="loading-overlay" hidden><div class="spinner" aria-label="Lädt"></div></div>
|
||||||
|
|
||||||
|
<!-- ── Confirm-Modal ── -->
|
||||||
|
<div class="modal-backdrop" id="confirm-backdrop" hidden>
|
||||||
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="confirm-title">
|
||||||
|
<h3 id="confirm-title">Bestätigen</h3>
|
||||||
|
<p id="confirm-message">Bist du sicher?</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-ghost" id="confirm-cancel">Abbrechen</button>
|
||||||
|
<button class="btn btn-danger" id="confirm-ok">Bestätigen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Prompt-Modal ── -->
|
||||||
|
<div class="modal-backdrop" id="prompt-backdrop" hidden>
|
||||||
|
<form class="modal" id="prompt-form" role="dialog" aria-modal="true" aria-labelledby="prompt-title">
|
||||||
|
<h3 id="prompt-title">Eingabe</h3>
|
||||||
|
<div id="prompt-fields"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" id="prompt-cancel">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="prompt-ok">OK</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/vendor/tinymce/tinymce.min.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
web-editor/public/logo.svg
Normal file
1
web-editor/public/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.5 KiB |
292
web-editor/public/style.css
Normal file
292
web-editor/public/style.css
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
/* HPS Vorlagen & Signaturen — Web-Editor
|
||||||
|
* Hotel-Park-Soltau-CI: Olivgrün (#95a322) + Anthrazit (#3c3c3b).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Schrift ── */
|
||||||
|
@font-face { font-family: 'Jakarta'; src: url('fonts/pjs-400.ttf') format('truetype'); font-weight: 400; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Jakarta'; src: url('fonts/pjs-500.ttf') format('truetype'); font-weight: 500; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Jakarta'; src: url('fonts/pjs-600.ttf') format('truetype'); font-weight: 600; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Jakarta'; src: url('fonts/pjs-700.ttf') format('truetype'); font-weight: 700; font-display: swap; }
|
||||||
|
@font-face { font-family: 'Jakarta'; src: url('fonts/pjs-800.ttf') format('truetype'); font-weight: 800; font-display: swap; }
|
||||||
|
|
||||||
|
/* ── Tokens ── */
|
||||||
|
:root {
|
||||||
|
--brand: #647219;
|
||||||
|
--brand-600: #556114;
|
||||||
|
--brand-700: #45500f;
|
||||||
|
--brand-50: #f3f6e6;
|
||||||
|
--brand-100: #e2e9c5;
|
||||||
|
--accent: #95a322;
|
||||||
|
--charcoal: #3c3c3b;
|
||||||
|
--charcoal-2: #2c2c2b;
|
||||||
|
|
||||||
|
--bg: #eef1ee;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--text: #222a26;
|
||||||
|
--muted: #69736d;
|
||||||
|
--muted-2: #9aa39c;
|
||||||
|
--border: #e6eae6;
|
||||||
|
--border-strong:#d6dcd6;
|
||||||
|
|
||||||
|
--danger: #d6453f;
|
||||||
|
--danger-50: #fdecea;
|
||||||
|
--success: #2f9e6b;
|
||||||
|
--info: #2b6c8f;
|
||||||
|
|
||||||
|
--radius: 16px;
|
||||||
|
--radius-md: 11px;
|
||||||
|
--radius-sm: 9px;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(30,40,30,.05), 0 1px 3px rgba(30,40,30,.07);
|
||||||
|
--shadow-md: 0 8px 22px rgba(30,45,30,.09), 0 2px 6px rgba(30,45,30,.06);
|
||||||
|
--shadow-lg: 0 24px 60px rgba(25,40,25,.24);
|
||||||
|
|
||||||
|
--font: 'Jakarta', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
--topbar-h: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
html, body { margin: 0; height: 100%; font-family: var(--font); color: var(--text); background: var(--bg); font-size: 14.5px; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
||||||
|
button { font-family: inherit; }
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
.ic { display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.ic svg { display: block; }
|
||||||
|
|
||||||
|
/* ── Topbar ── */
|
||||||
|
.topbar { height: var(--topbar-h); display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: linear-gradient(100deg, var(--charcoal-2), var(--charcoal) 60%, #494941); color: #fff; box-shadow: var(--shadow-md); position: sticky; top: 0; z-index: 30; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.brand-logo { display: flex; align-items: center; background: #fff; border-radius: 10px; padding: 5px 11px; box-shadow: var(--shadow-sm); }
|
||||||
|
.brand-logo img { height: 30px; display: block; }
|
||||||
|
.brand-divider { width: 1px; height: 26px; background: rgba(255,255,255,.2); }
|
||||||
|
.brand-title { font-size: 15px; font-weight: 600; letter-spacing: .2px; color: rgba(255,255,255,.9); }
|
||||||
|
.topbar-right { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.status-pill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 13px; border-radius: 999px; font-size: 12.5px; font-weight: 600; background: rgba(255,255,255,.1); color: #fff; border: 1px solid rgba(255,255,255,.16); max-width: 46vw; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.status-pill .status-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted-2); flex: none; }
|
||||||
|
.status-ok .status-dot { background: #b7d34a; box-shadow: 0 0 0 3px rgba(149,163,34,.3); }
|
||||||
|
.status-error .status-dot { background: #ff8a82; box-shadow: 0 0 0 3px rgba(255,138,130,.25); }
|
||||||
|
.status-ok { background: rgba(149,163,34,.22); border-color: rgba(149,163,34,.4); }
|
||||||
|
.status-error { background: rgba(255,138,130,.16); border-color: rgba(255,138,130,.4); }
|
||||||
|
|
||||||
|
/* ── Config-Banner ── */
|
||||||
|
.config-banner { margin: 16px 22px 0; padding: 14px 18px; background: #fff7e6; border: 1px solid #f0d8a0; border-left: 4px solid var(--accent); border-radius: var(--radius-md); color: #6a5320; font-size: 13.5px; line-height: 1.55; }
|
||||||
|
.config-banner code { font-family: var(--mono); background: #faedce; padding: 1px 6px; border-radius: 5px; font-size: 12.5px; }
|
||||||
|
|
||||||
|
/* ── App / Kategorie-Tabs ── */
|
||||||
|
.app { height: calc(100vh - var(--topbar-h)); display: flex; flex-direction: column; }
|
||||||
|
.cat-tabs { display: flex; align-items: center; gap: 2px; padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--border); }
|
||||||
|
.cat-tab { display: inline-flex; align-items: center; gap: 8px; border: none; background: none; cursor: pointer; padding: 16px 16px 14px; font-size: 14px; font-weight: 600; color: var(--muted); border-bottom: 2.5px solid transparent; margin-bottom: -1px; transition: color .15s, border-color .15s; }
|
||||||
|
.cat-tab .ic { color: var(--muted-2); transition: color .15s; }
|
||||||
|
.cat-tab:hover { color: var(--text); }
|
||||||
|
.cat-tab:hover .ic { color: var(--muted); }
|
||||||
|
.cat-tab.is-active { color: var(--brand-600); border-bottom-color: var(--brand); }
|
||||||
|
.cat-tab.is-active .ic { color: var(--brand); }
|
||||||
|
.cat-spacer { flex: 1; }
|
||||||
|
.icon-btn { width: 34px; height: 34px; border-radius: 9px; border: 1px solid var(--border-strong); background: #fff; color: var(--muted); cursor: pointer; transition: all .15s; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.icon-btn:hover { color: var(--brand); border-color: var(--brand-100); background: var(--brand-50); }
|
||||||
|
.icon-btn.danger:hover { color: var(--danger); border-color: #ecc4c2; background: var(--danger-50); }
|
||||||
|
|
||||||
|
/* ── Workspace ── */
|
||||||
|
.workspace { flex: 1; display: grid; grid-template-columns: 300px 1fr; min-height: 0; }
|
||||||
|
.listpane { background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; }
|
||||||
|
.listpane-head { display: flex; gap: 8px; align-items: center; padding: 14px 14px 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.search-wrap { flex: 1; position: relative; min-width: 0; }
|
||||||
|
.search-ic { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--muted-2); pointer-events: none; }
|
||||||
|
.search-ic svg { width: 16px; height: 16px; }
|
||||||
|
.tree-search { width: 100%; border: 1px solid var(--border-strong); background: #fbfcfb; border-radius: var(--radius-sm); padding: 9px 12px 9px 34px; font-size: 13.5px; color: var(--text); outline: none; transition: border-color .15s, box-shadow .15s; }
|
||||||
|
.tree-search::placeholder { color: var(--muted-2); }
|
||||||
|
.tree-search:focus { border-color: var(--brand); background: #fff; box-shadow: 0 0 0 3px var(--brand-50); }
|
||||||
|
|
||||||
|
.list-body { flex: 1; overflow-y: auto; padding: 8px 10px 30px; }
|
||||||
|
|
||||||
|
/* Gruppen */
|
||||||
|
.tree-group { margin-bottom: 7px; }
|
||||||
|
.group-head { display: flex; align-items: center; gap: 9px; padding: 6px 8px 6px 6px; border-radius: var(--radius-sm); cursor: pointer; color: var(--text); transition: background .12s; }
|
||||||
|
.group-head:hover { background: #f1f3ef; }
|
||||||
|
.group-head .g-caret { color: var(--muted-2); display: inline-flex; transition: transform .18s; flex: none; }
|
||||||
|
.group-head.collapsed .g-caret { transform: rotate(-90deg); }
|
||||||
|
.group-head .g-badge { width: 27px; height: 27px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 800; flex: none; letter-spacing: 0; }
|
||||||
|
.group-head .g-badge svg { width: 15px; height: 15px; }
|
||||||
|
.group-head .g-label { flex: 1; font-size: 13.5px; font-weight: 700; letter-spacing: .1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.group-head .g-count { font-size: 11px; font-weight: 700; color: var(--muted); background: var(--bg); border-radius: 999px; padding: 2px 8px; min-width: 22px; text-align: center; }
|
||||||
|
.group-head .g-add { border: none; background: none; color: var(--muted-2); cursor: pointer; padding: 3px; border-radius: 6px; display: inline-flex; opacity: 0; transition: opacity .12s, color .12s, background .12s; flex: none; }
|
||||||
|
.group-head:hover .g-add { opacity: 1; }
|
||||||
|
.group-head .g-add:hover { color: var(--brand); background: var(--brand-100); }
|
||||||
|
/* Dateien hängen mit einer dezenten Führungslinie an ihrer Abteilung */
|
||||||
|
.group-files { display: flex; flex-direction: column; gap: 1px; margin: 3px 0 0 19px; padding: 1px 0 2px 12px; border-left: 1.5px solid var(--border); }
|
||||||
|
.group-files.collapsed { display: none; }
|
||||||
|
|
||||||
|
/* Datei-Items */
|
||||||
|
.tree-item { display: flex; align-items: center; gap: 9px; padding: 8px 11px; border-radius: var(--radius-sm); cursor: pointer; font-size: 13.5px; color: #46504a; border-left: 3px solid transparent; transition: background .12s, color .12s; }
|
||||||
|
.tree-item:hover { background: #f1f3ef; color: var(--text); }
|
||||||
|
.tree-item .ti-icon { color: var(--muted-2); display: inline-flex; flex: none; }
|
||||||
|
.tree-item .ti-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tree-item.is-active { background: var(--brand-50); color: var(--brand-700); font-weight: 600; border-left-color: var(--brand); }
|
||||||
|
.tree-item.is-active .ti-icon { color: var(--brand); }
|
||||||
|
|
||||||
|
.tree-empty { padding: 9px 12px; font-size: 12.5px; color: var(--muted-2); font-style: italic; }
|
||||||
|
|
||||||
|
/* Admin-Nav (in der Listen-Spalte) */
|
||||||
|
.nav-list { display: flex; flex-direction: column; gap: 3px; padding-top: 4px; }
|
||||||
|
.nav-item { display: flex; align-items: center; gap: 11px; padding: 11px 12px; border-radius: var(--radius-sm); border: none; background: none; cursor: pointer; font-size: 14px; font-weight: 600; color: #46504a; border-left: 3px solid transparent; text-align: left; transition: all .12s; }
|
||||||
|
.nav-item .ni-icon { color: var(--muted-2); display: inline-flex; }
|
||||||
|
.nav-item:hover { background: #f1f3ef; color: var(--text); }
|
||||||
|
.nav-item.is-active { background: var(--brand-50); color: var(--brand-700); border-left-color: var(--brand); }
|
||||||
|
.nav-item.is-active .ni-icon { color: var(--brand); }
|
||||||
|
|
||||||
|
/* ── Inhalts-Spalte ── */
|
||||||
|
.editorpane { min-height: 0; display: flex; flex-direction: column; padding: 20px; overflow-y: auto; }
|
||||||
|
.empty-state { margin: auto; text-align: center; max-width: 420px; color: var(--muted); }
|
||||||
|
.empty-illu { color: var(--brand-100); margin-bottom: 6px; }
|
||||||
|
.empty-state h2 { margin: 6px 0 8px; color: var(--text); font-size: 21px; font-weight: 700; }
|
||||||
|
.empty-state p { margin: 0; line-height: 1.6; font-size: 14px; }
|
||||||
|
|
||||||
|
.editor-panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden; }
|
||||||
|
.editor-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 18px 22px 15px; border-bottom: 1px solid var(--border); }
|
||||||
|
.editor-titles { min-width: 0; }
|
||||||
|
.editor-titles h2 { margin: 0 0 7px; font-size: 19px; font-weight: 700; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.file-path { display: inline-block; font-family: var(--mono); font-size: 11.5px; color: var(--muted); background: var(--bg); border: 1px solid var(--border); padding: 3px 9px; border-radius: 999px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.editor-head-actions { display: flex; align-items: center; gap: 9px; flex: none; }
|
||||||
|
.dirty-badge { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: #8a6312; background: #fdf3df; border: 1px solid #f0dca6; padding: 5px 11px; border-radius: 999px; }
|
||||||
|
.dirty-badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn { display: inline-flex; align-items: center; gap: 7px; border: 1px solid transparent; border-radius: var(--radius-sm); padding: 9px 15px; font-size: 13.5px; font-weight: 600; cursor: pointer; transition: all .15s ease; white-space: nowrap; line-height: 1.1; }
|
||||||
|
.btn .ic svg { width: 16px; height: 16px; }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.btn-primary { background: var(--brand); color: #fff; box-shadow: var(--shadow-sm); }
|
||||||
|
.btn-primary:not(:disabled):hover { background: var(--brand-600); transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||||
|
.btn-ghost { background: #fff; color: var(--text); border-color: var(--border-strong); }
|
||||||
|
.btn-ghost:hover { background: var(--bg); border-color: var(--muted-2); }
|
||||||
|
.btn-danger { background: var(--danger); color: #fff; }
|
||||||
|
.btn-danger:hover { background: #bd3a35; }
|
||||||
|
.btn-danger-ghost { background: #fff; color: var(--danger); border-color: #ecc4c2; }
|
||||||
|
.btn-danger-ghost:hover { background: var(--danger-50); border-color: var(--danger); }
|
||||||
|
.btn-sm { padding: 8px 12px; font-size: 12.5px; }
|
||||||
|
|
||||||
|
/* Platzhalter-Leiste */
|
||||||
|
.ph-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 10px 22px; background: #fbfcf7; border-bottom: 1px solid var(--border); }
|
||||||
|
.ph-lead { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--muted); }
|
||||||
|
.ph-lead .ic { color: var(--accent); }
|
||||||
|
.ph-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.ph-chip { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--brand-700); background: var(--brand-50); border: 1px solid var(--brand-100); border-radius: 999px; padding: 4px 11px; cursor: pointer; transition: all .12s; }
|
||||||
|
.ph-chip:hover { background: var(--brand); color: #fff; border-color: var(--brand); transform: translateY(-1px); }
|
||||||
|
.ph-more { margin-left: auto; border: none; background: none; color: var(--brand-600); font-size: 12.5px; font-weight: 600; cursor: pointer; padding: 4px 6px; border-radius: 6px; }
|
||||||
|
.ph-more:hover { background: var(--brand-50); }
|
||||||
|
.ph-more::after { content: " ▾"; font-size: 9px; }
|
||||||
|
.ph-more.is-open::after { content: " ▴"; }
|
||||||
|
.ph-details { padding: 14px 22px 16px; background: #fbfcf7; border-bottom: 1px solid var(--border); font-size: 13px; color: #44524a; }
|
||||||
|
.ph-details p { margin: 0 0 10px; line-height: 1.55; }
|
||||||
|
.ph-details .ph-foot { margin: 12px 0 0; }
|
||||||
|
.ph-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.ph-table td { padding: 5px 10px 5px 0; vertical-align: top; border-bottom: 1px solid var(--border); }
|
||||||
|
.ph-table tr:last-child td { border-bottom: none; }
|
||||||
|
.ph-table code { font-family: var(--mono); font-size: 12px; background: var(--brand-50); color: var(--brand-700); padding: 2px 7px; border-radius: 5px; white-space: nowrap; }
|
||||||
|
.ph-table .ph-src { color: var(--muted); font-size: 12px; }
|
||||||
|
.ph-details a { color: var(--brand-600); font-weight: 600; text-decoration: none; border-bottom: 1px solid var(--brand-100); }
|
||||||
|
.ph-details a:hover { border-bottom-color: var(--brand); }
|
||||||
|
|
||||||
|
/* Editor-Tabs */
|
||||||
|
.editor-tabs { display: flex; gap: 2px; padding: 10px 22px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.editor-tab { border: none; background: none; cursor: pointer; padding: 9px 16px; font-size: 13.5px; font-weight: 600; color: var(--muted); border-bottom: 2.5px solid transparent; margin-bottom: -1px; transition: all .15s; }
|
||||||
|
.editor-tab:hover { color: var(--text); }
|
||||||
|
.editor-tab.is-active { color: var(--brand-600); border-bottom-color: var(--brand); }
|
||||||
|
|
||||||
|
.editor-body { flex: 1; min-height: 0; display: flex; padding: 16px 22px 22px; }
|
||||||
|
.epane { flex: 1; min-height: 0; display: flex; }
|
||||||
|
#pane-visual { flex-direction: column; }
|
||||||
|
.tox.tox-tinymce { flex: 1; border-radius: var(--radius-md) !important; border-color: var(--border-strong) !important; }
|
||||||
|
.tox .tox-editor-header { box-shadow: none !important; }
|
||||||
|
.html-editor { flex: 1; width: 100%; resize: none; border: 1px solid var(--border-strong); border-radius: var(--radius-md); background: #fbfcfb; padding: 16px 18px; font-family: var(--mono); font-size: 12.5px; line-height: 1.65; color: #2a3a30; outline: none; tab-size: 2; transition: border-color .15s, box-shadow .15s; }
|
||||||
|
.html-editor:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
|
||||||
|
.preview-frame-wrap { flex: 1; border: 1px solid var(--border); border-radius: var(--radius-md); background: #e7ebe7; overflow: hidden; min-height: 0; background-image: radial-gradient(rgba(60,60,59,.07) 1px, transparent 1px); background-size: 16px 16px; }
|
||||||
|
.preview-frame { width: 100%; height: 100%; border: none; background: transparent; }
|
||||||
|
|
||||||
|
/* ── Verwaltung ── */
|
||||||
|
.admin-panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-md); padding: 26px 28px; max-width: 860px; width: 100%; margin: 0 auto; }
|
||||||
|
.admin-head { margin-bottom: 22px; }
|
||||||
|
.admin-head h2 { margin: 0 0 6px; font-size: 22px; font-weight: 800; letter-spacing: -.2px; color: var(--text); }
|
||||||
|
.admin-head p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
|
||||||
|
|
||||||
|
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
|
||||||
|
.stat-card { background: linear-gradient(180deg, #fbfcf8, #fff); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 18px; box-shadow: var(--shadow-sm); }
|
||||||
|
.stat-ic { display: inline-flex; width: 40px; height: 40px; border-radius: 11px; background: var(--brand-50); color: var(--brand); align-items: center; justify-content: center; margin-bottom: 12px; }
|
||||||
|
.stat-n { font-size: 30px; font-weight: 800; color: var(--text); line-height: 1; letter-spacing: -.5px; }
|
||||||
|
.stat-l { font-size: 12.5px; font-weight: 600; color: var(--muted); margin-top: 5px; }
|
||||||
|
.info-card { border: 1px solid var(--border); border-radius: var(--radius-md); padding: 16px 18px; background: #fbfcf8; }
|
||||||
|
.info-row { display: flex; align-items: center; gap: 13px; }
|
||||||
|
.info-ic { width: 38px; height: 38px; border-radius: 10px; background: var(--brand-50); color: var(--brand); display: inline-flex; align-items: center; justify-content: center; flex: none; }
|
||||||
|
.info-k { font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--muted-2); }
|
||||||
|
.info-v { font-size: 14px; color: var(--text); font-weight: 600; margin-top: 2px; }
|
||||||
|
|
||||||
|
.inp { border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font-size: 14px; font-family: inherit; color: var(--text); outline: none; background: #fff; transition: border-color .15s, box-shadow .15s; }
|
||||||
|
.inp:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
|
||||||
|
|
||||||
|
.adm-add { display: flex; gap: 10px; margin-bottom: 18px; }
|
||||||
|
.adm-add .inp { flex: 1; }
|
||||||
|
.adm-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.adm-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-md); background: #fff; transition: border-color .12s, box-shadow .12s; }
|
||||||
|
.adm-row:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }
|
||||||
|
.adm-ic { color: var(--brand); display: inline-flex; }
|
||||||
|
.adm-name { font-weight: 700; flex: 1; }
|
||||||
|
.adm-meta { font-size: 12.5px; color: var(--muted); }
|
||||||
|
|
||||||
|
.map-head { display: grid; grid-template-columns: 1fr 220px 40px; gap: 10px; padding: 0 4px 8px; font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; color: var(--muted-2); }
|
||||||
|
.map-rows, .tag-rows { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.map-row { display: grid; grid-template-columns: 1fr 220px 40px; gap: 10px; align-items: center; }
|
||||||
|
.tag-row { display: grid; grid-template-columns: 28px 1fr 56px 40px; gap: 10px; align-items: center; }
|
||||||
|
.tag-swatch { width: 22px; height: 22px; border-radius: 7px; border: 1px solid rgba(0,0,0,.12); }
|
||||||
|
.tag-color { width: 100%; height: 38px; padding: 2px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: #fff; cursor: pointer; }
|
||||||
|
.adm-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
|
||||||
|
|
||||||
|
/* ── Toasts ── */
|
||||||
|
.toast-stack { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 60; max-width: 380px; }
|
||||||
|
.toast { display: flex; align-items: flex-start; gap: 11px; background: #fff; border: 1px solid var(--border); border-left: 4px solid var(--info); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); padding: 13px 16px; font-size: 13.5px; line-height: 1.45; color: var(--text); animation: toast-in .26s cubic-bezier(.21,1.02,.73,1); }
|
||||||
|
.toast-icon { width: 22px; height: 22px; flex: none; display: grid; place-items: center; border-radius: 50%; font-size: 13px; font-weight: 700; color: #fff; background: var(--info); }
|
||||||
|
.toast-msg { padding-top: 1px; }
|
||||||
|
.toast-success { border-left-color: var(--success); } .toast-success .toast-icon { background: var(--success); }
|
||||||
|
.toast-error { border-left-color: var(--danger); } .toast-error .toast-icon { background: var(--danger); }
|
||||||
|
.toast.fade-out { animation: toast-out .3s ease forwards; }
|
||||||
|
@keyframes toast-in { from { opacity: 0; transform: translateY(12px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes toast-out { to { opacity: 0; transform: translateX(20px); } }
|
||||||
|
|
||||||
|
/* ── Lade-Overlay ── */
|
||||||
|
.loading-overlay { position: fixed; inset: 0; background: rgba(25,40,25,.26); backdrop-filter: blur(2px); display: grid; place-items: center; z-index: 70; }
|
||||||
|
.spinner { width: 44px; height: 44px; border: 4px solid rgba(255,255,255,.4); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── Modals ── */
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; background: rgba(20,30,20,.45); backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 80; padding: 20px; animation: fade-in .15s ease; }
|
||||||
|
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
.modal { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow-lg); width: 100%; max-width: 440px; padding: 24px 24px 20px; animation: modal-pop .2s cubic-bezier(.21,1.02,.73,1); }
|
||||||
|
@keyframes modal-pop { from { opacity: 0; transform: translateY(10px) scale(.97); } to { opacity: 1; transform: none; } }
|
||||||
|
.modal h3 { margin: 0 0 10px; font-size: 17px; font-weight: 700; color: var(--text); }
|
||||||
|
.modal p { margin: 0 0 4px; color: #44524a; line-height: 1.55; font-size: 14px; }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
|
||||||
|
.field { margin-top: 14px; }
|
||||||
|
.field:first-child { margin-top: 6px; }
|
||||||
|
.field label { display: block; font-size: 12.5px; font-weight: 650; color: var(--muted); margin-bottom: 6px; }
|
||||||
|
.field input, .field select { width: 100%; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 10px 12px; font-size: 14px; font-family: inherit; color: var(--text); outline: none; background: #fff; transition: border-color .15s, box-shadow .15s; }
|
||||||
|
.field input:focus, .field select:focus { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
|
||||||
|
.field .hint { margin-top: 7px; font-size: 12.5px; color: var(--muted); line-height: 1.4; }
|
||||||
|
.field .hint .preview-name { font-family: var(--mono); font-size: 12px; background: var(--brand-50); color: var(--brand-600); padding: 1px 6px; border-radius: 5px; }
|
||||||
|
|
||||||
|
/* ── Scrollbars ── */
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 980px) { .workspace { grid-template-columns: 250px 1fr; } .stat-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.app { height: auto; }
|
||||||
|
.workspace { grid-template-columns: 1fr; }
|
||||||
|
.listpane { border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
||||||
|
.editorpane { height: auto; }
|
||||||
|
.editor-panel { min-height: 72vh; }
|
||||||
|
.map-head, .map-row { grid-template-columns: 1fr 130px 36px; }
|
||||||
|
.brand-title { display: none; }
|
||||||
|
}
|
||||||
580
web-editor/server.js
Normal file
580
web-editor/server.js
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
// server.js — HPS Vorlagen & Signaturen Web-Editor
|
||||||
|
// Express backend that proxies the Gitea/Forgejo Contents API.
|
||||||
|
// The Gitea token stays server-side (never reaches the browser) and CORS
|
||||||
|
// is avoided because the browser only ever talks to this server.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const {
|
||||||
|
GITEA_URL,
|
||||||
|
GITEA_OWNER,
|
||||||
|
GITEA_REPO,
|
||||||
|
GITEA_BRANCH = 'main',
|
||||||
|
GITEA_TOKEN,
|
||||||
|
PORT = 3000,
|
||||||
|
COMMIT_AUTHOR_NAME = 'Web-Editor',
|
||||||
|
COMMIT_AUTHOR_EMAIL = '',
|
||||||
|
BASIC_AUTH_USER,
|
||||||
|
BASIC_AUTH_PASS,
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
const SHARED_FOLDER = '_gemeinsam';
|
||||||
|
const USER_FOLDER = '_benutzer';
|
||||||
|
const CONFIG_FOLDER = '_config';
|
||||||
|
const SIG_FOOTERS = 'signatures/footers';
|
||||||
|
const SIG_HEADERS = 'signatures/headers';
|
||||||
|
|
||||||
|
// Demo mode: serve an in-memory sample repo so the editor can be tried out
|
||||||
|
// without a real Gitea server. Enable with DEMO=1.
|
||||||
|
const DEMO = process.env.DEMO === '1' || process.env.DEMO === 'true';
|
||||||
|
|
||||||
|
// Local mode: read/write a local checkout of the repo on disk instead of
|
||||||
|
// talking to Gitea. Useful for inspecting/editing real content offline.
|
||||||
|
const LOCAL_REPO = process.env.LOCAL_REPO || '';
|
||||||
|
const LOCAL = !!LOCAL_REPO;
|
||||||
|
const localRoot = LOCAL ? path.resolve(LOCAL_REPO) : null;
|
||||||
|
|
||||||
|
const configured = DEMO || LOCAL || !!(GITEA_URL && GITEA_OWNER && GITEA_REPO && GITEA_TOKEN);
|
||||||
|
if (!configured) {
|
||||||
|
console.warn('[WARN] Gitea-Verbindung unvollständig konfiguriert. ' +
|
||||||
|
'Bitte GITEA_URL, GITEA_OWNER, GITEA_REPO und GITEA_TOKEN setzen (siehe .env.example).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gitea API client ──
|
||||||
|
|
||||||
|
const apiBase = () => `${GITEA_URL.replace(/\/$/, '')}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}`;
|
||||||
|
|
||||||
|
const giteaHeaders = () => ({
|
||||||
|
'Authorization': `token ${GITEA_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
});
|
||||||
|
|
||||||
|
const authorInfo = () => {
|
||||||
|
if (!COMMIT_AUTHOR_NAME) return {};
|
||||||
|
return {
|
||||||
|
author: {
|
||||||
|
name: COMMIT_AUTHOR_NAME,
|
||||||
|
email: COMMIT_AUTHOR_EMAIL ||
|
||||||
|
`${COMMIT_AUTHOR_NAME.toLowerCase().replace(/\s+/g, '.')}@local`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const encodePath = (p) => p.split('/').map(encodeURIComponent).join('/');
|
||||||
|
const toBase64 = (str) => Buffer.from(str, 'utf-8').toString('base64');
|
||||||
|
const fromBase64 = (b64) => Buffer.from((b64 || '').replace(/\s/g, ''), 'base64').toString('utf-8');
|
||||||
|
|
||||||
|
async function giteaError(method, filepath, res) {
|
||||||
|
let detail = '';
|
||||||
|
try { const body = await res.json(); detail = body.message || JSON.stringify(body); } catch (_) {}
|
||||||
|
const err = new Error(`${method} ${filepath}: ${res.status} ${res.statusText}${detail ? ' — ' + detail : ''}`);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFile(filepath) {
|
||||||
|
if (LOCAL) return localGetFile(filepath);
|
||||||
|
if (DEMO) return demoGetFile(filepath);
|
||||||
|
const url = `${apiBase()}/contents/${encodePath(filepath)}?ref=${encodeURIComponent(GITEA_BRANCH)}`;
|
||||||
|
const res = await fetch(url, { headers: giteaHeaders() });
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) await giteaError('GET', filepath, res);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDir(dirpath) {
|
||||||
|
if (LOCAL) return localListDir(dirpath);
|
||||||
|
if (DEMO) return demoListDir(dirpath);
|
||||||
|
const pathPart = dirpath ? `/${encodePath(dirpath)}` : '';
|
||||||
|
const url = `${apiBase()}/contents${pathPart}?ref=${encodeURIComponent(GITEA_BRANCH)}`;
|
||||||
|
const res = await fetch(url, { headers: giteaHeaders() });
|
||||||
|
if (res.status === 404) return [];
|
||||||
|
if (!res.ok) await giteaError('LIST', dirpath, res);
|
||||||
|
const result = await res.json();
|
||||||
|
return Array.isArray(result) ? result : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFile(filepath, content, message) {
|
||||||
|
if (LOCAL) return localWrite(filepath, content);
|
||||||
|
if (DEMO) return demoWrite(filepath, content);
|
||||||
|
const url = `${apiBase()}/contents/${encodePath(filepath)}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: giteaHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: toBase64(content),
|
||||||
|
message: message || `Add ${filepath}`,
|
||||||
|
branch: GITEA_BRANCH,
|
||||||
|
...authorInfo(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) await giteaError('POST', filepath, res);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateFile(filepath, content, sha, message) {
|
||||||
|
if (LOCAL) return localWrite(filepath, content);
|
||||||
|
if (DEMO) return demoWrite(filepath, content);
|
||||||
|
const url = `${apiBase()}/contents/${encodePath(filepath)}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: giteaHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: toBase64(content),
|
||||||
|
sha,
|
||||||
|
message: message || `Update ${filepath}`,
|
||||||
|
branch: GITEA_BRANCH,
|
||||||
|
...authorInfo(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) await giteaError('PUT', filepath, res);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(filepath, sha, message) {
|
||||||
|
if (LOCAL) { fs.rmSync(localResolve(filepath), { force: true }); return { success: true }; }
|
||||||
|
if (DEMO) { delete demoStore[filepath]; return { success: true }; }
|
||||||
|
const url = `${apiBase()}/contents/${encodePath(filepath)}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: giteaHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
sha,
|
||||||
|
message: message || `Delete ${filepath}`,
|
||||||
|
branch: GITEA_BRANCH,
|
||||||
|
...authorInfo(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) await giteaError('DELETE', filepath, res);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save = create-or-update, server resolves the latest sha to avoid races.
|
||||||
|
async function saveFile(filepath, content, message) {
|
||||||
|
const existing = await getFile(filepath);
|
||||||
|
if (existing && typeof existing.content === 'string') {
|
||||||
|
const existingContent = fromBase64(existing.content);
|
||||||
|
if (existingContent === content) {
|
||||||
|
return { unchanged: true, sha: existing.sha };
|
||||||
|
}
|
||||||
|
const r = await updateFile(filepath, content, existing.sha, message);
|
||||||
|
return { sha: r.content?.sha };
|
||||||
|
}
|
||||||
|
const r = await createFile(filepath, content, message);
|
||||||
|
return { sha: r.content?.sha };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Local filesystem backend ──
|
||||||
|
// Reads/writes a local checkout of the repo. Mimics the Gitea response shapes
|
||||||
|
// so the rest of the server is unchanged. Paths are confined to localRoot.
|
||||||
|
|
||||||
|
function localResolve(filepath) {
|
||||||
|
const abs = path.resolve(localRoot, filepath);
|
||||||
|
if (abs !== localRoot && !abs.startsWith(localRoot + path.sep)) {
|
||||||
|
throw Object.assign(new Error('Pfad außerhalb des Repos'), { status: 400 });
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localGetFile(filepath) {
|
||||||
|
const abs = localResolve(filepath);
|
||||||
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
|
||||||
|
const content = fs.readFileSync(abs, 'utf-8');
|
||||||
|
return { path: filepath, name: path.basename(filepath), type: 'file', sha: 'local', content: toBase64(content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function localListDir(dirpath) {
|
||||||
|
const abs = localResolve(dirpath || '.');
|
||||||
|
if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) return [];
|
||||||
|
return fs.readdirSync(abs, { withFileTypes: true })
|
||||||
|
.filter(e => e.name !== '.git')
|
||||||
|
.map(e => ({
|
||||||
|
name: e.name,
|
||||||
|
path: dirpath ? `${dirpath}/${e.name}` : e.name,
|
||||||
|
type: e.isDirectory() ? 'dir' : 'file',
|
||||||
|
sha: 'local',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function localWrite(filepath, content) {
|
||||||
|
const abs = localResolve(filepath);
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
fs.writeFileSync(abs, content, 'utf-8');
|
||||||
|
return { content: { sha: 'local', path: filepath } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function localAllFiles() {
|
||||||
|
const out = [];
|
||||||
|
(function walk(dir) {
|
||||||
|
const abs = dir ? localResolve(dir) : localRoot;
|
||||||
|
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
|
||||||
|
if (e.name === '.git') continue;
|
||||||
|
const rel = dir ? `${dir}/${e.name}` : e.name;
|
||||||
|
if (e.isDirectory()) walk(rel);
|
||||||
|
else out.push({ path: rel, sha: 'local' });
|
||||||
|
}
|
||||||
|
})('');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Demo backend (in-memory) ──
|
||||||
|
// A flat map of repoPath → raw content, seeded with sample data. The demo
|
||||||
|
// versions of the primitives mimic the Gitea API response shapes so the rest
|
||||||
|
// of the server works unchanged.
|
||||||
|
|
||||||
|
const demoStore = {};
|
||||||
|
|
||||||
|
function demoGetFile(filepath) {
|
||||||
|
if (!(filepath in demoStore)) return null;
|
||||||
|
return { path: filepath, name: filepath.split('/').pop(), type: 'file', sha: 'demo', content: toBase64(demoStore[filepath]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function demoListDir(dirpath) {
|
||||||
|
const prefix = dirpath ? `${dirpath}/` : '';
|
||||||
|
const out = [];
|
||||||
|
const seenDirs = new Set();
|
||||||
|
for (const p of Object.keys(demoStore)) {
|
||||||
|
if (prefix && !p.startsWith(prefix)) continue;
|
||||||
|
const rest = p.slice(prefix.length);
|
||||||
|
const slash = rest.indexOf('/');
|
||||||
|
if (slash === -1) {
|
||||||
|
out.push({ name: rest, path: p, type: 'file', sha: 'demo' });
|
||||||
|
} else {
|
||||||
|
const dir = rest.slice(0, slash);
|
||||||
|
if (!seenDirs.has(dir)) {
|
||||||
|
seenDirs.add(dir);
|
||||||
|
out.push({ name: dir, path: prefix + dir, type: 'dir', sha: 'demo' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function demoWrite(filepath, content) {
|
||||||
|
demoStore[filepath] = content;
|
||||||
|
return { content: { sha: 'demo', path: filepath } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedDemo() {
|
||||||
|
const sig = (name, role) =>
|
||||||
|
`<div style="font-family:Arial,sans-serif;font-size:13px;color:#333;line-height:1.5">
|
||||||
|
<strong style="color:#0d3b66">${name}</strong><br>
|
||||||
|
<span style="color:#666">${role}</span><br>
|
||||||
|
Hotel Park Soltau · Winsener Straße 111 · 29614 Soltau
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
Object.assign(demoStore, {
|
||||||
|
// Empty department (only a .gitkeep) — shows up with no templates yet.
|
||||||
|
'Haustechnik/.gitkeep': '',
|
||||||
|
|
||||||
|
'_config/abteilungen.json': JSON.stringify({
|
||||||
|
'info@hotel-park-soltau.de': 'Rezeption',
|
||||||
|
'veranstaltung@hotel-park-soltau.de': 'Veranstaltungsbuero',
|
||||||
|
'it@hotel-park-soltau.de': 'IT',
|
||||||
|
'haustechnik@hotel-park-soltau.de': 'Haustechnik',
|
||||||
|
}, null, 2),
|
||||||
|
|
||||||
|
'_gemeinsam/Begruessung.html':
|
||||||
|
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Sehr geehrte Damen und Herren,</p>
|
||||||
|
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">vielen Dank für Ihre Nachricht an das <strong>Hotel Park Soltau</strong>. Wir freuen uns über Ihr Interesse und melden uns schnellstmöglich bei Ihnen.</p>`,
|
||||||
|
|
||||||
|
'_gemeinsam/Buchungsbestaetigung.html':
|
||||||
|
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Guten Tag,</p>
|
||||||
|
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">wir bestätigen Ihnen hiermit Ihre Buchung im Hotel Park Soltau. Bei Fragen stehen wir Ihnen jederzeit gerne zur Verfügung.</p>
|
||||||
|
<ul style="font-family:Arial,sans-serif;font-size:14px;color:#222">
|
||||||
|
<li>Anreise: ab 15:00 Uhr</li>
|
||||||
|
<li>Abreise: bis 11:00 Uhr</li>
|
||||||
|
<li>Inklusive Frühstücksbuffet</li>
|
||||||
|
</ul>`,
|
||||||
|
|
||||||
|
'Rezeption/Anfrage Verfuegbarkeit.html':
|
||||||
|
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Guten Tag,</p>
|
||||||
|
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">gerne prüfen wir die Verfügbarkeit für Ihren gewünschten Zeitraum. Könnten Sie uns bitte folgende Angaben mitteilen?</p>
|
||||||
|
<ul style="font-family:Arial,sans-serif;font-size:14px;color:#222"><li>An- und Abreisedatum</li><li>Anzahl der Personen</li><li>Zimmerkategorie</li></ul>`,
|
||||||
|
|
||||||
|
'Rezeption/Check-in Informationen.html':
|
||||||
|
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Liebe Gäste,</p>
|
||||||
|
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Ihr Check-in ist ab 15:00 Uhr möglich. Unsere Rezeption ist rund um die Uhr für Sie besetzt. Kostenfreie Parkplätze stehen direkt am Hotel zur Verfügung.</p>`,
|
||||||
|
|
||||||
|
'Veranstaltungsbuero/Angebot Tagung.html':
|
||||||
|
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Sehr geehrte Damen und Herren,</p>
|
||||||
|
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">anbei erhalten Sie unser unverbindliches Angebot für Ihre Tagung. Unsere Veranstaltungsräume bieten Platz für bis zu 120 Personen — inklusive moderner Tagungstechnik.</p>`,
|
||||||
|
|
||||||
|
'IT/Passwort zuruecksetzen.html':
|
||||||
|
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Hallo,</p>
|
||||||
|
<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Ihr Passwort wurde zurückgesetzt. Bitte melden Sie sich mit dem temporären Kennwort an und vergeben Sie umgehend ein neues.</p>`,
|
||||||
|
|
||||||
|
'_benutzer/info@hotel-park-soltau.de/Persoenliche Notiz.html':
|
||||||
|
`<p style="font-family:Arial,sans-serif;font-size:14px;color:#222">Kurzer persönlicher Hinweis — nur für meinen eigenen Gebrauch sichtbar.</p>`,
|
||||||
|
|
||||||
|
'signatures/footers/_default.html':
|
||||||
|
`<table style="font-family:Arial,sans-serif;font-size:12px;color:#888;border-top:2px solid #0d3b66;padding-top:8px;margin-top:12px">
|
||||||
|
<tr><td>Hotel Park Soltau GmbH · Geschäftsführer: M. Mustermann · Amtsgericht Lüneburg HRB 12345<br>
|
||||||
|
Tel. +49 5191 0000 · <a href="https://hotel-park-soltau.de" style="color:#0d3b66">hotel-park-soltau.de</a></td></tr>
|
||||||
|
</table>`,
|
||||||
|
|
||||||
|
'signatures/footers/Rezeption.html':
|
||||||
|
`<table style="font-family:Arial,sans-serif;font-size:12px;color:#888;border-top:2px solid #0d3b66;padding-top:8px;margin-top:12px">
|
||||||
|
<tr><td><strong>Rezeption</strong> — rund um die Uhr für Sie da · Tel. +49 5191 0000-0<br>
|
||||||
|
Hotel Park Soltau · Winsener Straße 111 · 29614 Soltau</td></tr>
|
||||||
|
</table>`,
|
||||||
|
|
||||||
|
'signatures/headers/_vorlage.html':
|
||||||
|
sig('[Vorname Nachname]', '[Position]'),
|
||||||
|
|
||||||
|
'signatures/headers/info@hotel-park-soltau.de.max-mustermann.html':
|
||||||
|
sig('Max Mustermann', 'Rezeptionsleitung'),
|
||||||
|
|
||||||
|
'signatures/headers/veranstaltung@hotel-park-soltau.de.anna-beispiel.html':
|
||||||
|
sig('Anna Beispiel', 'Veranstaltungsmanagement'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEMO) {
|
||||||
|
seedDemo();
|
||||||
|
console.log('[DEMO] In-Memory-Demodaten geladen — keine echte Gitea-Verbindung nötig.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liefert ALLE Dateien des Repos als flache Liste [{path, sha}].
|
||||||
|
// Gitea: ein einziger rekursiver git/trees-Aufruf (statt vieler listDir).
|
||||||
|
async function listAllFiles() {
|
||||||
|
if (LOCAL) return localAllFiles();
|
||||||
|
if (DEMO) return Object.keys(demoStore).map((p) => ({ path: p, sha: 'demo' }));
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
let page = 1, total = Infinity;
|
||||||
|
while (out.length < total) {
|
||||||
|
const url = `${apiBase()}/git/trees/${encodeURIComponent(GITEA_BRANCH)}?recursive=true&per_page=1000&page=${page}`;
|
||||||
|
const res = await fetch(url, { headers: giteaHeaders() });
|
||||||
|
if (res.status === 404) return [];
|
||||||
|
if (!res.ok) await giteaError('TREE', '', res);
|
||||||
|
const data = await res.json();
|
||||||
|
total = data.total_count || 0;
|
||||||
|
const blobs = (data.tree || []).filter((e) => e.type === 'blob').map((e) => ({ path: e.path, sha: e.sha }));
|
||||||
|
out.push(...blobs);
|
||||||
|
if (!data.tree || data.tree.length === 0) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Express app ──
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: '5mb' }));
|
||||||
|
|
||||||
|
// Optional basic auth — protects the whole editor when credentials are set.
|
||||||
|
if (BASIC_AUTH_USER && BASIC_AUTH_PASS) {
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const hdr = req.headers.authorization || '';
|
||||||
|
const [scheme, encoded] = hdr.split(' ');
|
||||||
|
if (scheme === 'Basic' && encoded) {
|
||||||
|
const [user, pass] = Buffer.from(encoded, 'base64').toString('utf-8').split(':');
|
||||||
|
if (user === BASIC_AUTH_USER && pass === BASIC_AUTH_PASS) return next();
|
||||||
|
}
|
||||||
|
res.set('WWW-Authenticate', 'Basic realm="HPS Web-Editor"');
|
||||||
|
res.status(401).send('Authentifizierung erforderlich');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async route wrapper → forwards errors to the JSON error handler.
|
||||||
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
|
||||||
|
function requireConfigured(_req, res, next) {
|
||||||
|
if (!configured) {
|
||||||
|
return res.status(503).json({ error: 'Gitea-Verbindung nicht konfiguriert. Bitte Umgebungsvariablen setzen.' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public, non-sensitive config (no token).
|
||||||
|
app.get('/api/config', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
configured,
|
||||||
|
demo: DEMO,
|
||||||
|
local: LOCAL,
|
||||||
|
url: GITEA_URL || (LOCAL ? `lokal: ${localRoot}` : (DEMO ? '(Demo-Modus — keine echten Daten)' : null)),
|
||||||
|
owner: GITEA_OWNER || (LOCAL ? 'lokal' : (DEMO ? 'demo' : null)),
|
||||||
|
repo: GITEA_REPO || (LOCAL ? path.basename(localRoot) : (DEMO ? 'email-vorlagen' : null)),
|
||||||
|
branch: GITEA_BRANCH,
|
||||||
|
author: COMMIT_AUTHOR_NAME,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health', wrap(async (_req, res) => {
|
||||||
|
if (LOCAL) return res.json({ ok: true, repo: `lokal: ${localRoot}` });
|
||||||
|
if (DEMO) return res.json({ ok: true, repo: 'demo/email-vorlagen (Demo-Modus)' });
|
||||||
|
if (!configured) return res.status(503).json({ ok: false, error: 'nicht konfiguriert' });
|
||||||
|
const r = await fetch(`${apiBase()}`, { headers: giteaHeaders() });
|
||||||
|
if (!r.ok) return res.status(502).json({ ok: false, error: `${r.status} ${r.statusText}` });
|
||||||
|
const info = await r.json();
|
||||||
|
res.json({ ok: true, repo: info.full_name });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use('/api', requireConfigured);
|
||||||
|
|
||||||
|
// Departments = top-level dirs minus the special folders.
|
||||||
|
app.get('/api/departments', wrap(async (_req, res) => {
|
||||||
|
const entries = await listDir('');
|
||||||
|
const departments = entries
|
||||||
|
.filter(e => e.type === 'dir'
|
||||||
|
&& ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'].includes(e.name)
|
||||||
|
&& !e.name.startsWith('.'))
|
||||||
|
.map(e => e.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'de'));
|
||||||
|
res.json({ departments });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/api/departments', wrap(async (req, res) => {
|
||||||
|
const name = (req.body?.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Kein Name angegeben' });
|
||||||
|
if (/[\/\\:*?"<>|]/.test(name)) return res.status(400).json({ error: 'Ungültiger Abteilungsname' });
|
||||||
|
await createFile(`${name}/.gitkeep`, '', `Abteilung "${name}" angelegt (Web-Editor)`);
|
||||||
|
res.json({ success: true, name });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// List .html files in any folder. Folder is validated against known prefixes.
|
||||||
|
const ALLOWED_LIST_PREFIXES = [SHARED_FOLDER, USER_FOLDER, SIG_FOOTERS, SIG_HEADERS];
|
||||||
|
app.get('/api/files', wrap(async (req, res) => {
|
||||||
|
const folder = String(req.query.folder || '');
|
||||||
|
const files = (await listDir(folder))
|
||||||
|
.filter(f => f.type === 'file' && f.name.endsWith('.html'))
|
||||||
|
.map(f => ({ name: f.name, path: f.path, sha: f.sha }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||||
|
res.json({ folder, files });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Full inventory in ONE request (recursive tree), strukturiert aus den Pfaden.
|
||||||
|
app.get('/api/tree', wrap(async (_req, res) => {
|
||||||
|
const files = await listAllFiles();
|
||||||
|
const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures'];
|
||||||
|
const dirOf = (p) => { const i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); };
|
||||||
|
const baseOf = (p) => p.slice(p.lastIndexOf('/') + 1);
|
||||||
|
|
||||||
|
// .html-Dateien direkt in einem bestimmten Ordner
|
||||||
|
const htmlIn = (folder) => files
|
||||||
|
.filter((f) => dirOf(f.path) === folder && baseOf(f.path).endsWith('.html'))
|
||||||
|
.map((f) => ({ name: baseOf(f.path), path: f.path, sha: f.sha }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||||
|
|
||||||
|
// Abteilungen = oberste Ordner außer den Spezialordnern (auch leere, via .gitkeep)
|
||||||
|
const deptSet = new Set();
|
||||||
|
for (const f of files) {
|
||||||
|
const seg = f.path.split('/')[0];
|
||||||
|
if (f.path.includes('/') && !special.includes(seg) && !seg.startsWith('.')) deptSet.add(seg);
|
||||||
|
}
|
||||||
|
const departments = [...deptSet].sort((a, b) => a.localeCompare(b, 'de'));
|
||||||
|
|
||||||
|
const templates = { [SHARED_FOLDER]: htmlIn(SHARED_FOLDER) };
|
||||||
|
departments.forEach((d) => { templates[d] = htmlIn(d); });
|
||||||
|
|
||||||
|
const users = {};
|
||||||
|
for (const f of files) {
|
||||||
|
const parts = f.path.split('/');
|
||||||
|
if (parts[0] === USER_FOLDER && parts.length >= 3) users[parts[1]] = users[parts[1]] || [];
|
||||||
|
}
|
||||||
|
Object.keys(users).forEach((email) => { users[email] = htmlIn(`${USER_FOLDER}/${email}`); });
|
||||||
|
|
||||||
|
res.json({ departments, templates, users, footers: htmlIn(SIG_FOOTERS), headers: htmlIn(SIG_HEADERS) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Read a single file. Missing file → exists:false, empty content (editor can create it).
|
||||||
|
app.get('/api/file', wrap(async (req, res) => {
|
||||||
|
const filepath = String(req.query.path || '');
|
||||||
|
if (!filepath || filepath.includes('..')) return res.status(400).json({ error: 'Ungültiger Pfad' });
|
||||||
|
const data = await getFile(filepath);
|
||||||
|
if (!data) return res.json({ path: filepath, content: '', sha: null, exists: false });
|
||||||
|
res.json({ path: filepath, content: fromBase64(data.content), sha: data.sha, exists: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create or update a file.
|
||||||
|
app.put('/api/file', wrap(async (req, res) => {
|
||||||
|
const { path: filepath, content = '', message } = req.body || {};
|
||||||
|
if (!filepath || filepath.includes('..')) return res.status(400).json({ error: 'Ungültiger Pfad' });
|
||||||
|
if (!filepath.endsWith('.html') && !filepath.endsWith('.json')) {
|
||||||
|
return res.status(400).json({ error: 'Nur .html- oder .json-Dateien erlaubt' });
|
||||||
|
}
|
||||||
|
const result = await saveFile(filepath, content, message);
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Delete a file.
|
||||||
|
app.delete('/api/file', wrap(async (req, res) => {
|
||||||
|
const filepath = String(req.body?.path || '');
|
||||||
|
if (!filepath || filepath.includes('..')) return res.status(400).json({ error: 'Ungültiger Pfad' });
|
||||||
|
const data = await getFile(filepath);
|
||||||
|
if (!data) return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||||
|
await deleteFile(filepath, data.sha, req.body?.message || `${filepath.split('/').pop()} gelöscht (Web-Editor)`);
|
||||||
|
res.json({ success: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Read/write the email→department mapping.
|
||||||
|
app.get('/api/abteilungen', wrap(async (_req, res) => {
|
||||||
|
const data = await getFile(`${CONFIG_FOLDER}/abteilungen.json`);
|
||||||
|
let mapping = {};
|
||||||
|
if (data) { try { mapping = JSON.parse(fromBase64(data.content)); } catch (_) {} }
|
||||||
|
res.json({ mapping, exists: !!data });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.put('/api/abteilungen', wrap(async (req, res) => {
|
||||||
|
const mapping = req.body?.mapping;
|
||||||
|
if (!mapping || typeof mapping !== 'object') return res.status(400).json({ error: 'Ungültiges Mapping' });
|
||||||
|
const json = JSON.stringify(mapping, null, 2);
|
||||||
|
const result = await saveFile(`${CONFIG_FOLDER}/abteilungen.json`, json, 'abteilungen.json aktualisiert (Web-Editor)');
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Read/write the Schlagwörter (Thunderbird-Tags) config.
|
||||||
|
app.get('/api/schlagwoerter', wrap(async (_req, res) => {
|
||||||
|
const data = await getFile(`${CONFIG_FOLDER}/schlagwoerter.json`);
|
||||||
|
let tags = [];
|
||||||
|
if (data) { try { tags = JSON.parse(fromBase64(data.content)); } catch (_) {} }
|
||||||
|
if (!Array.isArray(tags)) tags = [];
|
||||||
|
res.json({ tags, exists: !!data });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.put('/api/schlagwoerter', wrap(async (req, res) => {
|
||||||
|
const tags = req.body?.tags;
|
||||||
|
if (!Array.isArray(tags)) return res.status(400).json({ error: 'Ungültige Schlagwörter' });
|
||||||
|
const json = JSON.stringify(tags, null, 2);
|
||||||
|
const result = await saveFile(`${CONFIG_FOLDER}/schlagwoerter.json`, json, 'schlagwoerter.json aktualisiert (Web-Editor)');
|
||||||
|
res.json({ success: true, ...result });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Delete a whole department folder (all its files). Destructive – guarded.
|
||||||
|
app.delete('/api/departments', wrap(async (req, res) => {
|
||||||
|
const name = (req.body?.name || '').trim();
|
||||||
|
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('..')) {
|
||||||
|
return res.status(400).json({ error: 'Geschützter oder ungültiger Ordner' });
|
||||||
|
}
|
||||||
|
const entries = await listDir(name);
|
||||||
|
let deleted = 0;
|
||||||
|
for (const f of entries) {
|
||||||
|
if (f.type !== 'file') continue;
|
||||||
|
const fd = await getFile(f.path);
|
||||||
|
if (fd) { await deleteFile(f.path, fd.sha, `Abteilung "${name}" gelöscht (Web-Editor)`); deleted++; }
|
||||||
|
}
|
||||||
|
res.json({ success: true, deleted });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// TinyMCE (selbst gehostet, offline-tauglich) direkt aus node_modules ausliefern.
|
||||||
|
app.use('/vendor/tinymce', express.static(path.join(__dirname, 'node_modules', 'tinymce')));
|
||||||
|
|
||||||
|
// Static frontend.
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// JSON error handler.
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
console.error('[ERROR]', err.message);
|
||||||
|
res.status(err.status && err.status >= 400 && err.status < 600 ? err.status : 500)
|
||||||
|
.json({ error: err.message || 'Interner Fehler' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`HPS Web-Editor läuft auf http://localhost:${PORT}`);
|
||||||
|
if (configured) console.log(`→ Repo: ${GITEA_OWNER}/${GITEA_REPO}@${GITEA_BRANCH} (${GITEA_URL})`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user