/** * Bayan Chrome Extension — Popup Logic * * Fixes applied: * HIGH-1: Offset rebasing via applyAndRebase() after each individual apply * MED-1: Clipboard .catch() on all writeText() calls * MED-2: Staleness detection — locks corrections when user edits after analysis */ document.addEventListener('DOMContentLoaded', () => { // ── Element references ── const inputText = document.getElementById('input-text'); const charCount = document.getElementById('char-count'); const wordCount = document.getElementById('word-count'); const btnCorrect = document.getElementById('btn-correct'); const btnClear = document.getElementById('btn-clear'); const btnApplyAll = document.getElementById('btn-apply-all'); const btnCopyResult = document.getElementById('btn-copy-result'); const scoreSection = document.getElementById('score-section'); const resultSection = document.getElementById('result-section'); const resultText = document.getElementById('result-text'); const suggestionsSection = document.getElementById('suggestions-section'); const suggestionsList = document.getElementById('suggestions-list'); const timingSection = document.getElementById('timing-section'); const timingText = document.getElementById('timing-text'); const loadingOverlay = document.getElementById('loading-overlay'); const loadingTextEl = document.getElementById('loading-text'); // Summary tab elements const summaryInputText = document.getElementById('summary-input-text'); const summaryCharCount = document.getElementById('summary-char-count'); const btnSummarize = document.getElementById('btn-summarize'); const summaryResultSection = document.getElementById('summary-result-section'); const summaryText = document.getElementById('summary-text'); const summaryMeta = document.getElementById('summary-meta'); const btnCopySummary = document.getElementById('btn-copy-summary'); // Score elements const scoreValue = document.getElementById('score-value'); const scoreCircle = document.getElementById('score-circle'); const scoreHint = document.getElementById('score-hint'); const countSpelling = document.getElementById('count-spelling'); const countGrammar = document.getElementById('count-grammar'); const countPunctuation = document.getElementById('count-punctuation'); // ══════════════════════════════════════════════════════════ // State // ══════════════════════════════════════════════════════════ let currentSuggestions = []; /** * MED-2: Snapshot of the text that was last analyzed. * All suggestion offsets are relative to THIS text. * applyAndRebase() mutates this alongside suggestions. */ let analyzedText = ''; /** * MED-2: Whether the analysis results are stale. * Set to true when the user edits the textarea after analysis. * When stale, suggestion actions are blocked. */ let isStale = false; const SCORE_CIRCUMFERENCE = 440; // ══════════════════════════════════════════════════════════ // Tab switching // ══════════════════════════════════════════════════════════ document.querySelectorAll('.bayan-tab').forEach((tab) => { tab.addEventListener('click', () => { const targetTab = tab.dataset.tab; document.querySelectorAll('.bayan-tab').forEach((t) => { t.classList.toggle('active', t.dataset.tab === targetTab); t.setAttribute('aria-selected', t.dataset.tab === targetTab ? 'true' : 'false'); }); document.querySelectorAll('.bayan-panel').forEach((p) => { p.classList.toggle('active', p.id === `panel-${targetTab}`); }); }); }); // ══════════════════════════════════════════════════════════ // Character & word counter // ══════════════════════════════════════════════════════════ function updateCounts(textarea, charEl, wordEl) { const text = textarea.value; const chars = text.length; const words = text.trim() ? text.trim().split(/\s+/).length : 0; if (charEl) charEl.textContent = chars.toLocaleString('ar-EG'); if (wordEl) wordEl.textContent = words.toLocaleString('ar-EG'); } inputText.addEventListener('input', () => { updateCounts(inputText, charCount, wordCount); // MED-2: Detect user edit after analysis → mark stale if (currentSuggestions.length > 0 && inputText.value !== analyzedText) { markStale(); } }); summaryInputText.addEventListener('input', () => updateCounts(summaryInputText, summaryCharCount, null)); // ══════════════════════════════════════════════════════════ // MED-2: Staleness management // ══════════════════════════════════════════════════════════ /** * Mark analysis results as stale (user edited textarea). * Disables suggestion actions and shows a re-analysis prompt. */ function markStale() { if (isStale) return; // already stale isStale = true; // Visual indicator: dim the results area if (resultSection) resultSection.classList.add('bayan-stale'); if (suggestionsSection) suggestionsSection.classList.add('bayan-stale'); // Show re-analysis toast showToast('⚠ النص تغيّر — أعد التحليل لتحديث الاقتراحات', 4000); // Update button text to indicate re-analysis needed btnCorrect.innerHTML = ` إعادة التحليل`; } /** * Clear staleness (after re-analysis or clear). */ function clearStale() { isStale = false; if (resultSection) resultSection.classList.remove('bayan-stale'); if (suggestionsSection) suggestionsSection.classList.remove('bayan-stale'); // Restore button text btnCorrect.innerHTML = ` تحليل وتصحيح`; } // ══════════════════════════════════════════════════════════ // Loading state // ══════════════════════════════════════════════════════════ function setLoading(show, text = 'جارٍ التحليل...') { loadingOverlay.classList.toggle('is-hidden', !show); loadingTextEl.textContent = text; } // ══════════════════════════════════════════════════════════ // Toast // ══════════════════════════════════════════════════════════ function showToast(message, duration = 2500) { const toast = document.getElementById('toast'); toast.textContent = message; toast.classList.add('is-visible'); clearTimeout(toast._timer); toast._timer = setTimeout(() => toast.classList.remove('is-visible'), duration); } // ══════════════════════════════════════════════════════════ // Score ring // ══════════════════════════════════════════════════════════ function updateScore(spelling, grammar, punctuation) { const score = calculateWritingScore(spelling, grammar, punctuation); const total = spelling + grammar + punctuation; scoreSection.classList.remove('is-hidden'); if (scoreValue) { scoreValue.textContent = score > 0 || total > 0 ? score.toLocaleString('ar-EG') : '--'; } if (scoreCircle) { const offset = SCORE_CIRCUMFERENCE - (score / 100) * SCORE_CIRCUMFERENCE; scoreCircle.style.strokeDashoffset = String(offset); } if (scoreHint) { scoreHint.textContent = getScoreHint(score, total); } if (countSpelling) countSpelling.textContent = spelling.toLocaleString('ar-EG'); if (countGrammar) countGrammar.textContent = grammar.toLocaleString('ar-EG'); if (countPunctuation) countPunctuation.textContent = punctuation.toLocaleString('ar-EG'); } // ══════════════════════════════════════════════════════════ // Render suggestions list // ══════════════════════════════════════════════════════════ function renderSuggestions(suggestions) { currentSuggestions = suggestions; if (!suggestions || suggestions.length === 0) { suggestionsSection.classList.add('is-hidden'); return; } suggestionsSection.classList.remove('is-hidden'); const html = suggestions.map((s, i) => buildSuggestionCardHTML(s, i)).join(''); suggestionsList.innerHTML = html; // Bind suggestion card events suggestionsList.querySelectorAll('.bayan-alt-chip').forEach((chip) => { chip.addEventListener('click', (e) => { e.stopPropagation(); // MED-2: Block if stale if (isStale) { showToast('⚠ أعد التحليل أولاً — النص تغيّر'); return; } const suggestionId = chip.dataset.cardId; const altText = chip.dataset.cardAlt; const suggestion = currentSuggestions.find((s) => String(s.id || '') === String(suggestionId)); if (!suggestion) return; if (altText === suggestion.original) { // Dismiss — remove from list, no text change, no rebase needed currentSuggestions = removeSuggestion(currentSuggestions, suggestion.id); } else { // ═══════════════════════════════════════════════════ // HIGH-1 FIX: Apply + Rebase via atomic function // ═══════════════════════════════════════════════════ const result = applyAndRebase(analyzedText, suggestion, altText, currentSuggestions); analyzedText = result.text; currentSuggestions = result.suggestions; // Sync textarea with the rebased text inputText.value = analyzedText; updateCounts(inputText, charCount, wordCount); } // Re-render score and suggestions const counts = countByType(currentSuggestions); updateScore(counts.spelling, counts.grammar, counts.punctuation); renderSuggestions(currentSuggestions); // Re-render highlighted text with updated offsets resultText.innerHTML = renderHighlightedText(analyzedText, currentSuggestions); showToast('✓ تم التصحيح'); }); }); // Show apply-all only when >= 2 btnApplyAll.classList.toggle('is-hidden', suggestions.length < 2); } // ══════════════════════════════════════════════════════════ // Clear button // ══════════════════════════════════════════════════════════ btnClear.addEventListener('click', () => { inputText.value = ''; analyzedText = ''; updateCounts(inputText, charCount, wordCount); scoreSection.classList.add('is-hidden'); resultSection.classList.add('is-hidden'); suggestionsSection.classList.add('is-hidden'); timingSection.classList.add('is-hidden'); currentSuggestions = []; clearStale(); }); // ══════════════════════════════════════════════════════════ // Correct button // ══════════════════════════════════════════════════════════ btnCorrect.addEventListener('click', async () => { const text = inputText.value.trim(); if (!text) { showToast('أدخل نصاً للتحليل'); return; } if (text.length < CONFIG.MIN_ANALYZE_LENGTH) { showToast('النص قصير جداً (الحد الأدنى ١٥ حرفاً)'); return; } if (text.length > CONFIG.MAX_ANALYZE_LENGTH) { showToast('النص طويل جداً (الحد الأقصى ٥٠٠٠ حرف)'); return; } setLoading(true, 'جارٍ التحليل...'); clearStale(); // MED-2: Clear stale on re-analysis try { const data = await bayanAnalyze(text); if (data.status === 'success' || data.status === 'partial') { const suggestions = sortSuggestions(data.suggestions || []); currentSuggestions = suggestions; // MED-2: Snapshot the analyzed text — all offsets reference THIS string analyzedText = data.original; // Sync textarea to the exact text the backend analyzed inputText.value = analyzedText; updateCounts(inputText, charCount, wordCount); // Show corrected text with highlights resultSection.classList.remove('is-hidden'); resultText.innerHTML = renderHighlightedText(analyzedText, suggestions); // Update score const counts = countByType(suggestions); updateScore(counts.spelling, counts.grammar, counts.punctuation); // Render suggestion cards renderSuggestions(suggestions); // Show timing if (data.timing_ms) { timingSection.classList.remove('is-hidden'); timingText.textContent = `التحليل: ${data.timing_ms.total_ms || 0}ms (إملائي: ${data.timing_ms.spelling_ms || 0}ms، نحوي: ${data.timing_ms.grammar_ms || 0}ms، ترقيم: ${data.timing_ms.punctuation_ms || 0}ms)`; } if (suggestions.length === 0) { showToast('نصك ممتاز! لم نجد أي أخطاء ✨'); } } else { showToast('تعذّر التحليل — حاول مرة أخرى'); } } catch (error) { console.error('[Bayan] Analysis error:', error); showToast('خطأ في الاتصال — تحقق من الإنترنت'); } finally { setLoading(false); } }); // ══════════════════════════════════════════════════════════ // Apply all button // ══════════════════════════════════════════════════════════ btnApplyAll.addEventListener('click', () => { if (currentSuggestions.length === 0) return; // MED-2: Block if stale if (isStale) { showToast('⚠ أعد التحليل أولاً — النص تغيّر'); return; } // Apply all patches using reverse-order (no rebase needed — all applied at once) analyzedText = applyAllPatches(analyzedText, currentSuggestions); inputText.value = analyzedText; updateCounts(inputText, charCount, wordCount); currentSuggestions = []; resultText.innerHTML = escapeHtml(analyzedText); updateScore(0, 0, 0); renderSuggestions([]); showToast('✓ تم تطبيق جميع التصحيحات'); }); // ══════════════════════════════════════════════════════════ // Copy result (MED-1: .catch() for clipboard errors) // ══════════════════════════════════════════════════════════ btnCopyResult.addEventListener('click', () => { const text = resultText.textContent || ''; navigator.clipboard.writeText(text) .then(() => showToast('✓ تم نسخ النص')) .catch(() => showToast('تعذّر النسخ')); }); // ══════════════════════════════════════════════════════════ // Summarize button // ══════════════════════════════════════════════════════════ btnSummarize.addEventListener('click', async () => { const text = summaryInputText.value.trim(); if (!text) { showToast('أدخل نصاً للتلخيص'); return; } if (text.length < CONFIG.MIN_SUMMARIZE_LENGTH) { showToast('النص قصير جداً للتلخيص'); return; } const lengthValue = parseInt(document.querySelector('input[name="summary-length"]:checked')?.value || '2', 10); setLoading(true, 'جارٍ التلخيص...'); try { const data = await bayanSummarize(text, lengthValue); if (data.status === 'success' && data.summary) { summaryResultSection.classList.remove('is-hidden'); summaryText.textContent = data.summary; summaryMeta.textContent = `النص الأصلي: ${(data.original_length || 0).toLocaleString('ar-EG')} حرف → الملخص: ${(data.summary_length || 0).toLocaleString('ar-EG')} حرف`; showToast('✓ تم التلخيص'); } else { showToast('تعذّر التلخيص — حاول مرة أخرى'); } } catch (error) { console.error('[Bayan] Summarization error:', error); showToast('خطأ في الاتصال — تحقق من الإنترنت'); } finally { setLoading(false); } }); // ══════════════════════════════════════════════════════════ // Copy summary (MED-1: .catch() for clipboard errors) // ══════════════════════════════════════════════════════════ btnCopySummary.addEventListener('click', () => { const text = summaryText.textContent || ''; navigator.clipboard.writeText(text) .then(() => showToast('✓ تم نسخ الملخص')) .catch(() => showToast('تعذّر النسخ')); }); // ══════════════════════════════════════════════════════════ // Status check on load // ══════════════════════════════════════════════════════════ (async function checkStatus() { const statusDot = document.querySelector('.bayan-status-dot'); const statusText = document.getElementById('status-text'); try { await bayanHealthCheck(); if (statusDot) statusDot.classList.add('online'); if (statusText) statusText.textContent = 'متصل'; } catch { if (statusDot) statusDot.classList.add('offline'); if (statusText) statusText.textContent = 'غير متصل'; } })(); // ══════════════════════════════════════════════════════════ // Phase 4: Context Menu Pickup // ══════════════════════════════════════════════════════════ // Fix #2: Guard against double execution // Fix #4: Tab name constants // Fix #5: Storage fallback (session → local) // ══════════════════════════════════════════════════════════ const TAB_ACTIONS = { correct: 'correct', summarize: 'summarize' }; let contextConsumed = false; // Fix #2 (async function checkContextAction() { // Only available in extension context (not in test pages) if (typeof chrome === 'undefined' || !chrome.storage) return; // Fix #2: Prevent double-trigger if (contextConsumed) return; // Fix #5: Storage fallback const storage = chrome.storage?.session || chrome.storage?.local; if (!storage) return; try { const data = await storage.get(['contextAction', 'contextText', 'contextTimestamp']); if (!data.contextAction || !data.contextText) return; // Ignore stale actions (older than 15 seconds — matches background cleanup) const age = Date.now() - (data.contextTimestamp || 0); if (age > 15000) { chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' }); return; } // Fix #2: Mark as consumed BEFORE processing contextConsumed = true; console.log(`[Bayan] Context action: ${data.contextAction}, text length: ${data.contextText.length}`); if (data.contextAction === TAB_ACTIONS.correct) { // Fill the correction tab and auto-trigger analysis inputText.value = data.contextText; updateCounts(inputText, charCount, wordCount); // Switch to correction tab const correctTab = document.querySelector(`[data-tab="${TAB_ACTIONS.correct}"]`); if (correctTab) correctTab.click(); // Auto-click the correct button after a brief delay for UI paint setTimeout(() => btnCorrect.click(), 150); } else if (data.contextAction === TAB_ACTIONS.summarize) { // Fill the summary tab and auto-trigger summarization summaryInputText.value = data.contextText; updateCounts(summaryInputText, summaryCharCount, null); // Switch to summarize tab const summarizeTab = document.querySelector(`[data-tab="${TAB_ACTIONS.summarize}"]`); if (summarizeTab) summarizeTab.click(); // Auto-click the summarize button setTimeout(() => btnSummarize.click(), 150); } // Clear the context action so it doesn't re-trigger on next popup open chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' }); } catch (err) { console.warn('[Bayan] Context action check failed:', err); } })(); });