/* ============================================= HELLION NEWTAB — app.js Einstiegspunkt: Init, Clock, globale Events ============================================= */ async function init() { const savedBoards = await Store.get('boards'); const savedSettings = await Store.get('settings'); const savedTrash = await Store.get('trash'); boards = savedBoards ?? getDefaultBoards(); trash = Array.isArray(savedTrash) ? savedTrash : []; // Auto-Cleanup: Eintraege aelter als 30 Tage verwerfen (TRASH-02). Muss VOR // renderBoards() laufen, damit der Papierkorb-Stand konsistent ist. Schreibt nur // zurueck, wenn wirklich etwas entfernt wurde (kein unnoetiger Storage-Write). const cutoff = Date.now() - TRASH_RETENTION_MS; const beforeCount = trash.length; trash = trash.filter(entry => entry && typeof entry.deletedAt === 'number' && Number.isFinite(entry.deletedAt) && entry.deletedAt >= cutoff); if (trash.length !== beforeCount) await saveTrash(); if (savedSettings) Object.assign(settings, savedSettings); I18n.init(); applySettings(); renderBoards(); startClock(); bindGlobalEvents(); bindSettingsEvents(); bindStorageSync(); bindBoardResizeReclamp(); // Boards bei Fenster-Verkleinerung wieder in den sichtbaren Bereich holen await drainQuickSavePending(); // beim Start angesammelte Quick-Saves (kein Tab war offen) einlesen initSearch(); initPalette(); await migrateSticky(); await Notes.init(); await Calculator.init(); await Timer.init(); await ImageRef.init(); BrowserBookmarkImport.init(); initDataButtons(); Store.checkQuota(); // Onboarding beim ersten Start const onboardingDone = await Store.get('onboardingDone'); if (!onboardingDone) { Onboarding.start(); } else { // Backup-Reminder (nur wenn Onboarding schon durch ist) await checkBackupReminder(); } } // ---- STICKY NOTE MIGRATION ---- async function migrateSticky() { const stickyText = await Store.get('stickyNote'); const stickyPos = await Store.get('stickyPos'); const existingWidgets = await Store.get('widgetStates'); // Nur migrieren wenn alte Daten vorhanden UND noch keine Widgets existieren if (!stickyText && !stickyPos) return; if (existingWidgets && Array.isArray(existingWidgets.notes) && existingWidgets.notes.length > 0) return; const noteData = { id: 'note_' + uid(), title: (stickyText || '').split('\n')[0].trim().slice(0, 20) || 'Note', content: stickyText || '', template: 'text', x: stickyPos ? stickyPos.x : 120, y: stickyPos ? stickyPos.y : 80, width: 280, height: 220, open: true, checkedItems: [], checklistItems: [] }; await Store.set('widgetStates', { notes: [noteData] }); // Alte Keys aufraeumen try { if (typeof chrome !== 'undefined' && chrome.storage) { chrome.storage.local.remove(['stickyNote', 'stickyPos', 'stickyVisible']); } else { localStorage.removeItem('stickyNote'); localStorage.removeItem('stickyPos'); localStorage.removeItem('stickyVisible'); } } catch (e) { console.warn('Sticky-Migration: Alte Keys konnten nicht entfernt werden', e); } } // ---- BACKUP REMINDER ---- const BACKUP_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage async function checkBackupReminder() { const lastReminder = await Store.get('lastBackupReminder'); const now = Date.now(); // Beim allerersten Mal: Timestamp setzen, aber noch nicht nerven if (!lastReminder) { await Store.set('lastBackupReminder', now); return; } if (now - lastReminder < BACKUP_INTERVAL_MS) return; // Nur erinnern wenn es Boards gibt die sich lohnen zu sichern if (boards.length === 0) return; const doBackup = await HellionDialog.confirm( t('app.backup_reminder'), { type: 'warning', title: t('app.backup_reminder.title'), confirmText: t('app.backup_now'), cancelText: t('app.backup_later') } ); if (doBackup) { // JSON-Export auslösen (gleiche Logik wie btnExportJSON) const widgetData = await Store.get('widgetStates'); const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : []; const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : []; const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : []; const data = { version: '2.3.0', exported: new Date().toISOString(), boards, settings, trash, notes: notesData, calculator: calcHistory, timerPresets }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'hellion-newtab-backup-' + new Date().toISOString().slice(0, 10) + '.json'; a.click(); URL.revokeObjectURL(url); } // Timestamp immer aktualisieren (egal ob gesichert oder "Später") await Store.set('lastBackupReminder', now); } // ---- CLOCK & DATE ---- function startClock() { const DAY_KEYS = ['clock.days.sun','clock.days.mon','clock.days.tue','clock.days.wed','clock.days.thu','clock.days.fri','clock.days.sat']; const MONTH_KEYS = ['clock.months.jan','clock.months.feb','clock.months.mar','clock.months.apr','clock.months.may','clock.months.jun','clock.months.jul','clock.months.aug','clock.months.sep','clock.months.oct','clock.months.nov','clock.months.dec']; function tick() { const now = new Date(); document.getElementById('clock').textContent = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; document.getElementById('date').textContent = `${t(DAY_KEYS[now.getDay()])}, ${String(now.getDate()).padStart(2,'0')}. ${t(MONTH_KEYS[now.getMonth()])}`; } tick(); const clockInterval = setInterval(tick, 1000); } // ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ---- function bindGlobalEvents() { // Header document.getElementById('btnAddBoard').addEventListener('click', openAddBoardModal); document.getElementById('btnImport').addEventListener('click', () => { document.getElementById('importInput').click(); }); // HTML Bookmark Import document.getElementById('importInput').addEventListener('change', async e => { const file = e.target.files[0]; if (!file) return; const imported = parseBookmarkHtml(await file.text()); if (imported.length === 0) { await HellionDialog.alert(t('app.no_bookmarks'), { type: 'warning', title: t('app.import_title') }); return; } boards = [...boards, ...imported]; await saveBoards(); renderBoards(); e.target.value = ''; await HellionDialog.alert( t('app.html_import_success', { count: imported.length, total: imported.reduce((s,b) => s + b.bookmarks.length, 0) }), { type: 'success', title: t('app.import_success_title') } ); }); // Add Board Modal document.getElementById('btnCancelBoard').addEventListener('click', () => closeModal('addBoardOverlay')); document.getElementById('addBoardOverlay').addEventListener('click', e => { if (e.target === document.getElementById('addBoardOverlay')) closeModal('addBoardOverlay'); }); document.getElementById('btnConfirmBoard').addEventListener('click', async () => { const name = document.getElementById('newBoardName').value.trim(); if (!name) return; boards.push({ id: uid(), title: name, bookmarks: [] }); await saveBoards(); renderBoards(); closeModal('addBoardOverlay'); }); document.getElementById('newBoardName').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('btnConfirmBoard').click(); if (e.key === 'Escape') closeModal('addBoardOverlay'); }); // Add Bookmark Modal document.getElementById('btnCancelBookmark').addEventListener('click', () => closeModal('addBookmarkOverlay')); document.getElementById('addBookmarkOverlay').addEventListener('click', e => { if (e.target === document.getElementById('addBookmarkOverlay')) closeModal('addBookmarkOverlay'); }); document.getElementById('btnConfirmBookmark').addEventListener('click', async () => { const title = document.getElementById('newBmTitle').value.trim(); const url = document.getElementById('newBmUrl').value.trim(); const desc = document.getElementById('newBmDesc').value.trim(); if (!title || !url) return; try { new URL(url); } catch { await HellionDialog.alert(t('app.invalid_url'), { type: 'warning', title: t('app.invalid_url.title') }); return; } const board = boards.find(b => b.id === pendingBookmarkBoardId); if (!board) return; board.bookmarks.push({ id: uid(), title, url, desc }); await saveBoards(); renderBoards(); closeModal('addBookmarkOverlay'); }); document.getElementById('newBmUrl').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('btnConfirmBookmark').click(); if (e.key === 'Escape') closeModal('addBookmarkOverlay'); }); // Rename Modal document.getElementById('btnCancelRename').addEventListener('click', () => closeModal('renameOverlay')); document.getElementById('renameOverlay').addEventListener('click', e => { if (e.target === document.getElementById('renameOverlay')) closeModal('renameOverlay'); }); document.getElementById('btnConfirmRename').addEventListener('click', () => { const val = document.getElementById('renameInput').value.trim(); if (pendingRenameCallback) pendingRenameCallback(val); pendingRenameCallback = null; closeModal('renameOverlay'); }); document.getElementById('renameInput').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('btnConfirmRename').click(); if (e.key === 'Escape') closeModal('renameOverlay'); }); } // ---- QUICK-SAVE PENDING-QUEUE ---- // Der Background-Worker haengt Quick-Saves an den eigenen Store-Key 'quicksave_pending' an (er // schreibt NIE boards). Diese Seite ist die einzige boards-Schreiberin und drained die Queue in die // Inbox. Getrennte Schreib-Domaenen -> Worker und Seite koennen sich nicht im boards-Array // gegenseitig ueberschreiben (Datensicherheit, Phase-4-Review-Blocker 2b). let _drainBusy = false; let _drainQueued = false; // ein waehrend eines laufenden Drains angefragter Drain wird nachgeholt let _renderDeferredByDrag = false; // Drain hat den Render wegen eines laufenden Drags ausgelassen -> nach Drag-Ende nachholen async function drainQuickSavePending() { if (_drainBusy) { _drainQueued = true; return; } _drainBusy = true; try { const pending = await Store.get('quicksave_pending'); if (Array.isArray(pending) && pending.length > 0) { const drained = pending.slice(); const drainedIds = new Set(drained.map(e => e && e.id).filter(Boolean)); const inbox = await ensureInboxBoard(); // legt die Inbox an, falls noetig; gibt das Board zurueck // Idempotenz gegen den Worker/Drain-Race auf 'quicksave_pending': jede eingespielte Inbox- // Bookmark traegt die Pending-id ihres Ursprungs als srcId. Taucht ein bereits gedrainter // Eintrag durch einen gleichzeitigen Worker-Append erneut in der Queue auf, wird er hier // uebersprungen statt doppelt eingefuegt — kein Duplikat, und kein Verlust (boards-Write // bleibt vor der Queue-Bereinigung, daher keine umgekehrte Verlustgefahr). const seenSrc = new Set(inbox.bookmarks.map(b => b && b.srcId).filter(Boolean)); for (const e of drained) { if (!e || !e.id || seenSrc.has(e.id)) continue; // schon eingespielt if (typeof e.url !== 'string' || !e.url || !isSafeUrl(e.url)) continue; // leeres/unsicheres Protokoll verwerfen const bm = normalizeBookmark({ title: e.title, url: e.url }); bm.srcId = e.id; // Herkunft fuer kuenftige Dedup inbox.bookmarks.push(bm); seenSrc.add(e.id); } await saveBoards(); // NUR die verarbeiteten Eintraege entfernen — ein gleichzeitiger Worker-Append bleibt erhalten. const still = await Store.get('quicksave_pending'); const remaining = Array.isArray(still) ? still.filter(e => e && !drainedIds.has(e.id)) : []; await Store.set('quicksave_pending', remaining); // Render nur, wenn gerade KEIN Drag laeuft (renderBoards->replaceChildren wuerde ihn abreissen). // Laeuft einer, den Render-Wunsch merken und nach Drag-Ende nachholen (drag.js ruft // flushQuickSaveRenderIfDeferred), sonst bliebe der frisch gedrainte Quick-Save bis zu einem // unabhaengigen Fremd-Render unsichtbar (Phase-6-Review). if (document.querySelector('.board.dragging, .bm-item.dragging-source')) { _renderDeferredByDrag = true; } else { renderBoards(); } } } catch (e) { console.error('Quick-Save-Drain fehlgeschlagen:', e && e.message); } finally { _drainBusy = false; } // Kam waehrend des Drains ein weiterer Quick-Save an (onChanged wurde durch _drainBusy verworfen), // jetzt nachholen. Der Eintrag war sicher in der Queue, nur noch nicht eingelesen. if (_drainQueued) { _drainQueued = false; drainQuickSavePending(); } } // Wird von drag.js nach jedem Drag-Ende aufgerufen: einen waehrend des Drags ausgelassenen // Quick-Save-Render nachholen. Idempotent — tut nichts, wenn kein Render aussteht. function flushQuickSaveRenderIfDeferred() { if (_renderDeferredByDrag) { _renderDeferredByDrag = false; renderBoards(); } } // Live-Sync (QS-03): ein offener NewTab drained die Queue, sobald der Worker etwas anhaengt. function bindStorageSync() { if (typeof chrome === 'undefined' || !chrome.storage || !chrome.storage.onChanged) return; chrome.storage.onChanged.addListener((changes, area) => { // Nur auf die Quick-Save-Queue reagieren — 'boards' schreibt ausschliesslich diese Seite. if (area !== 'local' || !changes.quicksave_pending) return; drainQuickSavePending(); }); } // Freies Layout (LAYOUT-04): Boards stehen absolut positioniert. Schrumpft das Fenster, koennen // sie ganz aus dem sichtbaren Bereich rutschen. renderBoards() klemmt die --board-x/--board-y jedes // Boards beim Aufbau gegen die aktuelle Viewport — ein simpler Re-Render holt sie also zurueck. // Debounce (150ms), damit kontinuierliches Resizen nicht hunderte Renders ausloest. Waehrend eines // aktiven Drags NICHT neu rendern: renderBoards->replaceChildren wuerde den laufenden Drag abreissen. function bindBoardResizeReclamp() { let resizeTimer = null; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { if (document.querySelector('.board.dragging, .bm-item.dragging-source')) return; renderBoards(); }, 150); }); } document.addEventListener('DOMContentLoaded', init);