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