Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 76 additions & 12 deletions src/plugins/translate/plugin.translate.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,36 @@ 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';

// @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 = {
Expand Down Expand Up @@ -105,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")) : [];
Expand Down Expand Up @@ -383,6 +422,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;
Expand Down Expand Up @@ -422,13 +462,7 @@ export class BrTranslatePanel extends LitElement {
<label>
<span style="font-size: 12px;color: #ccc;">Translate To</span>
<select id="lang-to" name="to" class="lang-select" style="display:block; width:100%;" @change="${this._onLangToChange}">
${sortBy(this.toLanguages, ((lang) => lang.name.toLowerCase()
)).map((lang) => {
return html`<option value="${lang.code}"
?selected=${lang.code == this.detectedToLang}
> ${lang.name ? lang.name : lang.code} </option>`;
})
}
${this._renderToLanguageOptions()}
</select>
</label>
</div>
Expand All @@ -448,11 +482,11 @@ export class BrTranslatePanel extends LitElement {
</summary>
<select id="lang-from" name="from" class="lang-select" value=${this.detectedFromLang} @change="${this._onLangFromChange}" style="width:65%; margin-top: 3%; margin-bottom: 3%">
${sortBy(this.fromLanguages, ((lang) => lang.name.toLowerCase()
)).map((lang) => {
return html`<option value="${lang.code}"
)).map((lang) => {
return html`<option value="${lang.code}"
?selected=${lang.code == this.detectedFromLang}
>${lang.name ? lang.name : lang.code} </option>`;
})
})
}
</select>
</details>
Expand Down Expand Up @@ -500,6 +534,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`<option value="${lang.code}" ?selected=${selected}>${lang.name || lang.code}</option>`;
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`
<optgroup label="Recent">
${recentLanguages.map(lang => option(lang, lang.code == this.detectedToLang))}
</optgroup>
<optgroup label="All languages">
${allLanguages.map(lang => option(lang, !selectedIsRecent && lang.code == this.detectedToLang))}
</optgroup>
`;
}

_getSelectedLang(type) {
Expand Down
85 changes: 84 additions & 1 deletion tests/jest/plugins/translate/plugin.translate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, TranslatePlugin, loadRecentLanguages } from '@/src/plugins/translate/plugin.translate.js';

describe('BrTranslatePanel unsupported language warning', () => {
test('shows unsupported language warning message', () => {
Expand All @@ -15,3 +16,85 @@ 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([]);
});
});

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');
});
});
Loading