From 26457d581abf22cb13992a50f6b2008986c6f7bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 05:23:33 +0000 Subject: [PATCH 1/2] Show recently used languages at the top of the translate language list Co-Authored-By: Drini Cami --- src/plugins/translate/plugin.translate.js | 69 ++++++++++++++++--- .../translate/plugin.translate.test.js | 57 ++++++++++++++- 2 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/plugins/translate/plugin.translate.js b/src/plugins/translate/plugin.translate.js index 5037c0596..71174db72 100644 --- a/src/plugins/translate/plugin.translate.js +++ b/src/plugins/translate/plugin.translate.js @@ -3,7 +3,7 @@ import { css, html, LitElement } from 'lit'; import { BookReaderPlugin } from '../../BookReaderPlugin.js'; import { customElement, property, query } from 'lit/decorators.js'; import { TranslationManager } from "./TranslationManager.js"; -import { toISO6391, toNativeName } from '../tts/utils.js'; +import { hasLocalStorage, toISO6391, toNativeName } from '../tts/utils.js'; import { sortBy } from '../../../src/BookReader/utils.js'; import { TextSelectionManager } from '../../../src/util/TextSelectionManager.js'; import '@internetarchive/ia-activity-indicator'; @@ -11,6 +11,28 @@ import '@internetarchive/ia-activity-indicator'; // @ts-ignore const BookReader = /** @type {typeof import('@/src/BookReader.js').default} */(window.BookReader); +const RECENT_LANGUAGES_KEY = 'BrTranslateRecentLanguages'; +const MAX_RECENT_LANGUAGES = 3; + +/** @returns {string[]} language codes, most recent first */ +export function loadRecentLanguages() { + try { + const codes = hasLocalStorage() && JSON.parse(localStorage.getItem(RECENT_LANGUAGES_KEY)); + return Array.isArray(codes) ? codes : []; + } catch { + return []; + } +} + +/** @param {string[]} codes */ +function saveRecentLanguages(codes) { + try { + if (hasLocalStorage()) localStorage.setItem(RECENT_LANGUAGES_KEY, JSON.stringify(codes)); + } catch { + // Storage full or blocked; recent languages just won't persist + } +} + export class TranslatePlugin extends BookReaderPlugin { options = { @@ -383,6 +405,7 @@ export class BrTranslatePanel extends LitElement { @property({ type: String }) detectedToLang = ''; @property({ type: Boolean }) loadingModel; @property({ type: Boolean }) sourceLanguageSupported = true; + @property({ type: Array }) recentToLanguages = loadRecentLanguages(); // Codes of recently chosen "To" languages, most recent first @query('#lang-from') langFromDropdown; @query('#lang-to') langToDropdown; @@ -422,13 +445,7 @@ export class BrTranslatePanel extends LitElement { @@ -448,11 +465,11 @@ export class BrTranslatePanel extends LitElement { @@ -500,6 +517,36 @@ export class BrTranslatePanel extends LitElement { } this.loadingModel = true; this.detectedToLang = event.target.value; + + this.recentToLanguages = [event.target.value, ...this.recentToLanguages.filter(code => code !== event.target.value)] + .slice(0, MAX_RECENT_LANGUAGES); + saveRecentLanguages(this.recentToLanguages); + } + + /** + * "To" language options, with recently used languages in their own group at the top + */ + _renderToLanguageOptions() { + const option = (lang, selected) => html``; + const allLanguages = sortBy(this.toLanguages, (lang) => lang.name.toLowerCase()); + const recentLanguages = this.recentToLanguages + .map(code => this.toLanguages.find(lang => lang.code === code)) + .filter(Boolean); + + if (!recentLanguages.length) { + return allLanguages.map(lang => option(lang, lang.code == this.detectedToLang)); + } + + // Recent languages also stay in the full list, but are only marked selected in the "Recent" group + const selectedIsRecent = recentLanguages.some(lang => lang.code == this.detectedToLang); + return html` + + ${recentLanguages.map(lang => option(lang, lang.code == this.detectedToLang))} + + + ${allLanguages.map(lang => option(lang, !selectedIsRecent && lang.code == this.detectedToLang))} + + `; } _getSelectedLang(type) { diff --git a/tests/jest/plugins/translate/plugin.translate.test.js b/tests/jest/plugins/translate/plugin.translate.test.js index 81dbd5159..ca313e024 100644 --- a/tests/jest/plugins/translate/plugin.translate.test.js +++ b/tests/jest/plugins/translate/plugin.translate.test.js @@ -4,7 +4,8 @@ jest.mock('@internetarchive/bergamot-translator/translator.js', () => ({ BatchTranslator: class {}, })); -import { BrTranslatePanel } from '@/src/plugins/translate/plugin.translate.js'; +import { render } from 'lit'; +import { BrTranslatePanel, loadRecentLanguages } from '@/src/plugins/translate/plugin.translate.js'; describe('BrTranslatePanel unsupported language warning', () => { test('shows unsupported language warning message', () => { @@ -15,3 +16,57 @@ describe('BrTranslatePanel unsupported language warning', () => { expect(panel._statusWarning()).toBe('Translating from zzzz is not supported'); }); }); + +describe('BrTranslatePanel recent languages', () => { + const toLanguages = [ + {code: 'en', name: 'English (en)'}, + {code: 'fr', name: 'français (fr)'}, + {code: 'de', name: 'Deutsch (de)'}, + {code: 'ko', name: '한국어 (ko)'}, + ]; + + /** @param {BrTranslatePanel} panel */ + const renderOptions = (panel) => { + const select = document.createElement('select'); + render(panel._renderToLanguageOptions(), select); + return select; + }; + + beforeEach(() => localStorage.clear()); + + test('shows only the full list when there is no history', () => { + const panel = new BrTranslatePanel(); + panel.toLanguages = toLanguages; + const select = renderOptions(panel); + expect(select.querySelector('optgroup')).toBeNull(); + expect(select.options.length).toBe(4); + }); + + test('shows recent languages in their own group, most recent first', () => { + localStorage.setItem('BrTranslateRecentLanguages', JSON.stringify(['ko', 'xx', 'fr'])); + const panel = new BrTranslatePanel(); + panel.toLanguages = toLanguages; + panel.detectedToLang = 'fr'; + const select = renderOptions(panel); + const [recent, all] = select.querySelectorAll('optgroup'); + expect([...recent.children].map(o => o.value)).toEqual(['ko', 'fr']); + expect(all.children.length).toBe(4); + expect(select.value).toBe('fr'); + expect(recent.querySelector('[value=fr]').selected).toBe(true); + }); + + test('remembers the chosen language, up to three', () => { + const panel = new BrTranslatePanel(); + panel.recentToLanguages = ['de', 'fr', 'ko']; + panel._onLangToChange({target: {value: 'fr'}}); + expect(panel.recentToLanguages).toEqual(['fr', 'de', 'ko']); + panel._onLangToChange({target: {value: 'en'}}); + expect(panel.recentToLanguages).toEqual(['en', 'fr', 'de']); + expect(JSON.parse(localStorage.getItem('BrTranslateRecentLanguages'))).toEqual(['en', 'fr', 'de']); + }); + + test('ignores invalid stored data', () => { + localStorage.setItem('BrTranslateRecentLanguages', '{not json'); + expect(loadRecentLanguages()).toEqual([]); + }); +}); From 79115009110b6fc91745b99bfe7bcbeda7b8426d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 05:26:20 +0000 Subject: [PATCH 2/2] Default the translate language to the last used or browser language (#1459) Co-Authored-By: Drini Cami --- src/plugins/translate/plugin.translate.js | 19 +++++++++++- .../translate/plugin.translate.test.js | 30 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/plugins/translate/plugin.translate.js b/src/plugins/translate/plugin.translate.js index 71174db72..1676bd612 100644 --- a/src/plugins/translate/plugin.translate.js +++ b/src/plugins/translate/plugin.translate.js @@ -127,10 +127,27 @@ export class TranslatePlugin extends BookReaderPlugin { await this.translationManager.initWorker(); // Note await above lets _render function properly, since it gives the browser // time to render the rest of bookreader, which _render depends on - this.langToCode = this.translationManager.toLanguages[0].code; + this.langToCode = this.getDefaultToLanguage(); this._render(); } + /** + * The most recently used "To" language, else the first of the browser's languages + * we have a model for, else the first available language (English) + * @returns {string} + */ + getDefaultToLanguage() { + const available = this.translationManager.toLanguages.map(lang => lang.code); + const baseLanguage = (code) => code.split('-')[0].toLowerCase(); + const candidates = [...loadRecentLanguages(), ...(navigator.languages || [navigator.language])]; + for (const candidate of candidates) { + const match = available.find(code => code.toLowerCase() === candidate.toLowerCase()) + || available.find(code => baseLanguage(code) === baseLanguage(candidate)); + if (match) return match; + } + return available[0]; + } + /** @param {HTMLElement} page*/ getParagraphsOnPage = (page) => { return page ? Array.from(page.querySelectorAll(".BRtextLayer > .BRparagraphElement")) : []; diff --git a/tests/jest/plugins/translate/plugin.translate.test.js b/tests/jest/plugins/translate/plugin.translate.test.js index ca313e024..3121bba35 100644 --- a/tests/jest/plugins/translate/plugin.translate.test.js +++ b/tests/jest/plugins/translate/plugin.translate.test.js @@ -5,7 +5,7 @@ jest.mock('@internetarchive/bergamot-translator/translator.js', () => ({ })); import { render } from 'lit'; -import { BrTranslatePanel, loadRecentLanguages } from '@/src/plugins/translate/plugin.translate.js'; +import { BrTranslatePanel, TranslatePlugin, loadRecentLanguages } from '@/src/plugins/translate/plugin.translate.js'; describe('BrTranslatePanel unsupported language warning', () => { test('shows unsupported language warning message', () => { @@ -70,3 +70,31 @@ describe('BrTranslatePanel recent languages', () => { expect(loadRecentLanguages()).toEqual([]); }); }); + +describe('TranslatePlugin.getDefaultToLanguage', () => { + const plugin = /** @type {any} */ ({ + translationManager: {toLanguages: [{code: 'en'}, {code: 'fr'}, {code: 'zh-Hans'}]}, + }); + const getDefault = (browserLanguages) => { + jest.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLanguages); + return TranslatePlugin.prototype.getDefaultToLanguage.call(plugin); + }; + + beforeEach(() => localStorage.clear()); + afterEach(() => jest.restoreAllMocks()); + + test('uses the browser language, matching by base language', () => { + expect(getDefault(['fr-CA', 'en'])).toBe('fr'); + expect(getDefault(['zh-CN'])).toBe('zh-Hans'); + }); + + test('skips browser languages without a model', () => { + expect(getDefault(['xx', 'fr'])).toBe('fr'); + expect(getDefault(['xx'])).toBe('en'); + }); + + test('prefers the most recently used language', () => { + localStorage.setItem('BrTranslateRecentLanguages', JSON.stringify(['zh-Hans'])); + expect(getDefault(['fr'])).toBe('zh-Hans'); + }); +});