// lib/gitea-sync.js — Gitea Sync Engine const SYNC_CONFIG_KEY = 'gitea_config'; const SYNC_STATE_KEY = 'sync_state'; const TEMPLATE_STORAGE_KEY = 'message_templates'; const SYNC_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes const SHARED_FOLDER = '_gemeinsam'; // ── Gitea API Client ── class GiteaClient { constructor(config) { this.baseUrl = config.baseUrl; this.owner = config.owner; this.repo = config.repo; this.branch = config.branch || 'main'; this.token = config.token; this.authorName = config.authorName || ''; this.authorEmail = config.authorEmail || ''; } get apiBase() { return `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}`; } get headers() { return { 'Authorization': `token ${this.token}`, 'Content-Type': 'application/json', 'Accept': 'application/json' }; } get authorInfo() { if (!this.authorName) return {}; return { author: { name: this.authorName, email: this.authorEmail || `${this.authorName.toLowerCase().replace(/\s+/g, '.')}@local` } }; } static toBase64(str) { return btoa(unescape(encodeURIComponent(str))); } static fromBase64(b64) { return decodeURIComponent(escape(atob(b64))); } encodePath(p) { return p.split('/').map(encodeURIComponent).join('/'); } async apiError(method, filepath, res) { let detail = ''; try { const body = await res.json(); detail = body.message || JSON.stringify(body); } catch(_) {} throw new Error(`${method} ${filepath}: ${res.status} ${res.statusText}${detail ? ' — ' + detail : ''}`); } async getFile(filepath) { const url = `${this.apiBase}/contents/${this.encodePath(filepath)}?ref=${this.branch}`; const res = await fetch(url, { headers: this.headers }); if (res.status === 404) return null; if (!res.ok) await this.apiError('GET', filepath, res); return res.json(); } async createFile(filepath, content, message) { const url = `${this.apiBase}/contents/${this.encodePath(filepath)}`; const res = await fetch(url, { method: 'POST', headers: this.headers, body: JSON.stringify({ content: GiteaClient.toBase64(content), message: message || `Add ${filepath}`, branch: this.branch, ...this.authorInfo }) }); if (!res.ok) await this.apiError('POST', filepath, res); return res.json(); } async updateFile(filepath, content, sha, message) { const url = `${this.apiBase}/contents/${this.encodePath(filepath)}`; const res = await fetch(url, { method: 'PUT', headers: this.headers, body: JSON.stringify({ content: GiteaClient.toBase64(content), sha, message: message || `Update ${filepath}`, branch: this.branch, ...this.authorInfo }) }); if (!res.ok) await this.apiError('PUT', filepath, res); return res.json(); } async deleteFile(filepath, sha, message) { const url = `${this.apiBase}/contents/${this.encodePath(filepath)}`; const res = await fetch(url, { method: 'DELETE', headers: this.headers, body: JSON.stringify({ sha, message: message || `Delete ${filepath}`, branch: this.branch, ...this.authorInfo }) }); if (!res.ok) await this.apiError('DELETE', filepath, res); return res.json(); } async listDir(dirpath) { const pathPart = dirpath ? `/${this.encodePath(dirpath)}` : ''; const url = `${this.apiBase}/contents${pathPart}?ref=${this.branch}`; const res = await fetch(url, { headers: this.headers }); if (res.status === 404) return []; if (!res.ok) await this.apiError('LIST', dirpath, res); const result = await res.json(); return Array.isArray(result) ? result : []; } async testConnection() { const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}`; const res = await fetch(url, { headers: this.headers }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return res.json(); } } // ── Sync Manager ── class SyncManager { constructor() { this.client = null; this.config = null; } async init() { const result = await browser.storage.local.get(SYNC_CONFIG_KEY); this.config = result[SYNC_CONFIG_KEY]; if (!this.config || !this.config.baseUrl || !this.config.token) { this.client = null; return false; } this.client = new GiteaClient(this.config); return true; } get isConfigured() { return this.client !== null; } get department() { return this.config?.department || ''; } async getSyncState() { const result = await browser.storage.local.get(SYNC_STATE_KEY); return result[SYNC_STATE_KEY] || { fileShas: {} }; } async saveSyncState(state) { await browser.storage.local.set({ [SYNC_STATE_KEY]: state }); } async getLocalTemplates() { const result = await browser.storage.local.get(TEMPLATE_STORAGE_KEY); return result[TEMPLATE_STORAGE_KEY] || []; } async saveLocalTemplates(templates) { await browser.storage.local.set({ [TEMPLATE_STORAGE_KEY]: templates }); } static toFilename(name) { return name .toLowerCase() .replace(/[äÄ]/g, 'ae') .replace(/[öÖ]/g, 'oe') .replace(/[üÜ]/g, 'ue') .replace(/ß/g, 'ss') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } /** * List all top-level directories in the repo (= available departments) */ async listDepartments() { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); const entries = await this.client.listDir(''); const departments = []; for (const entry of entries) { if (entry.type === 'dir' && entry.name !== SHARED_FOLDER && entry.name !== 'signatures' && !entry.name.startsWith('.')) { departments.push(entry.name); } } return { success: true, departments }; } /** * Pull templates from repo: department folder + shared folder */ async pullTemplates() { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); if (!this.department) throw new Error('Keine Abteilung ausgewählt'); const syncState = await this.getSyncState(); const newTemplates = []; const newShas = {}; let updated = 0; // Load from both folders const folders = [SHARED_FOLDER, this.department]; for (const folder of folders) { const files = await this.client.listDir(folder); for (const file of files) { if (!file.name.endsWith('.html')) continue; const fileData = await this.client.getFile(file.path); if (!fileData) continue; const content = GiteaClient.fromBase64(fileData.content); const templateName = file.name.replace('.html', ''); newTemplates.push({ id: `${folder}/${file.name}`, name: templateName, content: content, folder: folder, remotePath: file.path }); newShas[file.path] = fileData.sha; updated++; } } await this.saveLocalTemplates(newTemplates); syncState.fileShas = newShas; await this.saveSyncState(syncState); return { success: true, updated }; } /** * Push local templates back to repo (explicit, with commit author) */ async pushTemplates() { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); if (!this.department) throw new Error('Keine Abteilung ausgewählt'); if (!this.config.authorName) throw new Error('Bitte Name eintragen (für Commit-Zuordnung)'); const templates = await this.getLocalTemplates(); const syncState = await this.getSyncState(); // Group templates by folder const byFolder = {}; for (const t of templates) { const folder = t.folder || this.department; if (!byFolder[folder]) byFolder[folder] = []; byFolder[folder].push(t); } let pushed = 0; // Only push to folders the user has templates in const allowedFolders = [SHARED_FOLDER, this.department]; for (const folder of allowedFolders) { const localInFolder = byFolder[folder] || []; const remoteFiles = await this.client.listDir(folder); const remoteByName = {}; for (const rf of remoteFiles) { if (rf.name.endsWith('.html')) { remoteByName[rf.name] = rf; } } // Push each local template for (const template of localInFolder) { const filename = (SyncManager.toFilename(template.name) || template.id) + '.html'; const filepath = `${folder}/${filename}`; const commitMsg = `${template.name} - bearbeitet von ${this.config.authorName}`; const existing = await this.client.getFile(filepath); if (existing) { const existingContent = GiteaClient.fromBase64(existing.content); if (existingContent !== template.content) { await this.client.updateFile(filepath, template.content, existing.sha, commitMsg); pushed++; } } else { await this.client.createFile(filepath, template.content, commitMsg); pushed++; } } // Delete remote files that were removed locally const localNames = new Set(localInFolder.map(t => (SyncManager.toFilename(t.name) || t.id) + '.html' )); for (const [name, rf] of Object.entries(remoteByName)) { if (!localNames.has(name)) { const fileData = await this.client.getFile(rf.path || `${folder}/${name}`); if (fileData) { const commitMsg = `${name} gelöscht von ${this.config.authorName}`; await this.client.deleteFile(fileData.path || `${folder}/${name}`, fileData.sha, commitMsg); pushed++; } } } } // Re-pull to get fresh SHAs await this.pullTemplates(); return { success: true, pushed }; } /** * Pull signatures from repo signatures/ folder and apply to matching Thunderbird identities * Filename = email address (e.g. info@hotel.de.html) */ /** * Get the personal filename slug from author name */ get authorSlug() { return SyncManager.toFilename(this.config.authorName || ''); } /** * Pull signatures from repo. * For each identity: if personal sig enabled AND personal file exists → use it. * Otherwise use the shared file (email.html). */ async pullSignatures() { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); const files = await this.client.listDir('signatures'); if (files.length === 0) { return { success: true, updated: 0 }; } // Build lookup: filename → file entry const fileMap = {}; for (const f of files) { if (f.name.endsWith('.html')) { fileMap[f.name.toLowerCase()] = f; } } // Get personal email list const personalResult = await browser.storage.local.get('sig_personal_emails'); const personalEmails = new Set((personalResult.sig_personal_emails || []).map(e => e.toLowerCase())); // Get all Thunderbird identities const accounts = await browser.accounts.list(); let updated = 0; for (const account of accounts) { const identities = await browser.identities.list(account.id); for (const identity of identities) { const email = identity.email.toLowerCase(); const isPersonal = personalEmails.has(email); let targetFile = null; if (isPersonal && this.authorSlug) { // Try personal file first: email.authorslug.html const personalName = `${email}.${this.authorSlug}.html`; targetFile = fileMap[personalName] || null; } // Fall back to shared file: email.html if (!targetFile) { targetFile = fileMap[`${email}.html`] || null; } if (!targetFile) continue; const fileData = await this.client.getFile(targetFile.path); if (!fileData) continue; const signature = GiteaClient.fromBase64(fileData.content); await browser.identities.update(identity.id, { signature: signature, signatureIsPlainText: false }); updated++; } } return { success: true, updated }; } /** * Push signatures to repo. * Personal: saves as email.authorslug.html * Shared: saves as email.html */ async pushSignatures() { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); if (!this.config.authorName) throw new Error('Bitte Name eintragen (für Commit-Zuordnung)'); const personalResult = await browser.storage.local.get('sig_personal_emails'); const personalEmails = new Set((personalResult.sig_personal_emails || []).map(e => e.toLowerCase())); const accounts = await browser.accounts.list(); let pushed = 0; for (const account of accounts) { const identities = await browser.identities.list(account.id); for (const identity of identities) { if (!identity.signature) continue; const email = identity.email.toLowerCase(); const isPersonal = personalEmails.has(email); let filename; if (isPersonal && this.authorSlug) { filename = `${email}.${this.authorSlug}.html`; } else { filename = `${email}.html`; } const filepath = `signatures/${filename}`; const label = isPersonal ? `(persönlich)` : `(gemeinsam)`; const commitMsg = `Signatur ${identity.email} ${label} - von ${this.config.authorName}`; const existing = await this.client.getFile(filepath); if (existing) { const existingContent = GiteaClient.fromBase64(existing.content); if (existingContent !== identity.signature) { await this.client.updateFile(filepath, identity.signature, existing.sha, commitMsg); pushed++; } } else { await this.client.createFile(filepath, identity.signature, commitMsg); pushed++; } } } return { success: true, pushed }; } /** * Pull a single template by its remote path */ async pullSingleTemplate(remotePath) { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); const fileData = await this.client.getFile(remotePath); if (!fileData) throw new Error('Datei nicht im Repository gefunden'); const content = GiteaClient.fromBase64(fileData.content); return { success: true, content }; } /** * Push a single template by ID */ async pushSingleTemplate(templateId) { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); if (!this.config.authorName) throw new Error('Bitte Name eintragen (für Commit-Zuordnung)'); const templates = await this.getLocalTemplates(); const template = templates.find(t => t.id === templateId); if (!template) throw new Error('Vorlage nicht gefunden'); const folder = template.folder || this.department; if (!folder) throw new Error('Keine Abteilung ausgewählt'); const filename = (SyncManager.toFilename(template.name) || template.id) + '.html'; const filepath = `${folder}/${filename}`; const commitMsg = `${template.name} - bearbeitet von ${this.config.authorName}`; const existing = await this.client.getFile(filepath); if (existing) { const existingContent = GiteaClient.fromBase64(existing.content); if (existingContent !== template.content) { await this.client.updateFile(filepath, template.content, existing.sha, commitMsg); } } else { await this.client.createFile(filepath, template.content, commitMsg); } return { success: true }; } async testConnection() { if (!this.isConfigured) throw new Error('Sync nicht konfiguriert'); const repoInfo = await this.client.testConnection(); return { success: true, repoName: repoInfo.full_name }; } } // ── Global sync manager instance ── const syncManager = new SyncManager(); // ── Background message handler for sync ── browser.runtime.onMessage.addListener(async (msg, sender) => { const syncActions = ['testConnection', 'pullTemplates', 'pushTemplates', 'pullSingleTemplate', 'pushSingleTemplate', 'listDepartments', 'pullSignatures', 'pushSignatures']; if (!syncActions.includes(msg.action)) return; try { const initialized = await syncManager.init(); if (!initialized && msg.action !== 'testConnection') { return { success: false, error: 'Sync nicht konfiguriert. Bitte Verbindung einrichten.' }; } switch (msg.action) { case 'testConnection': // Init with provided config for testing before save if (!initialized) return { success: false, error: 'Sync nicht konfiguriert.' }; return await syncManager.testConnection(); case 'listDepartments': return await syncManager.listDepartments(); case 'pullTemplates': return await syncManager.pullTemplates(); case 'pushTemplates': return await syncManager.pushTemplates(); case 'pullSingleTemplate': return await syncManager.pullSingleTemplate(msg.remotePath); case 'pushSingleTemplate': return await syncManager.pushSingleTemplate(msg.templateId); case 'pullSignatures': return await syncManager.pullSignatures(); case 'pushSignatures': return await syncManager.pushSignatures(); default: return { success: false, error: 'Unbekannte Aktion' }; } } catch (err) { console.error('Sync error:', err); return { success: false, error: err.message }; } }); // ── Auto-sync on startup (pull only) ── async function autoSync() { try { const initialized = await syncManager.init(); if (!initialized) return; if (syncManager.department) { console.log('[Sync] Auto-pull Vorlagen gestartet...'); const result = await syncManager.pullTemplates(); console.log('[Sync] Auto-pull Vorlagen abgeschlossen:', result); } console.log('[Sync] Auto-pull Signaturen gestartet...'); const sigResult = await syncManager.pullSignatures(); console.log('[Sync] Auto-pull Signaturen abgeschlossen:', sigResult); } catch (err) { console.error('[Sync] Auto-pull fehlgeschlagen:', err); } } autoSync(); setInterval(autoSync, SYNC_INTERVAL_MS);