diff --git a/src/background/background-script.js b/src/background/background-script.js index 813e6020..99035953 100644 --- a/src/background/background-script.js +++ b/src/background/background-script.js @@ -1,295 +1,25 @@ import compat from '../shared/compat.js'; -import { product } from '../shared/func.js'; +import { lazy } from '../shared/func.js'; import Recorder from './Recorder.js'; import preferences from '../shared/preferences.js'; +import { detectLanguage } from '../shared/langid.js' +import { MessageHandler, DefaultMap } from '../shared/common.js'; +import { StorageArea } from '../shared/storage.js'; function isSameDomain(url1, url2) { return url1 && url2 && new URL(url1).host === new URL(url2).host; } -// Just a little test to run in the web inspector for debugging -async function test(provider) { - console.log(await Promise.all([ - provider.translate({ - from: 'de', - to: 'en', - text: 'Hallo Welt. Wie geht es dir?' - }), - provider.translate({ - from: 'de', - to: 'en', - text: 'Mein Name ist Jelmer.', - html: true - }) - ])); -} - -/** - * Temporary fix around few models, bad classified, and similar looking languages. - * From https://github.com/bitextor/bicleaner/blob/3df2b2e5e2044a27b4f95b83710be7c751267e5c/bicleaner/bicleaner_hardrules.py#L50 - * @type {Set[]} - */ -const SimilarLanguages = [ - new Set(['es', 'ca', 'gl', 'pt']), - new Set(['no', 'nb', 'nn', 'da']) // no == nb for bicleaner -]; - -/** - * @typedef {Object} TranslationModel - * @property {String} from - * @property {String} to - * @property {Boolean} local - */ - -/** - * @typedef {Object} TranslationProvider - * @property {Promise} registry - * @property {(request:Object) => Promise} translate - */ - -/** - * Language detection function that also provides a sorted list of - * from->to language pairs, based on the detected language, the preferred - * target language, and what models are available. - * @param {{sample:String, suggested:{[lang:String]: Number}}} - * @param {TranslationProvider} provider - * @return {Promise<{from:String|Undefined, to:String|Undefined, models: TranslationModel[]}>} - */ -async function detectLanguage({sample, suggested}, provider, options) { - if (!sample) - throw new Error('Empty sample'); - - const [detected, models] = await Promise.all([ - compat.i18n.detectLanguage(sample), - provider.registry - ]); - - const modelsFromEng = models.filter(({from}) => from === 'en'); - const modelsToEng = models.filter(({to}) => to === 'en'); - - // List of all available from->to translation pairs including ones that we - // achieve by pivoting through English. - const pairs = [ - ...models.map(model => ({from: model.from, to: model.to, pivot: null, models: [model]})), - ...Array.from(product(modelsToEng, modelsFromEng)) - .filter(([{from}, {to}]) => from !== to) - .map(([from, to]) => ({from: from.from, to: to.to, pivot: 'en', models: [from, to]})) - ]; - - // {[lang]: 0.0 .. 1.0} map of likeliness the page is in this language - /** @type {{[lang:String]: Number }} **/ - let confidence = Object.fromEntries(detected.languages.map(({language, percentage}) => [language, percentage / 100])); - - // Take suggestions into account - Object.entries(suggested || {}).forEach(([lang, score]) => { - lang = lang.substr(0, 2); // TODO: not strip everything down to two letters - confidence[lang] = Math.max(score, confidence[lang] || 0.0); - }); - - // Work-around for language pairs that are close together - Object.entries(confidence).forEach(([lang, score]) => { - SimilarLanguages.forEach(group => { - if (group.has(lang)) { - group.forEach(other => { - if (!(other in confidence)) - confidence[other] = score / 2; // little bit lower though - }) - } - }) - }); - - // Fetch the languages that the browser says the user accepts (i.e Accept header) - /** @type {String[]} **/ - let accepted = await compat.i18n.getAcceptLanguages(); - - // TODO: right now all our models are just two-letter codes instead of BCP-47 :( - accepted = accepted.map(language => language.substr(0, 2)) - - // If the user has a preference, put that up front - if (options?.preferred) - accepted.unshift(options.preferred); - - // Remove duplicates - accepted = accepted.filter((val, index, values) => values.indexOf(val, index + 1) === -1) - - // {[lang]: 0.0 .. 1.0} map of likeliness the user wants to translate to this language. - /** @type {{[lang:String]: Number }} */ - const preferred = accepted.reduce((preferred, language, i, languages) => { - return language in preferred - ? preferred - : {...preferred, [language]: 1.0 - (i / languages.length)}; - }, {}); - - // Function to score a translation model. Higher score is better - const score = ({from, to, pivot, models}) => { - return 1.0 * (confidence[from] || 0.0) // from language is good - + 0.5 * (preferred[to] || 0.0) // to language is good - + 0.2 * (pivot ? 0.0 : 1.0) // preferably don't pivot - + 0.1 * (1.0 / models.reduce((acc, model) => acc + model.local ? 0.0 : 1.0, 1.0)) // prefer local models - }; - - // Sort our possible models, best one first - pairs.sort((a, b) => score(b) - score(a)); - - // console.log({ - // accepted, - // preferred, - // confidence, - // pairs: pairs.map(pair => ({...pair, score: score(pair)})) - // }); - - // (Using pairs instead of confidence and preferred because we prefer a pair - // we can actually translate to above nothing every time right now.) - return { - from: pairs.length ? pairs[0].from : undefined, - to: pairs.length ? pairs[0].to : undefined, - models: pairs - } +async function isTranslatedDomain(url) { + const {alwaysTranslateDomains} = await preferences.get({alwaysTranslateDomains: []}); + return url && alwaysTranslateDomains.includes(new URL(url).host); } -const State = { - PAGE_LOADING: 'page-loading', - PAGE_LOADED: 'page-loaded', - PAGE_ERROR: 'page-error', - TRANSLATION_NOT_AVAILABLE: 'translation-not-available', - TRANSLATION_AVAILABLE: 'translation-available', - DOWNLOADING_MODELS: 'downloading-models', - TRANSLATION_IN_PROGRESS: 'translation-in-progress', - TRANSLATION_FINISHED: 'translation-finished', - TRANSLATION_ABORTED: 'translation-aborted', - TRANSLATION_ERROR: 'translation-error' -}; - -// States in which the user has the translation enabled. Used to keep -// translating pages in the same domain. -const activeTranslationStates = [ - State.DOWNLOADING_MODELS, - State.TRANSLATION_IN_PROGRESS, - State.TRANSLATION_FINISHED, - State.TRANSLATION_ABORTED, -]; - -class Tab extends EventTarget { - /** - * @param {Number} id tab id - */ - constructor(id) { - super(); - this.id = id; - this.state = { - state: State.PAGE_LOADING, - active: false, - from: undefined, - to: undefined, - models: [], - debug: false, - error: null, - url: null, - pendingTranslationRequests: 0, - totalTranslationRequests: 0, - modelDownloadRead: undefined, - modelDownloadSize: undefined, - record: false, - recordedPagesCount: undefined, - recordedPagesURL: undefined - }; - - /** @type {Map} */ - this.frames = new Map(); - - /** @type {{diff:Object,callbackId:Number}|null} */ - this._scheduledUpdateEvent = null; - } - - /** - * Begins translation of the tab - */ - translate() { - this.update(state => ({ - state: State.TRANSLATION_IN_PROGRESS - })); - } - - /** - * Aborts translation of the tab - */ - abort() { - this.update(state => ({ - state: State.TRANSLATION_ABORTED - })); - - this.frames.forEach(frame => { - frame.postMessage({ - command: 'TranslateAbort' - }); - }); - } - - /** - * Resets the tab state after navigating away from a page. The disconnect - * of the tab's content scripts will already have triggered abort() - * @param {String} url - */ - reset(url) { - this.update(state => { - if (isSameDomain(url, state.url) && activeTranslationStates.includes(state.state)) { - return { - url, - pendingTranslationRequests: 0, - totalTranslationRequests: 0 - }; - } else { - return { - url, - page: undefined, - from: null, // Only reset from as page could be different - // language. We leave to selected as is - pendingTranslationRequests: 0, - totalTranslationRequests: 0, - state: State.PAGE_LOADING, - error: null - }; - } - }); - } - - /** - * @callback StateUpdatePredicate - * @param {Object} state - * @return {Object} state - */ - - /** - * @param {StateUpdatePredicate} callback - */ - update(callback) { - const diff = callback(this.state); - if (diff === undefined) - throw new Error('state update callback function did not return a value'); - - Object.assign(this.state, diff); - - // Delay the update notification to accumulate multiple changes in one - // notification. - if (!this._scheduledUpdateEvent) { - const callbackId = setTimeout(this._dispatchUpdateEvent.bind(this)); - this._scheduledUpdateEvent = {diff, callbackId}; - } else { - Object.assign(this._scheduledUpdateEvent.diff, diff); - } - } - - _dispatchUpdateEvent() { - const {diff} = this._scheduledUpdateEvent; - this._scheduledUpdateEvent = null; - - const updateEvent = new Event('update'); - updateEvent.data = diff; - this.dispatchEvent(updateEvent); - } -} +// Give content-script access to session storage +// compat.storage.session.setAccessLevel(compat.storage.TRUSTED_AND_UNTRUSTED_CONTEXTS); +/* function updateActionButton(event) { switch (event.target.state.state) { case State.TRANSLATION_AVAILABLE: @@ -327,124 +57,91 @@ function updateMenuItems({data, target: {state}}) { && state.models?.some(({from, to}) => from === state.to && to === state.from) }); } +*/ -// Supported translation providers /** - * @type{[name:String]:Promise>} + * Popup port per tab + *@type {Map} */ -const providers = {}; - -// WASM (shipped) wither in this thread or in an offscreen page -if (globalThis?.Worker) { - providers['wasm'] = async () => (await import('./WASMTranslationHelper.js')).default; -} else if (chrome?.offscreen) { - providers['wasm'] = async () => (await import('./WASMOffscreenTranslationHelper.js')).default; -} +const popups = new Map(); -// Locally installed -if (compat.runtime.connectNative) { - providers['translatelocally'] = async () => (await import('./TLTranslationHelper.js')).default; -} - -// State per tab -const tabs = new Map(); +/** + * Session storage per tab. Used for state. + *@type {Map} + */ +const session = new DefaultMap((tabId) => { + return new StorageArea('session', `tab:${tabId}`); +}); -function getTab(tabId) { - if (!tabs.has(tabId)) { - const tab = new Tab(tabId); - tabs.set(tabId, tab); - - // Update action button - tab.addEventListener('update', updateActionButton); - - // Update context menu items for this tab - tab.addEventListener('update', updateMenuItems) - } +/** + * Runtime storage per tab. Used for progress. + *@type {Map} + */ +const local = new DefaultMap((tabId) => { + return new StorageArea(); +}); - return tabs.get(tabId); -} +/** + * Supported translation providers + * @type{[name:String]:Promise>} + */ +const providers = { + // Chrome-compatible implementation which runs the Worker inside an offscreen page + ...(chrome?.offscreen ? {wasm: async () => (await import('./WASMOffscreenTranslationHelper.js')).default} : {}), + // Qt application running in headless mode on the user's machine + ...(compat.runtime.connectNative ? {translatelocally: async () => (await import('./TLTranslationHelper.js')).default} : {}), + // Normal implementation: uses Worker directly + ...(globalThis?.Worker ? {wasm: async () => (await import('./WASMTranslationHelper.js')).default} : {}), +}; -// Instantiation of a TranslationHelper. Access it through .get(). -let provider = new class { - /** - * @type {Promise} - */ - #provider; +// Instantiation of a TranslationHelper. Access it as if it is a promise. +const provider = lazy(async (self) => { + let {provider:preferred} = await preferences.get({provider: 'wasm'}) - constructor() { - // Reset provider instance if the selected provider is changed by the user. - preferences.listen('provider', this.reset.bind(this)); + if (!(preferred in providers)) { + console.info(`Provider ${preferred} not in list of supported translation providers. Falling back to 'wasm'`); + preferred = 'wasm'; + preferences.set({provider: preferred}, { + silent: true // Don't trigger the `provider.reset()` down below + }); } + + let {options} = await preferences.get({options: { + workers: 1, // be kind to the user's pc + cacheSize: 20000, // remember website boilerplate + useNativeIntGemm: true // faster is better (unless it is buggy: https://github.com/browsermt/marian-dev/issues/81) + }}); - /** - * Get (and if necessary instantiates) a translation helper. - * @returns {Promise} - */ - get() { - if (this.#provider) - return this.#provider; - - return this.#provider = new Promise(async (accept) => { - let preferred = await preferences.get('provider', 'wasm') - - if (!(preferred in providers)) { - console.info(`Provider ${preferred} not in list of supported translation providers. Falling back to 'wasm'`); - preferred = 'wasm'; - preferences.set('provider', preferred, {silent: true}); - } - - let options = await preferences.get('options', { - workers: 1, // be kind to the user's pc - cacheSize: 20000, // remember website boilerplate - useNativeIntGemm: true // faster is better (unless it is buggy: https://github.com/browsermt/marian-dev/issues/81) - }); - - const implementation = await providers[preferred](); - - const provider = new implementation(options); - - provider.onerror = err => { - console.error('Translation provider error:', err); + const implementation = await providers[preferred](); - tabs.forEach(tab => tab.update(() => ({ - state: State.PAGE_ERROR, - error: `Translation provider error: ${err.message}`, - }))); + const provider = new implementation(options); - // Try falling back to WASM is the current provider doesn't work - // out. Might lose some translations the process but - // InPageTranslation should be able to deal with that. - if (preferred !== 'wasm') { - console.info(`Provider ${preferred} encountered irrecoverable errors. Falling back to 'wasm'`); - preferences.delete('provider', preferred); - this.reset(); - } - }; + provider.onerror = err => { + console.error('Translation provider error:', err); - accept(provider); + compat.runtime.sendMessage({ + command: 'Error', + data: err }); - } - /** - * Useful to get access to the provider but only if it was instantiated. - * @returns {Promise|Null} - */ - has() { - return this.#provider - } + // Try falling back to WASM is the current provider doesn't work + // out. Might lose some translations the process but + // InPageTranslation should be able to deal with that. + if (preferred !== 'wasm') { + console.info(`Provider ${preferred} encountered irrecoverable errors. Falling back to 'wasm'`); + preferences.delete('provider'); + self.reset(); + } + }; - /** - * Releases the current translation provider. - */ - reset() { - // TODO: Why are we doing this again? - tabs.forEach(tab => tab.reset(tab.state.url)); + self.onReset(() => provider.delete()); - this.has()?.then(provider => provider.delete()); + return provider; +}); - this.#provider = null; - } -}; +// When the provider preference is changed in the options page, reload the +// translation engine. +preferences.listen(['provider'], () => provider.reset()); const recorder = new Recorder(); @@ -453,271 +150,134 @@ const recorder = new Recorder(); * mechanism of the tab. This allows the content-script to make UpdateRequest * calls to update the state, and receive state updates through Update messages. */ -function connectTab(tab, port) { - const updateListener = (event) => { - port.postMessage({ - command: 'Update', - data: event.data - }); - }; - - // Listen for state updates locally - tab.addEventListener('update', updateListener); - - // If the port disconnect, stop listening - port.onDisconnect.addListener(event => { - tab.removeEventListener('update', updateListener); - }); - - // Allow the port to update the tab state with update requests - port.onMessage.addListener(({command, data}) => { - if (command === 'UpdateRequest') { - tab.update(state => data); - } - }); - - // Send an initial update to the port - port.postMessage({ - command: 'Update', - data: tab.state - }); -} - function connectContentScript(contentScript) { - const tab = getTab(contentScript.sender.tab.id); - - // Register this content script with the tab - tab.frames.set(contentScript.sender.frameId, contentScript); + let abortSignal = {aborted: false}; - let _abortSignal = {aborted: false}; const abort = () => { // Use the signal we stored for this tab to signal all pending // translation promises to not resolve. - _abortSignal.aborted = true; + abortSignal.aborted = true; // Also prune any pending translation requests that have this same // signal from the queue. No need to put any work into it. - provider.has()?.then(provider => { - if (provider) + if (provider.instantiated) + provider.then(provider => { provider.remove((request) => request._abortSignal.aborted); - }) + }); // Create a new signal in case we want to start translating again. - _abortSignal = {aborted: false}; + abortSignal = {aborted: false}; }; - // Make the content-script receive state updates. Also sends the initial - // state update. - connectTab(tab, contentScript); + const tabId = contentScript.sender.tab.id; - // If the content-script stops (i.e. user navigates away) - contentScript.onDisconnect.addListener(event => { - // Disconnect it from this tab - tab.frames.delete(contentScript.sender.frameId); - - // Abort all in-progress translations that belonged to this page - abort(); - }); - - // Automatically start translating preferred domains. - tab.addEventListener('update', async ({target, data: {state}}) => { - if (state === State.TRANSLATION_AVAILABLE) { - const domains = await preferences.get('alwaysTranslateDomains', []); - if (target.state.from && target.state.to && target.state.url - && domains.includes(new URL(target.state.url).host)) - tab.translate(); - } + const handler = new MessageHandler(callback => { + contentScript.onMessage.addListener(callback) }); - // Respond to certain messages from the content script. Mainly individual - // translation requests, and detect language requests which then change the - // state of the tab to reflect whether translations are available or not. - contentScript.onMessage.addListener(async (message) => { - switch (message.command) { - // Send by the content-scripts inside this tab - case "DetectLanguage": - // TODO: When we support multiple frames inside a tab, we - // should integrate the results from each frame somehow. - // For now we ignore it, because 90% of the time it will be - // an ad that's in English and mess up our estimate. - if (contentScript.sender.frameId !== 0) - return; - - try { - const preferred = await preferences.get('preferredLanguageForPage') - - const summary = await detectLanguage(message.data, await provider.get(), {preferred}) - - tab.update(state => ({ - from: state.from || summary.from, // default to keeping chosen from/to languages - to: state.to || summary.to, - models: summary.models, - state: summary.models.length > 0 // TODO this is always true (?) - ? State.TRANSLATION_AVAILABLE - : State.TRANSLATION_NOT_AVAILABLE - })); - } catch (error) { - tab.update(state => ({ - state: State.PAGE_ERROR, - error - })); - } - break; - - // Send by the content-scripts inside this tab - case "TranslateRequest": - tab.update(state => ({ + // If the content-script stops (i.e. user navigates away) + contentScript.onDisconnect.addListener(() => abort()); + + handler.on("TranslateRequest", async (data) => { + local.get(tabId).get({ + pendingTranslationRequests: 0, + totalTranslationRequests: 0, + }).then((state) => { + local.get(tabId).set({ pendingTranslationRequests: state.pendingTranslationRequests + 1, totalTranslationRequests: state.totalTranslationRequests + 1 - })); - - // If we're recording requests from this tab, add the translation - // request. Also disabled when developer setting is false since - // then there are no controls to turn it on/off. - preferences.get('developer').then(developer => { - if (developer && tab.state.record) { - recorder.record(message.data); - tab.update(state => ({ - recordedPagesCount: recorder.size - })); - } }); + }); - try { - const translator = await provider.get(); - const response = await translator.translate({...message.data, _abortSignal}); - if (!response.request._abortSignal.aborted) { - contentScript.postMessage({ - command: "TranslateResponse", - data: response - }); - } - } catch(e) { - // Catch error messages caused by abort() - if (e?.message === 'removed by filter' && e?.request?._abortSignal?.aborted) - return; - - // Tell the requester that their request failed. - contentScript.postMessage({ - command: "TranslateResponse", - data: { - request: message.data, - error: e.message - } - }); - - // TODO: Do we want the popup to shout on every error? - // Because this can also be triggered by failing Outbound - // Translation! - tab.update(state => ({ - state: State.TRANSLATION_ERROR, - error: e.message - })); - } finally { - tab.update(state => ({ - // TODO what if we just navigated away and all the - // cancelled translations from the previous page come - // in and decrement the pending count of the current - // page? - pendingTranslationRequests: state.pendingTranslationRequests - 1 - })); - } - break; + // If we're recording requests from this tab, add the translation + // request. Also disabled when developer setting is false since + // then there are no controls to turn it on/off. + Promise.all([ + preferences.get({developer:false}), + session.get(tabId).get({record: false}) + ]).then(([{developer}, {record}]) => { + if (developer && record) + recorder.record(data); + }); - // Send by this script's Tab.abort() but handled per content-script - // since each content-script handler (connectContentScript) has the - // ability to abort all of the content-script's translation - // requests. Same code is called when content-script disconnects. - case "TranslateAbort": - abort(); - break; + try { + const translator = await provider + const response = await translator.translate({...data, abortSignal}); + if (!response.request.abortSignal.aborted) { + contentScript.postMessage({ + command: "TranslateResponse", + data: response + }); + } + } catch(e) { + // Catch error messages caused by abort() + if (e?.message === 'removed by filter' && e?.request?.abortSignal?.aborted) + return; + + console.error('Error during translation', e); + + // Tell the requester that their request failed. + contentScript.postMessage({ + command: "TranslateResponse", + data: { + request: data, + error: e.message + } + }); + + // TODO: Do we want the popup to shout on every error? + // Because this can also be triggered by failing Outbound + // Translation! + compat.runtime.sendMessage({ + command: 'Error', + data: e + }); + } finally { + local.get(tabId).get({ + pendingTranslationRequests: 0 + }).then((state) => { + local.get(tabId).set({ + // TODO what if we just navigated away and all the + // cancelled translations from the previous page come + // in and decrement the pending count of the current + // page? + pendingTranslationRequests: state.pendingTranslationRequests - 1 + }); + }); } }); } -function connectPopup(popup) { - const tabId = parseInt(popup.name.substr('popup-'.length)); - - const tab = getTab(tabId); - - // Make the popup receive state updates - connectTab(tab, popup); - - popup.onMessage.addListener(async message => { - switch (message.command) { - case "DownloadModels": - // Tell the tab we're downloading models - tab.update(state => ({ - state: State.DOWNLOADING_MODELS - })); - - const translator = await provider.get(); - - // Start the downloads and put them in a {[download:promise]: {read:int,size:int}} - const downloads = new Map(message.data.models.map(model => [translator.downloadModel(model), {read:0.0, size:0.0}])); - - // For each download promise, add a progress listener that updates the tab state - // with how far all our downloads have progressed so far. - downloads.forEach((_, promise) => { - // (not supported by the Chrome offscreen proxy implementation right now) - if (promise.addProgressListener) { - promise.addProgressListener(({read, size}) => { - // Update download we got a notification about - downloads.set(promise, {read, size}); - // Update tab state about all downloads combined (i.e. model, optionally pivot) - tab.update(state => ({ - modelDownloadRead: Array.from(downloads.values()).reduce((sum, {read}) => sum + read, 0), - modelDownloadSize: Array.from(downloads.values()).reduce((sum, {size}) => sum + size, 0) - })); - }); - } - - promise.then(() => { - // Trigger update of state.models because the `local` - // property this model has changed. We don't support - // any nested key updates so let's just push the whole - // damn thing. - tab.update(state => ({ - models: state.models - })); - }) - }); +async function connectPopup(port) { + const tabId = parseInt(port.name.slice('popup-'.length)); - // Finally, when all downloads have finished, start translating the page. - try { - await Promise.all(downloads.keys()); - tab.translate(); - } catch (e) { - tab.update(state => ({ - state: State.TRANSLATION_ERROR, - error: e.toString() - })); - } - break; - case "TranslateStart": - tab.translate(); - break; - - case 'TranslateAbort': - tab.abort(); - break; + popups.set(tabId, port); - case 'ExportRecordedPages': - popup.postMessage({ - command: 'DownloadRecordedPages', - data: { - name: 'recorded-pages.xml', - url: URL.createObjectURL(recorder.exportAXML()) - } - }); - recorder.clear(); - tab.update(state => ({recordedPagesCount: 0})); - break; - } + port.onDisconnect.addListener(() => popups.delete(tabId)); + + provider.then(async (translator) => { + port.postMessage({ + command: 'Models', + data: await translator.registry + }) + }); + + local.get(tabId).get().then(data => { + port.postMessage({ + command: 'Progress', + data + }); }); } -// Receive incoming connection requests from content-script and popup +// Receive incoming connection requests from content-script and popup. +// The content script connection is used only for translation. If the +// connection is dropped (page unload, tab closed, etc) then that is used +// as a signal to cancel those translations. +// The popup connection is only used for state updates, such as model +// downloads and translation state. Since these are tab-specific but very +// frequent sending them over the connection instead of compat.runtime should +// keep some event loops a little less busy. compat.runtime.onConnect.addListener((port) => { if (port.name == 'content-script') connectContentScript(port); @@ -725,48 +285,208 @@ compat.runtime.onConnect.addListener((port) => { connectPopup(port); }); -// Initialize or update the state of a tab when navigating -compat.tabs.onUpdated.addListener((tabId, diff) => { - if (diff.url) - getTab(tabId).reset(diff.url); - // Todo: treat reload and link different? Reload -> disable translation? -}); - // When a new tab is created start, track its active state -compat.tabs.onCreated.addListener(({id: tabId, active, openerTabId}) => { +compat.tabs.onCreated.addListener(async ({id: tabId, openerTabId}) => { let inheritedState = {}; // If the tab was opened from another tab that was already translating, // this tab will inherit that state and also automatically continue // translating. if (openerTabId) { - const {state, url, from, to, models} = getTab(openerTabId).state; - inheritedState = {state, url, from, to, models}; + inheritedState = await session.get(openerTabId).get({ + translate: false, + url: undefined, + from: undefined, + to: undefined, + }); } - getTab(tabId).update(() => ({...inheritedState, active})); + console.log('Setting', tabId, inheritedState); + + session.get(tabId).set(inheritedState); }); -// Remove the tab state if a tab is removed -compat.tabs.onRemoved.addListener(({tabId}) => { - tabs.delete(tabId); +// Initialize or update the state of a tab when navigating +compat.tabs.onUpdated.addListener(async (tabId, diff, tab) => { + if (diff.url) { + const state = await session.get(tabId).get({ + translate: false, + url: undefined + }); + + // If we changed domain, reset from, to and domain. + if (!isSameDomain(diff.url, state.url)) { + console.log('different domain', tabId, diff.url, 'was', state.url); + Object.assign(state, { + translate: await isTranslatedDomain(diff.url), + from: undefined, + to: undefined + }); + } + + session.get(tabId).set({ + ...state, + url: diff.url + }); + } + + if (diff.status && diff.status === 'complete') { + const {translate, from, to} = await session.get(tabId).get({ + translate: false, + from: undefined, + to: undefined + }); + + console.log('tabState status=complete', translate, from, to); + + if (translate && from && to) { + compat.tabs.sendMessage(tabId, { + command: 'TranslatePage', + data: {from, to} + }); + } + } + + // Todo: treat reload and link different? Reload -> disable translation? +}); + +const handler = new MessageHandler(callback => { + compat.runtime.onMessage.addListener(callback); }); -// Let each tab know whether its the active one. We use this state change -// event to keep the menu items in sync. -compat.tabs.onActivated.addListener(({tabId}) => { - for (let [id, tab] of tabs) { - // If the tab's active state doesn't match the activated tab, fix that. - if (tab.active != (tab.id === tabId)) - tab.update(() => ({active: Boolean(tab.id === tabId)})); +// Sent from content script once it has enough content to detect the language +handler.on('DetectLanguage', async (data, sender) => { + // TODO: When we support multiple frames inside a tab, we + // should integrate the results from each frame somehow. + // For now we ignore it, because 90% of the time it will be + // an ad that's in English and mess up our estimate. + if (sender.frameId !== 0) + return; + + try { + const {preferredLanguageForPage:preferred} = await preferences.get({preferredLanguageForPage:undefined}) + const {from, to, models} = await detectLanguage(data, (await provider).registry, {preferred}) + session.get(sender.tab.id).set({from, to, models}); + + const {translate} = await session.get(sender.tab.id).get({translate: false}); + + console.log('detectLanguage', translate, from, to); + + if (translate) + compat.tabs.sendMessage(sender.tab.id, { + command: 'TranslatePage', + data: {from, to} + }); + + } catch (error) { + console.error('Error during language detection', error); + compat.runtime.sendMessage({ + command: 'Error', + data: error + }); } }); -// On start-up init all (important) tabs we missed onCreated for -compat.tabs.query({active:true}).then(allTabs => { - for (const tab of allTabs) - getTab(tab.id).reset(tab.url); -}) +// Sent from the popup when the download button is clicked. +handler.on("DownloadModels", async ({tabId, from, to, models}) => { + // Tell the tab we're downloading models + /* + tab.update(state => ({ + state: State.DOWNLOADING_MODELS + })); + */ + + const translator = await provider; + + // Start the downloads and put them in a {[download:promise]: {read:int,size:int}} + const downloads = new Map(models.map(model => [translator.downloadModel(model), {read:0.0, size:0.0}])); + + // For each download promise, add a progress listener that updates the tab state + // with how far all our downloads have progressed so far. + downloads.forEach((_, promise) => { + // (not supported by the Chrome offscreen proxy implementation right now) + if (promise.addProgressListener) { + promise.addProgressListener(async ({read, size}) => { + // Update download we got a notification about + downloads.set(promise, {read, size}); + + // Update tab state about all downloads combined (i.e. model, optionally pivot) + const data = await local.get(tabId).set({ + modelDownloadRead: Array.from(downloads.values()).reduce((sum, {read}) => sum + read, 0), + modelDownloadSize: Array.from(downloads.values()).reduce((sum, {size}) => sum + size, 0) + }) + + // Tell the popup (if there is one) about the progress :D + popups.get(tabId)?.postMessage({ + command: 'Progress', + data + }); + }); + } + + promise.then(async () => { + // Trigger update of state.models because the `local` + // property this model has changed. We don't support + // any nested key updates so let's just push the whole + // damn thing. + compat.runtime.sendMessage('Models', await translator.registry); + }) + }); + + // Finally, when all downloads have finished, start translating the page. + try { + await Promise.all(downloads.keys()); + session.get(tabId).set({ + translate: true, + from, + to + }); + compat.tabs.sendMessage(tabId, { + command: 'TranslatePage', + data: {from, to} + }); + } catch (e) { + compat.runtime.sendMessage({ + command: 'Error', + data: e + }); + } +}); + +// Sent from Popup when translate button is pressed +handler.on("TranslateStart", ({tabId, from, to}) => { + session.get(tabId).set({ + translate: true, + from, + to + }); + + compat.tabs.sendMessage(tabId, { + command: 'TranslatePage', + data: {from, to} + }); +}); + +// Sent from Popup if "restore original page" button is pressed +handler.on('TranslateAbort', ({tabId}) => { + session.get(tabId).set({translate: false}); + + compat.tabs.sendMessage(tabId, { + command: 'RestorePage', + data: {} + }); +}); + +// Sent from popup when recorded pages download link is clicked +handler.on('ExportRecordedPages', ({}, sender, respond) => { + respond({ + name: 'recorded-pages.xml', + url: URL.createObjectURL(recorder.exportAXML()) + }); + recorder.clear(); + updateTab(state => ({recordedPagesCount: 0})); + return true; +}); compat.runtime.onInstalled.addListener(() => { // Add "translate selection" menu item to selections @@ -784,36 +504,35 @@ compat.runtime.onInstalled.addListener(() => { }); }); -chrome.contextMenus.onClicked.addListener((info, tab) => { +compat.contextMenus.onClicked.addListener(async ({menuItemId, frameId}, tab) => { // First sanity check whether we know from and to languages // (and it isn't the same by accident) - const {from, to} = getTab(tab.id).state; - if (from === undefined || to === undefined || from === to) { - compat.action.openPopup(); - return; - } + const {from, to} = session.get(tab.id).get({from, to}); // Send the appropriate message down to the content script of the // tab we just clicked inside of. - switch (info.menuItemId) { + switch (menuItemId) { case 'translate-selection': - getTab(tab.id).frames.get(info.frameId).postMessage({ - command: 'TranslateSelection' - }); + if (from === undefined || to === undefined || from === to) { + compat.action.openPopup(); + break; + } + + compat.tabs.sendMessage(tab.id, { + command: 'TranslateSelection', + data: {from, to}, + }, {frameId}); break; case 'show-outbound-translation': - getTab(tab.id).frames.get(info.frameId).postMessage({ - command: 'ShowOutboundTranslation' - }); + const translator = await provider; + compat.tabs.sendMessage(tab.id, { + command: 'ShowOutboundTranslation', + data: { + from, + to, + models: await translator.registry + }, + }, {frameId}); break; } }) - -// Makes debugging easier -Object.assign(self, { - tabs, - providers, - provider, - preferences, - test -}) diff --git a/src/content/content-script.js b/src/content/content-script.js index 7476d0d0..ed574245 100644 --- a/src/content/content-script.js +++ b/src/content/content-script.js @@ -1,102 +1,91 @@ import compat from '../shared/compat.js'; +import { MessageHandler } from '../shared/common.js'; import LanguageDetection from './LanguageDetection.js'; import InPageTranslation from './InPageTranslation.js'; import SelectionTranslation from './SelectionTranslation.js'; import OutboundTranslation from './OutboundTranslation.js'; import { LatencyOptimisedTranslator } from '@browsermt/bergamot-translator'; import preferences from '../shared/preferences.js'; +import { lazy } from '../shared/func.js'; const listeners = new Map(); -const state = { - state: 'page-loaded' -}; - // Loading indicator for html element translation preferences.bind('progressIndicator', progressIndicator => { document.body.setAttribute('x-bergamot-indicator', progressIndicator); }, {default: ''}) -function on(command, callback) { - if (!listeners.has(command)) - listeners.set(command, []); +preferences.bind('debug', debug => { + if (debug) + document.querySelector('html').setAttribute('x-bergamot-debug', true); + else + document.querySelector('html').removeAttribute('x-bergamot-debug'); +}, {default: false}); + +const sessionID = new Date().getTime(); + +async function detectPageLanguage() { + // request the language detection class to extract a page's snippet + const languageDetection = new LanguageDetection(); + const sample = await languageDetection.extractPageContent(); + const suggested = languageDetection.extractSuggestedLanguages(); - listeners.get(command).push(callback); + // Once we have the snippet, send it to background script for analysis + // and possibly further action (like showing the popup) + compat.runtime.sendMessage({ + command: "DetectLanguage", + data: { + url: document.location.href, + sample, + suggested + } + }); } -on('Update', diff => { - Object.assign(state, diff); - // document.body.dataset.xBergamotState = JSON.stringify(state); -}); +// Changed by translation start requests. +const state = { + from: null, + to: null +}; + +// background-script connection is only used for translation +let connection = lazy(async (self) => { + const port = compat.runtime.connect({name: 'content-script'}); + + // Reset lazy connection instance if port gets disconnected + port.onDisconnect.addListener(() => self.reset()); + + // Likewise, if the connection is reset from outside, disconnect port. + self.onReset(() => port.disconnect()); -on('Update', diff => { - if ('state' in diff) { - switch (diff.state) { - // Not sure why we have the page-loading event here, like, as soon - // as frame 0 connects we know we're in page-loaded territory. - case 'page-loading': - postBackgroundScriptMessage({ - command: 'UpdateRequest', - data: {state: 'page-loaded'} - }); + const handler = new MessageHandler(callback => { + port.onMessage.addListener(callback); + }) + + handler.on('TranslateResponse', data => { + switch (data.request.user?.source) { + case 'InPageTranslation': + inPageTranslation.enqueueTranslationResponse(data); break; - - case 'translation-in-progress': - inPageTranslation.addElement(document.querySelector("head > title")); - inPageTranslation.addElement(document.body); - inPageTranslation.start(state.from); + case 'SelectionTranslation': + selectionTranslation.enqueueTranslationResponse(data); break; - - default: - inPageTranslation.restore(); + case 'OutboundTranslation': + outboundTranslationWorker.enqueueTranslationResponse(data); break; } - } -}); - -on('Update', async diff => { - if ('state' in diff && diff.state === 'page-loaded') { - // request the language detection class to extract a page's snippet - const languageDetection = new LanguageDetection(); - const sample = await languageDetection.extractPageContent(); - const suggested = languageDetection.extractSuggestedLanguages(); - - // Once we have the snippet, send it to background script for analysis - // and possibly further action (like showing the popup) - postBackgroundScriptMessage({ - command: "DetectLanguage", - data: { - url: document.location.href, - sample, - suggested - } - }); - } -}); + }); -on('Update', diff => { - if ('debug' in diff) { - if (diff.debug) - document.querySelector('html').setAttribute('x-bergamot-debug', JSON.stringify(state)); - else - document.querySelector('html').removeAttribute('x-bergamot-debug'); - } + return port; }); -const sessionID = new Date().getTime(); - -// Used to track the last text selection translation request, so we don't show -// the response to an old request by accident. -let selectionTranslationId = null; - -function translate(text, user) { - console.assert(state.from !== undefined && state.to !== undefined, "state.from or state.to is not set"); - postBackgroundScriptMessage({ +async function translate(text, user) { + (await connection).postMessage({ command: "TranslateRequest", data: { // translation request - from: state.from, - to: state.to, + from: user.from || state.from, + to: user.to || state.to, html: user.html, text, @@ -185,33 +174,14 @@ class BackgroundScriptWorkerProxy { throw new TypeError('Only batches of 1 are expected'); return new Promise((accept, reject) => { - const request = { - // translation request - from: models[0].from, - to: models[0].to, - html: texts[0].html, - text: texts[0].text, - - // data useful for the response - user: { - id: ++this.#serial, - source: 'OutboundTranslation' - }, - - // data useful for the scheduling - priority: 3, - - // data useful for recording - session: { - id: sessionID, - url: document.location.href - } - }; - this.#pending.set(request.user.id, {request, accept, reject}); - postBackgroundScriptMessage({ - command: "TranslateRequest", - data: request + translate(texts[0].text, { + id: ++this.#serial, + source: 'OutboundTranslation', + from: texts[0].from, + to: texts[0].to, + html: texts[0].html, + priority: 3 }); }) } @@ -270,116 +240,71 @@ const outboundTranslation = new OutboundTranslation(new class { } }()); -// This one is mainly for the TRANSLATION_AVAILABLE event -on('Update', async (diff) => { - if ('from' in diff) - outboundTranslation.setPageLanguage(diff.from); +const handler = new MessageHandler(callback => { + compat.runtime.onMessage.addListener(callback); +}) - const preferredLanguage = await preferences.get('preferredLanguageForOutboundTranslation'); +handler.on('TranslatePage', ({from,to}) => { + // Save for the translate() function + Object.assign(state, {from,to}); - if ('to' in diff) - outboundTranslation.setUserLanguage(preferredLanguage || diff.to); + inPageTranslation.addElement(document.querySelector("head > title")); + inPageTranslation.addElement(document.body); + inPageTranslation.start(from); +}) - if ('from' in diff || 'models' in diff) { - outboundTranslation.setUserLanguageOptions(state.models.reduce((options, entry) => { - // `state` has already been updated at this point as well and we know - // that is complete. `diff` might not contain all the keys we need. - if (entry.to === state.from && !options.has(entry.from)) - options.add(entry.from) - return options - }, new Set())); - } -}); - -on('TranslateResponse', data => { - switch (data.request.user?.source) { - case 'InPageTranslation': - inPageTranslation.enqueueTranslationResponse(data); - break; - case 'SelectionTranslation': - selectionTranslation.enqueueTranslationResponse(data); - break; - case 'OutboundTranslation': - outboundTranslationWorker.enqueueTranslationResponse(data); - break; - } -}); - -// Timeout of retrying connectToBackgroundScript() -let retryTimeout = 100; - -let backgroundScript; - -function postBackgroundScriptMessage(message) { - if (!backgroundScript) - connectToBackgroundScript(); - - return backgroundScript.postMessage(message); -} - -function connectToBackgroundScript() { - // If we're already connected (e.g. when this function was called directly - // but then also through 'pageshow' event caused by 'onload') ignore it. - if (backgroundScript) - return; - - // Connect to our background script, telling it we're the content-script. - backgroundScript = compat.runtime.connect({name: 'content-script'}); - - // Connect all message listeners (the "on()" calls above) - backgroundScript.onMessage.addListener(({command, data}) => { - if (listeners.has(command)) - listeners.get(command).forEach(callback => callback(data)); - - // (We're connected, reset the timeout) - retryTimeout = 100; - }); - - // When the background script disconnects, also pause in-page translation - backgroundScript.onDisconnect.addListener(() => { - inPageTranslation.stop(); +handler.on('RestorePage', () => { + inPageTranslation.restore(); +}) - // If we cannot connect because the backgroundScript is not (yet?) - // available, try again in a bit. - if (backgroundScript.error && backgroundScript.error.toString().includes('Receiving end does not exist')) { - // Exponential back-off sounds like a safe thing, right? - retryTimeout *= 2; - - // Fallback fallback: if we keep retrying, stop. We're just wasting CPU at this point. - if (retryTimeout < 5000) - setTimeout(connectToBackgroundScript, retryTimeout); - } - - // Mark as disconnected - backgroundScript = null; - }); -} - -connectToBackgroundScript(); +detectPageLanguage(); // When this page shows up (either through onload or through history navigation) -window.addEventListener('pageshow', connectToBackgroundScript); +window.addEventListener('pageshow', () => { + // TODO: inPageTranslation.resume()??? +}); // When this page disappears (either onunload, or through history navigation) window.addEventListener('pagehide', e => { - if (backgroundScript) { - backgroundScript.disconnect(); - backgroundScript = null; - } + // Ditch the inPageTranslation state for pending translation requests. + inPageTranslation.stop(); + + // Disconnect from the background page, which will trigger it to prune + // our outstanding translation requests. + connection.reset(); }); let lastClickedElement = null; window.addEventListener('contextmenu', e => { + Object.assign(state, {from, to}); // TODO: HACK! lastClickedElement = e.target; }, {capture: true}); -on('TranslateSelection', () => { +handler.on('TranslateSelection', ({from, to}) => { + Object.assign(state, {from, to}); // TODO: HACK! const selection = document.getSelection(); selectionTranslation.start(selection); }); -on('ShowOutboundTranslation', () => { +handler.on('ShowOutboundTranslation', async ({from, to, models}) => { + if (from) + outboundTranslation.setPageLanguage(from); + + const {preferredLanguageForOutboundTranslation} = await preferences.get({preferredLanguageForOutboundTranslation:undefined}); + if (to) + outboundTranslation.setUserLanguage(preferredLanguageForOutboundTranslation || to); + + if (from || models) { + outboundTranslation.setUserLanguageOptions(models.reduce((options, entry) => { + // `state` has already been updated at this point as well and we know + // that is complete. `diff` might not contain all the keys we need. + if (entry.to === from && !options.has(entry.from)) + options.add(entry.from) + return options + }, new Set())); + } + outboundTranslation.target = lastClickedElement; outboundTranslation.start(); }); diff --git a/src/popup/popup.html b/src/popup/popup.html index f7b22363..be10009a 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -67,48 +67,44 @@ - + Wanna translate this page? Download language & Translate page - Translate page + Translate page - + Downloading language model… - + Translating page from to … Stop translating - + Translated page from to . Show original - + Error during translation: Stop translating - + Error: - + Translations not available for this page. - + Downloading list of available language models… - - - Translations not available for this page. - - + Always translate diff --git a/src/popup/popup.js b/src/popup/popup.js index 0834a92b..5eb326b5 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -2,193 +2,216 @@ import compat from '../shared/compat.js'; import { addEventListeners, addBoundElementListeners, + download, BoundElementRenderer, + Timer, + MessageHandler, } from '../shared/common.js'; import preferences from '../shared/preferences.js'; +import { StorageArea } from '../shared/storage.js'; const regionNamesInEnglish = new Intl.DisplayNames([...navigator.languages, 'en'], {type: 'language'}); -// Tab state -const tabState = { - state: undefined +function name(code) { + if (!code) + return undefined; + + try { + return regionNamesInEnglish.of(code); + } catch (RangeError) { + return `[${code}]`; // fallback if code is not known or invalid + } }; -// Plugin state (a synchronised view of what's currently in storage) -const globalState = preferences.view({ - 'developer': false, - 'alwaysTranslateDomains': [], -}) +const boundRenderer = new BoundElementRenderer(document.body); -let lastRenderedState = undefined; +async function main(tab) { + const connection = compat.runtime.connect({name: `popup-${tab.id}`}); -let renderTimeout = new class { - constructor() { - this.timeout = null; - } + const handler = new MessageHandler((callback) => { + // Listen on our connection for local updates such as progress + connection.onMessage.addListener(callback); - /** - * calls callback delayed. If there's already a delayed callback scheduled, - * the callback will be set to the new one, but the timeout will just continue - * and not reset from the beginning of `delay`. - */ - delayed(callback, delay) { - if (this.timeout === null) - this.timeout = setTimeout(this.immediate.bind(this), delay); - this.callback = callback; - } + // Listen on the runtime for global updates, such as the model list + // and errors. + compat.runtime.onMessage.addListener(callback); + }); + + const session = new StorageArea('session', `tab:${tab.id}`); + + const local = new StorageArea(); + + // Browsing session state (i.e. since browser start) + const tabState = session.view({ + translate: false, + from: undefined, + to: undefined, + models: undefined, + record: false, + error: undefined, + }); + + // Plugin state (a synchronised view of what's currently in storage) + const globalState = preferences.view({ + developer: false, + alwaysTranslateDomains: [], + }) + + // Progress state (i.e. since last unload of background script) + const localState = local.view({ + modelDownloadRead: 0, + modelDownloadSize: 0, + + totalTranslationRequests: 0, + pendingTranslationRequests: 0, + }); /** - * Call callback now, and clear any previously scheduled callback. + * @type {Map { + local.set(data); + }); -function render() { - // If the model (or one of the models in case of pivoting) needs - // downloading. This info is not always entirely up-to-date since `local` - // is a getter when queried from WASMTranslationHelper, but that doesn't - // survive the message passing we use to get state. - const needsDownload = tabState.models?.find(model => tabState.from === model.from && tabState.to === model.to)?.models?.some(({model}) => !model.local); - - const name = (code) => { - if (!code) - return undefined; - - try { - return regionNamesInEnglish.of(code); - } catch (RangeError) { - return `[${code}]`; // fallback if code is not known or invalid + // Sent as a response to connecting, but also when after downloading + // the downloaded state of models change. + handler.on('Models', (data) => { + models.clear(); + data.forEach(entry => models.set(entry.model.id, entry)); + }); + + let lastRenderedState = undefined; + + let renderTimeout = new Timer(); + + function render() { + console.log('Render popup', tabState, models); + // If the model (or one of the models in case of pivoting) needs + // downloading. This info is not always entirely up-to-date since `local` + // is a getter when queried from WASMTranslationHelper, but that doesn't + // survive the message passing we use to get state. + const modelsToDownload = models.size === 0 ? [] : tabState.models[0] + ?.models + ?.filter(id => !models.get(id).local) + ?.map(id => models.get(id).model.name); + + const renderState = { + ...globalState, + ...localState, + ...tabState, + langFromName: name(tabState.from), + langToName: name(tabState.to), + langFromOptions: new Map(tabState.models.map(({from}) => [from, name(from)])), + langToOptions: new Map(tabState.models.filter(model => tabState.from === model.from).map(({to, pivot}) => [to, name(to) + (pivot ? ` (via ${name(pivot)})` : '')])), // TODO doesn't this shadow direct models because just `to` is the key? + needsDownload: modelsToDownload.length > 0, + modelsToDownload, + completedTranslationRequests: localState.totalTranslationRequests - localState.pendingTranslationRequests || undefined, + canExportPages: tabState.recordedPagesCount > 0, + domain: tabState.url && new URL(tabState.url).host, + hasModelList: models.size > 0 + }; + + // Little hack because we don't have a translation-completed state in the + // background script, but we do want to render a different popup when there's + // no more translations pending. + // https://github.com/jelmervdl/translatelocally-web-ext/issues/54 + // if (renderState.state === 'translation-in-progress' && renderState.pendingTranslationRequests === 0) + // renderState.state = 'translation-completed'; + + // Callback to do the actual render + const work = () => { + // Remember the currently rendered state (for delay calculation below) + lastRenderedState = renderState.state; + boundRenderer.render(renderState); } - }; - - const renderState = { - ...globalState, - ...tabState, - 'langFromName': name(tabState.from), - 'langToName': name(tabState.to), - 'langFromOptions': new Map(tabState.models?.map(({from}) => [from, name(from)])), - 'langToOptions': new Map(tabState.models?.filter(model => tabState.from === model.from).map(({to, pivot}) => [to, name(to) + (pivot ? ` (via ${name(pivot)})` : '')])), - 'needsDownload': needsDownload, - 'completedTranslationRequests': tabState.totalTranslationRequests - tabState.pendingTranslationRequests || undefined, - 'canExportPages': tabState.recordedPagesCount > 0, - 'domain': tabState.url && new URL(tabState.url).host, - }; - - // Little hack because we don't have a translation-completed state in the - // background script, but we do want to render a different popup when there's - // no more translations pending. - // https://github.com/jelmervdl/translatelocally-web-ext/issues/54 - if (renderState.state === 'translation-in-progress' && renderState.pendingTranslationRequests === 0) - renderState.state = 'translation-completed'; - - // Callback to do the actual render - const render = () => { - // Remember the currently rendered state (for delay calculation below) - lastRenderedState = renderState.state; - boundRenderer.render(renderState); + + // If we switched state, we delay the render a bit because we might be + // flipping between two states e.g. a very brief translating-in-progress + // because a new element popped up, and mostly translation-completed for the + // rest of the time. We don't want that single brief element to make the + // interface flicker between the two states all the time. + if (tabState.state !== lastRenderedState && lastRenderedState !== undefined) + renderTimeout.delayed(work, 250); + else + renderTimeout.immediate(work); } - // If we switched state, we delay the render a bit because we might be - // flipping between two states e.g. a very brief translating-in-progress - // because a new element popped up, and mostly translation-completed for the - // rest of the time. We don't want that single brief element to make the - // interface flicker between the two states all the time. - if (tabState.state !== lastRenderedState && lastRenderedState !== undefined) - renderTimeout.delayed(render, 250); - else - renderTimeout.immediate(render); -} + const areas = [globalState, tabState, localState]; -// re-render if the 'developer' preference changes (but also when the real -// values are fetched from storage!) -globalState.addListener(render); - -function download(url, name) { - const a = document.createElement('a'); - a.href = url; - a.download = name; - a.click(); - a.addEventListener('click', e => { - requestIdleCallback(() => { - URL.revokeObjectURL(url); - }); - }); -} - -// Query which tab we represent and then connect to the tab state in the -// background-script. Connecting will cause us to receive an "Update" message -// with the tab's state (and any future updates to it) -compat.tabs.query({active: true, currentWindow: true}).then(tabs => { - const tabId = tabs[0].id; - - const backgroundScript = compat.runtime.connect({name: `popup-${tabId}`}); - - backgroundScript.onMessage.addListener(({command, data}) => { - switch (command) { - case 'Update': - Object.assign(tabState, data); - render(); - break; - case 'DownloadRecordedPages': - download(data.url, data.name); - break; - } - }); + // re-render if the 'developer' preference changes (but also when the real + // values are fetched from storage!) + areas.forEach(area => area.addListener(render)); addBoundElementListeners(document.body, (key, value) => { - backgroundScript.postMessage({ - command: 'UpdateRequest', - data: {[key]: value} - }); + + console.log('Setting', key, value); + + session.set({[key]: value}); // If the user changes the 'translate to' field, interpret this as a // strong preference to always translate to that language. if (key === 'to') - preferences.set('preferredLanguageForPage', value); + preferences.set({preferredLanguageForPage: value}); }); addEventListeners(document.body, { 'click .translate-btn': e => { - backgroundScript.postMessage({ - command: 'TranslateStart' + compat.runtime.sendMessage({ + command: 'TranslateStart', + data: { + tabId: tab.id, + from: tabState.from, + to: tabState.to + } }); }, 'click .download-btn': e => { - // TODO this assumes tabState.from and tabState.to reflect the current UI, - // which they should iff the UpdateRequest has been processed and - // broadcasted by backgroundScript. - const models = tabState.models + const ids = tabState + .models .find(({from, to}) => from === tabState.from && to === tabState.to) - .models - .map(({model}) => model.id) - .slice(0, 1); + .models; - backgroundScript.postMessage({ + if (!ids) + throw new Error('Selected language pair that has no models'); + + compat.runtime.sendMessage({ command: 'DownloadModels', - data: {models} + data: { + tabId: tab.id, + models: ids + } }); }, 'click .abort-translate-btn': e => { - backgroundScript.postMessage({ - command: 'TranslateAbort' + compat.runtime.sendMessage({ + command: 'TranslateAbort', + data: {tabId: tab.id} }); }, - 'click .export-recorded-pages-btn': e => { - backgroundScript.postMessage({ + 'click .export-recorded-pages-btn': async e => { + const data = await compat.runtime.sendMessage({ command: 'ExportRecordedPages' }); + download(data.url, data.name); }, 'change #always-translate-domain-toggle': e => { const domain = new URL(tabState.url).host; - preferences.set('alwaysTranslateDomains', e.target.checked + preferences.set({alwaysTranslateDomains: e.target.checked ? globalState.alwaysTranslateDomains.concat([domain]) - : globalState.alwaysTranslateDomains.filter(element => element !== domain)); + : globalState.alwaysTranslateDomains.filter(element => element !== domain) + }); } }); -}); +} + +// Start! +compat.tabs.query({active: true, currentWindow: true}).then(tabs => main(tabs[0])); diff --git a/src/shared/common.js b/src/shared/common.js index 3fe6f0c4..257c4a79 100644 --- a/src/shared/common.js +++ b/src/shared/common.js @@ -61,6 +61,8 @@ function stringifyAST(expression) { return `${stringifyAST(expression.callee)}(${expression.arguments.map(arg => stringifyAST(arg)).join(', ')})`; case 'ArrayExpression': return `[${expression.elements.map(element => stringifyAST(element)).join(', ')}]`; + case 'ConditionalExpression': + return `${stringifyAST(expression.test)} ? ${stringifyAST(expression.consequent)} : ${stringifyAST(expression.alternate)}`; case 'Identifier': return `${expression.name}` case 'Literal': @@ -77,8 +79,8 @@ const binaryOperators = { '!=': (left, right) => (state) => left(state) != right(state), '<=': (left, right) => (state) => left(state) <= right(state), '>=': (left, right) => (state) => left(state) >= right(state), - '<': (left, right) => (state) => left(state) < right(state), - '>': (left, right) => (state) => left(state) > right(state), + '<': (left, right) => (state) => left(state) < right(state), + '>': (left, right) => (state) => left(state) > right(state), '&&': (left, right) => (state) => left(state) && right(state), '||': (left, right) => (state) => left(state) || right(state), } @@ -124,6 +126,11 @@ function compileAST(expression, flags={}) { return fun.apply(undefined, args.map(arg => arg(state))); }; } + case 'ConditionalExpression': + const test = compileAST(expression.test); + const consequent = compileAST(expression.consequent); + const alternate = compileAST(expression.alternate); + return (state) => test(state) ? consequent(state) : alternate(state); case 'ArrayExpression': const elements = expression.elements.map(element => compileAST(element)); return (state) => elements.map(element => element(state)); @@ -269,6 +276,32 @@ export function debounce(callable) { }; } +export class Timer { + constructor() { + this.timeout = null; + } + + /** + * calls callback delayed. If there's already a delayed callback scheduled, + * the callback will be set to the new one, but the timeout will just continue + * and not reset from the beginning of `delay`. + */ + delayed(callback, delay) { + if (this.timeout === null) + this.timeout = setTimeout(this.immediate.bind(this), delay); + this.callback = callback; + } + + /** + * Call callback now, and clear any previously scheduled callback. + */ + immediate(callback) { + clearTimeout(this.timeout); + this.timeout = null; + (callback || this.callback)(); + } +} + export async function* asCompleted(iterable) { const promises = new Set(iterable); while (promises.size() > 0) { @@ -345,3 +378,55 @@ export function createElement(name, attributes, children) { return el } + +export function download(url, name) { + const a = document.createElement('a'); + a.href = url; + a.download = name; + a.click(); + a.addEventListener('click', e => { + requestIdleCallback(() => { + URL.revokeObjectURL(url); + }); + }); +} + +/** + * Helper class to wrap around a message channel supporting messages in the + * form {command:String,data:Object}. + */ +export class MessageHandler { + #listeners; + + constructor(register) { + this.#listeners = new Map(); + + register((message, ...rest) => { + if (!this.#listeners.has(message.command)) + console.warn('Received unhandled message', message); + else { + console.info('Received', message); + this.#listeners.get(message.command)(message.data, ...rest); + } + }) + } + + on(command, handler) { + this.#listeners.set(command, handler); + } +} + +export class DefaultMap extends Map { + #factory; + + constructor(factory) { + super(); + this.#factory = factory; + } + + get(key) { + if (!this.has(key)) + this.set(key, this.#factory(key)); + return super.get(key); + } +} diff --git a/src/shared/compat.js b/src/shared/compat.js index 1b18e895..64478a6b 100644 --- a/src/shared/compat.js +++ b/src/shared/compat.js @@ -1,82 +1,2 @@ -class NotImplementedError extends Error { - constructor() { - super('Not implemented'); - } -} - -function promisify(object, methods) { - return new Proxy(object, { - get(target, prop, receiver) { - // Note: I tried using Reflect.get() here, but Chrome doesn't like that. - if (methods.includes(prop)) - return (...args) => new Promise((accept, reject) => { - target[prop](...args, (retval) => { - if (chrome.runtime.lastError) - reject(chrome.runtime.lastError); - else - accept(retval); - }) - }); - else - return target[prop]; - } - }); -} - -export default new class { - #isFirefox = false; - #isChromium = false; - #runtime; - - constructor() { - if (typeof browser !== 'undefined') { - this.#isFirefox = true; - this.#runtime = browser; - } else if (typeof chrome !== 'undefined') { - this.#isChromium = true; - this.#runtime = chrome; - } else { - throw new NotImplementedError(); - } - } - - get storage() { - if (this.#isChromium) - return new Proxy(chrome.storage, { - get(target, prop, receiver) { - if (['sync', 'local', 'managed'].includes(prop)) - return promisify(chrome.storage[prop], ['get', 'set', 'remove']); - else - return chrome.storage[prop] - } - }); - else - return this.#runtime.storage; - } - - get runtime() { - return this.#runtime.runtime; - } - - get tabs() { - if (this.#isChromium) - return promisify(chrome.tabs, ['query']); - else - return this.#runtime.tabs; - } - - get i18n() { - if (this.#isChromium) - return promisify(chrome.i18n, ['detectLanguage', 'getAcceptLanguages']); - else - return this.#runtime.i18n; - } - - get action() { - return this.#runtime.action; - } - - get contextMenus() { - return this.#runtime.contextMenus; - } -}; \ No newline at end of file +const runtime = typeof browser !== 'undefined' ? browser : chrome; +export default runtime; diff --git a/src/shared/func.js b/src/shared/func.js index 254c699f..69792742 100644 --- a/src/shared/func.js +++ b/src/shared/func.js @@ -4,7 +4,7 @@ * @returns {Promise} promise that only calls `factory` when `then()` is first called */ export function lazy(factory) { - let promise = null; + let promise = null, destructor = () => {}; return { get instantiated() { @@ -14,7 +14,7 @@ export function lazy(factory) { then(...args) { // Ask for the actual promise if (promise === null) { - promise = factory(); + promise = factory(this); if (typeof promise?.then !== 'function') throw new TypeError('factory() did not return a promise-like object'); @@ -22,6 +22,19 @@ export function lazy(factory) { // Forward the current call to the promise return promise.then(...args); + }, + + onReset(callback) { + destructor = callback; + }, + + reset() { + if (promise === null) + return; + + promise = null; + + destructor(); } }; } diff --git a/src/shared/langid.js b/src/shared/langid.js new file mode 100644 index 00000000..42cd7fdc --- /dev/null +++ b/src/shared/langid.js @@ -0,0 +1,137 @@ +import compat from './compat.js'; +import {product} from './func.js' + +/** + * Temporary fix around few models, bad classified, and similar looking languages. + * From https://github.com/bitextor/bicleaner/blob/3df2b2e5e2044a27b4f95b83710be7c751267e5c/bicleaner/bicleaner_hardrules.py#L50 + * @type {Set[]} + */ +const SimilarLanguages = [ + new Set(['es', 'ca', 'gl', 'pt']), + new Set(['no', 'nb', 'nn', 'da']) // no == nb for bicleaner +]; + +/** + * @typedef {Object} TranslationModel + * @property {String} from + * @property {String} to + * @property {Boolean} local + */ + +/** + * @typedef {Object} TranslationProvider + * @property {Promise} registry + * @property {(request:Object) => Promise} translate + */ + +/** + * Language detection function that also provides a sorted list of + * from->to language pairs, based on the detected language, the preferred + * target language, and what models are available. + * @param {{sample:String, suggested:{[lang:String]: Number}}} + * @param {Promise} registry + * @return {Promise<{ + * from:String|Undefined, + * to:String|Undefined, + * models: { + * from:String, + * to:String, + * pivot: Boolean, + * models: number[] + * }[] + * }>} + */ +export async function detectLanguage({sample, suggested}, registry, options) { + if (!sample) + throw new Error('Empty sample'); + + const [detected, models] = await Promise.all([ + compat.i18n.detectLanguage(sample), + registry + ]); + + const modelsFromEng = models.filter(({from}) => from === 'en'); + const modelsToEng = models.filter(({to}) => to === 'en'); + + // List of all available from->to translation pairs including ones that we + // achieve by pivoting through English. + const pairs = [ + ...models.map(model => ({from: model.from, to: model.to, pivot: null, models: [model]})), + ...Array.from(product(modelsToEng, modelsFromEng)) + .filter(([{from}, {to}]) => from !== to) + .map(([from, to]) => ({from: from.from, to: to.to, pivot: 'en', models: [from, to]})) + ]; + + // {[lang]: 0.0 .. 1.0} map of likeliness the page is in this language + /** @type {{[lang:String]: Number }} **/ + let confidence = Object.fromEntries(detected.languages.map(({language, percentage}) => [language, percentage / 100])); + + // Take suggestions into account + Object.entries(suggested || {}).forEach(([lang, score]) => { + lang = lang.substr(0, 2); // TODO: not strip everything down to two letters + confidence[lang] = Math.max(score, confidence[lang] || 0.0); + }); + + // Work-around for language pairs that are close together + Object.entries(confidence).forEach(([lang, score]) => { + SimilarLanguages.forEach(group => { + if (group.has(lang)) { + group.forEach(other => { + if (!(other in confidence)) + confidence[other] = score / 2; // little bit lower though + }) + } + }) + }); + + // Fetch the languages that the browser says the user accepts (i.e Accept header) + /** @type {String[]} **/ + let accepted = await compat.i18n.getAcceptLanguages(); + + // TODO: right now all our models are just two-letter codes instead of BCP-47 :( + accepted = accepted.map(language => language.substr(0, 2)) + + // If the user has a preference, put that up front + if (options?.preferred) + accepted.unshift(options.preferred); + + // Remove duplicates + accepted = accepted.filter((val, index, values) => values.indexOf(val, index + 1) === -1) + + // {[lang]: 0.0 .. 1.0} map of likeliness the user wants to translate to this language. + /** @type {{[lang:String]: Number }} */ + const preferred = accepted.reduce((preferred, language, i, languages) => { + return language in preferred + ? preferred + : {...preferred, [language]: 1.0 - (i / languages.length)}; + }, {}); + + // Function to score a translation model. Higher score is better + const score = ({from, to, pivot, models}) => { + return 1.0 * (confidence[from] || 0.0) // from language is good + + 0.5 * (preferred[to] || 0.0) // to language is good + + 0.2 * (pivot ? 0.0 : 1.0) // preferably don't pivot + + 0.1 * (1.0 / models.reduce((acc, model) => acc + model.local ? 0.0 : 1.0, 1.0)) // prefer local models + }; + + // Sort our possible models, best one first + pairs.sort((a, b) => score(b) - score(a)); + + // console.log({ + // accepted, + // preferred, + // confidence, + // pairs: pairs.map(pair => ({...pair, score: score(pair)})) + // }); + + // (Using pairs instead of confidence and preferred because we prefer a pair + // we can actually translate to above nothing every time right now.) + return { + from: pairs.length ? pairs[0].from : undefined, + to: pairs.length ? pairs[0].to : undefined, + models: pairs.map(({models, ...props}) => ({ + ...props, + models: models.map(({model: {id}}) => id) + })) + } +} diff --git a/src/shared/preferences.js b/src/shared/preferences.js index 774c2226..aff6ab95 100644 --- a/src/shared/preferences.js +++ b/src/shared/preferences.js @@ -1,128 +1,4 @@ import compat from './compat.js'; + import { StorageArea } from './storage.js'; -export default new class { - #listeners; - - constructor() { - this.#listeners = new Set(); - } - /** - * Get preference from storage, or return `fallback` if there was no - * preference. - */ - async get(key, fallback) { - const response = await compat.storage.local.get(key); - return response[key] !== undefined ? response[key] : fallback; - } - - /** - * Changes preferences. Will notify other pages about the change. - * @param {String} key - * @param {Object} value - * @param {{silent:Boolean}?} options - */ - async set(key, value, options) { - console.log('[preferences] set', key, 'to', value); - await compat.storage.local.set({[key]: value}); - - // Notify local listeners with the same sort event that onChanged gets - if (!options?.silent) - this.#listeners.forEach(callback => callback({[key]: {newValue: value}}, 'local')); - } - - /** - * Deletes key from storage. `get(key)` will return fallback value afterwards - */ - async delete(key) { - return await compat.storage.local.remove(key); - } - - /** - * Listen to preference changes. - * @return {() => null} callback to stop listening - */ - listen(key, callback) { - const listener = (changes, area) => { - if (area === 'local' && key in changes) - callback(changes[key].newValue) - }; - - compat.storage.onChanged.addListener(listener); - this.#listeners.add(listener); - - return () => { - compat.storage.onChanged.removeListener(listener); - this.#listeners.delete(listener); - }; - } - - /** - * get() + listen() in an easy package. - * @param {String} key - * @param {(Object) => null} callback called with value and when value changes - * @return {() => null} callback to stop listening - */ - bind(key, callback, options) { - this.get(key, options?.default).then(value => callback(value)); - return this.listen(key, callback); - } - - /** - * Create a (not async) view of the preferences that's faster to access - * frequently. Will be kept in sync. Use addListener() to know when it - * changes. - */ - view(defaults) { - const listeners = new Set(); - - const view = Object.create({ - addListener(callback) { - listeners.add(callback); - }, - delete: () => { - compat.storage.onChanged.removeListeners(listener); - this.#listeners.delete(listener); - } - }); - - Object.assign(view, defaults); - - compat.storage.local.get(Object.keys(defaults)).then(result => { - Object.assign(view, result); - listeners.forEach(listener => listener(result)); - }); - - function listener(changes, area) { - if (area !== 'local') - return; - - const diff = {}; - - for (let key of Object.keys(defaults)) - if (key in changes && changes[key].newValue !== view[key]) - diff[key] = changes[key].newValue; - - if (Object.keys(diff).length === 0) - return; - - Object.assign(view, diff); - listeners.forEach(listener => listener(diff)); - } - - // Listen to changes from outside this context - compat.storage.onChanged.addListener(listener); - - // Listen to changes in the current context - this.#listeners.add(listener); - - return new Proxy(view, { - get(...args) { - return Reflect.get(...args) - }, - - set(...args) { - throw new Error('Preference view is read-only') - } - }) - } -}; \ No newline at end of file +export default new StorageArea('local', 'pref'); \ No newline at end of file diff --git a/src/shared/session.js b/src/shared/session.js new file mode 100644 index 00000000..559ee295 --- /dev/null +++ b/src/shared/session.js @@ -0,0 +1,12 @@ +import compat from './compat.js'; + +export async function setTabStorage(tabId, data) { + const entries = Object.entries(data).map(([key, val]) => [`tab:${tabId}:${key}`, val]); + await compat.storage.session.set(Object.fromEntries(entries)); +} + +export async function getTabStorage(tabId, keys) { + const namespacedKeys = keys.map(key => `tab:${tabId}:${key}`); + const response = await compat.storage.session.get(namespacedKeys); + return Object.fromEntries(keys.map((key, i) => [key, response[namespacedKeys[i]]])); +} \ No newline at end of file diff --git a/src/shared/storage.js b/src/shared/storage.js new file mode 100644 index 00000000..f704343f --- /dev/null +++ b/src/shared/storage.js @@ -0,0 +1,211 @@ +import compat from './compat.js'; + +class MemoryBacking { + #data; + + constructor(data) { + this.#data = data || {}; + } + + async get(keys) { + if (keys) + return Object.fromEntries(keys.map(key => [key, this.#data[key]])); + else + return Object.assign({}, this.#data); + } + + async set(data) { + return Object.assign(this.#data, data); + } + + async remove(key) { + delete this.#data[key]; + } + + get onChanged() { + return { + addListener(callback) { + // do nothing + }, + removeListener(callback) { + // do even less + } + } + } +} + +export class StorageArea { + #backing; + + #namespace; + + #listeners; + + /** + * @param {String|{[key:String]:any}|Null} area + * @param {String?} namespace; + */ + constructor(area, namespace) { + this.#backing = typeof area === 'string' + ? compat.storage[area] + : new MemoryBacking(area); + this.#namespace = namespace ? `${namespace}:` : ''; + this.#listeners = new Set(); + } + /** + * Get preference from storage, or return `fallback` if there was no + * preference. + * @param {{[key:String]:any}} defaults + * @return {Promise<{[key:String]:any}>} + */ + async get(defaults) { + let keys, fullKeys, response; + + if (defaults) { + keys = Object.keys(defaults); + fullKeys = keys.map(key => this.#namespace + key); + response = await this.#backing.get(fullKeys); + } else { + response = await this.#backing.get(); + fullKeys = Object.keys(response); + keys = fullKeys + .filter(fullKey => fullKey.startsWith(this.#namespace)) + .map(fullKey => fullKey.slice(this.#namespace.length)); + } + + return Object.fromEntries(keys.map((key, i) => { + if (response[fullKeys[i]] !== undefined) + return [key, response[fullKeys[i]]]; + else + return [key, defaults[key]]; + })); + } + + /** + * Changes preferences. Will notify other pages about the change. + * @param {{[key:String]:any}} entries + * @param {{silent:Boolean}?} options + */ + async set(data, options) { + const fullData = Object.fromEntries(Object.entries(data).map(([key, value]) => [this.#namespace + key, value])); + await this.#backing.set(fullData); + + // Emulate the onChanged event storage normally sends to global listeners + // but to our local listeners + if (!options?.silent) { + const diff = Object.fromEntries(Object.entries(data).map(([key, value]) => [this.#namespace + key, {newValue:value}])); + this.#listeners.forEach(callback => callback(diff)); + } + } + + /** + * Deletes key from storage. `get(key)` will return fallback value afterwards + * @param {String} key + */ + async remove(key) { + return await this.#backing.remove(this.#namespace + key); + } + + /** + * Listen to preference changes. + * @param {String[]} keys + * @param {({[key:String]:Any}) => undefined} callback + * @return {() => null} callback to stop listening + */ + listen(keys, callback) { + const fullKeys = keys.map(key => this.#namespace + key); + + const listener = (changes) => { + // Select only the keys that we're listening for + const relevant = Object.keys(changes).filter(key => { + return fullKeys.includes(key); + }); + + if (relevant.length === 0) + return; + + // Create a {[key]:val} object with only the changes + // for the keys we're listening for + const diff = Object.fromEntries(relevant.map(fullKey => [ + fullKey.slice(this.#namespace.length), + changes[fullKey].newValue + ])); + + callback(diff); + }; + + // Global events + this.#backing.onChanged.addListener(listener); + + // Local events + this.#listeners.add(listener); + + return () => { + this.#backing.onChanged.removeListener(listener); + this.#listeners.delete(listener); + }; + } + + /** + * get() + listen() in an easy package. + * @param {String} key + * @param {(Object) => null} callback called with value and when value changes + * @return {() => null} callback to stop listening + */ + bind(key, callback, options) { + this.get({[key]: options?.default}).then(response => callback(response[key])); + return this.listen([key], diff => callback(diff[key])); + } + + /** + * Create a (not async) view of the preferences that's faster to access + * frequently. Will be kept in sync. Use addListener() to know when it + * changes. + */ + view(defaults) { + const listeners = new Set(); + + // Our returned view prototype + const view = Object.create({ + addListener(callback) { + listeners.add(callback); + }, + delete: () => { + stopListening(); + } + }); + + Object.assign(view, defaults); + + const callback = (changes) => { + const diff = {}; + + for (const key in changes) { + if (changes[key] !== view[key]) + diff[key] = changes[key]; + } + + if (Object.keys(diff).length === 0) + return; + + Object.assign(view, diff); + listeners.forEach(listener => listener(diff)); + }; + + // Listen for changes to any of these keys + var stopListening = this.listen(Object.keys(defaults), callback); + + // Get their initial value from storage + this.get(defaults).then(callback); + + return new Proxy(view, { + get(...args) { + return Reflect.get(...args) + }, + + set(...args) { + throw new Error('Preference view is read-only') + } + }); + } +};
Wanna translate this page?
Downloading language model…
Translating page from to …
Translated page from to .
Error during translation:
Error:
Translations not available for this page.
Downloading list of available language models…