'use strict'; import { LANGUAGES, ERROR_CODES, RUNTIME_MESSAGES, POPUP_STATUSES } from '../shared/constants.js'; import { defaultSettings, sanitizeSettings } from '../shared/settings-schema.js'; const $enabled = /** @type {HTMLInputElement} */ (document.getElementById('enabled-toggle')); const $model = /** @type {HTMLSelectElement} */ (document.getElementById('model-select')); const $reload = /** @type {HTMLButtonElement} */ (document.getElementById('reload-models')); const $modelStatus = /** @type {HTMLParagraphElement} */ (document.getElementById('model-status')); const $language = /** @type {HTMLSelectElement} */ (document.getElementById('language-select')); const $openOptions = /** @type {HTMLButtonElement} */ (document.getElementById('open-options')); // Mode navigation + translation result mode elements const $navSettings = /** @type {HTMLButtonElement} */ (document.getElementById('nav-settings')); const $navTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('nav-translation')); const $settingsMode = /** @type {HTMLElement} */ (document.getElementById('settings-mode')); const $translationMode = /** @type {HTMLElement} */ (document.getElementById('translation-mode')); const $translationStatus = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-status')); const $translationMeta = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-meta')); const $translationText = /** @type {HTMLDivElement} */ (document.getElementById('translation-text')); const $translationError = /** @type {HTMLParagraphElement} */ (document.getElementById('translation-error')); const $copyTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('copy-translation')); const $regenerateTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('regenerate-translation')); const $openOptionsFromTranslation = /** @type {HTMLButtonElement} */ (document.getElementById('open-options-from-translation')); /** @type {string[]} current model list for change detection */ let knownModels = []; // --------------------------------------------------------------------- // Mode switching // --------------------------------------------------------------------- /** @param {'settings'|'translation'} mode */ function showMode(mode) { const translation = mode === 'translation'; $settingsMode.hidden = translation; $translationMode.hidden = !translation; $navSettings.classList.toggle('active', !translation); $navTranslation.classList.toggle('active', translation); $navSettings.setAttribute('aria-pressed', String(!translation)); $navTranslation.setAttribute('aria-pressed', String(translation)); } $navSettings.addEventListener('click', () => { showMode('settings'); void persistMode('settings'); }); $navTranslation.addEventListener('click', () => { showMode('translation'); void persistMode('translation'); void refreshTranslationState(); }); $openOptionsFromTranslation.addEventListener('click', () => { browser.runtime.openOptionsPage(); }); /** * Remember the tab the user picked so the next popup open restores it * instead of always jumping to the translation tab. * @param {'settings'|'translation'} mode */ async function persistMode(mode) { try { await browser.storage.local.set({ popupMode: mode }); } catch { // non-fatal } } // --------------------------------------------------------------------- // Translation result mode // --------------------------------------------------------------------- const STATUS_LABELS = { [POPUP_STATUSES.IDLE]: '待機中', [POPUP_STATUSES.STARTING]: '翻訳を準備中…', [POPUP_STATUSES.STREAMING]: '受信中…', [POPUP_STATUSES.COMPLETED]: '完了', [POPUP_STATUSES.ERROR]: 'エラー', [POPUP_STATUSES.CANCELLED]: '中断' }; function renderTranslationState(state) { if (!state || typeof state !== 'object') return; const status = typeof state.status === 'string' ? state.status : POPUP_STATUSES.IDLE; const busy = status === POPUP_STATUSES.STARTING || status === POPUP_STATUSES.STREAMING; $translationStatus.textContent = STATUS_LABELS[status] || status; if (state.model) { $translationMeta.hidden = false; $translationMeta.textContent = `Ollama / ${state.model}`; } else { $translationMeta.hidden = true; } // Plain-text rendering only (Security.md §7.4). $translationText.textContent = typeof state.translatedText === 'string' ? state.translatedText : ''; if (state.error) { $translationError.hidden = false; $translationError.textContent = state.error; } else { $translationError.hidden = true; } $copyTranslation.disabled = busy || !(state.translatedText && state.translatedText.length > 0); $regenerateTranslation.disabled = busy || !state.sourceText; } async function refreshTranslationState() { try { const response = await browser.runtime.sendMessage({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }); if (response && response.ok && response.state) { renderTranslationState(response.state); return response.state; } } catch { // background unavailable; leave the current view } return null; } // --------------------------------------------------------------------- // Live state updates: subscribe to the shared background state so an // open popup receives streaming chunks in real time (Design.md §7.4). // --------------------------------------------------------------------- try { const statePort = browser.runtime.connect({ name: 'popup-state' }); statePort.onMessage.addListener((message) => { if (message && message.type === 'popup-state' && message.state) { renderTranslationState(message.state); } }); // onDisconnect: popup is closing or the background restarted; nothing to do. } catch { // Port may be unavailable in non-extension test contexts; polling in // refreshTranslationState() still covers the initial state. } $copyTranslation.addEventListener('click', () => { const text = $translationText.textContent || ''; if (!text) return; void (async () => { try { await navigator.clipboard.writeText(text); $translationStatus.textContent = 'コピーしました'; } catch { // No execCommand fallback (deprecated): show failure only. $translationStatus.textContent = 'コピーできませんでした'; } })(); }); $regenerateTranslation.addEventListener('click', () => { void (async () => { try { const response = await browser.runtime.sendMessage({ type: RUNTIME_MESSAGES.REGENERATE_LAST }); if (response && response.ok) { await refreshTranslationState(); } else { $translationError.hidden = false; $translationError.textContent = '再生成を開始できませんでした。'; } } catch { $translationError.hidden = false; $translationError.textContent = '再生成を開始できませんでした。'; } })(); }); // --------------------------------------------------------------------- // Settings mode (existing behavior) // --------------------------------------------------------------------- function fillLanguages(selected) { $language.textContent = ''; for (const lang of LANGUAGES) { const option = document.createElement('option'); option.value = lang.code; option.textContent = lang.label; if (lang.code === selected) option.selected = true; $language.append(option); } } function fillModels(models, selected) { $model.textContent = ''; if (models.length === 0) { const option = document.createElement('option'); option.value = ''; option.textContent = '(モデルなし / Ollama未起動)'; $model.append(option); } for (const name of models) { const option = document.createElement('option'); option.value = name; option.textContent = name; if (name === selected) option.selected = true; $model.append(option); } if (selected && !models.includes(selected)) { const option = document.createElement('option'); option.value = selected; option.textContent = `${selected}(一覧にありません)`; option.selected = true; $model.append(option); } if (!selected && models.length > 0) { // No model saved yet: explicitly select the first one so the UI value // matches what will be used. Otherwise the browser auto-selects the // first option visually while storage still has an empty model, // which made translation fail until the user pressed Save. $model.selectedIndex = 0; } } function showStatus(message, isError) { $modelStatus.hidden = false; $modelStatus.textContent = message; $modelStatus.classList.toggle('error', Boolean(isError)); } async function loadModels() { showStatus('モデル一覧を取得中…', false); try { const response = await browser.runtime.sendMessage({ type: RUNTIME_MESSAGES.LIST_MODELS }); if (response && response.ok && Array.isArray(response.models)) { knownModels = response.models; const current = $model.value; fillModels(response.models, current); if (response.models.length === 0) { $modelStatus.hidden = true; } else { $modelStatus.hidden = true; } } else { const code = response && response.error; if (code === ERROR_CODES.OLLAMA_UNAVAILABLE) { showStatus('Ollamaに接続できません。', true); } else { showStatus('モデル一覧の取得に失敗しました。', true); } fillModels([], $model.value); } } catch { showStatus('モデル一覧の取得に失敗しました。', true); } } async function loadSettings() { const result = await browser.storage.local.get({ settings: null }); const settings = sanitizeSettings(result.settings || defaultSettings()); $enabled.checked = Boolean(settings.enabled); for (const lang of LANGUAGES) { const option = document.createElement('option'); option.value = lang.code; option.textContent = lang.label; $language.append(option); } $language.value = settings.targetLanguage || 'ja'; fillModels(knownModels, settings.model || ''); } async function persist(patch) { const result = await browser.storage.local.get({ settings: null }); const settings = sanitizeSettings(result.settings || defaultSettings()); const merged = { ...settings, ...patch }; // Sanitize before saving so unrelated fields stay valid. const validated = sanitizeSettings(merged); await browser.storage.local.set({ settings: validated }); } $enabled.addEventListener('change', () => { void persist({ enabled: $enabled.checked }); }); $model.addEventListener('change', () => { const value = $model.value; if (value) void persist({ model: value }); }); $language.addEventListener('change', () => { void persist({ targetLanguage: $language.value }); }); $reload.addEventListener('click', () => { void loadModels(); }); $openOptions.addEventListener('click', () => { browser.runtime.openOptionsPage(); }); void (async () => { await loadSettings(); await loadModels(); // If the model list loaded and no model is saved yet, persist the // auto-selected first model so the stored settings match the UI. if (knownModels.length > 0) { const result = await browser.storage.local.get({ settings: null, popupMode: null }); const stored = sanitizeSettings(result.settings || defaultSettings()); if (!stored.model) { await persist({ model: $model.value }); } // Decide the initial mode. Active/recent translation wins; otherwise // restore the tab the user last picked (default: settings). const state = await refreshTranslationState(); const active = state && (state.status === 'starting' || state.status === 'streaming'); if (active) { showMode('translation'); } else if (result.popupMode === 'translation' && state && state.translatedText && state.translatedText.length > 0) { showMode('translation'); } else { showMode('settings'); } } else { showMode('settings'); } })();