Nicht verbunden

📊 Dashboard

📈 Status Verteilung
🔍 Quellen
🕐 Letzte Aktivitäten

📁 Bewerbungen

Alle
📤 Beworben
📨 Antwort
🎤 Interview
✅ Zusage
❌ Absage
👻 Ghosting
FirmaPositionStatus DatumTageQuelleAktionen

🗂️ Kanban Board

🔍 Job-Vorschläge

Lade Vorschläge…

Seite 1

📧 Mail Connector

Wähle eine Methode um deine Emails zu importieren – unterstützt Gmail, Outlook, Yahoo und andere IMAP/POP3-Provider.

🔧 Apps Script
📂 EML Import
📋 Text / Paste
📮 IMAP / POP3
🔧 Google Apps Script Methode

Erstelle ein kostenloses Google Apps Script das deine Gmail-Emails liest und als JSON-URL bereitstellt. Kein OAuth-Flow, kein externer Server – nur ein Google Script in deinem eigenen Google-Konto.

1
Google Apps Script öffnen
Gehe zu script.google.com und klicke auf "Neues Projekt"
2
Code einfügen
Lösche den vorhandenen Code und füge folgenden Code ein:
3
Als Web-App deployen
Klicke auf Bereitstellen → Neue Bereitstellung
Typ: Web-App
Ausführen als: Ich
Zugriff: Jeder (oder "Jeder, auch anonym")
→ Klicke Bereitstellen und bestätige die Berechtigungen
4
Web-App URL hier eintragen
Kopiere die URL und füge sie unten ein:
⚙️
Automatisches Monitoring aktivieren

Lasse die App regelmäßig dein Apps Script prüfen und erkenne automatisch Antworten:

Minuten

ℹ️ Die App prüft im Hintergrund regelmäßig auf neue Antworten und aktualisiert automatisch den Status.

📂 EML / MBOX Datei importieren

Exportiere einzelne Emails aus Gmail als .eml Datei und lade sie hier hoch. Die App erkennt automatisch Bewerbungsrelevante Informationen.

💡 So geht's: Gmail → Email öffnen → Drei-Punkte-Menü → "Nachricht herunterladen" → .eml Datei speichern
📧
EML Dateien hier ablegen
oder klicken zum Auswählen (mehrere möglich)
🔒
Sicherheitshinweis: Dein Passwort wird niemals gespeichert – weder lokal noch irgendwo sonst. Der Proxy läuft ausschließlich lokal auf diesem Rechner. Für Gmail / Yahoo bitte ein App-Passwort verwenden (kein normales Passwort). ⏳ Proxy-Status wird geprüft…
🚀 Einrichtung (einmalig)
1
Proxy starten
Terminal öffnen und folgenden Befehl eingeben:
python3 /Library/WebServer/Documents/Bewerbungstracker/imap_proxy.py
Der Proxy läuft solange das Terminal-Fenster geöffnet ist (Port 8765, nur localhost).
2
Gmail / Yahoo: App-Passwort erstellen
Gmail: myaccount.google.com/apppasswords (2-Faktor-Auth muss aktiv sein) → App-Passwort für Mail generieren.
Yahoo: Kontoeinstellungen → Sicherheit → App-Passwörter.
3
Verbindung herstellen
Anbieter wählen, Daten eingeben, verbinden.
📮 Verbindungseinstellungen
📋 Email-Text einfügen

Kopiere den kompletten Email-Text (inkl. Von, An, Betreff, Datum) aus Gmail und füge ihn hier ein. Die App analysiert den Text und extrahiert Bewerbungsdaten.

🗑️ Gelöschte Einträge

📋 CV Vergleich mit KI

⚙️ Einstellungen

🔍 Bewerbungs-Erkennung
Leer = alle Emails importieren
🔍 Job-Vorschläge Filter

Filtert die automatisch gecrawlten Job-Vorschläge nach deinen Präferenzen. Passt nichts? Dann wird der Job nicht angezeigt.

Leer lassen = alle Standorte akzeptieren
Wirkt nur auf Quellen die diese Info liefern (z.B. Bundesagentur). Jobs ohne Info werden nicht herausgefiltert.
80
Push-Notification ab diesem Match-Score
Window: Tage
Firmen, die dich kürzlich abgelehnt haben, erscheinen für die gewählte Zeit nicht mehr in den Vorschlägen.
Stellen mit diesen Typ-Schlüsselwörtern im Titel werden automatisch verworfen.
Ein Stichwort pro Zeile. Trifft eines auf Titel oder Beschreibung zu, wird die Stelle automatisch verworfen.<br>
🧠 Match-Learning (Adaptiv)

Lernt aus deinen Importen und Dismiss-Aktionen und passt zukünftige Match-Scores entsprechend an.

Erst ab dieser Anzahl bewerteter Jobs wird das Lernen aktiv (Cold-Start-Schutz).
30%
Wie stark gelernte Präferenzen den Basis-Score beeinflussen (0 = nur Basis, 100 = nur gelernt).
Lade Stats…
📡 Job-Quellen

System-Quellen (🌐) sind voraktiviert und für alle User da. Eigene Quellen (👤) gelten nur für dich.

Name Typ Status Aktionen
Lade…

🖥️ Server-IMAP (für Indeed-Import)

Credentials werden im Tab 📧 Mail Connector eingegeben — dort Checkbox 🌐 Auch zum Server speichern aktivieren und Verbinden. Hier nur Status-Anzeige + Folder-Listing zum Anlegen einer Indeed-Quelle.

Status: lade…

📧 Indeed-Email Import (Gmail Apps Script)

Empfohlen für Gmail-User: Deploy ein kleines Google-Apps-Script (kein IMAP-Passwort auf dem VPS). Anleitung & Code-Snippet unten, dann die /exec-URL hier einfügen. Leer lassen, um stattdessen VPS-IMAP-Mode zu nutzen (erfordert DB-Credentials).

🧠 Gelernte Pattern

Pro Plattform wird ein Layout-Pattern global aktiv. Bei Layout-Drift der Mails: 🧠-Button in der Job-Quellen-Tabelle. Rollback wenn neues Pattern schlechter als Vorversion.

Plattform Hit-Rate Trainiert am Versionen Aktion
Lade…
🌐 Plattformen (Email-Quellen)
Neue Email-Plattformen anlegen ohne Code-Push. Indeed/LinkedIn/XING sind hardcoded und nicht editierbar. AI-Pattern-Learning übernimmt das Layout beim ersten 🧠 Lernen.
Slug Anzeige-Name Domain Subject-Keywords Aktion
Lädt...
📋 Briefkopf-Daten

Wird automatisch in jedes generierte Anschreiben als DIN-Briefkopf eingebaut. Felder die leer sind, werden weggelassen.

🔔 Benachrichtigungen
Erhalte Benachrichtigungen auf deinem Handy bei wichtigen Status-Änderungen
Erhalte regelmäßige Email-Zusammenfassungen mit Bewerbungs-Übersicht

⚙️ SMTP-Konfiguration (Automatischer Email-Versand)

Für Gmail: App-Passwort verwenden (nicht normales Passwort!)

🔍 Email-Monitoring (Automatische Antwort-Erkennung)

Prüft automatisch auf Antworten von Unternehmen, bei denen du dich beworben hast

📖 Handbuch

Wähle links ein Thema…

👥 Nutzer-Management

Benutzername E-Mail Rolle Erstellt Aktionen
Lädt...
`; return { subject: `📋 Bewerbungs-Tracker Zusammenfassung - ${new Date().toLocaleDateString('de-DE')}`, html: html, text: `Bewerbungs-Übersicht\n\nGesamt: ${total}\nBeworben: ${beworben}\nAntwort: ${antwort}\nInterview: ${interview}\nZusage: ${zusage}\nAbsage: ${absage}\nGhosting: ${ghosting}\n\nRückmeldungsquote: ${responseRate}%` }; } function exportEmailSummary() { const summary = generateEmailSummary(); const blob = new Blob([summary.html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bewerbungs-zusammenfassung_${new Date().toISOString().split('T')[0]}.html`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // Letzten Versanddatum aktualisieren state.settings.lastEmailSummarySent = new Date().toISOString(); saveToStorage(); showToast('📧 Zusammenfassung exportiert ✓', 'success'); } function copyScript() { // Copy current Apps Script code with updated keywords const currentCode = generateAppsScriptCode(state.settings.keywords); navigator.clipboard.writeText(currentCode) .then(() => showToast('Code kopiert! ✓ (mit aktuellen Keywords)', 'success')) .catch(() => showToast('Kopieren fehlgeschlagen', 'error')); } function saveAndFetchScript() { const url = document.getElementById('appsScriptUrl').value.trim(); if (!url) { showToast('Bitte Apps Script URL eingeben!', 'error'); return; } state.settings.appsScriptUrl = url; saveToStorage(); updateConnectionStatus(); fetchFromAppsScript(); } function refreshScriptEmails() { fetchFromAppsScript(); } /* ═══════════════════════════════════════════════════════ EXPORT / IMPORT ═══════════════════════════════════════════════════════ */ function exportPDF() { const { jsPDF } = window.jspdf; const doc = new jsPDF({ orientation:'landscape', unit:'mm', format:'a4' }); // Header doc.setFillColor(79, 70, 229); doc.rect(0, 0, 297, 20, 'F'); doc.setTextColor(255, 255, 255); doc.setFontSize(14); doc.setFont(undefined, 'bold'); doc.text('Bewerbungs-Tracker – Übersicht', 14, 13); doc.setFontSize(9); doc.setFont(undefined, 'normal'); doc.text('Erstellt: ' + new Date().toLocaleDateString('de-DE'), 250, 13); // Stats Row const b = state.bewerbungen; const stats = [ ['Gesamt', b.length], ['Beworben', b.filter(x=>x.status==='beworben').length], ['Interview', b.filter(x=>x.status==='interview').length], ['Zusage', b.filter(x=>x.status==='zusage').length], ['Absage', b.filter(x=>x.status==='absage').length], ['Ghosting', b.filter(x=>x.status==='ghosting').length], ]; doc.setTextColor(60, 60, 80); doc.setFontSize(8); stats.forEach(([label, val], i) => { const x = 14 + i * 46; doc.setFillColor(240, 240, 250); doc.roundedRect(x, 24, 42, 14, 2, 2, 'F'); doc.setFont(undefined, 'bold'); doc.setFontSize(12); doc.text(String(val), x + 21, 33, {align:'center'}); doc.setFontSize(7); doc.setFont(undefined, 'normal'); doc.text(label, x + 21, 36, {align:'center'}); }); // Status color map const statusColors = { beworben: [59, 130, 246], antwort: [245, 158, 11], interview: [139, 92, 246], zusage: [16, 185, 129], absage: [239, 68, 68], ghosting: [148, 163, 184] }; // Table with link data const sortedB = b.sort((a,x) => new Date(x.datum) - new Date(a.datum)); const rows = sortedB.map(item => [ item.firma, item.position || '–', item.status.charAt(0).toUpperCase() + item.status.slice(1), formatDate(item.datum), getDaysSince(item.datum) + 'd', getQuelleLabel(item.quelle).replace(/[^\x20-\x7E]/g, ''), item.ort || '–', item.gehalt || '–' ]); doc.autoTable({ head: [['Firma', 'Position', 'Status', 'Datum', 'Tage', 'Quelle', 'Ort', 'Gehalt']], body: rows, startY: 42, styles: { fontSize: 8, cellPadding: 2.5, textColor: [30,30,50] }, headStyles: { fillColor: [79,70,229], textColor: 255, fontStyle: 'bold', fontSize: 8 }, alternateRowStyles: { fillColor: [248, 248, 255] }, didDrawCell: (data) => { // Add clickable link to company name if (data.column.index === 0 && data.section === 'body') { const item = sortedB[data.row.index]; if (item && item.link) { // Draw link text in blue and underlined doc.setTextColor(0, 0, 255); doc.setFont(undefined, 'underline'); doc.textWithLink(item.firma, data.cell.x + 2, data.cell.y + data.cell.height/2 + 1, { pageNumber: undefined, url: item.link }); doc.setTextColor(30,30,50); doc.setFont(undefined, 'normal'); } } // Style status column if (data.column.index === 2 && data.section === 'body') { const statusKey = rows[data.row.index]?.[2]?.toLowerCase(); const color = statusColors[statusKey] || [100,100,100]; doc.setFillColor(...color); doc.roundedRect(data.cell.x + 1, data.cell.y + 1.5, data.cell.width - 2, data.cell.height - 3, 2, 2, 'F'); doc.setTextColor(255,255,255); doc.setFontSize(7); doc.text(data.cell.raw, data.cell.x + data.cell.width/2, data.cell.y + data.cell.height/2 + 1, {align:'center'}); doc.setTextColor(30,30,50); doc.setFontSize(8); } }, margin: { left: 14, right: 14 } }); // Footer const pageCount = doc.internal.getNumberOfPages(); for (let i = 1; i <= pageCount; i++) { doc.setPage(i); doc.setFontSize(7); doc.setTextColor(150); doc.text(`Seite ${i} von ${pageCount} · Bewerbungs-Tracker`, 148.5, 205, {align:'center'}); } doc.save(`Bewerbungen_${new Date().toISOString().split('T')[0]}.pdf`); showToast('PDF exportiert ✓', 'success'); } function exportJSON() { const cvComparisons = JSON.parse(localStorage.getItem('cvComparisons') || '[]'); const json = JSON.stringify({ bewerbungen: state.bewerbungen, settings: state.settings, cvComparisons, exportedAt: new Date().toISOString() }, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bewerbungen_backup_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); // Pflicht für Safari a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast(`JSON exportiert ✓ (${state.bewerbungen.length} Einträge)`, 'success'); } // Store import data globally for use by proceedWithImport() let pendingImportData = null; function handleImport(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (e) => { try { const data = JSON.parse(e.target.result); if (data.bewerbungen && Array.isArray(data.bewerbungen)) { // Store the data and show the import choice modal pendingImportData = data; openModal('importChoiceModal'); } else { showToast('Keine Bewerbungen in der Datei gefunden!', 'error'); } } catch(err) { console.error('Import error:', err); showToast('Ungültige JSON-Datei oder Fehler beim Importieren!', 'error'); } }; reader.readAsText(file); event.target.value = ''; } /** * Proceed with import based on selected mode (add or replace) */ async function proceedWithImport() { if (!pendingImportData) { showToast('Keine Importdaten vorhanden', 'error'); return; } const data = pendingImportData; const importMode = document.querySelector('input[name="importMode"]:checked').value; closeModal('importChoiceModal'); try { const importCount = data.bewerbungen.length; const duplicateCount = data.bewerbungen.filter(b => state.bewerbungen.some(x => x.id === b.id)).length; const newCount = importCount - duplicateCount; console.log(`📊 Import mode: ${importMode} | Total: ${importCount} | New: ${newCount} | Duplicates: ${duplicateCount}`); if (importMode === 'replace') { // Clear all existing data before importing const confirmed = confirm(`⚠️ Alle ${state.bewerbungen.length} bestehenden Bewerbungen werden gelöscht und durch die ${importCount} Bewerbungen aus der Datei ersetzt. Fortfahren?`); if (!confirmed) { showToast('Import abgebrochen', 'warning'); return; } state.bewerbungen = []; } // Import applications with full state preservation (status, dates, all properties) let importedCount = 0; const applicationsForDb = []; data.bewerbungen.forEach((b, idx) => { // Ensure the object has required properties if (b.id && b.firma) { const existing = state.bewerbungen.find(x => x.id === b.id); if (!existing) { // Log first app to see what's in the JSON if (idx === 0) { console.log('📋 First app from JSON:', { firma: b.firma, quelle: b.quelle, position: b.position, gehalt: b.gehalt, ort: b.ort }); } // New application: preserve all properties including status, dates, notes const importedApp = { id: b.id, firma: b.firma, position: b.position || '', status: b.status || 'beworben', // Preserve application status datum: b.datum || '', // Preserve application date gehalt: b.gehalt || '', ort: b.ort || '', email: b.email || '', quelle: b.quelle || 'import', link: b.link || '', notizen: b.notizen || '', // Preserve notes createdAt: b.createdAt || new Date().toISOString(), // Preserve creation date updatedAt: b.updatedAt || new Date().toISOString() // Preserve update date }; state.bewerbungen.push(importedApp); importedCount++; // Convert to database format for backup API // Map: firma → company, position → position, datum → applied_date // NOTE: Optional fields (quelle, gehalt, etc) are kept in localStorage only const dbApp = { id: b.id, company: b.firma, position: b.position || '', status: b.status || 'applied', applied_date: b.datum || null, created_at: b.createdAt || new Date().toISOString(), updated_at: b.updatedAt || new Date().toISOString() }; // Log first DB app to verify quelle is included if (idx === 0) { console.log('📤 First app for DB sync:', { company: dbApp.company, quelle: dbApp.quelle, gehalt: dbApp.gehalt }); } applicationsForDb.push(dbApp); } } }); // Import settings with full preservation if (data.settings && typeof data.settings === 'object') { state.settings = { ...state.settings, ...data.settings }; } // Import CV comparisons if (data.cvComparisons && Array.isArray(data.cvComparisons)) { localStorage.setItem('cvComparisons', JSON.stringify(data.cvComparisons)); } saveToStorage(); renderAll(); loadSettingsForm(); const modeText = importMode === 'replace' ? '(Ersetzen)' : '(Hinzufügen)'; const message = importedCount > 0 ? `${importedCount} Bewerbung(en) importiert ${modeText} ✓` : `Keine neuen Bewerbungen (alle waren Duplikate) ${modeText} ✓`; showToast(message, 'success'); // === NEW: Also sync to database via BackupClient API === if (importedCount > 0 && backupClient !== null) { try { console.log(`🔄 Syncing ${importedCount} imported applications to database...`); console.log('📋 Applications for DB:', applicationsForDb); // Prepare backup data in the format the API expects const backupData = { exported_at: new Date().toISOString(), applications: applicationsForDb, emails: [] }; console.log('📦 Backup data prepared:', backupData); // Create a temporary JSON file to import via the backup API const backupFile = new File( [JSON.stringify(backupData)], 'settings-import-backup.json', { type: 'application/json' } ); console.log('📄 File created:', { name: backupFile.name, size: backupFile.size, type: backupFile.type }); // Import via BackupClient API console.log('🚀 Calling backupClient.importBackup()...'); const dbResult = await backupClient.importBackup(backupFile); console.log('✅ Database sync completed:', dbResult); // Show additional info about database sync showToast( `✓ Datenbank: ${dbResult.imported_applications}/${importedCount} gespeichert`, 'info' ); // After successful API import, reload from database to update UI console.log('🔄 Reloading from database to update UI...'); try { await loadFromStorage(); renderAll(); console.log(`✅ UI updated with database state: ${state.bewerbungen.length} applications`); } catch(reloadErr) { console.error('⚠️ Failed to reload after import:', reloadErr); } } catch (apiError) { console.error('❌ Database sync error:', apiError); console.error('Error message:', apiError.message); // Don't fail the entire import - localStorage data is safe showToast( `⚠️ Offline-Speicherung OK, aber Datenbank-Sync fehlgeschlagen: ${apiError.message}`, 'warning' ); } } else if (importedCount === 0) { console.log('ℹ️ No new applications to sync (all were duplicates)'); showToast('ℹ️ Alle Bewerbungen waren bereits vorhanden', 'info'); } pendingImportData = null; } catch(err) { console.error('Import error:', err); showToast('Fehler beim Importieren: ' + err.message, 'error'); pendingImportData = null; } } async function clearAllData() { if (!confirm('ALLE Daten löschen? Dies kann nicht rückgängig gemacht werden!')) return; if (!confirm('Wirklich sicher? Alle Bewerbungen werden gelöscht!')) return; try { // First, delete all applications from the database const applicationsToDelete = [...state.bewerbungen]; console.log(`🗑️ Deleting ${applicationsToDelete.length} applications from database...`); for (const app of applicationsToDelete) { try { // Use Auth.fetch() for authenticated requests - it handles auth headers correctly const response = await Auth.fetch(`/applications/${app.id}`, { method: 'DELETE' }); console.log(`✅ Deleted ${app.id}:`, response); } catch (err) { console.error(`❌ Error deleting ${app.id}:`, err); } } console.log('✅ Database deletion complete'); } catch (err) { console.error('❌ Error during database cleanup:', err); showToast('⚠️ Fehler beim Löschen aus Datenbank. Lokale Daten werden trotzdem gelöscht.', 'warning'); } // Clear local data state.bewerbungen = []; saveToStorage(); renderAll(); showToast('✅ Alle Daten gelöscht (lokal und in Datenbank)', 'success'); } /* ═══════════════════════════════════════════════════════ BACKUP MANAGER - Cloud Backup/Export/Import ═══════════════════════════════════════════════════════ */ const BackupManager = { backupClient: null, init() { this.backupClient = new BackupClient(); this.loadBackups(); }, async loadBackups() { try { // listBackups() returnt bereits das Array (data.backups || []) const backups = await this.backupClient.listBackups(); this.displayBackups(backups); } catch (error) { console.error('Failed to load backups:', error); const el = document.getElementById('backupList'); if (el) { el.innerHTML = `

Fehler beim Laden der Backups

`; } } }, displayBackups(backups) { const el = document.getElementById('backupList'); if (!el) return; if (!backups || backups.length === 0) { el.innerHTML = '

Keine Backups vorhanden

'; return; } const sorted = [...backups].sort((a, b) => { return new Date(b.created_at) - new Date(a.created_at); }); const html = ``; el.innerHTML = html; }, async exportAs(format) { try { const btn = event.target.closest('.btn') || event.target; const originalText = btn.textContent; btn.disabled = true; btn.textContent = '⏳ Exportiert...'; await this.backupClient.downloadExport(format); showToast(`${format.toUpperCase()} Export erfolgreich`, 'success'); } catch (error) { console.error('Export error:', error); showToast(`Export fehlgeschlagen: ${error.message}`, 'error'); } finally { const btn = event.target.closest('.btn') || event.target; btn.disabled = false; btn.textContent = 'Download'; } }, async importBackup() { const file = document.getElementById('backupFile').files[0]; if (!file) { showToast('Bitte wähle eine Datei aus', 'warning'); return; } try { const result = await this.backupClient.importBackup(file); showToast(`${result.imported_applications} Bewerbungen importiert`, 'success'); document.getElementById('backupFile').value = ''; await this.loadBackups(); } catch (error) { console.error('Import error:', error); showToast(`Import fehlgeschlagen: ${error.message}`, 'error'); } }, async restoreBackup(version) { if (!confirm(`Backup Version ${version} wiederherstellen? Existierende Daten bleiben erhalten.`)) { return; } try { const result = await this.backupClient.restoreBackup(version, false); showToast(`${result.restored_applications} Bewerbungen wiederhergestellt`, 'success'); await this.loadBackups(); } catch (error) { console.error('Restore error:', error); showToast(`Wiederherstellung fehlgeschlagen: ${error.message}`, 'error'); } } }; /* ═══════════════════════════════════════════════════════ ADMIN SETTINGS - admin user management in Settings ═══════════════════════════════════════════════════════ */ const AdminSettings = { init() { this.loadUsers(); }, async loadUsers() { try { const response = await Auth.fetch('/admin/users', { method: 'GET' }); if (response.users) { this.cachedUsers = response.users; // Cache for later access this.displayUsers(response.users); } } catch (error) { console.error('Failed to load users:', error); const el = document.getElementById('adminUsersList'); if (el) { el.innerHTML = `Fehler beim Laden der Benutzer`; } } }, displayUsers(users) { const html = users.map(u => ` ${escapeHtml(u.email)} ${new Date(u.created_at).toLocaleDateString('de-DE')} ${u.is_active ? '✓ Aktiv' : '⏳ Ausstehend'} ${u.is_admin ? '👑 Admin' : 'Benutzer'} `).join(''); document.getElementById('adminUsersList').innerHTML = html; }, currentActionUserId: null, showActions(userId) { this.currentActionUserId = userId; const user = AdminSettings.getAllUsers().find(u => u.id === userId); if (!user) return; // Create action menu const menu = `

Aktionen für ${escapeHtml(user.email)}

${!user.is_active ? `` : ''}
`; // Remove existing menu if present const existing = document.getElementById('adminActionMenu'); if (existing) existing.remove(); // Add new menu const container = document.createElement('div'); container.id = 'adminActionMenu'; container.innerHTML = menu; document.body.appendChild(container); }, closeActionMenu() { const menu = document.getElementById('adminActionMenu'); if (menu) menu.remove(); this.currentActionUserId = null; }, getAllUsers() { // Extract users from table - for simplicity, store them when loading return this.cachedUsers || []; }, async resetPassword(userId) { try { const response = await Auth.fetch(`/admin/users/${userId}/reset-password`, { method: 'POST' }); const tempPassword = response.temporary_password || response.temp_password || response.password; if (tempPassword) { showToast(`Temporäres Passwort: ${tempPassword}`, 'info'); } else { showToast('Passwort zurückgesetzt ✓', 'success'); } this.closeActionMenu(); await this.loadUsers(); } catch (error) { showToast('Fehler beim Zurücksetzen: ' + error.message, 'error'); } }, async approveUser(userId) { try { await Auth.fetch(`/admin/users/${userId}/approve`, { method: 'POST' }); showToast('Benutzer genehmigt ✓', 'success'); this.closeActionMenu(); await this.loadUsers(); } catch (error) { showToast('Fehler beim Genehmigen: ' + error.message, 'error'); } }, async toggleAdmin(userId) { const user = this.getAllUsers().find(u => u.id === userId); if (!user) return; try { await Auth.fetch(`/admin/users/${userId}/promote`, { method: 'PATCH' }); showToast(`${user.is_admin ? 'Admin-Rechte entfernt' : 'Zum Admin promoten'} ✓`, 'success'); this.closeActionMenu(); await this.loadUsers(); } catch (error) { showToast('Fehler: ' + error.message, 'error'); } }, async deleteUser(userId) { const user = this.getAllUsers().find(u => u.id === userId); if (!user) return; if (!confirm(`Benutzer "${user.email}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden!`)) { return; } try { await Auth.fetch(`/admin/users/${userId}`, { method: 'DELETE' }); showToast('Benutzer gelöscht ✓', 'success'); this.closeActionMenu(); await this.loadUsers(); } catch (error) { showToast('Fehler beim Löschen: ' + error.message, 'error'); } } }; /* ═══════════════════════════════════════════════════════ FILTER ═══════════════════════════════════════════════════════ */ function filterByChip(el, status) { state.activeFilter = status; document.querySelectorAll('.chip').forEach(c => c.classList.remove('active')); el.classList.add('active'); renderBewerbungen(); } /* ═══════════════════════════════════════════════════════ MODAL ═══════════════════════════════════════════════════════ */ function openModal(id) { document.getElementById(id).classList.add('open'); document.body.style.overflow = 'hidden'; } function closeModal(id) { document.getElementById(id).classList.remove('open'); document.body.style.overflow = ''; } document.addEventListener('keydown', e => { if (e.key === 'Escape') { document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open')); document.body.style.overflow = ''; } }); /* ═══════════════════════════════════════════════════════ TOAST ═══════════════════════════════════════════════════════ */ function showToast(msg, type = 'info') { const icons = { success:'✅', error:'❌', warning:'⚠️', info:'ℹ️' }; const container = document.getElementById('toastContainer'); const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.innerHTML = `${icons[type]||'ℹ️'}${msg}`; container.appendChild(toast); setTimeout(() => { toast.style.opacity='0'; toast.style.transform='translateX(100%)'; toast.style.transition='all 0.3s'; setTimeout(() => toast.remove(), 300); }, 3500); } /* ═══════════════════════════════════════════════════════ HELPERS ═══════════════════════════════════════════════════════ */ function escHtml(str) { return String(str||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function escJs(s) { return String(s==null?'':s).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"').replace(//g, '\\x3e').replace(/\n/g, '\\n').replace(/\r/g, '\\r'); } function formatDate(str) { if (!str) return '–'; try { return new Date(str).toLocaleDateString('de-DE'); } catch(e) { return str; } } function escapeHtml(text) { if (!text) return ''; const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text.replace(/[&<>"']/g, m => map[m]); } function getDaysSince(datum) { if (!datum) return 0; return Math.floor((Date.now() - new Date(datum)) / 86400000); } function getDaysColor(datum) { const d = getDaysSince(datum); if (d < 14) return '#10B981'; if (d < 30) return '#F59E0B'; return '#EF4444'; } function getStatusBadge(status) { const map = { beworben: ['📤','badge-beworben','Beworben'], antwort: ['📨','badge-antwort','Antwort'], interview: ['🎤','badge-interview','Interview'], zusage: ['✅','badge-zusage','Zusage'], absage: ['❌','badge-absage','Absage'], ghosting: ['👻','badge-ghosting','Ghosting'] }; const [icon, cls, label] = map[status] || ['❓','','Unbekannt']; return `${icon} ${label}`; } function getQuelleLabel(q) { const map = { gmail:'📧 Mail Connector', imap:'📮 IMAP/POP3', manuell:'✏️ Manuell', linkedin:'💼 LinkedIn', indeed:'🔍 Indeed', xing:'📌 XING', website:'🌐 Website', empfehlung:'🤝 Empfehlung' }; return map[q] || q || '–'; } /* ═══════════════════════════════════════════════════════ COMPONENT HELPER FUNCTIONS - Reduce inline HTML ═══════════════════════════════════════════════════════ */ function createEmailItemHtml(email, parsed, index, importFnName='importImapEmail') { const snippet = email.snippet ? `
${escHtml(email.snippet.substring(0,150))}…
` : ''; return `
Von: ${escHtml(email.from)} · ${formatDate(email.date)}
${snippet}
${getStatusBadge(parsed.status)}
`; } function createStatCardHtml(icon, value, label, variant='blue') { return `
${icon}
${value}
${label}
`; } function createKanbanCardHtml(id, firma, position, days, daysColor) { return `
${escHtml(firma)}
${escHtml(position||'–')}
⏱ ${days}d
`; } function createStatCardCompactHtml(icon, label, value, percentage, color) { return `
${icon} ${label}
${value}
${percentage}%
`; } function createTabsHtml(tabs, activeId) { return `
${tabs.map(t => `
${t.icon} ${t.label}
`).join('')}
`; } /* ═══════════════════════════════════════════════════════ IMAP / POP3 ═══════════════════════════════════════════════════════ */ const IMAP_PROXY = _isLocalSetup ? 'http://127.0.0.1:8765' : '/imap-proxy'; const IMAP_PRESETS = { gmail_imap: { host:'imap.gmail.com', port:993, protocol:'imap' }, gmail_pop3: { host:'pop.gmail.com', port:995, protocol:'pop3' }, outlook: { host:'imap-mail.outlook.com', port:993, protocol:'imap' }, yahoo: { host:'imap.mail.yahoo.com', port:993, protocol:'imap' }, gmx: { host:'imap.gmx.net', port:993, protocol:'imap' }, webde: { host:'imap.web.de', port:993, protocol:'imap' }, }; const IMAP_FOLDER_HINTS = { gmail_imap: '💡 Keine Treffer? Archivierte Emails findest du unter [Gmail]/All Mail statt INBOX.', gmail_pop3: '💡 POP3: Nur Emails die noch nicht heruntergeladen wurden. IMAP ist zuverlässiger.', outlook: '💡 Gesendete Emails: Ordner Sent oder Gesendete Elemente.', }; function applyImapPreset(val) { const p = IMAP_PRESETS[val]; if (!p) return; document.getElementById('imapHost').value = p.host; document.getElementById('imapPort').value = p.port; document.getElementById('imapProtocol').value = p.protocol; const hintEl = document.getElementById('imapFolderHint'); if (hintEl) { const hint = IMAP_FOLDER_HINTS[val] || ''; hintEl.innerHTML = hint; hintEl.style.display = hint ? 'block' : 'none'; } } function updateImapPortDefault() { const proto = document.getElementById('imapProtocol').value; const portEl = document.getElementById('imapPort'); if (proto === 'pop3' && portEl.value === '993') portEl.value = '995'; if (proto === 'imap' && portEl.value === '995') portEl.value = '993'; } function toggleImapPw() { const pw = document.getElementById('imapPass'); const btn = document.getElementById('imapPwToggle'); pw.type = pw.type === 'password' ? 'text' : 'password'; btn.textContent = pw.type === 'password' ? '👁' : '🙈'; } async function checkImapProxy() { const statusEl = document.getElementById('imapProxyStatus'); if (!statusEl) return; statusEl.textContent = '⏳ Prüfe Proxy…'; statusEl.style.color = 'var(--text-muted)'; try { const res = await fetch(IMAP_PROXY + '/ping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(3000) }); const data = await res.json(); if (data.status === 'ok') { statusEl.textContent = '✅ Proxy aktiv (Port 8765)'; statusEl.style.color = '#34D399'; } else { throw new Error('unexpected'); } } catch { statusEl.textContent = '❌ Proxy nicht erreichbar – bitte imap_proxy.py im Terminal starten'; statusEl.style.color = '#F87171'; } } // Server-Credentials-Mode: ruft den Backend-Wrapper auf, der die in der DB // gespeicherten Credentials nutzt → Passwort muss nicht im Browser-Form sein. async function _fetchImapViaServerCredentials(folder, limit) { const btn = document.getElementById('imapConnectBtn'); if (btn) { btn.innerHTML = ' Server-Credentials…'; btn.disabled = true; } try { const r = await Auth.fetch('/profile/imap/proxy-fetch', { method: 'POST', body: JSON.stringify({ folder, limit, offset: 0 }), }); if (!r || r.error) throw new Error((r && r.error) || 'Leere Antwort'); updateProxyStatus(r); state.imapEmails = r.emails || []; renderImapResults(r.debug || ''); showToast(`${r.count} Email(s) via Server-Credentials geladen ✓`, 'success'); } catch (err) { showToast('Server-Fetch fehlgeschlagen: ' + (err.message || ''), 'error'); } finally { if (btn) { btn.innerHTML = '🔌 Verbinden & Abrufen'; btn.disabled = false; } } } async function _listImapFoldersViaServerCredentials() { const picker = document.getElementById('imapFolderPicker'); if (picker) { picker.style.display = 'block'; picker.innerHTML = '⏳ Lade Folder (Server-Credentials)…'; } try { const r = await Auth.fetch('/profile/imap/proxy-fetch', { method: 'POST', body: JSON.stringify({ listFolders: true }), }); if (!r || r.error) throw new Error((r && r.error) || 'Leere Antwort'); const folders = r.folders || []; if (!picker) return; if (folders.length === 0) { picker.innerHTML = 'Keine Folder gefunden.'; return; } picker.innerHTML = `
${folders.length} Folder gefunden — klick einen zum Übernehmen:
` + folders.map((f, i) => `` ).join(''); picker.querySelectorAll('button[data-folder-idx]').forEach(b => { b.addEventListener('click', () => { const idx = parseInt(b.getAttribute('data-folder-idx'), 10); const folder = folders[idx]; if (!folder) return; document.getElementById('imapFolder').value = folder; showToast('Ordner übernommen: ' + folder, 'success'); }); }); } catch (err) { if (picker) picker.innerHTML = `❌ ${escHtml(err.message || 'Fehler')}`; } } async function listImapFolders() { // Wie fetchImapEmails, aber mit Body-Flag listFolders:true — der // imap_proxy macht dann nur LIST und gibt eine Folder-Liste zurück, // kein Search/Fetch. const host = document.getElementById('imapHost').value.trim(); const port = parseInt(document.getElementById('imapPort').value) || 993; const protocol = document.getElementById('imapProtocol').value; const user = document.getElementById('imapUser').value.trim(); const pass = document.getElementById('imapPass').value; const noVerify = document.getElementById('imapNoVerify').checked; const picker = document.getElementById('imapFolderPicker'); // Server-Credentials-Mode: leeres Passwort + DB-stored if (!pass && state.settings.imapServerSync) { return _listImapFoldersViaServerCredentials(); } if (!host || !user || !pass) { showToast('Server-Adresse, E-Mail und Passwort sind erforderlich!', 'error'); return; } if (protocol !== 'imap') { showToast('Folder-Listing geht nur mit IMAP, nicht POP3', 'warning'); return; } if (picker) { picker.style.display = 'block'; picker.innerHTML = '⏳ Lade Folder…'; } try { const res = await fetch(IMAP_PROXY + '/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host, port, protocol, user, pass, noVerify, listFolders: true }), signal: AbortSignal.timeout(30000), }); const data = await res.json(); if (data.error) throw new Error(data.error); const folders = data.folders || []; if (!picker) return; if (folders.length === 0) { picker.innerHTML = 'Keine Folder gefunden.'; return; } // Klickbare Buttons rendern (XSS-safe: data-folder-idx Attribute statt onclick-Inline) picker.innerHTML = `
${folders.length} Folder gefunden — klick einen zum Übernehmen:
` + folders.map((f, i) => `` ).join(''); picker.querySelectorAll('button[data-folder-idx]').forEach(b => { b.addEventListener('click', () => { const idx = parseInt(b.getAttribute('data-folder-idx'), 10); const folder = folders[idx]; if (!folder) return; document.getElementById('imapFolder').value = folder; showToast('Ordner übernommen: ' + folder, 'success'); // Picker offen lassen, falls User nochmal wechseln will. }); }); } catch (err) { if (picker) { picker.innerHTML = `❌ ${escHtml(err.message || 'Fehler')}`; } showToast('Folder-Listing fehlgeschlagen: ' + (err.message || ''), 'error'); } } async function fetchImapEmails() { const host = document.getElementById('imapHost').value.trim(); const port = parseInt(document.getElementById('imapPort').value) || 993; const protocol = document.getElementById('imapProtocol').value; const folder = document.getElementById('imapFolder').value.trim() || 'INBOX'; const user = document.getElementById('imapUser').value.trim(); const pass = document.getElementById('imapPass').value; const limit = parseInt(document.getElementById('imapLimit').value) || 50; const noVerify = document.getElementById('imapNoVerify').checked; const remember = document.getElementById('imapRemember').checked; const syncServer = (document.getElementById('imapServerSync') || {}).checked; // Server-Credentials-Mode: Passwort-Feld leer, aber DB hat Creds gespeichert. // Wir rufen den Wrapper-Endpoint auf statt direkt zum imap_proxy zu POSTen. if (!pass && state.settings.imapServerSync) { return _fetchImapViaServerCredentials(folder, limit); } if (!host || !user || !pass) { showToast('Server-Adresse, E-Mail und Passwort sind erforderlich! (Oder Passwort leer + zuvor Server-stored)', 'error'); return; } // Persist only non-sensitive settings if (remember) { state.settings.imapHost = host; state.settings.imapPort = port; state.settings.imapProtocol = protocol; state.settings.imapUser = user; state.settings.imapServerSync = syncServer; saveToStorage(); } // VPS-Sync: einmaliger Push der Credentials zum Server, damit der // Indeed-Email-Import (Backend-IMAP) damit arbeiten kann. Nur sinnvoll // bei IMAP-SSL (Port 993) — POP3 unterstützt das Backend nicht. if (syncServer) { if (protocol !== 'imap' || port !== 993) { showToast('Server-Sync erfordert IMAP auf Port 993 — Push übersprungen', 'warning'); } else { try { await Auth.fetch('/profile/imap', { method: 'POST', body: JSON.stringify({ host, user, password: pass }), }); showToast('🌐 Credentials zum Server gepusht ✓', 'success'); } catch (e) { showToast('⚠️ Server-Push fehlgeschlagen: ' + (e.message || e), 'warning'); } } } const btn = document.getElementById('imapConnectBtn'); btn.innerHTML = ' Verbinde…'; btn.disabled = true; try { const res = await fetch(IMAP_PROXY + '/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ host, port, protocol, folder, user, pass, limit, noVerify }), signal: AbortSignal.timeout(35000) }); const data = await res.json(); if (data.error) throw new Error(data.error); // Update proxy status indicator updateProxyStatus(data); state.imapEmails = data.emails || []; renderImapResults(data.debug || ''); showToast(`${data.count} Email(s) geladen ✓`, 'success'); } catch(err) { const msg = err.message || ''; if (err.name === 'TimeoutError' || msg.includes('fetch') || msg.includes('NetworkError')) { showToast('Proxy nicht erreichbar – bitte imap_proxy.py starten!', 'error'); } else { showToast('Fehler: ' + msg, 'error'); } } finally { btn.innerHTML = '🔌 Verbinden & Abrufen'; btn.disabled = false; } } function renderImapResults(debugInfo) { const card = document.getElementById('imapResultCard'); const container = document.getElementById('imapEmailList'); const emails = state.imapEmails; card.style.display = 'block'; document.getElementById('imapEmailCount').textContent = emails.length; // Reset search box and update counts const searchBox = document.getElementById('imapSearchBox'); if (searchBox) { searchBox.value = ''; } imapFilteredEmails = emails; const countEl = document.getElementById('imapFilterCount'); const totalEl = document.getElementById('imapTotalCount'); if (countEl) countEl.textContent = emails.length; if (totalEl) totalEl.textContent = emails.length; // Show debug info line (helps diagnose "0 results") const debugEl = document.getElementById('imapDebugInfo'); if (debugEl && debugInfo) { debugEl.textContent = debugInfo; debugEl.style.display = 'block'; } if (!emails.length) { const folder = document.getElementById('imapFolder')?.value || 'INBOX'; const gmailHint = folder === 'INBOX' ? ' Tipp: Gmail-Emails archiviert? Ordner auf [Gmail]/All Mail ändern.' : ''; container.innerHTML = `

Keine relevanten Emails gefunden.${gmailHint}

`; return; } container.innerHTML = emails.map((e, i) => { const parsed = parseEmailToApplication(e); return createEmailItemHtml(e, parsed, i, 'importImapEmail'); }).join(''); } async function importImapEmail(index) { const e = state.imapEmails[index]; if (!isEmailWithinDateRange(e.date)) { showToast('Email ist vor dem eingestellten Start-Datum', 'warning'); return; } const parsed = parseEmailToApplication(e); const exists = state.bewerbungen.find(b => b.firma.toLowerCase() === parsed.firma.toLowerCase() && b.datum === parsed.datum ); if (exists) { showToast('Bereits vorhanden: ' + parsed.firma, 'warning'); return; } const apiData = { company: parsed.firma, position: parsed.position, status: parsed.status, applied_date: parsed.datum, salary: parsed.gehalt || '', location: parsed.ort || '', contact_email: parsed.email || '', source: parsed.quelle, link: parsed.link || '', notes: `Importiert via IMAP/POP3\nBetreff: ${e.subject}` }; try { const response = await fetchAPI('/api/applications', { method: 'POST', body: JSON.stringify(apiData) }); if (!response.ok) throw new Error(`API error: ${response.statusText}`); const created = await response.json(); state.bewerbungen.unshift(_mapBackendToDB(created)); saveToStorage(); renderAll(); showToast('Importiert: ' + parsed.firma, 'success'); renderImapResults(); } catch (error) { console.error('Import error:', error); showToast('❌ Fehler beim Importieren: ' + (error.message || 'unbekannt'), 'error'); } } async function importAllImapEmails() { let imported = 0, skipped = 0; const toImport = []; state.imapEmails.forEach((e, i) => { if (!isEmailWithinDateRange(e.date)) { skipped++; return; } if (!isRelevantApplicationEmail(e)) { skipped++; return; } const parsed = parseEmailToApplication(e); const exists = state.bewerbungen.find(b => b.firma.toLowerCase() === parsed.firma.toLowerCase() && b.datum === parsed.datum ); if (exists) { skipped++; return; } toImport.push({ parsed, email: e }); }); for (const item of toImport) { const { parsed, email } = item; const apiData = { company: parsed.firma, position: parsed.position, status: parsed.status, applied_date: parsed.datum, salary: parsed.gehalt || '', location: parsed.ort || '', contact_email: parsed.email || '', source: parsed.quelle, link: parsed.link || '', notes: `Importiert via IMAP/POP3\nBetreff: ${email.subject}` }; try { const response = await fetchAPI('/api/applications', { method: 'POST', body: JSON.stringify(apiData) }); if (response.ok) { const created = await response.json(); state.bewerbungen.unshift(_mapBackendToDB(created)); imported++; } else { skipped++; } } catch (error) { console.error('Import error for ' + parsed.firma + ':', error); skipped++; } } saveToStorage(); renderAll(); showToast(`${imported} importiert, ${skipped} übersprungen`, 'success'); renderImapResults(); } function copyImapCommand() { navigator.clipboard.writeText('python3 /Library/WebServer/Documents/Bewerbungstracker/imap_proxy.py') .then(() => showToast('Befehl kopiert! ✓', 'success')) .catch(() => showToast('Kopieren fehlgeschlagen', 'error')); } // ── Dark/Light Mode Toggle ───────────────────────────────────────────────────── function applyColorScheme(scheme) { const root = document.documentElement; const btn = document.getElementById('colorSchemeToggle'); if (scheme === 'light') { root.setAttribute('data-scheme', 'light'); if (btn) btn.textContent = '☀️'; localStorage.setItem('colorScheme', 'light'); } else { root.removeAttribute('data-scheme'); if (btn) btn.textContent = '🌙'; localStorage.setItem('colorScheme', 'dark'); } } function toggleColorScheme() { const root = document.documentElement; const currentScheme = root.getAttribute('data-scheme') || 'dark'; const newScheme = currentScheme === 'dark' ? 'light' : 'dark'; applyColorScheme(newScheme); } function initColorScheme() { const saved = localStorage.getItem('colorScheme') || 'dark'; applyColorScheme(saved); } // ── Mobile Menu Toggle ───────────────────────────────────────────────────── function toggleMobileMenu() { document.body.classList.toggle('nav-open'); } // Close menu when navigation item is clicked document.querySelectorAll('aside button').forEach(btn => { btn.addEventListener('click', () => { document.body.classList.remove('nav-open'); }); }); // Close menu when clicking outside document.addEventListener('click', (e) => { if (!e.target.closest('aside') && !e.target.closest('#mobileMenuBtn')) { document.body.classList.remove('nav-open'); } }); // ── Proxy Status & Cache Tracking ────────────────────────────────────────────── let proxyStatus = { active: false, cacheHits: 0, lastFetch: null }; function updateProxyStatus(response) { if (!response || response.status !== 'ok') { updateStatusIndicator(false, '❌ Fehler'); return; } proxyStatus.active = true; proxyStatus.lastFetch = new Date(); // Check for cache hit in response if (response.debug && response.debug.includes('Cache HIT')) { proxyStatus.cacheHits++; updateStatusIndicator(true, `✅ Aktiv | Cache: ${proxyStatus.cacheHits}`); } else { updateStatusIndicator(true, `✅ Aktiv | Cache: ${proxyStatus.cacheHits}`); } } function updateStatusIndicator(isActive, text) { const dot = document.getElementById('connStatusDot'); const txt = document.getElementById('connStatusText'); if (!dot || !txt) return; if (isActive) { dot.style.background = '#10B981'; // Green txt.textContent = text || '✅ Aktiv'; } else { dot.style.background = '#EF4444'; // Red txt.textContent = text || '❌ Nicht verbunden'; } } // ── Email Search & Filter ───────────────────────────────────────────────────── let imapSearchTimeout; let imapFilteredEmails = []; function debounceImapSearch() { clearTimeout(imapSearchTimeout); imapSearchTimeout = setTimeout(filterImapResults, 300); } function filterImapResults() { const searchBox = document.getElementById('imapSearchBox'); if (!searchBox) return; const searchText = searchBox.value.toLowerCase().trim(); const emailList = document.getElementById('imapEmailList'); if (!emailList) return; // Filter emails based on search text if (!searchText) { imapFilteredEmails = state.imapEmails; } else { imapFilteredEmails = state.imapEmails.filter(email => { const firma = (email.firma || '').toLowerCase(); const subject = (email.subject || '').toLowerCase(); const from = (email.from || '').toLowerCase(); return firma.includes(searchText) || subject.includes(searchText) || from.includes(searchText); }); } // Update counts const countEl = document.getElementById('imapFilterCount'); const totalEl = document.getElementById('imapTotalCount'); if (countEl) countEl.textContent = imapFilteredEmails.length; if (totalEl) totalEl.textContent = state.imapEmails.length; // Re-render with filtered results renderImapResultsFiltered(imapFilteredEmails); } // Klassifiziert eine Mail per Subject-Heuristik in eine Bewerbungs-Status-Kategorie. // Returnt null wenn keine Kategorie passt — Render zeigt dann keinen Badge. function classifyEmailStatus(subject, fromAddr) { const s = (subject || '').toLowerCase(); const userEmail = (state && state.settings && state.settings.imapUser || '').toLowerCase(); const fromLower = (fromAddr || '').toLowerCase(); const isFromMe = userEmail && fromLower.includes(userEmail); if (isFromMe) return { label: '📤 Gesendet', bg: '#94a3b833', fg: '#94a3b8' }; if (/(zusage|stellenangebot|wir freuen uns,? ihnen|gratulier)/i.test(s)) return { label: '✅ Zusage', bg: '#10b98133', fg: '#10b981' }; if (/(absage|leider|bedauern|keine.{0,5}zusage|nicht.{0,5}weiter|nicht.{0,5}berücksichtigen|haben uns gegen)/i.test(s)) return { label: '❌ Absage', bg: '#ef444433', fg: '#ef4444' }; if (/(vorstellungs|interview|kennenlern|gespräch|video.?call|termin.{0,5}vereinbar)/i.test(s)) return { label: '🎤 Interview', bg: '#a78bfa33', fg: '#a78bfa' }; if (/(eingangsbest|ihre bewerbung.{0,15}(erhalten|eingegangen|angekommen)|vielen dank für ihre bewerbung)/i.test(s)) return { label: '📨 Eingang', bg: '#3b82f633', fg: '#3b82f6' }; if (/(im auswahlverfahren|prüfen|sichtung|in bearbeitung)/i.test(s)) return { label: '⏳ Prüfung', bg: '#f59e0b33', fg: '#f59e0b' }; return null; } function renderImapResultsFiltered(emailsToRender) { const list = document.getElementById('imapEmailList'); if (!list) return; if (!emailsToRender || emailsToRender.length === 0) { list.innerHTML = '

Keine Emails gefunden

'; return; } // XSS-safe: escHtml für alle vom IMAP-Server kommenden Felder list.innerHTML = emailsToRender.map((email, idx) => { const status = classifyEmailStatus(email.subject, email.from); const badge = status ? `${status.label}` : ''; return `
Von: ${escHtml(email.from || 'N/A')}
${escHtml(email.subject || '(kein Betreff)')}
${escHtml(email.date || 'Kein Datum')}
`; }).join(''); } function importSingleImapEmail(idx) { const email = imapFilteredEmails[idx]; if (!email) return; if (!isEmailWithinDateRange(email.date)) { showToast('Email ist vor dem eingestellten Start-Datum', 'warning'); return; } const parsed = parseEmailToApplication(email); const exists = state.bewerbungen.find(b => b.firma.toLowerCase() === parsed.firma.toLowerCase() && b.datum === parsed.datum ); if (exists) { showToast('Bereits vorhanden: ' + parsed.firma, 'warning'); return; } state.bewerbungen.unshift({ ...parsed, id: 'bew_' + Date.now() + '_' + Math.random().toString(36).slice(2,7), quelle: 'imap', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), notizen: `Importiert via IMAP/POP3\nBetreff: ${email.subject}` }); saveToStorage(); renderAll(); showToast('Importiert: ' + parsed.firma, 'success'); renderImapResults(); } /* ═══════════════════════════════════════════════════════ SERVICE WORKER & PWA REGISTRATION ═══════════════════════════════════════════════════════ */ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/service-worker.js', { scope: '/' }) .then(registration => { console.log('✅ Service Worker registered:', registration); // Check for updates periodically setInterval(() => { registration.update(); }, 60000); // Check every minute }) .catch(error => { console.warn('⚠️ Service Worker registration failed:', error); }); }); // Handle service worker updates let refreshing = false; navigator.serviceWorker.addEventListener('controllerchange', () => { if (refreshing) return; refreshing = true; window.location.reload(); }); } // Prompt to install PWA let deferredPrompt; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; // Show install button/prompt if needed console.log('📱 PWA install prompt available'); }); // Handle PWA installation function installPWA() { if (deferredPrompt) { deferredPrompt.prompt(); deferredPrompt.userChoice.then((choiceResult) => { if (choiceResult.outcome === 'accepted') { console.log('✅ PWA installed'); deferredPrompt = null; showToast('✅ App installed! Jetzt können Sie offline arbeiten.', 'success'); } }); } } // Notify user when app is updated window.addEventListener('appinstalled', () => { console.log('✅ PWA installation complete'); showToast('✅ App erfolgreich installiert!', 'success'); }); /* ═══ CV COMPARISON FUNCTIONS ═══ */ let cvData = { text: '', fileName: '', uploadDate: null }; let customAIPlatforms = []; const BUILTIN_AI_PLATFORMS = [ { id: 'claude', name: 'Claude', url: 'https://claude.ai', type: 'web', emoji: '🧠' }, { id: 'chatgpt', name: 'ChatGPT', url: 'https://chat.openai.com', type: 'web', emoji: '🤖' }, { id: 'gemini', name: 'Google Gemini', url: 'https://gemini.google.com', type: 'web', emoji: '✨' }, { id: 'copilot', name: 'Microsoft Copilot', url: 'https://copilot.microsoft.com', type: 'web', emoji: '💬' } ]; function showCVTab(tabName) { document.getElementById('cv-upload-tab').style.display = 'none'; document.getElementById('cv-compare-tab').style.display = 'none'; document.getElementById('custom-ai-tab').style.display = 'none'; document.getElementById('cv-coverletter-tab').style.display = 'none'; document.getElementById('cv-' + tabName + '-tab').style.display = 'block'; if (tabName === 'upload') loadCVStatus(); if (tabName === 'compare') loadComparePage(); if (tabName === 'custom-ai') loadCustomAIPlatforms(); if (tabName === 'coverletter') loadCoverLetterPage(); } function showSettingsSection(name) { const valid = ['jobs', 'ai', 'data', 'admin']; if (!valid.includes(name)) name = 'jobs'; document.querySelectorAll('.settings-section').forEach(el => { el.hidden = (el.dataset.section !== name); }); // Active-State auf den Tab-Buttons document.querySelectorAll('.settings-tab').forEach(btn => { const isActive = btn.dataset.section === name; btn.classList.toggle('btn-primary', isActive); btn.classList.toggle('btn-secondary', !isActive); }); try { localStorage.setItem('settingsLastSection', name); } catch (e) {} if (name === 'ai') { loadFeatureOverrides(); loadBackupSettings(); } if (name === 'jobs') { if (typeof initLearnSettingsHandlers === 'function') initLearnSettingsHandlers(); if (typeof loadLearnSettings === 'function') loadLearnSettings(); } if (name === 'admin' && typeof AdminUrlCleanup !== 'undefined') { AdminUrlCleanup.refresh(); } } // Beim Öffnen der Settings-View: zuletzt aktive Section wiederherstellen function initSettingsSection() { let last = 'jobs'; try { last = localStorage.getItem('settingsLastSection') || 'jobs'; } catch (e) {} showSettingsSection(last); } async function uploadCV() { const fileInput = document.getElementById('cvFileInput'); const file = fileInput.files[0]; if (!file) { showToast('⚠️ Bitte wähle eine Datei', 'warning'); return; } showToast(`📄 Lese ${file.name}...`, 'info'); try { let text = ''; if (file.type === 'text/plain' || file.name.endsWith('.txt')) { // Plain text file text = await file.text(); } else if (file.name.endsWith('.pdf')) { // PDF file text = await extractPDFText(file); } else if (file.name.endsWith('.docx') || file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { // DOCX file text = await extractDOCXText(file); } else if (file.name.endsWith('.doc') || file.type === 'application/msword') { // Legacy DOC file - show warning showToast('⚠️ .doc Format wird nicht unterstützt. Bitte in .docx oder PDF konvertieren.', 'warning'); return; } else { showToast('❌ Dateiformat nicht unterstützt. Verwende .txt, .pdf oder .docx', 'error'); return; } if (!text || text.trim().length === 0) { showToast('❌ Keine Text extrahiert. Bitte überprüfe die Datei.', 'error'); return; } cvData = { text: cleanCVText(text), fileName: file.name, uploadDate: new Date().toISOString() }; localStorage.setItem('cvData', JSON.stringify(cvData)); const stats = getCVStats(cvData.text); showToast(`✅ CV hochgeladen (${stats.words} Wörter, ${stats.estimatedTokens} Tokens)`, 'success'); loadCVStatus(); document.getElementById('cvTextInput').value = cvData.text; } catch (error) { showToast(`❌ Fehler beim Lesen der Datei: ${error.message}`, 'error'); console.error('File upload error:', error); } } async function extractPDFText(file) { // Configure PDF.js worker if (typeof pdfjsLib !== 'undefined') { pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; } const arrayBuffer = await file.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; let fullText = ''; for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { const page = await pdf.getPage(pageNum); const textContent = await page.getTextContent(); const pageText = textContent.items.map(item => item.str).join(' '); fullText += pageText + '\n\n'; } return fullText; } async function extractDOCXText(file) { const arrayBuffer = await file.arrayBuffer(); const result = await mammoth.extractRawText({ arrayBuffer }); return result.value; } function cleanCVText(text) { // Remove multiple spaces and normalize whitespace text = text.replace(/[ \t]+/g, ' '); // Remove multiple line breaks text = text.replace(/\n\n\n+/g, '\n\n'); // Remove leading/trailing whitespace from each line text = text.split('\n').map(line => line.trim()).join('\n'); // Remove trailing spaces at end text = text.trim(); return text; } function formatCVForAI(text) { // Format CV text optimally for AI processing let formatted = cleanCVText(text); // Add section markers for better parsing const sections = { 'contact': ['Email', 'Phone', 'LinkedIn', 'Kontakt'], 'profile': ['Profile', 'Profil', 'Summary', 'Zusammenfassung'], 'experience': ['Experience', 'Erfahrung', 'Work Experience', 'Berufserfahrung', 'Employment'], 'skills': ['Skills', 'Fähigkeiten', 'Competencies', 'Kompetenzen', 'Technical Skills'], 'education': ['Education', 'Ausbildung', 'Qualifications', 'Qualifikationen'], 'certifications': ['Certifications', 'Zertifikate', 'Certificates'], 'languages': ['Languages', 'Sprachen'] }; return formatted; } function getCVStats(text) { const words = text.trim().split(/\s+/).length; const chars = text.length; // Rough token estimation (1 token ≈ 4 chars) const estimatedTokens = Math.ceil(chars / 4); return { words, chars, estimatedTokens }; } function validateCVSize(text) { const stats = getCVStats(text); // Max reasonable size for most AI APIs const MAX_CHARS = 50000; // ~12,500 tokens const MAX_WORDS = 10000; const warnings = []; if (stats.chars > MAX_CHARS) { warnings.push(`⚠️ CV zu lang (${stats.chars} Zeichen, max: ${MAX_CHARS})`); } if (stats.words > MAX_WORDS) { warnings.push(`⚠️ Zu viele Wörter (${stats.words}, max: ${MAX_WORDS})`); } return { valid: warnings.length === 0, warnings, stats }; } function saveCV() { let text = document.getElementById('cvTextInput').value.trim(); if (!text) { showToast('⚠️ CV-Text ist leer', 'warning'); return; } // Clean CV text text = cleanCVText(text); // Validate size const validation = validateCVSize(text); if (!validation.valid) { validation.warnings.forEach(w => showToast(w, 'warning')); if (!confirm('CV überschreitet empfohlene Größe. Fortfahren?')) return; } const stats = validation.stats; cvData = { text, fileName: 'manual_cv.txt', uploadDate: new Date().toISOString() }; localStorage.setItem('cvData', JSON.stringify(cvData)); showToast(`✅ CV gespeichert (${stats.words} Wörter, ${stats.estimatedTokens} Tokens)`, 'success'); document.getElementById('cvTextInput').value = text; loadCVStatus(); } function loadCVStatus() { const stored = localStorage.getItem('cvData'); if (stored) { cvData = JSON.parse(stored); const date = new Date(cvData.uploadDate).toLocaleDateString('de-DE'); const stats = getCVStats(cvData.text); document.getElementById('cvStatus').innerHTML = `✅ Gespeichert (${cvData.fileName}) - ${date}
📊 ${stats.words} Wörter | ${stats.chars} Zeichen | ~${stats.estimatedTokens} Tokens`; document.getElementById('cvTextInput').value = cvData.text; } } function cleanupCVText() { const textarea = document.getElementById('cvTextInput'); const original = textarea.value; if (!original.trim()) { showToast('⚠️ CV-Text ist leer', 'warning'); return; } const beforeStats = getCVStats(original); const cleaned = cleanCVText(original); const afterStats = getCVStats(cleaned); textarea.value = cleaned; showToast(`✅ Formatiert: ${beforeStats.chars} → ${afterStats.chars} Zeichen (-${beforeStats.chars - afterStats.chars})`, 'success'); } function showCVStatistics() { const text = document.getElementById('cvTextInput').value; if (!text.trim()) { showToast('⚠️ CV-Text ist leer', 'warning'); return; } const stats = getCVStats(text); const validation = validateCVSize(text); let msg = `📊 CV STATISTIK\n\n`; msg += `Wörter: ${stats.words}\n`; msg += `Zeichen: ${stats.chars}\n`; msg += `Geschätzte Tokens: ${stats.estimatedTokens}\n\n`; msg += `LIMITS:\n`; msg += `Max Zeichen: 50.000 (${((stats.chars/50000)*100).toFixed(1)}% genutzt)\n`; msg += `Max Wörter: 10.000 (${((stats.words/10000)*100).toFixed(1)}% genutzt)\n`; msg += `Max Tokens: 12.500 (${((stats.estimatedTokens/12500)*100).toFixed(1)}% genutzt)\n\n`; if (validation.valid) { msg += `✅ BEREIT für AI-Vergleich`; } else { msg += `⚠️ WARNUNGEN:\n` + validation.warnings.map(w => w.replace(/^.+?: /, '')).join('\n'); } alert(msg); } function validatePromptSize(prompt) { const stats = getCVStats(prompt); // Token limit validation const SAFE_LIMIT = 30000; // Safe for most APIs (Claude, GPT-4, Gemini) const WARNING_LIMIT = 20000; const warnings = []; if (stats.estimatedTokens > SAFE_LIMIT) { warnings.push(`❌ Prompt zu groß (${stats.estimatedTokens} Tokens, max: ${SAFE_LIMIT})`); } else if (stats.estimatedTokens > WARNING_LIMIT) { warnings.push(`⚠️ Prompt groß (${stats.estimatedTokens} Tokens)`); } return { valid: stats.estimatedTokens <= SAFE_LIMIT, warnings, stats }; } // Sendet einen Prompt an /api/providers/chat. Returns parsed response data. // Wirft Error bei HTTP-Fehler oder Queue (Caller behandelt das). async function _sendChatPrompt(prompt, maxTokens) { const response = await fetchAPI('/api/providers/chat', { method: 'POST', body: JSON.stringify({ prompt, max_tokens: maxTokens || 3000 }) }); const data = await response.json(); if (response.status === 202 && data.queued) { const err = new Error('queued'); err.queueData = data; throw err; } if (!response.ok) { throw new Error(data.error || `HTTP ${response.status}`); } return data; } // Bittet die KI, die Stellenanzeige zu summarisieren — als Fallback wenn der // erste Versuch leer/unbrauchbar war (z.B. weil kleines lokales Modell mit // dem langen Original-Prompt nicht klar kommt). async function _summarizeJobDescription(jobDesc) { const summarizePrompt = `Fasse die folgende Stellenanzeige in maximal 1500 Zeichen zusammen. Wichtig: Behalte alle technischen Anforderungen, Skills, Aufgaben und Qualifikationen bei. Lass Marketing-Sprache, Boilerplate und allgemeine Firmenbeschreibungen weg. Antworte AUSSCHLIESSLICH mit der Zusammenfassung, kein Drumherum. STELLENANZEIGE: ${jobDesc.slice(0, 8000)}`; const data = await _sendChatPrompt(summarizePrompt, 600); const text = (data.response || '').trim(); if (!text || text.length < 100) { throw new Error('Summary leer oder zu kurz'); } return text; } async function analyzeWithConfiguredProvider() { const prompt = document.getElementById('generatedPrompt').value.trim(); if (!prompt) { showToast('⚠️ Bitte erst Prompt generieren', 'warning'); return; } const btn = document.getElementById('analyzeBtn'); const status = document.getElementById('analyzeStatus'); const resultText = document.getElementById('resultText'); const originalLabel = btn.textContent; btn.disabled = true; btn.textContent = '⏳ Provider antwortet...'; status.style.color = 'var(--text-muted)'; status.innerHTML = '🔄 Anfrage an konfigurierten Provider...'; // Hilfsfunktion: ist eine Antwort "leer/zu kurz" → ggf. Summary-Retry triggern const isUnusableResponse = (text) => !text || text.trim().length < 80; try { let data; try { data = await _sendChatPrompt(prompt, 3000); } catch (e) { if (e.message === 'queued') { const q = e.queueData; status.style.color = 'var(--warning, #d97706)'; status.innerHTML = `⏳ Anfrage in Queue (ID: ${q.queue_id.slice(0, 8)}…)
` + `Provider ${q.provider} nicht erreichbar. ` + `Wird automatisch nachgeholt sobald wieder online.`; showToast('⏳ Anfrage gequeued — Provider nicht erreichbar', 'warning'); return; } throw e; } let usedSummaryFallback = false; let totalIn = data.usage.input_tokens; let totalOut = data.usage.output_tokens; // Auto-Summarize-Fallback: bei leerem/zu-kurzem Output mit zusammengefasster Anzeige retryen if (isUnusableResponse(data.response)) { const jobDesc = document.getElementById('jobDescriptionInput').value.trim(); if (jobDesc && jobDesc.length > 1500) { status.innerHTML = '🔁 Provider gab leere Antwort — fasse Stellenanzeige zusammen und versuche erneut...'; try { const summary = await _summarizeJobDescription(jobDesc); // Im Original-Prompt die Anzeige durch die Summary ersetzen const newPrompt = prompt.replace(jobDesc, summary); const data2 = await _sendChatPrompt(newPrompt, 3000); if (!isUnusableResponse(data2.response)) { data = data2; usedSummaryFallback = true; // Tokens kumulieren (Summarize-Call + zweiter Match-Call) totalIn += data2.usage.input_tokens; totalOut += data2.usage.output_tokens; } } catch (sumErr) { console.warn('Summary-Retry fehlgeschlagen:', sumErr); } } } if (isUnusableResponse(data.response)) { throw new Error('Provider lieferte leere Antwort (auch nach Zusammenfassen-Retry)'); } // Result anzeigen resultText.value = data.response || ''; if (!document.getElementById('resultTitle').value) { const jobDesc = document.getElementById('jobDescriptionInput').value.trim(); const firstLine = jobDesc.split('\n')[0].slice(0, 60); document.getElementById('resultTitle').value = firstLine || 'CV-Vergleich'; } const viaInfo = data.fallback_used ? `Fallback: ${data.via} statt ${data.provider}` : `${data.via}`; const summaryInfo = usedSummaryFallback ? ` (via Auto-Summary)` : ''; status.style.color = 'var(--text)'; status.innerHTML = `✅ Antwort via ${viaInfo}${summaryInfo} — ${totalIn} in / ${totalOut} out Tokens`; showToast('✅ Analyse fertig' + (usedSummaryFallback ? ' (Auto-Summary)' : ''), 'success'); } catch (e) { status.style.color = 'var(--danger)'; status.innerHTML = `❌ Fehler: ${e.message}`; showToast('❌ ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = originalLabel; } } function generateComparisonPrompt() { if (!cvData.text) { showToast('⚠️ Bitte speichere zuerst ein CV', 'warning'); return; } const jobDesc = document.getElementById('jobDescriptionInput').value.trim(); if (!jobDesc) { showToast('⚠️ Bitte gib eine Stellenanzeige ein', 'warning'); return; } // Clean and format CV for better AI processing const cleanCV = formatCVForAI(cvData.text); const prompt = `Du bist ein erfahrener Recruiter und Karriereberater. Analysiere folgendes CV im Vergleich zur Stellenanzeige: ═══ KANDIDATEN-CV ═══ ${cleanCV} ═══ STELLENANZEIGE ═══ ${jobDesc} ═══ ANALYSE-ANFORDERUNGEN ═══ Bitte analysiere STRUKTURIERT: 1. **Skill-Match Score**: Prozentsatz der geforderten Skills, die vorhanden sind (0-100%) 2. **Erfahrung Alignment**: Passt die Berufserfahrung zur Anforderung? (ja/teilweise/nein + Erklärung) 3. **Skill-Lücken**: Welche kritischen Fähigkeiten/Erfahrung fehlen? 4. **Top 3 Stärken**: Beste Qualifikationen für diese Position mit konkreten Gründen 5. **Potenzielle Bedenken**: Was könnten Recruiter kritisch sehen? 6. **Konkrete Empfehlungen**: 3-5 spezifische Verbesserungen für das CV 7. **Erfolgswahrscheinlichkeit**: Einschätzung (niedrig/mittel/hoch) mit Begründung 8. **Gehaltsbereich**: Geschätzter Bereich basierend auf Erfahrung Nutze konkrete Beispiele aus CV und Anzeige. Sei konstruktiv und fair.`; // Validate prompt size const validation = validatePromptSize(prompt); if (!validation.valid) { validation.warnings.forEach(w => showToast(w, 'error')); showToast('Bitte kürze CV oder Stellenanzeige', 'warning'); return; } if (validation.warnings.length > 0) { validation.warnings.forEach(w => showToast(w, 'warning')); } document.getElementById('generatedPrompt').value = prompt; const stats = getCVStats(prompt); const infoHTML = `
📊 ${stats.words} Wörter | ${stats.chars} Zeichen | ~${stats.estimatedTokens} Tokens
`; document.querySelector('[id="promptOutput"]').insertAdjacentHTML('afterbegin', infoHTML); document.getElementById('promptOutput').style.display = 'block'; showToast('✅ Prompt generiert und validiert', 'success'); renderAIPlatforms(); } function renderAIPlatforms() { const container = document.getElementById('aiPlatformsList'); let html = ''; // Built-in platforms if (BUILTIN_AI_PLATFORMS.length > 0) { html += '
🌐 Web-basierte Plattformen (manuell)
'; BUILTIN_AI_PLATFORMS.forEach(ai => { html += `
${ai.emoji} ${ai.name}
Öffne in neuem Tab und füge Prompt ein
`; }); html += '
'; } // Custom API platforms const apiPlatforms = customAIPlatforms.filter(ai => ai.type === 'api'); if (apiPlatforms.length > 0) { html += '
⚡ API-basierte Plattformen (automatisch)
'; apiPlatforms.forEach(ai => { html += `
${ai.emoji} ${ai.name}
${ai.description || 'Benutzerdefinierte API'}
`; }); html += '
'; } // Custom Web platforms const webPlatforms = customAIPlatforms.filter(ai => ai.type === 'web'); if (webPlatforms.length > 0) { html += '
🔧 Custom Web-Plattformen
'; webPlatforms.forEach(ai => { html += `
${ai.emoji} ${ai.name}
${ai.description || 'Benutzerdefinierte Plattform'}
`; }); html += '
'; } if (!BUILTIN_AI_PLATFORMS.length && customAIPlatforms.length === 0) { html = '

Keine KI-Plattformen konfiguriert

'; } container.innerHTML = html; } function openAIWithPrompt(url) { const prompt = document.getElementById('generatedPrompt').value; const encoded = encodeURIComponent(prompt); window.open(`${url}?text=${encoded}`, '_blank'); showToast('🚀 KI-Plattform geöffnet', 'success'); } function copyToClipboard(elementId) { const text = document.getElementById(elementId).value; navigator.clipboard.writeText(text).then(() => { showToast('📋 Kopiert!', 'success'); }).catch(err => { showToast('❌ Kopieren fehlgeschlagen', 'error'); }); } function exportPromptAsJSON() { const prompt = document.getElementById('generatedPrompt').value; const data = { prompt, cvFileName: cvData.fileName, timestamp: new Date().toISOString() }; const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `cv-comparison-${Date.now()}.json`; a.click(); showToast('💾 Exportiert', 'success'); } function saveComparison() { const title = document.getElementById('resultTitle').value || `Vergleich - ${new Date().toLocaleDateString()}`; const resultText = document.getElementById('resultText').value; if (!resultText) { showToast('⚠️ Bitte füge Analyseergebnisse ein', 'warning'); return; } let comparisons = JSON.parse(localStorage.getItem('cvComparisons') || '[]'); comparisons.push({ id: Date.now(), title, result: resultText, cvFile: cvData.fileName, timestamp: new Date().toISOString() }); localStorage.setItem('cvComparisons', JSON.stringify(comparisons)); showToast('✅ Vergleich gespeichert', 'success'); loadComparePage(); } function clearComparison() { document.getElementById('resultTitle').value = ''; document.getElementById('resultText').value = ''; showToast('🗑️ Gelöscht', 'success'); } function loadComparePage() { loadCVStatus(); loadSavedComparisons(); } function loadSavedComparisons() { const comparisons = JSON.parse(localStorage.getItem('cvComparisons') || '[]'); const container = document.getElementById('savedComparisonsList'); if (comparisons.length === 0) { container.innerHTML = '

Keine Vergleiche gespeichert

'; return; } container.innerHTML = comparisons.map(c => `
${escapeHtml(c.title)}
${new Date(c.timestamp).toLocaleDateString()} - ${c.cvFile}
${escapeHtml(c.result)}
`).join(''); } function loadComparison(id) { const comparisons = JSON.parse(localStorage.getItem('cvComparisons') || '[]'); const comparison = comparisons.find(c => c.id === id); if (comparison) { document.getElementById('resultTitle').value = comparison.title; document.getElementById('resultText').value = comparison.result; showCVTab('compare'); showToast('📝 Vergleich geladen', 'success'); } } function deleteComparison(id) { if (!confirm('Vergleich löschen?')) return; let comparisons = JSON.parse(localStorage.getItem('cvComparisons') || '[]'); comparisons = comparisons.filter(c => c.id !== id); localStorage.setItem('cvComparisons', JSON.stringify(comparisons)); showToast('🗑️ Gelöscht', 'success'); loadSavedComparisons(); } /* Custom AI Platform Management */ function addCustomAI() { const name = document.getElementById('aiName').value.trim(); const url = document.getElementById('aiUrl').value.trim(); const apiKey = document.getElementById('aiApiKey').value.trim(); const type = document.getElementById('aiType').value; const description = document.getElementById('aiDescription').value.trim(); if (!name || !url) { showToast('⚠️ Name und URL erforderlich', 'warning'); return; } const ai = { id: `custom-${Date.now()}`, name, url, apiKey, type, description, emoji: '🔧', custom: true }; customAIPlatforms.push(ai); localStorage.setItem('customAIPlatforms', JSON.stringify(customAIPlatforms)); document.getElementById('aiName').value = ''; document.getElementById('aiUrl').value = ''; document.getElementById('aiApiKey').value = ''; document.getElementById('aiDescription').value = ''; showToast('✅ KI-Plattform hinzugefügt', 'success'); loadCustomAIPlatforms(); } function loadCustomAIPlatforms() { customAIPlatforms = JSON.parse(localStorage.getItem('customAIPlatforms') || '[]'); const container = document.getElementById('customAIList'); let html = ''; // Show built-in platforms (read-only) html += '
🌐 Eingebaute Plattformen (immer verfügbar)
'; html += BUILTIN_AI_PLATFORMS.map(ai => `
${ai.emoji} ${ai.name}
Webbasiert (manuell öffnen)
${ai.url}
Standard
`).join(''); html += '
'; // Show custom platforms html += '
⚙️ Benutzerdefinierte Plattformen
'; if (customAIPlatforms.length === 0) { html += '

Keine benutzerdefinierten Plattformen. Füge eine über das Formular hinzu!

'; } else { html += customAIPlatforms.map(ai => `
${ai.emoji} ${escapeHtml(ai.name)}
${ai.type === 'web' ? '🌐 Webbasiert' : '⚡ API-basiert'} - ${escapeHtml(ai.description || 'Keine Beschreibung')}
${escapeHtml(ai.url)}
`).join(''); } html += '
'; container.innerHTML = html; } function viewCustomAI(id) { const ai = customAIPlatforms.find(a => a.id === id); if (!ai) return; const info = `KI-Plattform Informationen ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ${ai.emoji} Name: ${ai.name} 🔗 URL/Endpoint: ${ai.url} 📝 Typ: ${ai.type === 'web' ? '🌐 Webbasiert' : '⚡ API-basiert'} 📄 Beschreibung: ${ai.description || '(keine)'} 🔑 API Key: ${ai.apiKey ? '✅ Gespeichert' : '❌ Nicht gespeichert'}`; alert(info); } function deleteCustomAI(id) { if (!confirm('KI-Plattform löschen?')) return; customAIPlatforms = customAIPlatforms.filter(ai => ai.id !== id); localStorage.setItem('customAIPlatforms', JSON.stringify(customAIPlatforms)); showToast('🗑️ Gelöscht', 'success'); loadCustomAIPlatforms(); } function toggleAPIKeyVisibility() { const field = document.getElementById('aiApiKey'); field.type = field.type === 'password' ? 'text' : 'password'; } function validateAPIRequest(prompt, apiName) { // Validate request size for different API providers const apiLimits = { 'claude': 100000, // Claude 3 context window 'openai': 128000, // GPT-4 context 'gemini': 30000, // Gemini API safe limit 'default': 20000 // Safe default }; const stats = getCVStats(prompt); const maxTokens = apiLimits[apiName.toLowerCase()] || apiLimits['default']; if (stats.estimatedTokens > maxTokens) { return { valid: false, error: `Request überschreitet Limit (${stats.estimatedTokens}/${maxTokens} Tokens)` }; } // Check payload size (5MB limit for most APIs) const payloadSize = JSON.stringify({ prompt }).length; const MAX_PAYLOAD = 5242880; // 5MB if (payloadSize > MAX_PAYLOAD) { return { valid: false, error: `Payload zu groß (${(payloadSize/1024/1024).toFixed(2)}MB)` }; } return { valid: true, stats }; } function sendToAPI(aiId) { const prompt = document.getElementById('generatedPrompt').value; if (!prompt) { showToast('⚠️ Kein Prompt generiert', 'warning'); return; } const ai = customAIPlatforms.find(a => a.id === aiId); if (!ai) { showToast('❌ KI-Plattform nicht gefunden', 'error'); return; } if (!ai.apiKey) { showToast('❌ Kein API Key gespeichert', 'error'); return; } // Validate request size const validation = validateAPIRequest(prompt, ai.name); if (!validation.valid) { showToast('❌ ' + validation.error, 'error'); return; } showToast(`⚡ Sende zu ${ai.name} (${validation.stats.estimatedTokens} Tokens)...`, 'info'); // Timeout after 60 seconds const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 60000); fetch(ai.url, { method: 'POST', headers: { 'Authorization': `Bearer ${ai.apiKey}`, 'Content-Type': 'application/json', 'User-Agent': 'Bewerbungs-Tracker/4.4' }, body: JSON.stringify({ prompt }), signal: controller.signal }) .then(r => { clearTimeout(timeoutId); if (!r.ok) throw new Error(`API Error ${r.status}: ${r.statusText}`); return r.json(); }) .then(data => { document.getElementById('resultText').value = (data.response || data.content || JSON.stringify(data, null, 2)); showToast(`✅ Antwort erhalten von ${ai.name}`, 'success'); }) .catch(e => { clearTimeout(timeoutId); if (e.name === 'AbortError') { showToast('❌ Request Timeout (60s)', 'error'); } else { showToast('❌ Fehler: ' + e.message, 'error'); } console.error('API Error:', e); }); } /* ═══ AI PROVIDER MANAGEMENT ═══ */ // Liste aller bekannten Provider-Typen mit Anzeige-Namen const ALL_PROVIDERS = [ { id: 'claude', name: 'Claude (Anthropic)', system: true }, { id: 'ollama', name: 'Ollama (lokal)', system: true }, { id: 'openai', name: 'ChatGPT / OpenAI', system: false }, { id: 'mammouth', name: 'Mammouth', system: false }, { id: 'custom', name: 'Custom Endpoint (OpenAI-kompatibel)', system: false }, { id: 'opencode', name: 'opencode.ai (Zen)', system: false } ]; async function loadProviders() { let availableFromApi = []; let userSettings = null; try { const response = await fetchAPI('/api/providers', { method: 'GET' }); if (response.ok) { const data = await response.json(); availableFromApi = data.providers || []; } const settingsResponse = await fetchAPI('/api/providers/user/settings', { method: 'GET' }); if (settingsResponse.ok) { userSettings = await settingsResponse.json(); } } catch (e) { console.warn('Provider-API nicht erreichbar:', e); } const select = document.getElementById('aiProviderSelect'); select.replaceChildren(); // Zeige ALLE Provider-Typen, mit Status-Hinweis (konfiguriert/verfügbar/nicht konfiguriert) ALL_PROVIDERS.forEach(p => { const apiEntry = availableFromApi.find(x => x.id === p.id); const option = document.createElement('option'); option.value = p.id; let suffix = ''; if (p.system) { // System-Provider: zeigt "verfügbar" wenn API-Key/Service erkannt suffix = apiEntry ? ' ✓' : ' (nicht verfügbar)'; } else { // User-Provider: zeigt "konfiguriert" wenn Settings vorhanden suffix = apiEntry ? ' ✓ (konfiguriert)' : ' (nicht konfiguriert)'; } option.textContent = p.name + suffix; select.appendChild(option); }); // Setze gespeicherten Provider oder default Claude const targetProvider = userSettings?.provider || 'claude'; select.value = targetProvider; // Trigger change handler await onProviderChange(); // Setze Model nach onProviderChange (das die Models lädt) if (userSettings?.model) { const modelSelect = document.getElementById('aiModelSelect'); modelSelect.value = userSettings.model; } } // ═══════════════════════════════════════════════════════════════════ // PRO-TASK MODEL EMPFEHLUNGS-ENGINE (heuristisch) // ═══════════════════════════════════════════════════════════════════ const FEATURE_LABELS = { match: 'Job-Matching', cover_letter: 'Cover-Letter', cv_summarize: 'CV-Summarize', email_analyse: 'Email-Analyse', }; const PROVIDER_DISPLAY = { claude: 'Claude', openai: 'OpenAI', ollama: 'Ollama', mammouth: 'Mammouth', custom: 'Custom', }; function providerDisplayName(id) { if (!id) return ''; return PROVIDER_DISPLAY[id] || (id.charAt(0).toUpperCase() + id.slice(1)); } const TASK_THRESHOLDS = { match: { ok: 60, warn: 40 }, cover_letter: { ok: 75, warn: 55 }, cv_summarize: { ok: 65, warn: 45 }, email_analyse: { ok: 50, warn: 30 }, }; function capabilityScore(provider, model) { if (!model) return 50; const m = String(model).toLowerCase(); // Use benchmark data if available (most accurate) if (_benchmarkCache[model] && _benchmarkCache[model].overall != null) { return _benchmarkCache[model].overall; } // Embedding-Modelle: NICHT für Chat/JSON-Tasks geeignet if (m.includes('embed') || m.includes('embedding') || m.includes('bge-') || m.includes('e5-') || m.includes('nomic-')) { return 0; } // Base score by provider (cloud-hosted > local) if (provider === 'opencode') { // All opencode models are cloud-hosted → generally capable // Start high, let modifiers adjust down or up let score = 70; if (m.includes('ultra') || m.includes('max') || m.includes('pro')) score += 10; if (m.includes('flash') || m.includes('mini') || m.includes('nano')) score -= 5; if (m.includes('reason') || m.includes('think')) score += 8; // Version recency: extract version numbers like 4-8, 5.4, 3.6 const verMatch = m.match(/(\d+)[-.]?(\d+)?/); if (verMatch) { const major = parseInt(verMatch[1]); const minor = parseInt(verMatch[2] || '0'); // Newer major version → better capability if (major >= 5) score += 10; else if (major >= 4) score += 5; else if (major >= 3) score += 0; else score -= 5; // Minor version recency (within same major) if (minor >= 6) score += 3; else if (minor >= 3) score += 1; } return Math.min(100, Math.max(0, score)); } if (provider === 'claude') { let score = 78; const verMatch = m.match(/(\d+)[-.]?(\d+)?/); if (verMatch) { const major = parseInt(verMatch[1]); const minor = parseInt(verMatch[2] || '0'); if (major >= 4) score += 10; else if (major >= 3) score += 5; if (minor >= 5) score += 3; else if (minor >= 0) score += 0; } if (m.includes('haiku')) score -= 10; if (m.includes('sonnet')) score += 2; if (m.includes('opus')) score += 12; return Math.min(100, Math.max(0, score)); } // Parameter-based scoring for local models (ollama, etc.) const paramMatch = m.match(/(\d+)b\b/); const params = paramMatch ? parseInt(paramMatch[1], 10) : null; let score = 50; if (params) { if (params >= 400) score = 95; else if (params >= 70) score = 88; else if (params >= 30) score = 78; else if (params >= 12) score = 62; else if (params >= 7) score = 50; else score = 30; } // Family knowledge without hardcoded model names if (m.includes('qwen3')) score += 8; else if (m.includes('qwen2.5')) score += 5; if (m.includes('llama3.1') || m.includes('llama3.2') || m.includes('llama3.3')) score += 3; if (m.includes('deepseek') && params) score += 5; // only with known size if (m.includes('gemma3') || m.includes('gemma4') || m.includes('gemma-3') || m.includes('gemma-4')) score += 3; if (m.includes('phi-3') || m.includes('phi3')) score -= 5; if (m.includes('mixtral') && !params && !m.match(/\d+b/)) score += 8; // MoE, treat as large // Coder/Code-Modelle: stark für strukturiertes JSON if (m.includes('coder') || m.includes('-code') || m.endsWith('-code')) score += 4; return Math.min(100, Math.max(0, score)); } function modelRecommendation(provider, model, feature) { let score = capabilityScore(provider, model); const isReasoning = /(thinking|\br1\b|\bo1\b|\bo3\b)/i.test(model || ''); if (isReasoning && (feature === 'cover_letter' || feature === 'match')) score += 8; const t = TASK_THRESHOLDS[feature]; if (!t) return { level: 'unknown', icon: '❔', text: 'Unbekannte Task' }; if (score >= t.ok) return { level: 'ok', icon: '✅', text: 'Empfohlen' }; if (score >= t.warn) return { level: 'warn', icon: '⚠️', text: 'Geht, aber suboptimal' }; return { level: 'bad', icon: '❌', text: 'Wird vermutlich scheitern' }; } // ═══════════════════════════════════════════════════════════════════ // FEATURE-OVERRIDES UI // ═══════════════════════════════════════════════════════════════════ let _featureOverridesState = {}; let _standardModelState = { provider: '', model: '' }; let _benchmarkCache = {}; async function loadBenchmarks() { try { const r = await fetchAPI('/api/providers/benchmarks'); if (r.ok) { const data = await r.json(); _benchmarkCache = data.models || {}; } } catch (e) { /* silent */ } } async function loadFeatureOverrides() { try { const r = await fetchAPI('/api/profile/feature-models'); if (!r.ok) { console.error('feature-models load failed:', r.status); return; } const data = await r.json(); _featureOverridesState = data.overrides || {}; _standardModelState = data.standard || { provider: '', model: '' }; renderFeatureOverrideCards(); } catch (e) { console.error('feature-models error:', e); } } // ═══════════════════════════════════════════════════════════════════ // BACKUP-KI / FALLBACK-PROVIDER UI // ═══════════════════════════════════════════════════════════════════ let _backupState = { provider: null, model: null, auto: false }; async function loadBackupSettings() { const provSel = document.getElementById('backupProviderSelect'); const modelSel = document.getElementById('backupModelSelect'); const hint = document.getElementById('backupAutoHint'); if (!provSel || !modelSel) return; try { // 1. Provider-Dropdown mit configured Providern befüllen const provRes = await fetchAPI('/api/providers'); if (provRes.ok) { const data = await provRes.json(); const providers = (data.providers || []).filter(p => p.configured); provSel.replaceChildren(); const noneOpt = document.createElement('option'); noneOpt.value = ''; noneOpt.textContent = '— Kein Backup —'; provSel.appendChild(noneOpt); for (const p of providers) { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name || p.id; provSel.appendChild(opt); } } // 2. Aktuelle Settings holen const r = await fetchAPI('/api/providers/user/settings'); if (!r.ok) return; const s = await r.json(); _backupState = { provider: s.backup_provider || null, model: s.backup_model || null, auto: !!s.backup_auto, }; // 3. UI-State setzen if (_backupState.auto) { // Admin-Auto-Default: kein explizites Setting, aber Hinweis anzeigen hint.style.display = 'block'; provSel.value = ''; modelSel.replaceChildren(); const opt = document.createElement('option'); opt.value = ''; opt.textContent = '—'; modelSel.appendChild(opt); } else { hint.style.display = 'none'; provSel.value = _backupState.provider || ''; if (_backupState.provider) { await onBackupProviderChange(); modelSel.value = _backupState.model || ''; } else { modelSel.replaceChildren(); const opt = document.createElement('option'); opt.value = ''; opt.textContent = '—'; modelSel.appendChild(opt); } } } catch (e) { console.error('loadBackupSettings:', e); } } async function onBackupProviderChange() { const provSel = document.getElementById('backupProviderSelect'); const modelSel = document.getElementById('backupModelSelect'); if (!provSel || !modelSel) return; const provider = provSel.value; modelSel.replaceChildren(); if (!provider) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = '—'; modelSel.appendChild(opt); return; } try { const r = await fetchAPI('/api/providers/' + provider + '/models'); if (!r.ok) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = '⚠️ Models nicht verfügbar'; modelSel.appendChild(opt); return; } const data = await r.json(); for (const m of (data.models || [])) { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; modelSel.appendChild(opt); } // Wenn aktuelle Backup-Config diesen Provider hat → Modell auto-selecten if (_backupState.provider === provider && _backupState.model) { modelSel.value = _backupState.model; } else if (data.default) { modelSel.value = data.default; } } catch (e) { console.error('backup models load:', e); } } async function saveBackupSettings() { const provSel = document.getElementById('backupProviderSelect'); const modelSel = document.getElementById('backupModelSelect'); const status = document.getElementById('backupStatus'); const primaryProv = document.getElementById('aiProviderSelect'); if (!provSel || !modelSel) return; const backupProvider = provSel.value || null; const backupModel = modelSel.value || null; if (backupProvider && primaryProv && backupProvider === primaryProv.value) { if (!confirm('Backup-Provider ist identisch mit Primary. Weitermachen?')) return; } try { const r = await fetchAPI('/api/providers/user/settings', { method: 'PATCH', body: JSON.stringify({ backup_provider: backupProvider, backup_model: backupModel, }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); status.textContent = '❌ ' + (err.error || ('HTTP ' + r.status)); return; } const data = await r.json(); _backupState = { provider: data.backup_provider || null, model: data.backup_model || null, auto: !!data.backup_auto, }; status.textContent = '✓ Backup gespeichert'; document.getElementById('backupAutoHint').style.display = _backupState.auto ? 'block' : 'none'; } catch (e) { status.textContent = '❌ Fehler: ' + e.message; } } async function clearBackupSettings() { const status = document.getElementById('backupStatus'); try { const r = await fetchAPI('/api/providers/user/settings', { method: 'PATCH', body: JSON.stringify({ backup_provider: null, backup_model: null, }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); status.textContent = '❌ ' + (err.error || ('HTTP ' + r.status)); return; } const data = await r.json(); _backupState = { provider: data.backup_provider || null, model: data.backup_model || null, auto: !!data.backup_auto, }; document.getElementById('backupProviderSelect').value = ''; const modelSel = document.getElementById('backupModelSelect'); modelSel.replaceChildren(); const opt = document.createElement('option'); opt.value = ''; opt.textContent = '—'; modelSel.appendChild(opt); document.getElementById('backupAutoHint').style.display = _backupState.auto ? 'block' : 'none'; status.textContent = _backupState.auto ? '✓ Backup entfernt — Auto-Default greift' : '✓ Backup entfernt'; } catch (e) { status.textContent = '❌ Fehler: ' + e.message; } } function renderFeatureOverrideCards() { const container = document.getElementById('featureOverrideCards'); if (!container) return; const features = ['match', 'cover_letter', 'email_analyse', 'cv_summarize']; container.replaceChildren(); for (const f of features) { container.appendChild(buildOverrideCard(f)); } } function buildOverrideCard(feature) { const label = FEATURE_LABELS[feature] || feature; const override = _featureOverridesState[feature]; const isActive = !!(override && override.provider); const activeProv = isActive ? override.provider : _standardModelState.provider; const activeModel = isActive ? override.model : _standardModelState.model; const rec = modelRecommendation(activeProv, activeModel, feature); const details = document.createElement('details'); details.style.cssText = 'border:1px solid var(--border);border-radius:6px;margin-bottom:0.5rem;padding:0.5rem;'; const summary = document.createElement('summary'); summary.style.cssText = 'cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:0.75rem;flex-wrap:wrap;font-weight:500;'; const labelSpan = document.createElement('span'); labelSpan.textContent = label; labelSpan.style.cssText = 'flex:0 0 auto;'; summary.appendChild(labelSpan); const infoSpan = document.createElement('span'); infoSpan.style.cssText = 'display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;font-weight:400;font-size:0.9rem;'; const modelSpan = document.createElement('span'); if (activeProv && activeModel) { const provName = document.createElement('span'); provName.style.color = 'var(--text-muted)'; provName.textContent = providerDisplayName(activeProv) + ': '; modelSpan.appendChild(provName); modelSpan.appendChild(document.createTextNode(activeModel)); } else { modelSpan.style.color = 'var(--text-muted)'; modelSpan.textContent = '— kein Modell —'; } infoSpan.appendChild(modelSpan); const statusSpan = document.createElement('span'); statusSpan.style.cssText = 'color:var(--text-muted);'; statusSpan.textContent = rec.icon + ' ' + (isActive ? 'Override' : 'Standard') + ' · ' + rec.text; infoSpan.appendChild(statusSpan); summary.appendChild(infoSpan); details.appendChild(summary); const body = document.createElement('div'); body.style.cssText = 'margin-top:0.75rem;padding-top:0.75rem;border-top:1px solid var(--border);'; const checkboxLabel = document.createElement('label'); checkboxLabel.style.cssText = 'display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.id = 'override-active-' + feature; checkbox.checked = isActive; checkbox.onchange = () => toggleOverride(feature, checkbox.checked); const checkboxText = document.createElement('span'); checkboxText.textContent = 'Eigenes Modell für diese Task nutzen'; checkboxLabel.appendChild(checkbox); checkboxLabel.appendChild(checkboxText); body.appendChild(checkboxLabel); const fields = document.createElement('div'); fields.id = 'override-fields-' + feature; fields.style.display = isActive ? 'block' : 'none'; const grid = document.createElement('div'); grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;margin-bottom:0.5rem;'; const provSelect = document.createElement('select'); provSelect.id = 'override-provider-' + feature; provSelect.style.cssText = 'padding:0.4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-card);color:var(--text);'; provSelect.onchange = () => onOverrideProviderChange(feature); grid.appendChild(provSelect); const modelSelect = document.createElement('select'); modelSelect.id = 'override-model-' + feature; modelSelect.style.cssText = 'padding:0.4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-card);color:var(--text);'; modelSelect.onchange = () => onOverrideModelChange(feature); grid.appendChild(modelSelect); fields.appendChild(grid); const recDiv = document.createElement('div'); recDiv.id = 'override-recommendations-' + feature; recDiv.style.cssText = 'font-size:0.8rem;color:var(--text-muted);margin-bottom:0.5rem;'; fields.appendChild(recDiv); const saveBtn = document.createElement('button'); saveBtn.className = 'btn btn-primary btn-sm'; saveBtn.textContent = '💾 Speichern'; saveBtn.onclick = () => saveOverride(feature); fields.appendChild(saveBtn); body.appendChild(fields); details.appendChild(body); return details; } async function toggleOverride(feature, checked) { const fields = document.getElementById('override-fields-' + feature); fields.style.display = checked ? 'block' : 'none'; if (checked) { await populateOverrideProviders(feature); } else { delete _featureOverridesState[feature]; await persistOverrides(); renderFeatureOverrideCards(); } } async function populateOverrideProviders(feature) { const select = document.getElementById('override-provider-' + feature); if (!select) return; try { const r = await fetchAPI('/api/providers'); if (!r.ok) return; const data = await r.json(); const providers = (data.providers || []).filter(p => p.configured); select.replaceChildren(); for (const p of providers) { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.name || p.id; select.appendChild(opt); } const current = _featureOverridesState[feature]; if (current && current.provider) select.value = current.provider; await onOverrideProviderChange(feature); } catch (e) { console.error('providers load:', e); } } async function onOverrideProviderChange(feature) { const provSel = document.getElementById('override-provider-' + feature); const modelSel = document.getElementById('override-model-' + feature); if (!provSel || !modelSel) return; const provider = provSel.value; try { const r = await fetchAPI('/api/providers/' + provider + '/models'); modelSel.replaceChildren(); if (!r.ok) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = '⚠️ Models nicht verfügbar'; modelSel.appendChild(opt); return; } const data = await r.json(); const models = data.models || []; const freeOverrideModels = models.filter(m => m.endsWith('-free')).sort(); const paidOverrideModels = models.filter(m => !m.endsWith('-free')).sort(); if (freeOverrideModels.length > 0) { const grp = document.createElement('optgroup'); grp.label = '⭐ Free-Modelle'; freeOverrideModels.forEach(m => { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; grp.appendChild(opt); }); modelSel.appendChild(grp); } if (paidOverrideModels.length > 0) { const grp = document.createElement('optgroup'); grp.label = '💳 Paid-Modelle'; paidOverrideModels.forEach(m => { const opt = document.createElement('option'); opt.value = m; opt.textContent = m; grp.appendChild(opt); }); modelSel.appendChild(grp); } const currentOv = _featureOverridesState[feature]; if (current && current.model) modelSel.value = current.model; else modelSel.value = data.default || (data.models[0] || ''); onOverrideModelChange(feature); } catch (e) { console.error('models load:', e); } } function onOverrideModelChange(feature) { const provSel = document.getElementById('override-provider-' + feature); const modelSel = document.getElementById('override-model-' + feature); const recDiv = document.getElementById('override-recommendations-' + feature); if (!provSel || !modelSel || !recDiv) return; const provider = provSel.value; const model = modelSel.value; recDiv.replaceChildren(); const features = ['match', 'cover_letter', 'cv_summarize', 'email_analyse']; for (const f of features) { const rec = modelRecommendation(provider, model, f); const isActive = (f === feature); const line = document.createElement('div'); if (isActive) line.style.fontWeight = '600'; line.textContent = FEATURE_LABELS[f] + ': ' + rec.icon + ' ' + rec.text + (isActive ? ' ← aktiv' : ''); recDiv.appendChild(line); } } async function saveOverride(feature) { const provSel = document.getElementById('override-provider-' + feature); const modelSel = document.getElementById('override-model-' + feature); if (!provSel || !modelSel) return; _featureOverridesState[feature] = { provider: provSel.value, model: modelSel.value || null, }; const ok = await persistOverrides(); if (ok) { renderFeatureOverrideCards(); } } async function persistOverrides() { try { const r = await fetchAPI('/api/profile/feature-models', { method: 'PATCH', body: JSON.stringify({ overrides: _featureOverridesState }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); alert('Speichern fehlgeschlagen: ' + (err.error || r.status)); return false; } const data = await r.json(); _featureOverridesState = data.overrides || {}; return true; } catch (e) { console.error('persistOverrides:', e); alert('Fehler: ' + e.message); return false; } } async function openModelComparisonModal() { const modal = document.getElementById('modelComparisonModal'); const content = document.getElementById('modelComparisonContent'); modal.style.display = 'flex'; content.replaceChildren(); const loading = document.createElement('em'); loading.textContent = 'Lade Modelle...'; content.appendChild(loading); try { const provRes = await fetchAPI('/api/providers'); if (!provRes.ok) throw new Error('providers load failed'); const provData = await provRes.json(); const providers = (provData.providers || []).filter(p => p.configured); const rows = []; for (const p of providers) { try { const mr = await fetchAPI('/api/providers/' + p.id + '/models'); if (!mr.ok) continue; const mdata = await mr.json(); for (const m of (mdata.models || [])) { rows.push({ provider: p.id, providerName: p.name || p.id, model: m }); } } catch (e) { /* skip */ } } // Sort rows: free models first (groupted by -free suffix), then paid const freeRows = rows.filter(r => r.model.endsWith('-free')).sort((a, b) => a.model.localeCompare(b.model)); const paidRows = rows.filter(r => !r.model.endsWith('-free')).sort((a, b) => a.model.localeCompare(b.model)); content.replaceChildren(); if (rows.length === 0) { const em = document.createElement('em'); em.textContent = 'Keine Modelle gefunden.'; content.appendChild(em); return; } const features = ['match', 'cover_letter', 'cv_summarize', 'email_analyse']; // Pre-Select: aktueller Override pro Feature, sonst Standard const initialSelection = {}; for (const f of features) { const ov = _featureOverridesState[f]; if (ov && ov.provider && ov.model) { initialSelection[f] = ov.provider + '|' + ov.model; } else { initialSelection[f] = 'STANDARD'; } } const table = document.createElement('table'); table.style.cssText = 'width:100%;border-collapse:collapse;'; const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); headerRow.style.background = 'var(--bg-card2)'; const thModel = document.createElement('th'); thModel.style.cssText = 'padding:0.5rem;text-align:left;'; thModel.textContent = 'Modell'; headerRow.appendChild(thModel); const thBench = document.createElement('th'); thBench.style.cssText = 'padding:0.5rem;text-align:center;font-size:0.8rem;'; thBench.textContent = '📊 Test'; headerRow.appendChild(thBench); for (const f of features) { const th = document.createElement('th'); th.style.cssText = 'padding:0.5rem;text-align:center;'; th.textContent = FEATURE_LABELS[f]; headerRow.appendChild(th); } thead.appendChild(headerRow); table.appendChild(thead); const tbody = document.createElement('tbody'); // "Standard"-Zeile (Reset-Option pro Spalte) const stdTr = document.createElement('tr'); stdTr.style.cssText = 'border-top:1px solid var(--border);background:var(--bg-card2);'; const stdLabel = document.createElement('td'); stdLabel.style.cssText = 'padding:0.4rem;font-size:0.85rem;font-style:italic;'; let stdText = '🔄 Standard'; if (_standardModelState.provider && _standardModelState.model) { stdText += ' (' + providerDisplayName(_standardModelState.provider) + ': ' + _standardModelState.model + ')'; } else { stdText += ' (kein Modell konfiguriert)'; } stdLabel.textContent = stdText; stdTr.appendChild(stdLabel); for (const f of features) { const td = document.createElement('td'); td.style.cssText = 'padding:0.4rem;text-align:center;'; const radio = document.createElement('input'); radio.type = 'radio'; radio.name = 'model-pick-' + f; radio.value = 'STANDARD'; radio.checked = (initialSelection[f] === 'STANDARD'); radio.style.cssText = 'cursor:pointer;'; td.appendChild(radio); stdTr.appendChild(td); } tbody.appendChild(stdTr); // Free models section header if (freeRows.length > 0) { const sepTr = document.createElement('tr'); sepTr.style.cssText = 'background:var(--bg-card);'; const sepTd = document.createElement('td'); sepTd.style.cssText = 'padding:0.5rem 0.4rem;font-size:0.9rem;font-weight:600;color:#b8860b;'; sepTd.textContent = '⭐ Free-Modelle (kein Guthaben nötig)'; sepTd.colSpan = 1 + features.length; sepTr.appendChild(sepTd); tbody.appendChild(sepTr); for (const r of freeRows) { renderModelRow(tbody, r, features, initialSelection, _benchmarkCache); } } // Paid models section header if (paidRows.length > 0) { const sepTr = document.createElement('tr'); sepTr.style.cssText = 'background:var(--bg-card);'; const sepTd = document.createElement('td'); sepTd.style.cssText = 'padding:0.5rem 0.4rem;font-size:0.9rem;font-weight:600;color:var(--text-muted);'; sepTd.textContent = '💳 Paid-Modelle'; sepTd.colSpan = 1 + features.length; sepTr.appendChild(sepTd); tbody.appendChild(sepTr); for (const r of paidRows) { renderModelRow(tbody, r, features, initialSelection, _benchmarkCache); } } table.appendChild(tbody); content.appendChild(table); const hint = document.createElement('div'); hint.style.cssText = 'margin-top:0.75rem;font-size:0.8rem;color:var(--text-muted);'; hint.textContent = 'Wähle pro Task ein Modell und klicke "Übernehmen". "Standard" entfernt den Override für diese Task.'; content.appendChild(hint); const footer = document.createElement('div'); footer.style.cssText = 'margin-top:1rem;display:flex;justify-content:flex-end;gap:0.5rem;'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'btn btn-secondary btn-sm'; cancelBtn.textContent = 'Abbrechen'; cancelBtn.onclick = () => closeModelComparisonModal(); const applyBtn = document.createElement('button'); applyBtn.className = 'btn btn-primary btn-sm'; applyBtn.textContent = '✓ Übernehmen'; applyBtn.onclick = () => applyModelComparisonSelections(); footer.appendChild(cancelBtn); footer.appendChild(applyBtn); content.appendChild(footer); } catch (e) { content.replaceChildren(); const err = document.createElement('em'); err.textContent = 'Fehler: ' + e.message; content.appendChild(err); } } function closeModelComparisonModal() { document.getElementById('modelComparisonModal').style.display = 'none'; } function renderModelRow(tbody, r, features, initialSelection, benchmarks) { const tr = document.createElement('tr'); tr.style.borderTop = '1px solid var(--border)'; const tdModel = document.createElement('td'); tdModel.style.cssText = 'padding:0.4rem;font-size:0.85rem;'; const muted = document.createElement('span'); muted.style.color = 'var(--text-muted)'; muted.textContent = r.providerName + ': '; tdModel.appendChild(muted); tdModel.appendChild(document.createTextNode(r.model)); tr.appendChild(tdModel); // Benchmark-score cell const tdBench = document.createElement('td'); tdBench.style.cssText = 'padding:0.4rem;text-align:center;font-size:0.8rem;'; const benchEntry = benchmarks && benchmarks[r.model]; if (benchEntry && benchEntry.overall != null) { const score = benchEntry.overall; tdBench.textContent = score + '/100'; tdBench.style.color = score >= 70 ? '#46b450' : (score >= 50 ? '#f0ad4e' : '#dc3232'); tdBench.title = 'Benchmark: ' + Object.entries(benchEntry.scores || {}).map(([k,v]) => k + '=' + v).join(', '); } else { tdBench.textContent = '—'; tdBench.style.color = '#ccc'; } tr.appendChild(tdBench); const rowValue = r.provider + '|' + r.model; for (const f of features) { const rec = modelRecommendation(r.provider, r.model, f); const td = document.createElement('td'); td.style.cssText = 'padding:0.4rem;text-align:center;'; td.title = rec.text; const wrap = document.createElement('label'); wrap.style.cssText = 'display:inline-flex;align-items:center;gap:0.3rem;cursor:pointer;'; const radio = document.createElement('input'); radio.type = 'radio'; radio.name = 'model-pick-' + f; radio.value = rowValue; radio.checked = (initialSelection[f] === rowValue); radio.style.cssText = 'cursor:pointer;'; wrap.appendChild(radio); const iconSpan = document.createElement('span'); iconSpan.textContent = rec.icon; wrap.appendChild(iconSpan); td.appendChild(wrap); tr.appendChild(td); } tbody.appendChild(tr); } async function quickSetOverride(provider, model, feature) { _featureOverridesState[feature] = { provider, model: model || null }; const ok = await persistOverrides(); if (ok) { renderFeatureOverrideCards(); closeModelComparisonModal(); alert('✓ ' + FEATURE_LABELS[feature] + ' → ' + model); } } async function applyModelComparisonSelections() { const features = ['match', 'cover_letter', 'cv_summarize', 'email_analyse']; const newOverrides = { ..._featureOverridesState }; const changes = []; for (const f of features) { const radio = document.querySelector('input[name="model-pick-' + f + '"]:checked'); if (!radio) continue; const val = radio.value; if (val === 'STANDARD') { if (newOverrides[f]) { delete newOverrides[f]; changes.push(FEATURE_LABELS[f] + ' → Standard'); } } else { const idx = val.indexOf('|'); if (idx < 0) continue; const provider = val.slice(0, idx); const model = val.slice(idx + 1); const prev = newOverrides[f]; if (!prev || prev.provider !== provider || prev.model !== model) { newOverrides[f] = { provider, model }; changes.push(FEATURE_LABELS[f] + ' → ' + providerDisplayName(provider) + ': ' + model); } } } _featureOverridesState = newOverrides; const ok = await persistOverrides(); if (ok) { renderFeatureOverrideCards(); closeModelComparisonModal(); if (changes.length > 0) { alert('✓ Pro-Task Modelle aktualisiert:\n\n' + changes.join('\n')); } else { alert('✓ Keine Änderungen'); } } } async function onProviderChange() { const provider = document.getElementById('aiProviderSelect').value; const modelSelect = document.getElementById('aiModelSelect'); const configSection = document.getElementById('providerConfigSection'); const info = document.getElementById('providerInfo'); // Reset alle Config-Forms document.getElementById('config-claude').style.display = 'none'; document.getElementById('config-openai').style.display = 'none'; document.getElementById('config-mammouth').style.display = 'none'; document.getElementById('config-custom').style.display = 'none'; document.getElementById('config-opencode').style.display = 'none'; configSection.style.display = 'none'; document.getElementById('providerConfigStatus').textContent = ''; if (!provider) { modelSelect.replaceChildren(); const opt = document.createElement('option'); opt.textContent = 'Wähle zuerst einen Provider'; modelSelect.appendChild(opt); return; } // Zeige Provider-Info if (provider === 'claude') { info.textContent = 'ℹ️ Anthropic Claude — Server-Key wird genutzt wenn dein Account freigeschaltet ist, sonst eigenen Key hinterlegen'; } else if (provider === 'ollama') { info.textContent = '✓ Nutzt lokales Ollama (keine API-Kosten)'; } else if (provider === 'openai') { info.textContent = 'ℹ️ Eigener OpenAI-API-Key erforderlich (kostenpflichtig)'; } else if (provider === 'mammouth') { info.textContent = 'ℹ️ Mammouth-Endpoint erforderlich'; } else if (provider === 'custom') { info.textContent = 'ℹ️ Eigener OpenAI-kompatibler Endpoint'; } else if (provider === 'opencode') { info.textContent = 'ℹ️ Eigener opencode.ai API-Key erforderlich'; } // Zeige Config-Form für User-Provider. Bei claude: nur wenn der User // den Server-Key NICHT nutzen darf (d.h. der Provider liefert configured=false). let showConfig = ['openai', 'mammouth', 'custom', 'opencode'].includes(provider); if (provider === 'claude') { try { const cfg = await fetchAPI('/api/providers/claude/config', { method: 'GET' }); const data = await cfg.json(); // Wenn der User keinen eigenen Key hat UND Server-Key nicht erlaubt ist, // erscheint der Provider in der providers-Liste als configured=false. // Wir prüfen dafür einfach das Provider-Listing oder zeigen das Form // immer als optional an: User kann eigenen Key setzen wenn er will. const provListResp = await fetchAPI('/api/providers', { method: 'GET' }); if (provListResp.ok) { const provData = await provListResp.json(); const claudeP = (provData.providers || []).find(p => p.id === 'claude'); if (claudeP && !claudeP.configured) { showConfig = true; info.textContent = '⚠️ Claude ist für deinen Account nicht freigegeben — bitte eigenen Anthropic API-Key hinterlegen.'; } else if (data && data.configured) { // User hat eigenen Key gespeichert — Form zeigen damit er ihn aktualisieren kann showConfig = true; info.textContent = '✓ Eigener Anthropic API-Key hinterlegt'; } } } catch (e) { /* silent — fall through to default behavior */ } } if (showConfig) { configSection.style.display = 'block'; document.getElementById(`config-${provider}`).style.display = 'block'; await loadProviderConfig(provider); } // Lade Models try { const response = await fetchAPI(`/api/providers/${provider}/models`, { method: 'GET' }); modelSelect.replaceChildren(); if (!response.ok) { const errData = await response.json().catch(() => ({})); const opt = document.createElement('option'); opt.value = ''; if (errData.configured === false) { opt.textContent = '⚠️ Provider noch nicht konfiguriert'; } else { opt.textContent = `⚠️ ${errData.error || 'Models nicht verfügbar'}`; } modelSelect.appendChild(opt); return; } const data = await response.json(); if (!data.models || data.models.length === 0) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = 'Keine Models verfügbar'; modelSelect.appendChild(opt); return; } const models = data.models || []; const freeModels = models.filter(m => m.endsWith('-free')).sort(); const paidModels = models.filter(m => !m.endsWith('-free')).sort(); if (freeModels.length > 0) { const grp = document.createElement('optgroup'); grp.label = '⭐ Free-Modelle'; freeModels.forEach(model => { const option = document.createElement('option'); option.value = model; option.textContent = model; grp.appendChild(option); }); modelSelect.appendChild(grp); } if (paidModels.length > 0) { const grp = document.createElement('optgroup'); grp.label = '💳 Paid-Modelle'; paidModels.forEach(model => { const option = document.createElement('option'); option.value = model; option.textContent = model; grp.appendChild(option); }); modelSelect.appendChild(grp); } modelSelect.value = data.default || (data.models[0] || ''); } catch (e) { console.error('Models-Load Error:', e); modelSelect.replaceChildren(); const opt = document.createElement('option'); opt.value = ''; opt.textContent = '⚠️ Fehler beim Laden'; modelSelect.appendChild(opt); } } async function loadProviderConfig(provider) { try { const response = await fetchAPI(`/api/providers/${provider}/config`, { method: 'GET' }); if (!response.ok) return; const config = await response.json(); if (!config.configured) return; if (provider === 'claude') { const apiKeyField = document.getElementById('claudeApiKey'); apiKeyField.value = ''; apiKeyField.placeholder = '••••••• (gespeichert, leer lassen zum Beibehalten)'; } else if (provider === 'openai') { const apiKeyField = document.getElementById('openaiApiKey'); apiKeyField.value = ''; apiKeyField.placeholder = '••••••• (gespeichert, leer lassen zum Beibehalten)'; } else if (provider === 'mammouth') { document.getElementById('mammouthEndpoint').value = config.endpoint || ''; } else if (provider === 'custom') { document.getElementById('customName').value = config.name || ''; document.getElementById('customEndpoint').value = config.endpoint || ''; const apiKeyField = document.getElementById('customApiKey'); apiKeyField.value = ''; apiKeyField.placeholder = '••••••• (gespeichert)'; } else if (provider === 'opencode') { const apiKeyField = document.getElementById('opencodeApiKey'); apiKeyField.value = ''; apiKeyField.placeholder = '••••••• (gespeichert, leer lassen zum Beibehalten)'; document.getElementById('opencodeEndpoint').value = config.endpoint || ''; } } catch (e) { console.warn('Provider-Config nicht ladbar:', e); } } async function saveProviderConfig() { const provider = document.getElementById('aiProviderSelect').value; if (!['claude', 'openai', 'mammouth', 'custom', 'opencode'].includes(provider)) { showToast('⚠️ Dieser Provider braucht keine Konfiguration', 'warning'); return; } const config = {}; if (provider === 'claude') { const apiKey = document.getElementById('claudeApiKey').value.trim(); if (!apiKey) { showToast('⚠️ Anthropic API Key erforderlich', 'warning'); return; } config.api_key = apiKey; } else if (provider === 'openai') { const apiKey = document.getElementById('openaiApiKey').value.trim(); if (!apiKey) { showToast('⚠️ API Key erforderlich', 'warning'); return; } config.api_key = apiKey; const orgId = document.getElementById('openaiOrgId').value.trim(); if (orgId) config.organization_id = orgId; } else if (provider === 'mammouth') { const endpoint = document.getElementById('mammouthEndpoint').value.trim(); if (!endpoint) { showToast('⚠️ API Endpoint erforderlich', 'warning'); return; } config.api_endpoint = endpoint; } else if (provider === 'custom') { const endpoint = document.getElementById('customEndpoint').value.trim(); if (!endpoint) { showToast('⚠️ API Endpoint erforderlich', 'warning'); return; } config.api_endpoint = endpoint; const name = document.getElementById('customName').value.trim(); if (name) config.name = name; const apiKey = document.getElementById('customApiKey').value.trim(); if (apiKey) config.api_key = apiKey; } else if (provider === 'opencode') { const apiKey = document.getElementById('opencodeApiKey').value.trim(); if (!apiKey) { showToast('⚠️ opencode.ai API Key erforderlich', 'warning'); return; } config.api_key = apiKey; const endpoint = document.getElementById('opencodeEndpoint').value.trim(); if (endpoint) config.api_endpoint = endpoint; } const status = document.getElementById('providerConfigStatus'); status.textContent = '🔄 Speichere...'; status.style.color = 'var(--text-muted)'; try { const response = await fetchAPI(`/api/providers/${provider}/config`, { method: 'POST', body: JSON.stringify(config) }); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Speicher-Fehler'); status.textContent = `✓ ${result.message}`; status.style.color = 'var(--secondary)'; showToast(`✓ ${result.message}`, 'success'); // Reload provider list und Models await loadProviders(); } catch (e) { status.textContent = `❌ Fehler: ${e.message}`; status.style.color = 'var(--danger)'; showToast('❌ ' + e.message, 'error'); } } async function testProviderConfig() { const provider = document.getElementById('aiProviderSelect').value; if (!['claude', 'openai', 'mammouth', 'custom', 'opencode'].includes(provider)) { showToast('⚠️ Dieser Provider hat keinen Test-Endpunkt', 'warning'); return; } const status = document.getElementById('providerConfigStatus'); status.textContent = '🔄 Teste Verbindung...'; status.style.color = 'var(--text-muted)'; try { const response = await fetchAPI(`/api/providers/${provider}/test`, { method: 'POST' }); const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Verbindung fehlgeschlagen'); const sample = (result.sample_models || []).join(', '); status.textContent = `✅ Verbunden! ${result.models_available} Models${sample ? ' (z.B. ' + sample + ')' : ''}`; status.style.color = 'var(--secondary)'; } catch (e) { status.textContent = `❌ ${e.message}`; status.style.color = 'var(--danger)'; } } async function saveProviderSettings() { const provider = document.getElementById('aiProviderSelect').value; const model = document.getElementById('aiModelSelect').value; if (!provider) { showToast('⚠️ Wähle einen Provider', 'warning'); return; } try { const response = await fetchAPI('/api/providers/user/settings', { method: 'PATCH', body: JSON.stringify({ provider, model }) }); const result = await response.json(); if (!response.ok) { // User-Provider nicht konfiguriert? → Hinweis geben if (result.error && result.error.includes('nicht konfiguriert')) { showToast('⚠️ Provider zuerst konfigurieren (siehe Konfigurationsbereich)', 'warning'); } else { showToast('❌ ' + (result.error || 'Speicher-Fehler'), 'error'); } return; } showToast(`✓ ${result.message}`, 'success'); } catch (e) { console.error('Save Error:', e); showToast('❌ Fehler beim Speichern: ' + (e.message || 'unbekannt'), 'error'); } } async function testProviderConnection() { const provider = document.getElementById('aiProviderSelect').value; if (!provider) { showToast('⚠️ Wähle einen Provider', 'warning'); return; } const status = document.getElementById('providerStatus'); status.textContent = '🔄 Teste Verbindung...'; status.style.color = 'var(--text-muted)'; try { const response = await fetchAPI(`/api/providers/${provider}/models`, { method: 'GET' }); const data = await response.json(); if (!response.ok) throw new Error(data.error || `API error: ${response.statusText}`); const count = (data.models || []).length; status.textContent = `✅ Verbindung erfolgreich! ${count} Models verfügbar`; status.style.color = 'var(--secondary)'; } catch (e) { status.textContent = '❌ Verbindung fehlgeschlagen: ' + e.message; status.style.color = 'var(--danger)'; } } /* ═══ START ═══ */ document.addEventListener('DOMContentLoaded', () => { initColorScheme(); init(); loadCustomAIPlatforms(); // Add visibility change listener to reload data when tab regains focus // This ensures data stays in sync when user switches to other tabs/windows document.addEventListener('visibilitychange', async () => { if (document.visibilityState === 'visible') { console.log('👁️ Tab regained focus - checking for data updates...'); try { await loadFromStorage(); // Refresh current view with new data const activeView = document.querySelector('.view.active'); if (activeView) { const viewName = activeView.id.replace('view-', ''); console.log(`🔄 Refreshing ${viewName} with updated data`); if (viewName === 'dashboard') renderDashboard(); if (viewName === 'bewerbungen') renderBewerbungen(); if (viewName === 'kanban') renderKanban(); } } catch (e) { console.error('⚠️ Failed to refresh on tab focus:', e); } } }); }); // ═══════════════════════════════════════════════════════════════════ // COVER LETTER GENERATOR // ═══════════════════════════════════════════════════════════════════ let _currentCoverLetterId = null; let _currentCoverLetterAnalysis = null; function _clShowAlert(message, type = 'info') { const container = document.getElementById('clAlerts'); if (!container) return; const colors = { error: 'background:#fee;color:#c33;border:1px solid #fcc;', success: 'background:#efe;color:#3a3;border:1px solid #cfc;', info: 'background:#eef;color:#33c;border:1px solid #ccf;', warn: 'background:#ffd;color:#963;border:1px solid #fc9;' }; const div = document.createElement('div'); div.style.cssText = `padding:0.5rem 0.75rem;border-radius:4px;margin-bottom:0.5rem;font-size:0.9rem;${colors[type]||colors.info}`; div.textContent = message; container.appendChild(div); setTimeout(() => div.remove(), 5000); } function _clGetCVText() { // Liest gespeicherten CV aus localStorage. Format: {text, file?, …} try { const raw = localStorage.getItem('cvData'); if (!raw || raw === 'null') return null; const cv = JSON.parse(raw); return (cv && cv.text) ? cv.text : null; } catch (e) { return null; } } async function generateCoverLetter() { const companyName = document.getElementById('clCompanyName').value.trim(); const jobTitle = document.getElementById('clJobTitle').value.trim(); const jobDescription = document.getElementById('clJobDescription').value.trim(); const tone = document.getElementById('clTone').value; const length = document.getElementById('clLength').value; const focus = document.getElementById('clFocus').value; if (!companyName || !jobTitle || !jobDescription) { _clShowAlert('Bitte Firma, Position und Stellenbeschreibung ausfüllen', 'error'); return; } if (jobDescription.length < 50) { _clShowAlert('Stellenbeschreibung zu kurz — mindestens 50 Zeichen', 'error'); return; } const cvText = _clGetCVText(); if (!cvText) { _clShowAlert('Kein CV gefunden — bitte zuerst im Tab "📤 CV" hochladen/speichern', 'error'); return; } const loading = document.getElementById('clLoading'); const result = document.getElementById('clResult'); loading.style.display = 'block'; result.style.display = 'none'; try { // Step 1: Create draft const createRes = await fetchAPI('/api/cover-letters/create', { method: 'POST', body: JSON.stringify({ company_name: companyName, job_title: jobTitle, job_description: jobDescription, tone, length, focus, }), }); if (!createRes.ok) { const err = await createRes.json().catch(() => ({})); throw new Error(err.error || `HTTP ${createRes.status}`); } const draft = await createRes.json(); _currentCoverLetterId = draft.id; // Step 2: Generate content const genRes = await fetchAPI(`/api/cover-letters/${draft.id}/generate`, { method: 'POST', body: JSON.stringify({ cv_text: cvText }), }); if (!genRes.ok) { const err = await genRes.json().catch(() => ({})); throw new Error(err.error || `Generierung fehlgeschlagen (HTTP ${genRes.status})`); } const generated = await genRes.json(); _currentCoverLetterAnalysis = generated.analysis; _clRenderContent(generated.content); _clShowAlert('Anschreiben generiert ✓', 'success'); await loadCoverLetterList(); } catch (e) { _clShowAlert(`Fehler: ${e.message}`, 'error'); } finally { loading.style.display = 'none'; } } function _clRenderContent(htmlContent) { const container = document.getElementById('clContent'); // Note: htmlContent comes from our own backend API (trusted server-generated HTML). // All user-supplied strings in it are escaped server-side before being stored. container.innerHTML = htmlContent; // trusted server HTML // Confidence-Badges nachträglich anhängen container.querySelectorAll('p[data-confidence]').forEach(p => { const c = parseFloat(p.dataset.confidence); const badge = document.createElement('span'); badge.className = 'cl-confidence-badge'; badge.textContent = `${Math.round(c * 100)}%`; let label = 'interpretiert'; if (c >= 0.85) label = 'faktisch (aus CV/Job)'; else if (c >= 0.70) label = 'überwiegend faktisch'; badge.title = `Confidence: ${label}`; p.appendChild(badge); }); // Warnung wenn > 30% niedrige Confidence const all = container.querySelectorAll('p[data-confidence]'); if (all.length > 0) { const low = Array.from(all).filter(p => parseFloat(p.dataset.confidence) < 0.7).length; if (low / all.length > 0.3) { _clShowAlert('⚠️ Über 30% der Absätze sind eher interpretiert als faktisch — bitte vor Versand prüfen', 'warn'); } } document.getElementById('clResult').style.display = 'block'; } function toggleCoverLetterAnalysis() { const div = document.getElementById('clAnalysis'); if (div.style.display === 'block') { div.style.display = 'none'; return; } if (!_currentCoverLetterAnalysis) { _clShowAlert('Keine Analyse vorhanden', 'info'); return; } const a = _currentCoverLetterAnalysis; const sec = (title, items, renderer) => { if (!items || items.length === 0) return ''; return `
${title}
${items.map(renderer).join('')}
`; }; const pct = c => `${Math.round(c*100)}%`; // Note: all user-supplied strings rendered via _esc() (HTML-escapes textContent) div.innerHTML = [ // analysis display built from _esc()-sanitized strings sec('✓ Matched Skills', a.matched_skills, s => `
${_esc(s.skill)} → ${_esc(s.job_requirement || '')}${pct(s.confidence)}
`), sec('✓ Matched Experience', a.matched_experience, e => `
${_esc(e.experience)} → ${_esc(e.alignment || '')}${pct(e.confidence)}
`), sec('◆ Interpretiert', a.interpreted_requirements, r => `
${_esc(r.requirement)} — ${_esc(r.reasoning || '')}${pct(r.confidence)}
`), sec('⚠ Schwache Übereinstimmungen', a.missing_or_weak, m => `
${_esc(m.requirement)} — ${_esc(m.cv_status || '')}${pct(m.confidence_of_fit)}
`), ].join('') || 'Keine Analyse-Daten'; div.style.display = 'block'; } function _esc(s) { if (s == null) return ''; const div = document.createElement('div'); div.textContent = String(s); return div.innerHTML; } async function exportCoverLetter(format) { if (!_currentCoverLetterId) { _clShowAlert('Kein Anschreiben zum Exportieren', 'error'); return; } try { const res = await fetchAPI(`/api/cover-letters/${_currentCoverLetterId}/export`, { method: 'POST', body: JSON.stringify({ format }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `HTTP ${res.status}`); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; // Filename aus Content-Disposition extrahieren const cd = res.headers.get('Content-Disposition') || ''; const m = cd.match(/filename="?([^";]+)"?/); a.download = m ? m[1] : `Anschreiben.${format}`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); _clShowAlert(`Als ${format.toUpperCase()} exportiert ✓`, 'success'); } catch (e) { _clShowAlert(`Export-Fehler: ${e.message}`, 'error'); } } function copyCoverLetterText() { const container = document.getElementById('clContent'); if (!container || !(container.innerText || '').trim()) { _clShowAlert('Nichts zu kopieren', 'error'); return; } // Confidence-Badges (z.B. "97%") sind UI-only — beim Kopieren raus. // Wir clonen das DOM, entfernen die Badge-Spans, lesen dann den // sauberen innerText aus. const clone = container.cloneNode(true); clone.querySelectorAll('.cl-confidence-badge').forEach(b => b.remove()); const text = (clone.innerText || '').trim(); navigator.clipboard.writeText(text).then( () => _clShowAlert('In Zwischenablage kopiert ✓ (ohne Confidence-Prozente)', 'success'), () => _clShowAlert('Kopieren fehlgeschlagen', 'error') ); } async function finalizeCoverLetter() { if (!_currentCoverLetterId) return; try { const res = await fetchAPI(`/api/cover-letters/${_currentCoverLetterId}`, { method: 'PATCH', body: JSON.stringify({ status: 'finalized' }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); _clShowAlert('Als finalisiert markiert ✓', 'success'); await loadCoverLetterList(); } catch (e) { _clShowAlert(`Fehler: ${e.message}`, 'error'); } } function clearCoverLetterForm() { document.getElementById('clCompanyName').value = ''; document.getElementById('clJobTitle').value = ''; document.getElementById('clJobDescription').value = ''; document.getElementById('clContent').innerHTML = ''; // safe: clearing own element document.getElementById('clResult').style.display = 'none'; document.getElementById('clAnalysis').style.display = 'none'; _currentCoverLetterId = null; _currentCoverLetterAnalysis = null; } async function loadCoverLetterList() { const container = document.getElementById('clSavedList'); try { const res = await fetchAPI('/api/cover-letters'); if (!res.ok) { container.innerHTML = `Liste konnte nicht geladen werden (HTTP ${res.status})`; // status is a number, safe return; } const data = await res.json(); const items = data.cover_letters || []; if (items.length === 0) { container.innerHTML = 'Noch keine Anschreiben gespeichert.'; // static string return; } // All user data escaped via _esc(); numeric fields (status code) are safe numbers container.innerHTML = items.map(cl => { // list items: all strings via _esc() const date = cl.updated_at ? new Date(cl.updated_at).toLocaleDateString('de-DE') : ''; const statusBadge = cl.status === 'finalized' ? '✅' : (cl.status === 'generated' ? '✍️' : '📝'); return `
${statusBadge} ${_esc(cl.company_name)} — ${_esc(cl.job_title)}
${date} · ${_esc(cl.status)}
`; }).join(''); } catch (e) { container.innerHTML = `Fehler: ${_esc(e.message)}`; // e.message via _esc() } } async function loadCoverLetter(id) { try { const res = await fetchAPI(`/api/cover-letters/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const cl = await res.json(); document.getElementById('clCompanyName').value = cl.company_name || ''; document.getElementById('clJobTitle').value = cl.job_title || ''; document.getElementById('clJobDescription').value = cl.job_description || ''; document.getElementById('clTone').value = cl.tone || 'professional'; document.getElementById('clLength').value = cl.length || 'medium'; document.getElementById('clFocus').value = cl.focus || 'balanced'; _currentCoverLetterId = cl.id; _currentCoverLetterAnalysis = cl.analysis || null; if (cl.content) _clRenderContent(cl.content); } catch (e) { _clShowAlert(`Laden fehlgeschlagen: ${e.message}`, 'error'); } } async function deleteCoverLetter(id) { if (!confirm('Anschreiben wirklich löschen?')) return; try { const res = await fetchAPI(`/api/cover-letters/${id}`, { method: 'DELETE', }); if (!res.ok) throw new Error(`HTTP ${res.status}`); _clShowAlert('Gelöscht ✓', 'success'); if (_currentCoverLetterId === id) clearCoverLetterForm(); await loadCoverLetterList(); } catch (e) { _clShowAlert(`Löschen fehlgeschlagen: ${e.message}`, 'error'); } } function loadCoverLetterPage() { loadCoverLetterList(); }