diff --git a/lib/gitea-sync.js b/lib/gitea-sync.js index 7bd5a55..6344bf3 100644 --- a/lib/gitea-sync.js +++ b/lib/gitea-sync.js @@ -8,6 +8,7 @@ const FULL_SYNC_COOLDOWN_MS = 10 * 1000; // min 10s between full pulls const SHARED_FOLDER = '_gemeinsam'; const USER_FOLDER = '_benutzer'; const CONFIG_FOLDER = '_config'; +const CLIENTS_FOLDER = '_clients'; const SCHLAGWOERTER_CACHE_KEY = 'schlagwoerter_cache'; // ── Gitea API Client ── @@ -190,6 +191,59 @@ class SyncManager { return this.config?.authorEmail || ''; } + get authorName() { + return this.config?.authorName || ''; + } + + /** + * Meldet den eigenen Client-Status (Name, Abteilung, Plugin- + TB-Version) + * als _clients/.json ins Repo — Basis für die Admin-Übersicht im + * Web-Editor. Schreibt NUR, wenn sich tatsächlich etwas geändert hat + * (Plugin-Version, TB-Version, Name oder Abteilung) — sonst kein Commit. + */ + async reportClientStatus() { + if (!this.isConfigured) return { success: false, skipped: 'not-configured' }; + const email = this.authorEmail.trim().toLowerCase(); + if (!email) return { success: false, skipped: 'no-email' }; + + let tbVersion = ''; + try { + const info = await browser.runtime.getBrowserInfo(); + tbVersion = [info.name, info.version].filter(Boolean).join(' '); + } catch (_) {} + + const status = { + name: this.authorName, + email, + department: this.department, + pluginVersion: browser.runtime.getManifest().version, + tbVersion, + lastSeen: new Date().toISOString() + }; + + const filepath = `${CLIENTS_FOLDER}/${email}.json`; + const body = JSON.stringify(status, null, 2); + const commitMsg = `Client-Status: ${email} (v${status.pluginVersion})`; + + const existing = await this.client.getFile(filepath); + if (existing && existing.content) { + try { + const prev = JSON.parse(GiteaClient.fromBase64(existing.content)); + const unchanged = prev.name === status.name + && prev.department === status.department + && prev.pluginVersion === status.pluginVersion + && prev.tbVersion === status.tbVersion; + if (unchanged) { + return { success: true, skipped: 'unchanged' }; + } + } catch (_) { /* kaputter Vorgänger → einfach überschreiben */ } + await this.client.updateFile(filepath, body, existing.sha, commitMsg); + } else { + await this.client.createFile(filepath, body, commitMsg); + } + return { success: true, status }; + } + async autoDetect() { if (!this.isConfigured) return null; return await this.client.getConfig(); @@ -233,7 +287,7 @@ class SyncManager { const entries = await this.client.listDir(''); const departments = []; for (const entry of entries) { - if (entry.type === 'dir' && entry.name !== SHARED_FOLDER && entry.name !== USER_FOLDER && entry.name !== CONFIG_FOLDER && entry.name !== 'signatures' && !entry.name.startsWith('.')) { + if (entry.type === 'dir' && entry.name !== SHARED_FOLDER && entry.name !== USER_FOLDER && entry.name !== CONFIG_FOLDER && entry.name !== CLIENTS_FOLDER && entry.name !== 'signatures' && !entry.name.startsWith('.')) { departments.push(entry.name); } } @@ -968,6 +1022,7 @@ let lastKnownShas = null; let lastFullSync = 0; let lastTagSync = 0; let syncInProgress = false; +let clientStatusReported = false; const TAG_SYNC_INTERVAL_MS = 60 * 1000; const HASH_STORAGE_KEY_BG = 'sync_hashes'; @@ -1009,6 +1064,15 @@ async function smartSync() { const initialized = await syncManager.init(); if (!initialized) return; + // Einmal pro Session den eigenen Client-Status melden (Admin-Übersicht). + // Fire-and-forget, blockiert den Sync nicht. + if (!clientStatusReported) { + clientStatusReported = true; + syncManager.reportClientStatus() + .then((r) => { if (r?.status) console.log('[Sync] Client-Status gemeldet:', r.status.email, 'v' + r.status.pluginVersion); }) + .catch((e) => console.error('[Sync] Client-Status fehlgeschlagen:', e)); + } + syncInProgress = true; // Tag sync every 60s (schlagwoerter.json is not in SHA-checked folders) diff --git a/manifest.json b/manifest.json index 833100f..76d078e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "HPS Vorlagen & Signaturen", - "version": "2.4.0", + "version": "2.5.0", "description": "Vorlagen- und Signaturverwaltung für Hotel Park Soltau mit Git-Sync", "browser_specific_settings": { "gecko": { diff --git a/templates-reply-hotel.xpi b/templates-reply-hotel.xpi index 227a645..614f2a4 100644 Binary files a/templates-reply-hotel.xpi and b/templates-reply-hotel.xpi differ diff --git a/web-editor/public/app.js b/web-editor/public/app.js index 4d7b75e..1459522 100644 --- a/web-editor/public/app.js +++ b/web-editor/public/app.js @@ -31,6 +31,7 @@ 'link2': '', 'chevron': '', 'plug': '', + 'users': '', }; function icon(name, size) { const s = size || 18; @@ -213,7 +214,7 @@ const isAdmin = cat === 'admin'; el.btnListAdd.style.display = isAdmin ? 'none' : ''; el.treeSearch.parentElement.style.display = isAdmin ? 'none' : ''; - if (cat === 'templates') el.btnListAddLabel.textContent = 'Abteilung'; + if (cat === 'templates') el.btnListAddLabel.textContent = 'Vorlage'; else if (cat === 'footers') el.btnListAddLabel.textContent = 'Fußzeile'; else if (cat === 'headers') el.btnListAddLabel.textContent = 'Signatur'; renderList(); @@ -309,6 +310,7 @@ wrap.appendChild(adminNavItem('departments', 'Abteilungen', 'building')); wrap.appendChild(adminNavItem('mapping', 'E-Mail-Zuordnung', 'at-sign')); wrap.appendChild(adminNavItem('tags', 'Schlagwörter', 'tag')); + wrap.appendChild(adminNavItem('clients', 'User & Versionen', 'users')); c.appendChild(wrap); return; } @@ -528,6 +530,21 @@ const path = folder + '/' + slug + '.html'; if (existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; } openNewFile(path, slug, 'template'); } + // Vorlage anlegen mit Ordner-Auswahl (Haupt-„+"-Button). Das kleine „+" am + // Gruppenkopf nutzt weiterhin newTemplate(folder) direkt. + async function newTemplatePrompt() { + const t = state.tree || {}; + const options = [{ value: SHARED_FOLDER, label: 'Alle Abteilungen' }] + .concat((t.departments || []).map((d) => ({ value: d, label: d }))); + const res = await promptModal('Neue Vorlage', [ + { key: 'folder', label: 'Für welche Abteilung?', type: 'select', options, required: true }, + { key: 'name', label: 'Vorlagenname', placeholder: 'z. B. Angebot Doppelzimmer', required: true, live: true }, + ], (values, inputs, root) => { const slug = slugifyName(values.name || ''); const h = root.querySelector('[data-live="name"]'); if (h) h.innerHTML = slug ? 'Datei: ' + esc(slug) + '.html' : 'Bitte einen Namen eingeben.'; }); + if (!res) return; const slug = slugifyName(res.name); if (!slug) { toast('Ungültiger Name.', 'error'); return; } + const folder = res.folder || SHARED_FOLDER; const path = folder + '/' + slug + '.html'; + if (existsInTree(path)) { toast('Eine Vorlage mit diesem Namen existiert bereits.', 'error'); return; } + openNewFile(path, slug, 'template'); + } async function newFooter() { const t = state.tree || {}; const options = [{ value: '_default', label: 'Gemeinsam (alle Abteilungen)' }].concat((t.departments || []).map((d) => ({ value: d, label: d }))); @@ -554,7 +571,7 @@ try { const r = await api('/api/departments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); toast('Abteilung „' + (r.name || name) + '“ angelegt.', 'success'); await loadTree(); if (state.category === 'admin') setAdminView('departments'); } catch (e) { toast('Abteilung anlegen fehlgeschlagen: ' + e.message, 'error'); } } - function listAddAction() { if (state.category === 'templates') newDepartment(); else if (state.category === 'footers') newFooter(); else if (state.category === 'headers') newHeader(); } + function listAddAction() { if (state.category === 'templates') newTemplatePrompt(); else if (state.category === 'footers') newFooter(); else if (state.category === 'headers') newHeader(); } // ════════════════════════════════════════════════════════════ // Verwaltung (Admin) @@ -568,6 +585,56 @@ else if (view === 'departments') renderDepartments(); else if (view === 'mapping') renderMapping(); else if (view === 'tags') renderTags(); + else if (view === 'clients') renderClients(); + } + + // Semver-ähnlicher Vergleich: -1 / 0 / 1 (ab). + function cmpVersion(a, b) { + const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0); + const pb = String(b || '').split('.').map((n) => parseInt(n, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d) return d < 0 ? -1 : 1; + } + return 0; + } + + async function renderClients() { + const sub = 'Welche Plugin- und Thunderbird-Version läuft bei wem. Clients melden sich beim Start (Datei _clients/.json).'; + el.adminPanel.innerHTML = adminHeader('User & Versionen', sub) + '
Lädt…
'; + let clients = []; + try { const r = await api('/api/clients'); clients = r.clients || []; } + catch (e) { toast('Clients nicht ladbar: ' + e.message, 'error'); } + + if (!clients.length) { + el.adminPanel.innerHTML = adminHeader('User & Versionen', sub) + + '
Noch keine Client-Meldungen. Sobald Clients mit der neuen Plugin-Version starten, tauchen sie hier auf.
'; + return; + } + + // Neueste gemeldete Plugin-Version → alle anderen sind „veraltet". + const latest = clients.map((c) => c.pluginVersion).filter(Boolean).sort(cmpVersion).slice(-1)[0] || ''; + const onLatest = clients.filter((c) => c.pluginVersion === latest).length; + + const rows = clients.map((c) => { + const outdated = latest && c.pluginVersion && c.pluginVersion !== latest; + const ver = esc(c.pluginVersion || '—') + (outdated ? ' veraltet' : ''); + return '' + + '' + esc(c.name || '—') + '' + + '' + esc(c.email) + '' + + '' + esc(c.department || '—') + '' + + '' + ver + '' + + '' + esc(c.tbVersion || '—') + '' + + ''; + }).join(''); + + const summary = clients.length + ' Client' + (clients.length === 1 ? '' : 's') + + ' · ' + onLatest + ' auf neuester Version' + (latest ? ' (' + esc(latest) + ')' : ''); + + el.adminPanel.innerHTML = adminHeader('User & Versionen', summary) + + '
' + + '' + + '' + rows + '
NameE-MailAbteilungPluginThunderbird
'; } function adminHeader(title, subtitle) { return '

' + esc(title) + '

' + (subtitle ? '

' + esc(subtitle) + '

' : '') + '
'; diff --git a/web-editor/public/style.css b/web-editor/public/style.css index ee50426..f2c5406 100644 --- a/web-editor/public/style.css +++ b/web-editor/public/style.css @@ -279,6 +279,19 @@ button { font-family: inherit; } .list-body::-webkit-scrollbar, .html-editor::-webkit-scrollbar, .editorpane::-webkit-scrollbar { width: 10px; } .list-body::-webkit-scrollbar-thumb, .html-editor::-webkit-scrollbar-thumb, .editorpane::-webkit-scrollbar-thumb { background: #cdd6c9; border-radius: 10px; border: 2px solid transparent; background-clip: content-box; } +/* ── User & Versionen (Clients) ── */ +.cl-tablewrap { margin-top: 6px; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--panel); } +.cl-table { width: 100%; border-collapse: collapse; font-size: 13.5px; } +.cl-table th, .cl-table td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); white-space: nowrap; } +.cl-table thead th { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; background: var(--bg); } +.cl-table tbody tr:last-child td { border-bottom: none; } +.cl-table tbody tr:hover { background: var(--bg); } +.cl-table .cl-mail { color: var(--muted); font-family: var(--mono); font-size: 12.5px; } +.cl-row-old td { background: var(--danger-50); } +.cl-row-old:hover td { background: #fbe3e1; } +.cl-badge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; vertical-align: middle; } +.cl-badge.cl-old { background: var(--danger-50); color: var(--danger); border: 1px solid #ecc4c2; } + /* ── Responsive ── */ @media (max-width: 980px) { .workspace { grid-template-columns: 250px 1fr; } .stat-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 760px) { diff --git a/web-editor/server.js b/web-editor/server.js index 1a83d9c..5dcb073 100644 --- a/web-editor/server.js +++ b/web-editor/server.js @@ -23,6 +23,7 @@ const { const SHARED_FOLDER = '_gemeinsam'; const USER_FOLDER = '_benutzer'; const CONFIG_FOLDER = '_config'; +const CLIENTS_FOLDER = '_clients'; const SIG_FOOTERS = 'signatures/footers'; const SIG_HEADERS = 'signatures/headers'; @@ -421,7 +422,7 @@ 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) + && ![SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures'].includes(e.name) && !e.name.startsWith('.')) .map(e => e.name) .sort((a, b) => a.localeCompare(b, 'de')); @@ -450,7 +451,7 @@ app.get('/api/files', wrap(async (req, res) => { // Full inventory in ONE request (recursive tree), strukturiert aus den Pfaden. app.get('/api/tree', wrap(async (_req, res) => { const files = await listAllFiles(); - const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, 'signatures']; + const special = [SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures']; const dirOf = (p) => { const i = p.lastIndexOf('/'); return i < 0 ? '' : p.slice(0, i); }; const baseOf = (p) => p.slice(p.lastIndexOf('/') + 1); @@ -511,6 +512,31 @@ app.delete('/api/file', wrap(async (req, res) => { res.json({ success: true }); })); +// Client-Status-Übersicht: liest alle _clients/.json und liefert die +// gemeldeten Plugin-/TB-Versionen je User (Basis für den Admin-Tab). +app.get('/api/clients', wrap(async (_req, res) => { + const entries = await listDir(CLIENTS_FOLDER); + const clients = []; + for (const e of entries) { + if (e.type !== 'file' || !e.name.endsWith('.json')) continue; + const data = await getFile(e.path); + if (!data) continue; + try { + const c = JSON.parse(fromBase64(data.content)); + clients.push({ + name: c.name || '', + email: c.email || e.name.replace(/\.json$/, ''), + department: c.department || '', + pluginVersion: c.pluginVersion || '', + tbVersion: c.tbVersion || '', + lastSeen: c.lastSeen || '', + }); + } catch (_) { /* kaputte Statusdatei überspringen */ } + } + clients.sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email, 'de')); + res.json({ clients }); +})); + // Read/write the email→department mapping. app.get('/api/abteilungen', wrap(async (_req, res) => { const data = await getFile(`${CONFIG_FOLDER}/abteilungen.json`); @@ -548,7 +574,7 @@ app.put('/api/schlagwoerter', wrap(async (req, res) => { app.delete('/api/departments', wrap(async (req, res) => { 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('..')) { + if ([SHARED_FOLDER, USER_FOLDER, CONFIG_FOLDER, CLIENTS_FOLDER, 'signatures'].includes(name) || name.includes('/') || name.includes('..')) { return res.status(400).json({ error: 'Geschützter oder ungültiger Ordner' }); } const entries = await listDir(name);