/** * Bayan — Background Service Worker (Phase 7.1 Simplified) * * Single network boundary. Owns: * 1. Context menu → side panel (Phase 5) * 2. API calls with cache + timeout + retry (Phase 7) * * Uses: BAYAN (shared/constants.js), bayanHash (shared/hash.js) * loaded via importScripts(). */ importScripts('shared/constants.js', 'shared/hash.js'); // ── Context constants ── const ACTIONS = { CORRECT: 'correct', SUMMARIZE: 'summarize' }; const CONTEXT_KEYS = ['contextAction', 'contextText', 'contextTimestamp']; const SIDE_PANEL_PATH = 'sidepanel/sidepanel.html'; // ── Deduplication cache ── const _cache = new Map(); function cacheGet(text) { const key = bayanHash(text); const entry = _cache.get(key); if (!entry) return null; if (Date.now() - entry.ts > BAYAN.CACHE_TTL_MS) { _cache.delete(key); return null; } if (entry.text !== text) return null; // FIX-19: LRU — move to end on access (Map maintains insertion order) _cache.delete(key); _cache.set(key, entry); return entry.data; } function cacheSet(text, data) { const key = bayanHash(text); if (_cache.size >= BAYAN.CACHE_MAX_ENTRIES) { _cache.delete(_cache.keys().next().value); } _cache.set(key, { text, data, ts: Date.now() }); } // ── Storage helper ── function getStorage() { return chrome.storage?.session || chrome.storage?.local; } // ══════════════════════════════════════════════════════════ // Context Menu // ══════════════════════════════════════════════════════════ chrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.create({ id: 'bayan-correct', title: chrome.i18n.getMessage('contextMenuCorrect') || 'تصحيح مع بيان', contexts: ['selection'], }); chrome.contextMenus.create({ id: 'bayan-summarize', title: chrome.i18n.getMessage('contextMenuSummarize') || 'تلخيص مع بيان', contexts: ['selection'], }); }); chrome.contextMenus.onClicked.addListener((info, tab) => { const selectedText = info.selectionText?.trim(); if (!selectedText) return; let action = null; if (info.menuItemId === 'bayan-correct') action = ACTIONS.CORRECT; if (info.menuItemId === 'bayan-summarize') action = ACTIONS.SUMMARIZE; if (!action) return; // Open side panel IMMEDIATELY (preserve user gesture) if (chrome.sidePanel) { chrome.sidePanel.open({ windowId: tab.windowId }).catch(() => { chrome.tabs.create({ url: chrome.runtime.getURL(SIDE_PANEL_PATH) }); }); } else { chrome.tabs.create({ url: chrome.runtime.getURL(SIDE_PANEL_PATH) }); } // Store context (async) const storage = getStorage(); storage.set({ contextAction: action, contextText: selectedText, contextTimestamp: Date.now(), }); setTimeout(() => storage.remove(CONTEXT_KEYS), 15000); }); // ══════════════════════════════════════════════════════════ // Message Handler // ══════════════════════════════════════════════════════════ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'HEALTH_CHECK') { sendResponse({ status: 'ok', version: chrome.runtime.getManifest().version, cache: _cache.size, }); return true; } if (message.type === 'CLEAR_CONTEXT') { getStorage().remove(CONTEXT_KEYS); sendResponse({ status: 'cleared' }); return true; } if (message.type === 'INLINE_ANALYZE') { const text = message.text; if (!text || text.length < BAYAN.MIN_TEXT_LENGTH) { sendResponse({ error: 'Text too short' }); return true; } if (text.length > BAYAN.MAX_TEXT_LENGTH) { sendResponse({ error: 'Text too long' }); return true; } // Cache hit → zero network const cached = cacheGet(text); if (cached) { sendResponse({ data: cached, cached: true }); return true; } // Network call with retry analyzeWithRetry(text) .then((data) => { if (data) cacheSet(text, data); sendResponse({ data: data || null }); }) .catch((err) => { console.warn('[Bayan BG] Analysis error:', err.message); sendResponse({ error: err.message }); }); return true; } if (message.type === 'OPEN_SIDEPANEL') { const storage = getStorage(); storage.set({ contextAction: ACTIONS.CORRECT, contextText: message.text || '', contextTimestamp: Date.now(), }); if (chrome.sidePanel && sender.tab) { chrome.sidePanel.open({ windowId: sender.tab.windowId }).catch(() => { chrome.tabs.create({ url: chrome.runtime.getURL(SIDE_PANEL_PATH) }); }); } sendResponse({ status: 'ok' }); return true; } return false; }); // ══════════════════════════════════════════════════════════ // Network: fetch with timeout + 1 retry // ══════════════════════════════════════════════════════════ async function analyzeWithRetry(text, attempt = 0) { try { return await fetchWithTimeout(text); } catch (err) { if (attempt < BAYAN.MAX_RETRIES) { return analyzeWithRetry(text, attempt + 1); } throw err; } } async function fetchWithTimeout(text) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), BAYAN.API_TIMEOUT_MS); try { const res = await fetch(`${BAYAN.API_BASE}/api/analyze`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), signal: controller.signal, }); if (!res.ok) throw new Error(`API ${res.status}`); return await res.json(); } finally { clearTimeout(timeout); } }