feat(assets): bundle runtime assets and vendor dependencies

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent d3fd47f0ec
commit ae95601698
429 changed files with 165389 additions and 0 deletions

View File

@@ -0,0 +1,378 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2017-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ThemeController} from '../app/theme-controller.js';
import {Application} from '../application.js';
import {getAllPermissions, hasRequiredPermissionsForOptions} from '../data/permissions-util.js';
import {HotkeyHelpController} from '../input/hotkey-help-controller.js';
import {HotkeyUtil} from '../input/hotkey-util.js';
class DisplayController {
/**
* @param {import('../comm/api.js').API} api
*/
constructor(api) {
/** @type {import('../comm/api.js').API} */
this._api = api;
/** @type {?import('settings').Options} */
this._optionsFull = null;
/** @type {ThemeController} */
this._themeController = new ThemeController(document.documentElement);
/** @type {HotkeyUtil} */
this._hotkeyUtil = new HotkeyUtil();
}
/** */
async prepare() {
this._themeController.prepare();
const manifest = chrome.runtime.getManifest();
const {platform: {os}} = await this._api.getEnvironmentInfo();
this._hotkeyUtil.os = os;
this._showExtensionInfo(manifest);
void this._setupEnvironment();
this._setupButtonEvents('.action-open-search', 'openSearchPage', chrome.runtime.getURL('/search.html'), this._onSearchClick.bind(this));
this._setupButtonEvents('.action-open-info', 'openInfoPage', chrome.runtime.getURL('/info.html'));
const optionsFull = await this._api.optionsGetFull();
this._optionsFull = optionsFull;
void this._setupHotkeys();
const optionsPageUrl = (
typeof manifest.options_ui === 'object' &&
manifest.options_ui !== null &&
typeof manifest.options_ui.page === 'string' ?
manifest.options_ui.page :
''
);
this._setupButtonEvents('.action-open-settings', 'openSettingsPage', chrome.runtime.getURL(optionsPageUrl));
const {profiles, profileCurrent} = optionsFull;
const defaultProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null;
if (defaultProfile !== null) {
this._setupOptions(defaultProfile);
}
/** @type {NodeListOf<HTMLElement>} */
const profileSelect = document.querySelectorAll('.action-select-profile');
for (let i = 0; i < profileSelect.length; i++) {
profileSelect[i].hidden = (profiles.length <= 1);
}
this._updateProfileSelect(profiles, profileCurrent);
setTimeout(() => {
document.body.dataset.loaded = 'true';
}, 10);
}
// Private
/** */
_updateDisplayModifierKey() {
const {profiles, profileCurrent} = /** @type {import('settings').Options} */ (this._optionsFull);
/** @type {NodeListOf<HTMLElement>} */
const modifierKeyHint = document.querySelectorAll('.tooltip');
const currentModifierKey = profiles[profileCurrent].options.scanning.inputs[0].include;
/** @type {{ [key: string]: string }} */
const modifierKeys = {};
for (const value of /** @type {import('input').ModifierKey[]} */ (['alt', 'ctrl', 'shift', 'meta'])) {
const name = this._hotkeyUtil.getModifierDisplayValue(value);
modifierKeys[value] = name;
}
for (let i = 0; i < modifierKeyHint.length; i++) {
modifierKeyHint[i].textContent = currentModifierKey ? 'Hold ' : 'Hover over text to scan';
if (currentModifierKey) {
const em = document.createElement('em');
em.textContent = modifierKeys[currentModifierKey];
modifierKeyHint[i].appendChild(em);
modifierKeyHint[i].appendChild(document.createTextNode(' to scan'));
}
}
}
/**
* @param {MouseEvent} e
*/
_onSearchClick(e) {
if (!e.shiftKey) { return; }
e.preventDefault();
location.href = '/search.html?action-popup=true';
}
/**
* @param {chrome.runtime.Manifest} manifest
*/
_showExtensionInfo(manifest) {
const node = document.getElementById('extension-info');
if (node === null) { return; }
node.textContent = `${manifest.name} v${manifest.version}`;
}
/**
* @param {string} selector
* @param {?string} command
* @param {string} url
* @param {(event: MouseEvent) => void} [customHandler]
*/
_setupButtonEvents(selector, command, url, customHandler) {
/** @type {NodeListOf<HTMLAnchorElement>} */
const nodes = document.querySelectorAll(selector);
for (const node of nodes) {
if (typeof command === 'string') {
/**
* @param {MouseEvent} e
*/
const onClick = (e) => {
if (e.button !== 0) { return; }
if (typeof customHandler === 'function') {
const result = customHandler(e);
if (typeof result !== 'undefined') { return; }
}
let mode = 'existingOrNewTab';
if (e.ctrlKey) {
mode = 'newTab';
} else if (e.shiftKey) {
mode = 'popup';
}
void this._api.commandExec(command, {mode: mode});
e.preventDefault();
};
/**
* @param {MouseEvent} e
*/
const onAuxClick = (e) => {
if (e.button !== 1) { return; }
void this._api.commandExec(command, {mode: 'newTab'});
e.preventDefault();
};
node.addEventListener('click', onClick, false);
node.addEventListener('auxclick', onAuxClick, false);
}
if (typeof url === 'string') {
node.href = url;
node.target = '_blank';
node.rel = 'noopener';
}
}
}
/** */
async _setupEnvironment() {
const urlSearchParams = new URLSearchParams(location.search);
let mode = urlSearchParams.get('mode');
switch (mode) {
case 'full':
case 'mini':
break;
default:
{
let tab;
try {
tab = await this._getCurrentTab();
// Safari assigns a tab object to the popup, other browsers do not
if (tab && await this._isSafari()) {
tab = void 0;
}
} catch (e) {
// NOP
}
mode = (tab ? 'full' : 'mini');
}
break;
}
document.documentElement.dataset.mode = mode;
}
/**
* @returns {Promise<chrome.tabs.Tab|undefined>}
*/
_getCurrentTab() {
return new Promise((resolve, reject) => {
chrome.tabs.getCurrent((result) => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve(result);
}
});
});
}
/**
* @param {import('settings').Profile} profile
*/
_setupOptions({options}) {
const extensionEnabled = options.general.enable;
const onToggleChanged = () => this._api.commandExec('toggleTextScanning');
for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.enable-search,.enable-search2'))) {
if (toggle.checked !== extensionEnabled) {
toggle.checked = extensionEnabled;
}
toggle.addEventListener('change', onToggleChanged, false);
}
void this._updateDisplayModifierKey();
void this._updateDictionariesEnabledWarnings(options);
void this._updatePermissionsWarnings(options);
this._themeController.theme = options.general.popupTheme;
this._themeController.siteOverride = true;
this._themeController.updateTheme();
}
/** */
async _setupHotkeys() {
const hotkeyHelpController = new HotkeyHelpController();
await hotkeyHelpController.prepare(this._api);
const {profiles, profileCurrent} = /** @type {import('settings').Options} */ (this._optionsFull);
const defaultProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null;
if (defaultProfile !== null) {
hotkeyHelpController.setOptions(defaultProfile.options);
}
hotkeyHelpController.setupNode(document.documentElement);
}
/**
* @param {import('settings').Profile[]} profiles
* @param {number} profileCurrent
*/
_updateProfileSelect(profiles, profileCurrent) {
/** @type {NodeListOf<HTMLSelectElement>} */
const selects = document.querySelectorAll('.profile-select');
/** @type {NodeListOf<HTMLElement>} */
for (let i = 0; i < Math.min(selects.length); i++) {
const fragment = document.createDocumentFragment();
for (let j = 0, jj = profiles.length; j < jj; ++j) {
const {name} = profiles[j];
const option = document.createElement('option');
option.textContent = name;
option.value = `${j}`;
fragment.appendChild(option);
}
selects[i].textContent = '';
selects[i].appendChild(fragment);
selects[i].value = `${profileCurrent}`;
selects[i].addEventListener('change', this._onProfileSelectChange.bind(this), false);
}
}
/**
* @param {Event} event
*/
_onProfileSelectChange(event) {
const node = /** @type {HTMLInputElement} */ (event.currentTarget);
const value = Number.parseInt(node.value, 10);
if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < /** @type {import('settings').Options} */ (this._optionsFull).profiles.length) {
const optionsFull = this._optionsFull;
if (optionsFull && value < optionsFull.profiles.length) {
void this._setDefaultProfileIndex(value);
optionsFull.profileCurrent = value;
const defaultProfile = optionsFull.profiles[optionsFull.profileCurrent];
if (defaultProfile !== null) {
this._setupOptions(defaultProfile);
}
}
}
}
/**
* @param {number} value
*/
async _setDefaultProfileIndex(value) {
/** @type {import('settings-modifications').ScopedModificationSet} */
const modification = {
action: 'set',
path: 'profileCurrent',
value,
scope: 'global',
optionsContext: null,
};
await this._api.modifySettings([modification], 'action-popup');
}
/**
* @param {import('settings').ProfileOptions} options
*/
async _updateDictionariesEnabledWarnings(options) {
const tooltip = document.querySelectorAll('.tooltip');
const dictionaries = await this._api.getDictionaryInfo();
const enabledDictionaries = new Set();
for (const {name, enabled} of options.dictionaries) {
if (enabled) {
enabledDictionaries.add(name);
}
}
let enabledCount = 0;
for (const {title} of dictionaries) {
if (enabledDictionaries.has(title)) {
++enabledCount;
}
}
if (enabledCount === 0) {
for (let i = 0; i < tooltip.length; i++) {
tooltip[i].innerHTML = 'No dictionary enabled';
tooltip[i].classList.add('enable-dictionary-tooltip');
}
}
}
/**
* @param {import('settings').ProfileOptions} options
*/
async _updatePermissionsWarnings(options) {
const permissions = await getAllPermissions();
if (hasRequiredPermissionsForOptions(permissions, options)) { return; }
const tooltip = document.querySelectorAll('.tooltip');
for (let i = 0; i < tooltip.length; i++) {
tooltip[i].innerHTML = '<a class="action-open-permissions">Please enable permissions</a>';
}
this._setupButtonEvents('.action-open-permissions', null, chrome.runtime.getURL('/permissions.html'));
}
/** @returns {Promise<boolean>} */
async _isSafari() {
const {browser} = await this._api.getEnvironmentInfo();
return browser === 'safari';
}
}
await Application.main(true, async (application) => {
void application.api.logIndicatorClear();
const displayController = new DisplayController(application.api);
await displayController.prepare();
});

View File

@@ -0,0 +1,154 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Environment} from '../../extension/environment.js';
export class ExtensionContentController {
/** */
prepare() {
this._prepareSpecialUrls();
this._prepareExtensionIdExamples();
void this._prepareEnvironmentInfo();
}
// Private
/** */
async _prepareEnvironmentInfo() {
const {dataset} = document.documentElement;
const {manifest_version: manifestVersion} = chrome.runtime.getManifest();
dataset.manifestVersion = `${manifestVersion}`;
const environment = new Environment();
await environment.prepare();
const {browser, platform} = environment.getInfo();
dataset.browser = browser;
dataset.os = platform.os;
}
/** */
_prepareExtensionIdExamples() {
const nodes = document.querySelectorAll('.extension-id-example');
let url = '';
try {
url = chrome.runtime.getURL('/');
} catch (e) {
// NOP
}
for (const node of nodes) {
node.textContent = url;
}
}
/** */
_prepareSpecialUrls() {
const nodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-special-url]'));
if (nodes.length === 0) { return; }
let extensionId = '';
try {
extensionId = chrome.runtime.id;
} catch (e) {
// NOP
}
const idPattern = /\{id\}/g;
const onSpecialUrlLinkClick = this._onSpecialUrlLinkClick.bind(this);
const onSpecialUrlLinkMouseDown = this._onSpecialUrlLinkMouseDown.bind(this);
for (const node of nodes) {
let {specialUrl} = node.dataset;
if (typeof specialUrl !== 'string') { specialUrl = ''; }
node.dataset.specialUrl = specialUrl.replace(idPattern, extensionId);
node.addEventListener('click', onSpecialUrlLinkClick, false);
node.addEventListener('auxclick', onSpecialUrlLinkClick, false);
node.addEventListener('mousedown', onSpecialUrlLinkMouseDown, false);
}
}
/**
* @param {MouseEvent} e
*/
_onSpecialUrlLinkClick(e) {
switch (e.button) {
case 0:
case 1:
{
const element = /** @type {HTMLElement} */ (e.currentTarget);
const {specialUrl} = element.dataset;
if (typeof specialUrl !== 'string') { return; }
e.preventDefault();
void this._createTab(specialUrl, true);
}
break;
}
}
/**
* @param {MouseEvent} e
*/
_onSpecialUrlLinkMouseDown(e) {
switch (e.button) {
case 0:
case 1:
e.preventDefault();
break;
}
}
/**
* @param {string} url
* @param {boolean} useOpener
* @returns {Promise<chrome.tabs.Tab>}
*/
async _createTab(url, useOpener) {
/** @type {number|undefined} */
let openerTabId;
if (useOpener) {
try {
/** @type {chrome.tabs.Tab|undefined} */
const tab = await new Promise((resolve, reject) => {
chrome.tabs.getCurrent((result) => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve(result);
}
});
});
if (typeof tab !== 'undefined') {
openerTabId = tab.id;
}
} catch (e) {
// NOP
}
}
return await new Promise((resolve, reject) => {
chrome.tabs.create({url, openerTabId}, (tab2) => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve(tab2);
}
});
});
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ThemeController} from '../app/theme-controller.js';
import {Application} from '../application.js';
import {DocumentFocusController} from '../dom/document-focus-controller.js';
import {ExtensionContentController} from './common/extension-content-controller.js';
import {SettingsController} from './settings/settings-controller.js';
await Application.main(true, async (application) => {
const settingsController = new SettingsController(application);
await settingsController.prepare();
/** @type {ThemeController} */
const themeController = new ThemeController(document.documentElement);
themeController.prepare();
const optionsFull = await application.api.optionsGetFull();
const {profiles, profileCurrent} = optionsFull;
const defaultProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null;
if (defaultProfile !== null) {
themeController.theme = defaultProfile.options.general.popupTheme;
themeController.siteOverride = true;
themeController.updateTheme();
}
document.body.hidden = false;
const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
const extensionContentController = new ExtensionContentController();
extensionContentController.prepare();
document.documentElement.dataset.loaded = 'true';
});

178
vendor/yomitan/js/pages/info-main.js vendored Normal file
View File

@@ -0,0 +1,178 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ThemeController} from '../app/theme-controller.js';
import {Application} from '../application.js';
import {promiseTimeout} from '../core/utilities.js';
import {DocumentFocusController} from '../dom/document-focus-controller.js';
import {querySelectorNotNull} from '../dom/query-selector.js';
import {BackupController} from './settings/backup-controller.js';
import {SettingsController} from './settings/settings-controller.js';
/**
* @param {import('environment').Browser} browser
* @returns {string}
*/
function getBrowserDisplayName(browser) {
switch (browser) {
case 'chrome': return 'Chrome';
case 'firefox': return 'Firefox';
case 'firefox-mobile': return 'Firefox for Android';
case 'edge': return 'Edge';
case 'edge-legacy': return 'Edge Legacy';
case 'safari': return 'Safari';
default: return `${browser}`;
}
}
/**
* @param {import('environment').OperatingSystem} os
* @returns {string}
*/
function getOperatingSystemDisplayName(os) {
switch (os) {
case 'mac': return 'Mac OS';
case 'win': return 'Windows';
case 'android': return 'Android';
case 'cros': return 'Chrome OS';
case 'linux': return 'Linux';
case 'openbsd': return 'Open BSD';
case 'unknown': return 'Unknown';
default: return `${os}`;
}
}
/**
* @param {import('../comm/api.js').API} api
*/
async function showAnkiConnectInfo(api) {
let ankiConnectVersion = null;
try {
ankiConnectVersion = await api.getAnkiConnectVersion();
} catch (e) {
// NOP
}
/** @type {HTMLElement} */
const ankiVersionElement = querySelectorNotNull(document, '#anki-connect-version');
/** @type {HTMLElement} */
const ankiVersionContainerElement = querySelectorNotNull(document, '#anki-connect-version-container');
/** @type {HTMLElement} */
const ankiVersionUnknownElement = querySelectorNotNull(document, '#anki-connect-version-unknown-message');
ankiVersionElement.textContent = (ankiConnectVersion !== null ? `${ankiConnectVersion}` : 'Unknown');
ankiVersionContainerElement.dataset.hasError = `${ankiConnectVersion === null}`;
ankiVersionUnknownElement.hidden = (ankiConnectVersion !== null);
}
/**
* @param {import('../comm/api.js').API} api
*/
async function showDictionaryInfo(api) {
let dictionaryInfos;
try {
dictionaryInfos = await api.getDictionaryInfo();
} catch (e) {
return;
}
const fragment = document.createDocumentFragment();
let first = true;
for (const {title} of dictionaryInfos) {
if (first) {
first = false;
} else {
fragment.appendChild(document.createTextNode(', '));
}
const node = document.createElement('span');
node.className = 'installed-dictionary';
node.textContent = title;
fragment.appendChild(node);
}
/** @type {HTMLElement} */
const noneElement = querySelectorNotNull(document, '#installed-dictionaries-none');
noneElement.hidden = (dictionaryInfos.length > 0);
/** @type {HTMLElement} */
const container = querySelectorNotNull(document, '#installed-dictionaries');
container.textContent = '';
container.appendChild(fragment);
}
await Application.main(true, async (application) => {
const settingsController = new SettingsController(application);
await settingsController.prepare();
/** @type {ThemeController} */
const themeController = new ThemeController(document.documentElement);
themeController.prepare();
const optionsFull = await application.api.optionsGetFull();
const {profiles, profileCurrent} = optionsFull;
const defaultProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null;
if (defaultProfile !== null) {
themeController.theme = defaultProfile.options.general.popupTheme;
themeController.siteOverride = true;
themeController.updateTheme();
}
document.body.hidden = false;
const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
const manifest = chrome.runtime.getManifest();
const language = chrome.i18n.getUILanguage();
const {userAgent} = navigator;
const {name, version} = manifest;
const {browser, platform: {os}} = await application.api.getEnvironmentInfo();
/** @type {HTMLLinkElement} */
const thisVersionLink = querySelectorNotNull(document, '#release-notes-this-version-link');
const {hrefFormat} = thisVersionLink.dataset;
thisVersionLink.href = typeof hrefFormat === 'string' ? hrefFormat.replace(/\{version\}/g, version) : '';
/** @type {HTMLElement} */
const versionElement = querySelectorNotNull(document, '#version');
/** @type {HTMLElement} */
const browserElement = querySelectorNotNull(document, '#browser');
/** @type {HTMLElement} */
const platformElement = querySelectorNotNull(document, '#platform');
/** @type {HTMLElement} */
const languageElement = querySelectorNotNull(document, '#language');
/** @type {HTMLElement} */
const userAgentElement = querySelectorNotNull(document, '#user-agent');
versionElement.textContent = `${name} ${version}`;
browserElement.textContent = getBrowserDisplayName(browser);
platformElement.textContent = getOperatingSystemDisplayName(os);
languageElement.textContent = `${language}`;
userAgentElement.textContent = userAgent;
void showAnkiConnectInfo(application.api);
void showDictionaryInfo(application.api);
const backupController = new BackupController(settingsController, null);
await backupController.prepare();
await promiseTimeout(100);
document.documentElement.dataset.loaded = 'true';
});

View File

@@ -0,0 +1,139 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Application} from '../application.js';
import {promiseTimeout} from '../core/utilities.js';
import {DocumentFocusController} from '../dom/document-focus-controller.js';
import {querySelectorNotNull} from '../dom/query-selector.js';
import {ExtensionContentController} from './common/extension-content-controller.js';
import {ModalController} from './settings/modal-controller.js';
import {PermissionsOriginController} from './settings/permissions-origin-controller.js';
import {PermissionsToggleController} from './settings/permissions-toggle-controller.js';
import {PersistentStorageController} from './settings/persistent-storage-controller.js';
import {SettingsController} from './settings/settings-controller.js';
import {SettingsDisplayController} from './settings/settings-display-controller.js';
/**
* @param {import('../comm/api.js').API} api
*/
async function setupEnvironmentInfo(api) {
const {manifest_version: manifestVersion} = chrome.runtime.getManifest();
const {browser, platform} = await api.getEnvironmentInfo();
document.documentElement.dataset.browser = browser;
document.documentElement.dataset.os = platform.os;
document.documentElement.dataset.manifestVersion = `${manifestVersion}`;
}
/**
* @returns {Promise<boolean>}
*/
async function isAllowedIncognitoAccess() {
return await new Promise((resolve) => { chrome.extension.isAllowedIncognitoAccess(resolve); });
}
/**
* @returns {Promise<boolean>}
*/
async function isAllowedFileSchemeAccess() {
return await new Promise((resolve) => { chrome.extension.isAllowedFileSchemeAccess(resolve); });
}
/**
* @returns {void}
*/
function setupPermissionsToggles() {
const manifest = chrome.runtime.getManifest();
const optionalPermissions = manifest.optional_permissions;
/** @type {Set<string>} */
const optionalPermissionsSet = new Set(optionalPermissions);
if (Array.isArray(optionalPermissions)) {
for (const permission of optionalPermissions) {
optionalPermissionsSet.add(permission);
}
}
/**
* @param {Set<string>} set
* @param {string[]} values
* @returns {boolean}
*/
const hasAllPermisions = (set, values) => {
for (const value of values) {
if (!set.has(value)) { return false; }
}
return true;
};
for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.permissions-toggle'))) {
const permissions = toggle.dataset.requiredPermissions;
const permissionsArray = (typeof permissions === 'string' && permissions.length > 0 ? permissions.split(' ') : []);
toggle.disabled = !hasAllPermisions(optionalPermissionsSet, permissionsArray);
}
}
await Application.main(true, async (application) => {
const modalController = new ModalController([]);
await modalController.prepare();
const settingsController = new SettingsController(application);
await settingsController.prepare();
const settingsDisplayController = new SettingsDisplayController(settingsController, modalController);
await settingsDisplayController.prepare();
document.body.hidden = false;
const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
const extensionContentController = new ExtensionContentController();
extensionContentController.prepare();
setupPermissionsToggles();
void setupEnvironmentInfo(application.api);
/** @type {HTMLInputElement} */
const permissionCheckbox1 = querySelectorNotNull(document, '#permission-checkbox-allow-in-private-windows');
/** @type {HTMLInputElement} */
const permissionCheckbox2 = querySelectorNotNull(document, '#permission-checkbox-allow-file-url-access');
/** @type {HTMLInputElement[]} */
const permissionsCheckboxes = [permissionCheckbox1, permissionCheckbox2];
const permissions = await Promise.all([
isAllowedIncognitoAccess(),
isAllowedFileSchemeAccess(),
]);
for (let i = 0, ii = permissions.length; i < ii; ++i) {
permissionsCheckboxes[i].checked = permissions[i];
}
const permissionsToggleController = new PermissionsToggleController(settingsController);
void permissionsToggleController.prepare();
const permissionsOriginController = new PermissionsOriginController(settingsController);
void permissionsOriginController.prepare();
const persistentStorageController = new PersistentStorageController(application);
void persistentStorageController.prepare();
await promiseTimeout(100);
document.documentElement.dataset.loaded = 'true';
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,667 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ExtensionError} from '../../core/extension-error.js';
import {log} from '../../core/log.js';
import {toError} from '../../core/to-error.js';
import {AnkiNoteBuilder} from '../../data/anki-note-builder.js';
import {getDynamicTemplates} from '../../data/anki-template-util.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {getLanguageSummaries} from '../../language/languages.js';
import {getRequiredAudioSources} from '../../media/audio-downloader.js';
import {TemplateRendererProxy} from '../../templates/template-renderer-proxy.js';
export class AnkiDeckGeneratorController {
/**
* @param {import('../../application.js').Application} application
* @param {import('./settings-controller.js').SettingsController} settingsController
* @param {import('./modal-controller.js').ModalController} modalController
* @param {import('./anki-controller.js').AnkiController} ankiController
*/
constructor(application, settingsController, modalController, ankiController) {
/** @type {import('../../application.js').Application} */
this._application = application;
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {import('./modal-controller.js').ModalController} */
this._modalController = modalController;
/** @type {import('./anki-controller.js').AnkiController} */
this._ankiController = ankiController;
/** @type {?string} */
this._defaultFieldTemplates = null;
/** @type {HTMLTextAreaElement} */
this._mainSettingsEntry = querySelectorNotNull(document, '#generate-anki-notes-main-settings-entry');
/** @type {HTMLTextAreaElement} */
this._wordInputTextarea = querySelectorNotNull(document, '#generate-anki-notes-textarea');
/** @type {HTMLInputElement} */
this._renderTextInput = querySelectorNotNull(document, '#generate-anki-notes-test-text-input');
/** @type {HTMLElement} */
this._renderResult = querySelectorNotNull(document, '#generate-anki-notes-render-result');
/** @type {HTMLElement} */
this._activeModelText = querySelectorNotNull(document, '#generate-anki-notes-active-model');
/** @type {HTMLElement} */
this._activeDeckText = querySelectorNotNull(document, '#generate-anki-notes-active-deck');
/** @type {HTMLSelectElement} */
this._activeFlashcardFormatSelect = querySelectorNotNull(document, '#generate-anki-flashcard-format');
/** @type {import('settings').AnkiCardFormat[]} */
this._flashcardFormatDetails = [];
/** @type {HTMLInputElement} */
this._addMediaCheckbox = querySelectorNotNull(document, '#generate-anki-notes-add-media');
/** @type {HTMLInputElement} */
this._disallowDuplicatesCheckbox = querySelectorNotNull(document, '#generate-anki-notes-disallow-duplicates');
/** @type {string} */
this._activeNoteType = '';
/** @type {string} */
this._activeAnkiDeck = '';
/** @type {HTMLSpanElement} */
this._sendWordcount = querySelectorNotNull(document, '#generate-anki-notes-send-wordcount');
/** @type {HTMLSpanElement} */
this._exportWordcount = querySelectorNotNull(document, '#generate-anki-notes-export-wordcount');
/** @type {HTMLButtonElement} */
this._sendToAnkiButtonConfirmButton = querySelectorNotNull(document, '#generate-anki-notes-send-button-confirm');
/** @type {HTMLButtonElement} */
this._exportButtonConfirmButton = querySelectorNotNull(document, '#generate-anki-notes-export-button-confirm');
/** @type {NodeListOf<HTMLElement>} */
this._progressContainers = (document.querySelectorAll('.generate-anki-notes-progress'));
/** @type {?import('./modal.js').Modal} */
this._sendToAnkiConfirmModal = null;
/** @type {?import('./modal.js').Modal} */
this._exportConfirmModal = null;
/** @type {boolean} */
this._cancel = false;
/** @type {boolean} */
this._inProgress = false;
/** @type {AnkiNoteBuilder} */
this._ankiNoteBuilder = new AnkiNoteBuilder(settingsController.application.api, new TemplateRendererProxy());
}
/** */
async prepare() {
this._defaultFieldTemplates = await this._settingsController.application.api.getDefaultAnkiFieldTemplates();
/** @type {HTMLButtonElement} */
const parseWordsButton = querySelectorNotNull(document, '#generate-anki-notes-parse-button');
/** @type {HTMLButtonElement} */
const dedupeWordsButton = querySelectorNotNull(document, '#generate-anki-notes-dedupe-button');
/** @type {HTMLButtonElement} */
const testRenderButton = querySelectorNotNull(document, '#generate-anki-notes-test-render-button');
/** @type {HTMLButtonElement} */
const sendToAnkiButton = querySelectorNotNull(document, '#generate-anki-notes-send-to-anki-button');
/** @type {HTMLButtonElement} */
const sendToAnkiCancelButton = querySelectorNotNull(document, '#generate-anki-notes-send-to-anki-cancel-button');
/** @type {HTMLButtonElement} */
const exportButton = querySelectorNotNull(document, '#generate-anki-notes-export-button');
/** @type {HTMLButtonElement} */
const exportCancelButton = querySelectorNotNull(document, '#generate-anki-notes-export-cancel-button');
/** @type {HTMLButtonElement} */
const generateButton = querySelectorNotNull(document, '#generate-anki-notes-export-button');
this._sendToAnkiConfirmModal = this._modalController.getModal('generate-anki-notes-send-to-anki');
this._exportConfirmModal = this._modalController.getModal('generate-anki-notes-export');
parseWordsButton.addEventListener('click', this._onParse.bind(this), false);
dedupeWordsButton.addEventListener('click', this._onDedupe.bind(this), false);
testRenderButton.addEventListener('click', this._onRender.bind(this), false);
sendToAnkiButton.addEventListener('click', this._onSendToAnki.bind(this), false);
this._sendToAnkiButtonConfirmButton.addEventListener('click', this._onSendToAnkiConfirm.bind(this), false);
sendToAnkiCancelButton.addEventListener('click', (() => { this._cancel = true; }).bind(this), false);
exportButton.addEventListener('click', this._onExport.bind(this), false);
this._exportButtonConfirmButton.addEventListener('click', this._onExportConfirm.bind(this), false);
exportCancelButton.addEventListener('click', (() => { this._cancel = true; }).bind(this), false);
generateButton.addEventListener('click', this._onExport.bind(this), false);
void this._updateExampleText();
this._mainSettingsEntry.addEventListener('click', this._updateExampleText.bind(this), false);
void this._setupModelSelection();
this._mainSettingsEntry.addEventListener('click', this._setupModelSelection.bind(this), false);
this._activeFlashcardFormatSelect.addEventListener('change', this._updateActiveModel.bind(this), false);
}
// Private
/** */
async _onParse() {
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
const parserResult = await this._application.api.parseText(this._wordInputTextarea.value, optionsContext, options.scanning.length, !options.parsing.enableMecabParser, options.parsing.enableMecabParser);
const parsedText = parserResult[0].content;
const parsedParts = [];
for (const parsedTextLine of parsedText) {
let combinedSegments = '';
for (const parsedTextSegment of parsedTextLine) {
combinedSegments += parsedTextSegment.text;
}
combinedSegments = combinedSegments.trim();
if (combinedSegments.length > 0) {
parsedParts.push(combinedSegments);
}
}
this._wordInputTextarea.value = parsedParts.join('\n');
}
/** */
_onDedupe() {
this._wordInputTextarea.value = [...new Set(this._wordInputTextarea.value.split('\n'))].join('\n');
}
/** */
async _setupModelSelection() {
const activeFlashcardFormat = /** @type {HTMLSelectElement} */ (this._activeFlashcardFormatSelect);
const options = await this._settingsController.getOptions();
this._flashcardFormatDetails = options.anki.cardFormats;
activeFlashcardFormat.innerHTML = '';
for (let i = 0; i < options.anki.cardFormats.length; i++) {
const option = document.createElement('option');
option.value = i.toString();
option.text = options.anki.cardFormats[i].name;
activeFlashcardFormat.add(option);
}
void this._updateActiveModel();
}
/** */
async _updateActiveModel() {
const activeModelText = /** @type {HTMLElement} */ (this._activeModelText);
const activeDeckText = /** @type {HTMLElement} */ (this._activeDeckText);
const activeDeckTextConfirm = querySelectorNotNull(document, '#generate-anki-notes-active-deck-confirm');
const index = Number(this._activeFlashcardFormatSelect.value);
this._activeNoteType = this._flashcardFormatDetails[index].model;
this._activeAnkiDeck = this._flashcardFormatDetails[index].deck;
activeModelText.textContent = this._activeNoteType;
activeDeckText.textContent = this._activeAnkiDeck;
activeDeckTextConfirm.textContent = this._activeAnkiDeck;
}
/** */
async _resetState() {
this._updateProgressBar(true, '', 0, 1, false);
this._cancel = false;
this._exportButtonConfirmButton.disabled = false;
this._exportWordcount.textContent = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n').filter(Boolean).length.toString();
this._sendToAnkiButtonConfirmButton.disabled = false;
this._addMediaCheckbox.disabled = false;
this._disallowDuplicatesCheckbox.disabled = false;
this._sendWordcount.textContent = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n').filter(Boolean).length.toString();
}
/** */
async _startGenerationState() {
this._inProgress = true;
this._exportButtonConfirmButton.disabled = true;
this._sendToAnkiButtonConfirmButton.disabled = true;
this._addMediaCheckbox.disabled = true;
this._disallowDuplicatesCheckbox.disabled = true;
}
/** */
async _endGenerationState() {
this._inProgress = false;
if (this._exportConfirmModal !== null) {
this._exportConfirmModal.setVisible(false);
}
if (this._sendToAnkiConfirmModal !== null) {
this._sendToAnkiConfirmModal.setVisible(false);
}
this._updateProgressBar(false, '', 1, 1, false);
}
/** */
async _endGenerationStateError() {
this._inProgress = false;
}
/**
* @param {MouseEvent} e
*/
_onExport(e) {
e.preventDefault();
if (this._exportConfirmModal !== null) {
this._exportConfirmModal.setVisible(true);
if (this._inProgress) { return; }
void this._resetState();
}
}
/**
* @param {MouseEvent} e
*/
async _onExportConfirm(e) {
e.preventDefault();
void this._startGenerationState();
const terms = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n');
let ankiTSV = '#separator:tab\n#html:true\n#notetype column:1\n#deck column:2\n#tags column:3\n';
let index = 0;
requestAnimationFrame(() => {
this._updateProgressBar(true, 'Exporting to File...', 0, terms.length, true);
setTimeout(async () => {
for (const value of terms) {
if (!value) { continue; }
if (this._cancel) {
void this._endGenerationState();
return;
}
const noteData = await this._generateNoteData(value, false);
if (noteData !== null) {
const fieldsTSV = this._fieldsToTSV(noteData.fields);
if (fieldsTSV) {
ankiTSV += this._activeNoteType + '\t';
ankiTSV += this._activeAnkiDeck + '\t';
ankiTSV += noteData.tags.join(' ') + '\t';
ankiTSV += fieldsTSV;
ankiTSV += '\n';
}
}
index++;
this._updateProgressBar(false, '', index, terms.length, true);
}
const today = new Date();
const fileName = 'anki-deck-' + today.toISOString().split('.')[0].replaceAll(/(T|:)/g, '-') + '.txt';
const blob = new Blob([ankiTSV], {type: 'application/octet-stream'});
this._saveBlob(blob, fileName);
void this._endGenerationState();
}, 1);
});
}
/**
* @param {MouseEvent} e
*/
_onSendToAnki(e) {
e.preventDefault();
if (this._sendToAnkiConfirmModal !== null) {
this._sendToAnkiConfirmModal.setVisible(true);
if (this._inProgress) { return; }
void this._resetState();
}
}
/**
* @param {MouseEvent} e
*/
async _onSendToAnkiConfirm(e) {
e.preventDefault();
void this._startGenerationState();
const terms = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n');
const addMedia = this._addMediaCheckbox.checked;
const disallowDuplicates = this._disallowDuplicatesCheckbox.checked;
/** @type {import("anki.js").Note[]} */
let notes = [];
let index = 0;
requestAnimationFrame(() => {
this._updateProgressBar(true, 'Sending to Anki...', 0, terms.length, true);
setTimeout(async () => {
for (const value of terms) {
if (!value) { continue; }
if (this._cancel) {
void this._endGenerationState();
return;
}
const noteData = await this._generateNoteData(value, addMedia);
if (noteData) {
notes.push(noteData);
}
if (notes.length >= 100) {
const sendNotesResult = await this._sendNotes(notes, disallowDuplicates);
if (sendNotesResult === false) {
void this._endGenerationStateError();
return;
}
notes = [];
}
index++;
this._updateProgressBar(false, '', index, terms.length, true);
}
if (notes.length > 0) {
const sendNotesResult = await this._sendNotes(notes, disallowDuplicates);
if (sendNotesResult === false) {
void this._endGenerationStateError();
return;
}
}
void this._endGenerationState();
}, 1);
});
}
/**
* @param {import("anki.js").Note[]} notes
* @param {boolean} disallowDuplicates
* @returns {Promise<boolean>}
*/
async _sendNotes(notes, disallowDuplicates) {
try {
if (disallowDuplicates) {
const duplicateNotes = await this._ankiController.canAddNotes(notes.map((note) => ({...note, options: {...note.options, allowDuplicate: false}})));
notes = notes.filter((_, i) => duplicateNotes[i]);
}
const addNotesResult = await this._ankiController.addNotes(notes);
if (addNotesResult === null || addNotesResult.includes(null)) {
this._updateProgressBarError('Ankiconnect error: Failed to add cards');
return false;
}
} catch (error) {
if (error instanceof Error) {
this._updateProgressBarError('Ankiconnect error: ' + error.message + '');
log.error(error);
return false;
}
}
return true;
}
/**
* @param {boolean} init
* @param {string} text
* @param {number} current
* @param {number} end
* @param {boolean} visible
*/
_updateProgressBar(init, text, current, end, visible) {
if (!visible) {
for (const progress of this._progressContainers) { progress.hidden = true; }
return;
}
if (init) {
for (const progress of this._progressContainers) {
progress.hidden = false;
for (const infoLabel of progress.querySelectorAll('.progress-info')) {
infoLabel.textContent = text;
infoLabel.classList.remove('danger-text');
}
}
}
for (const progress of this._progressContainers) {
/** @type {NodeListOf<HTMLElement>} */
const statusLabels = progress.querySelectorAll('.progress-status');
for (const statusLabel of statusLabels) { statusLabel.textContent = ((current / end) * 100).toFixed(0).toString() + '%'; }
/** @type {NodeListOf<HTMLElement>} */
const progressBars = progress.querySelectorAll('.progress-bar');
for (const progressBar of progressBars) { progressBar.style.width = ((current / end) * 100).toString() + '%'; }
}
}
/**
* @param {string} text
*/
_updateProgressBarError(text) {
for (const progress of this._progressContainers) {
progress.hidden = false;
for (const infoLabel of progress.querySelectorAll('.progress-info')) {
infoLabel.textContent = text;
infoLabel.classList.add('danger-text');
}
}
}
/**
* @param {HTMLElement} infoNode
* @param {boolean} showSuccessResult
*/
async _testNoteData(infoNode, showSuccessResult) {
/** @type {Error[]} */
const allErrors = [];
const text = /** @type {HTMLInputElement} */ (this._renderTextInput).value;
let result;
try {
const noteData = await this._generateNoteData(text, false);
result = noteData ? this._fieldsToTSV(noteData.fields) : `No definition found for ${text}`;
} catch (e) {
allErrors.push(toError(e));
}
/**
* @param {Error} e
* @returns {string}
*/
const errorToMessageString = (e) => {
if (e instanceof ExtensionError) {
const v = e.data;
if (typeof v === 'object' && v !== null) {
const v2 = /** @type {import('core').UnknownObject} */ (v).error;
if (v2 instanceof Error) {
return v2.message;
}
}
}
return e.message;
};
const hasError = allErrors.length > 0;
infoNode.hidden = !(showSuccessResult || hasError);
if (hasError || !result) {
infoNode.textContent = allErrors.map(errorToMessageString).join('\n');
} else {
infoNode.textContent = showSuccessResult ? result : '';
}
infoNode.classList.toggle('text-danger', hasError);
}
/**
* @param {string} word
* @param {boolean} addMedia
* @returns {Promise<?import('anki.js').Note>}
*/
async _generateNoteData(word, addMedia) {
const optionsContext = this._settingsController.getOptionsContext();
const activeFlashcardFormatDetails = this._flashcardFormatDetails[Number(this._activeFlashcardFormatSelect.value)];
const data = await this._getDictionaryEntry(word, optionsContext, activeFlashcardFormatDetails.type);
if (data === null) {
return null;
}
const {dictionaryEntry, text: sentenceText} = data;
const options = await this._settingsController.getOptions();
const context = {
url: window.location.href,
sentence: {
text: sentenceText,
offset: 0,
},
documentTitle: document.title,
query: sentenceText,
fullQuery: sentenceText,
};
const template = await this._getAnkiTemplate(options);
const deckOptionsFields = activeFlashcardFormatDetails.fields;
const {general: {resultOutputMode, glossaryLayoutMode, compactTags}} = options;
const idleTimeout = (Number.isFinite(options.anki.downloadTimeout) && options.anki.downloadTimeout > 0 ? options.anki.downloadTimeout : null);
const languageSummary = getLanguageSummaries().find(({iso}) => iso === options.general.language);
const requiredAudioSources = options.audio.enableDefaultAudioSources ? getRequiredAudioSources(options.general.language, options.audio.sources) : [];
const mediaOptions = addMedia ? {audio: {sources: [...options.audio.sources, ...requiredAudioSources], preferredAudioIndex: null, idleTimeout: idleTimeout, languageSummary: languageSummary}} : null;
const requirements = addMedia ? [...getDictionaryEntryMedia(dictionaryEntry), {type: 'audio'}] : [];
const dictionaryStylesMap = this._ankiNoteBuilder.getDictionaryStylesMap(options.dictionaries);
const cardFormat = /** @type {import('settings').AnkiCardFormat} */ ({
deck: this._activeAnkiDeck,
model: this._activeNoteType,
fields: deckOptionsFields,
type: activeFlashcardFormatDetails.type,
name: '',
icon: 'big-circle',
});
const {note} = await this._ankiNoteBuilder.createNote(/** @type {import('anki-note-builder').CreateNoteDetails} */ ({
dictionaryEntry,
cardFormat,
context,
template,
resultOutputMode,
glossaryLayoutMode,
compactTags,
tags: options.anki.tags,
mediaOptions: mediaOptions,
requirements: requirements,
duplicateScope: options.anki.duplicateScope,
duplicateScopeCheckAllModels: options.anki.duplicateScopeCheckAllModels,
dictionaryStylesMap: dictionaryStylesMap,
}));
return note;
}
/**
* @param {string} text
* @param {import('settings').OptionsContext} optionsContext
* @param {import('settings').AnkiCardFormatType} type
* @returns {Promise<?{dictionaryEntry: (import('dictionary').DictionaryEntry), text: string}>}
*/
async _getDictionaryEntry(text, optionsContext, type) {
let dictionaryEntriesTermKanji = null;
if (type === 'term') {
const {dictionaryEntries} = await this._settingsController.application.api.termsFind(text, {}, optionsContext);
dictionaryEntriesTermKanji = dictionaryEntries;
}
if (type === 'kanji') {
dictionaryEntriesTermKanji = await this._settingsController.application.api.kanjiFind(text[0], optionsContext);
}
if (!dictionaryEntriesTermKanji || dictionaryEntriesTermKanji.length === 0) { return null; }
return {
dictionaryEntry: /** @type {import('dictionary').DictionaryEntry} */ (dictionaryEntriesTermKanji[0]),
text: text,
};
}
/**
* @param {import('settings').ProfileOptions} options
* @returns {Promise<string>}
*/
async _getAnkiTemplate(options) {
let staticTemplates = options.anki.fieldTemplates;
if (typeof staticTemplates !== 'string') { staticTemplates = this._defaultFieldTemplates; }
const dictionaryInfo = await this._application.api.getDictionaryInfo();
const dynamicTemplates = getDynamicTemplates(options, dictionaryInfo);
return staticTemplates + '\n' + dynamicTemplates;
}
/**
* @param {Event} e
*/
_onRender(e) {
e.preventDefault();
const infoNode = /** @type {HTMLElement} */ (this._renderResult);
infoNode.hidden = true;
void this._testNoteData(infoNode, true);
}
/** */
async _updateExampleText() {
const languageSummaries = await this._application.api.getLanguageSummaries();
const options = await this._settingsController.getOptions();
const activeLanguage = /** @type {import('language').LanguageSummary} */ (languageSummaries.find(({iso}) => iso === options.general.language));
this._renderTextInput.lang = options.general.language;
this._renderTextInput.value = activeLanguage.exampleText;
this._renderResult.lang = options.general.language;
}
/**
* @param {import('anki.js').NoteFields} noteFields
* @returns {string}
*/
_fieldsToTSV(noteFields) {
let tsv = '';
for (const key in noteFields) {
if (Object.prototype.hasOwnProperty.call(noteFields, key)) {
tsv += noteFields[key].replaceAll('\t', '&nbsp;&nbsp;&nbsp;').replaceAll('\n', '').replaceAll('\r', '') + '\t';
}
}
return tsv;
}
/**
* @param {Blob} blob
* @param {string} fileName
*/
_saveBlob(blob, fileName) {
if (
typeof navigator === 'object' && navigator !== null &&
// @ts-expect-error - call for legacy Edge
typeof navigator.msSaveBlob === 'function' &&
// @ts-expect-error - call for legacy Edge
navigator.msSaveBlob(blob)
) {
return;
}
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
a.rel = 'noopener';
a.target = '_blank';
const revoke = () => {
URL.revokeObjectURL(blobUrl);
a.href = '';
this._settingsExportRevoke = null;
};
this._settingsExportRevoke = revoke;
a.dispatchEvent(new MouseEvent('click'));
setTimeout(revoke, 60000);
}
}
/**
* @param {import('dictionary').DictionaryEntry} dictionaryEntry
* @returns {Array<import('anki-note-builder').RequirementDictionaryMedia>}
*/
export function getDictionaryEntryMedia(dictionaryEntry) {
if (dictionaryEntry.type !== 'term') {
return [];
}
/** @type {Array<import('anki-note-builder').RequirementDictionaryMedia>} */
const media = [];
const definitions = dictionaryEntry.definitions;
for (const definition of definitions) {
const paths = [...new Set(findAllPaths(definition))];
for (const path of paths) {
media.push({dictionary: definition.dictionary, path: path, type: 'dictionaryMedia'});
}
}
return media;
}
/**
* Extracts all values of json keys named `path` which contain a string value.
* Example json snippet containing a path:
* ...","path":"example-dictionary/svg/example-media.svg","...
* The path can be found in many different positions in the structure of the definition json.
* It is most reliable to flatten it to a string and use regex.
* @param {object} obj
* @returns {Array<string>}
*/
function findAllPaths(obj) {
return JSON.stringify(obj).match(/(?<="path":").*?(?=")/g) ?? [];
}

View File

@@ -0,0 +1,335 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2019-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ExtensionError} from '../../core/extension-error.js';
import {toError} from '../../core/to-error.js';
import {AnkiNoteBuilder} from '../../data/anki-note-builder.js';
import {getDynamicTemplates} from '../../data/anki-template-util.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {TemplateRendererProxy} from '../../templates/template-renderer-proxy.js';
export class AnkiTemplatesController {
/**
* @param {import('../../application.js').Application} application
* @param {import('./settings-controller.js').SettingsController} settingsController
* @param {import('./modal-controller.js').ModalController} modalController
* @param {import('./anki-controller.js').AnkiController} ankiController
*/
constructor(application, settingsController, modalController, ankiController) {
/** @type {import('../../application.js').Application} */
this._application = application;
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {import('./modal-controller.js').ModalController} */
this._modalController = modalController;
/** @type {import('./anki-controller.js').AnkiController} */
this._ankiController = ankiController;
/** @type {?import('dictionary').TermDictionaryEntry} */
this._cachedDictionaryEntryValue = null;
/** @type {?string} */
this._cachedDictionaryEntryText = null;
/** @type {?string} */
this._defaultFieldTemplates = null;
/** @type {HTMLTextAreaElement} */
this._fieldTemplatesTextarea = querySelectorNotNull(document, '#anki-card-templates-textarea');
/** @type {HTMLElement} */
this._compileResultInfo = querySelectorNotNull(document, '#anki-card-templates-compile-result');
/** @type {HTMLInputElement} */
this._renderFieldInput = querySelectorNotNull(document, '#anki-card-templates-test-field-input');
/** @type {HTMLInputElement} */
this._renderTextInput = querySelectorNotNull(document, '#anki-card-templates-test-text-input');
/** @type {HTMLElement} */
this._renderResult = querySelectorNotNull(document, '#anki-card-templates-render-result');
/** @type {HTMLElement} */
this._mainSettingsEntry = querySelectorNotNull(document, '[data-modal-action="show,anki-card-templates"]');
/** @type {?import('./modal.js').Modal} */
this._fieldTemplateResetModal = null;
/** @type {AnkiNoteBuilder} */
this._ankiNoteBuilder = new AnkiNoteBuilder(settingsController.application.api, new TemplateRendererProxy());
}
/** */
async prepare() {
this._defaultFieldTemplates = await this._settingsController.application.api.getDefaultAnkiFieldTemplates();
/** @type {HTMLButtonElement} */
const menuButton = querySelectorNotNull(document, '#anki-card-templates-test-field-menu-button');
/** @type {HTMLButtonElement} */
const testRenderButton = querySelectorNotNull(document, '#anki-card-templates-test-render-button');
/** @type {HTMLButtonElement} */
const resetButton = querySelectorNotNull(document, '#anki-card-templates-reset-button');
/** @type {HTMLButtonElement} */
const resetConfirmButton = querySelectorNotNull(document, '#anki-card-templates-reset-button-confirm');
this._fieldTemplateResetModal = this._modalController.getModal('anki-card-templates-reset');
this._fieldTemplatesTextarea.addEventListener('change', this._onChanged.bind(this), false);
testRenderButton.addEventListener('click', this._onRender.bind(this), false);
resetButton.addEventListener('click', this._onReset.bind(this), false);
resetConfirmButton.addEventListener('click', this._onResetConfirm.bind(this), false);
if (menuButton !== null) {
menuButton.addEventListener(
/** @type {string} */ ('menuClose'),
/** @type {EventListener} */ (this._onFieldMenuClose.bind(this)),
false,
);
}
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._onOptionsChanged({options, optionsContext});
void this._updateExampleText();
this._mainSettingsEntry.addEventListener('click', this._updateExampleText.bind(this), false);
}
// Private
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
let templates = options.anki.fieldTemplates;
if (typeof templates !== 'string') {
templates = this._defaultFieldTemplates;
if (typeof templates !== 'string') { templates = ''; }
}
/** @type {HTMLTextAreaElement} */ (this._fieldTemplatesTextarea).value = templates;
this._onValidateCompile();
}
/**
* @param {MouseEvent} e
*/
_onReset(e) {
e.preventDefault();
if (this._fieldTemplateResetModal !== null) {
this._fieldTemplateResetModal.setVisible(true);
}
}
/**
* @param {MouseEvent} e
*/
_onResetConfirm(e) {
e.preventDefault();
if (this._fieldTemplateResetModal !== null) {
this._fieldTemplateResetModal.setVisible(false);
}
const value = this._defaultFieldTemplates;
const textarea = /** @type {HTMLTextAreaElement} */ (this._fieldTemplatesTextarea);
textarea.value = typeof value === 'string' ? value : '';
textarea.dispatchEvent(new Event('change'));
}
/**
* @param {Event} e
*/
async _onChanged(e) {
// Get value
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
/** @type {?string} */
let templates = element.value;
if (templates === this._defaultFieldTemplates) {
// Default
templates = null;
}
// Overwrite
await this._settingsController.setProfileSetting('anki.fieldTemplates', templates);
// Compile
this._onValidateCompile();
}
/** */
_onValidateCompile() {
if (this._compileResultInfo === null) { return; }
void this._validate(this._compileResultInfo, '{expression}', false, true);
}
/**
* @param {Event} e
*/
_onRender(e) {
e.preventDefault();
const field = /** @type {HTMLInputElement} */ (this._renderFieldInput).value;
const infoNode = /** @type {HTMLElement} */ (this._renderResult);
infoNode.hidden = true;
this._cachedDictionaryEntryText = null;
void this._validate(infoNode, field, true, false);
}
/** */
async _updateExampleText() {
const languageSummaries = await this._application.api.getLanguageSummaries();
const options = await this._settingsController.getOptions();
const activeLanguage = /** @type {import('language').LanguageSummary} */ (languageSummaries.find(({iso}) => iso === options.general.language));
this._renderTextInput.lang = options.general.language;
this._renderTextInput.value = activeLanguage.exampleText;
this._renderResult.lang = options.general.language;
}
/**
* @param {import('popup-menu').MenuCloseEvent} event
*/
_onFieldMenuClose({detail: {action, item}}) {
switch (action) {
case 'setFieldMarker':
{
const {marker} = /** @type {HTMLElement} */ (item).dataset;
if (typeof marker === 'string') {
this._setFieldMarker(marker);
}
}
break;
}
}
/**
* @param {string} marker
*/
_setFieldMarker(marker) {
const input = /** @type {HTMLInputElement} */ (this._renderFieldInput);
input.value = `{${marker}}`;
input.dispatchEvent(new Event('change'));
}
/**
* @param {string} text
* @param {import('settings').OptionsContext} optionsContext
* @returns {Promise<?{dictionaryEntry: import('dictionary').TermDictionaryEntry, text: string}>}
*/
async _getDictionaryEntry(text, optionsContext) {
if (this._cachedDictionaryEntryText !== text) {
const {dictionaryEntries} = await this._settingsController.application.api.termsFind(text, {}, optionsContext);
if (dictionaryEntries.length === 0) { return null; }
this._cachedDictionaryEntryValue = dictionaryEntries[0];
this._cachedDictionaryEntryText = text;
}
return {
dictionaryEntry: /** @type {import('dictionary').TermDictionaryEntry} */ (this._cachedDictionaryEntryValue),
text: this._cachedDictionaryEntryText,
};
}
/**
* @param {HTMLElement} infoNode
* @param {string} field
* @param {boolean} showSuccessResult
* @param {boolean} invalidateInput
*/
async _validate(infoNode, field, showSuccessResult, invalidateInput) {
/** @type {Error[]} */
const allErrors = [];
const text = /** @type {HTMLInputElement} */ (this._renderTextInput).value;
let result = `No definition found for ${text}`;
try {
const optionsContext = this._settingsController.getOptionsContext();
const data = await this._getDictionaryEntry(text, optionsContext);
if (data !== null) {
const {dictionaryEntry, text: sentenceText} = data;
const options = await this._settingsController.getOptions();
const context = {
url: window.location.href,
sentence: {
text: sentenceText,
offset: 0,
},
documentTitle: document.title,
query: sentenceText,
fullQuery: sentenceText,
};
const template = await this._getAnkiTemplate(options);
const {general: {resultOutputMode, glossaryLayoutMode, compactTags}} = options;
const fields = {
field: {
value: field,
overwriteMode: 'skip',
},
};
const cardFormat = /** @type {import('settings').AnkiCardFormat} */ ({
type: 'term',
name: '',
deck: '',
model: '',
fields,
icon: 'big-circle',
});
const {note, errors} = await this._ankiNoteBuilder.createNote(/** @type {import('anki-note-builder').CreateNoteDetails} */ ({
dictionaryEntry,
context,
template,
cardFormat,
resultOutputMode,
glossaryLayoutMode,
compactTags,
dictionaryStylesMap: this._ankiNoteBuilder.getDictionaryStylesMap(options.dictionaries),
}));
result = note.fields.field;
allErrors.push(...errors);
}
} catch (e) {
allErrors.push(toError(e));
}
/**
* @param {Error} e
* @returns {string}
*/
const errorToMessageString = (e) => {
if (e instanceof ExtensionError) {
const v = e.data;
if (typeof v === 'object' && v !== null) {
const v2 = /** @type {import('core').UnknownObject} */ (v).error;
if (v2 instanceof Error) {
return v2.message;
}
}
}
return e.message;
};
const hasError = allErrors.length > 0;
infoNode.hidden = !(showSuccessResult || hasError);
infoNode.textContent = hasError ? allErrors.map(errorToMessageString).join('\n') : (showSuccessResult ? result : '');
infoNode.classList.toggle('text-danger', hasError);
if (invalidateInput) {
/** @type {HTMLTextAreaElement} */ (this._fieldTemplatesTextarea).dataset.invalid = `${hasError}`;
}
}
/**
* @param {import('settings').ProfileOptions} options
* @returns {Promise<string>}
*/
async _getAnkiTemplate(options) {
let staticTemplates = options.anki.fieldTemplates;
if (typeof staticTemplates !== 'string') { staticTemplates = this._defaultFieldTemplates; }
const dictionaryInfo = await this._application.api.getDictionaryInfo();
const dynamicTemplates = getDynamicTemplates(options, dictionaryInfo);
return staticTemplates + '\n' + dynamicTemplates;
}
}

View File

@@ -0,0 +1,604 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2019-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventDispatcher} from '../../core/event-dispatcher.js';
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {AudioSystem} from '../../media/audio-system.js';
/**
* @augments EventDispatcher<import('audio-controller').Events>
*/
export class AudioController extends EventDispatcher {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
* @param {import('./modal-controller.js').ModalController} modalController
*/
constructor(settingsController, modalController) {
super();
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {import('./modal-controller.js').ModalController} */
this._modalController = modalController;
/** @type {AudioSystem} */
this._audioSystem = new AudioSystem();
/** @type {HTMLElement} */
this._audioSourceContainer = querySelectorNotNull(document, '#audio-source-list');
/** @type {HTMLButtonElement} */
this._audioSourceAddButton = querySelectorNotNull(document, '#audio-source-add');
/** @type {AudioSourceEntry[]} */
this._audioSourceEntries = [];
/** @type {HTMLInputElement} */
this._voiceTestTextInput = querySelectorNotNull(document, '#text-to-speech-voice-test-text');
/** @type {import('audio-controller').VoiceInfo[]} */
this._voices = [];
/** @type {string} */
this._language = 'ja';
}
/** @type {import('./settings-controller.js').SettingsController} */
get settingsController() {
return this._settingsController;
}
/** @type {import('./modal-controller.js').ModalController} */
get modalController() {
return this._modalController;
}
/** @type {number} */
get audioSourceCount() {
return this._audioSourceEntries.length;
}
/** */
async prepare() {
this._audioSystem.prepare();
this._audioSourceContainer.textContent = '';
/** @type {HTMLButtonElement} */
const testButton = querySelectorNotNull(document, '#text-to-speech-voice-test');
/** @type {HTMLButtonElement} */
const audioSourceMoveButton = querySelectorNotNull(document, '#audio-source-move-button');
audioSourceMoveButton.addEventListener('click', this._onAudioSourceMoveButtonClick.bind(this), false);
this._audioSourceAddButton.addEventListener('click', this._onAddAudioSource.bind(this), false);
this._audioSystem.on('voiceschanged', this._updateTextToSpeechVoices.bind(this));
this._updateTextToSpeechVoices();
testButton.addEventListener('click', this._onTestTextToSpeech.bind(this), false);
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._onOptionsChanged({options, optionsContext});
}
/**
* @param {AudioSourceEntry} entry
*/
async removeSource(entry) {
const {index} = entry;
this._audioSourceEntries.splice(index, 1);
entry.cleanup();
for (let i = index, ii = this._audioSourceEntries.length; i < ii; ++i) {
this._audioSourceEntries[i].index = i;
}
await this._settingsController.modifyProfileSettings([{
action: 'splice',
path: 'audio.sources',
start: index,
deleteCount: 1,
items: [],
}]);
}
/**
* @param {number} currentIndex
* @param {number} targetIndex
*/
async moveAudioSourceOptions(currentIndex, targetIndex) {
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
const {audio} = options;
if (
currentIndex < 0 || currentIndex >= audio.sources.length ||
targetIndex < 0 || targetIndex >= audio.sources.length ||
currentIndex === targetIndex
) {
return;
}
const item = audio.sources.splice(currentIndex, 1)[0];
audio.sources.splice(targetIndex, 0, item);
await this._settingsController.modifyProfileSettings([{
action: 'set',
path: 'audio.sources',
value: audio.sources,
}]);
this._onOptionsChanged({options, optionsContext});
}
/**
* @returns {import('audio-controller').VoiceInfo[]}
*/
getVoices() {
return this._voices;
}
/**
* @param {string} voice
*/
setTestVoice(voice) {
/** @type {HTMLInputElement} */ (this._voiceTestTextInput).dataset.voice = voice;
}
// Private
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
const {
general: {language},
audio: {sources},
} = options;
this._language = language;
for (const entry of this._audioSourceEntries) {
entry.cleanup();
}
this._audioSourceEntries = [];
for (let i = 0, ii = sources.length; i < ii; ++i) {
this._createAudioSourceEntry(i, sources[i]);
}
}
/** */
_onAddAudioSource() {
void this._addAudioSource();
}
/** */
_onTestTextToSpeech() {
try {
const input = /** @type {HTMLInputElement} */ (this._voiceTestTextInput);
const text = input.value || '';
const voiceUri = input.dataset.voice;
const audio = this._audioSystem.createTextToSpeechAudio(text, typeof voiceUri === 'string' ? voiceUri : '');
audio.volume = 1;
void audio.play();
} catch (e) {
// NOP
}
}
/** */
_updateTextToSpeechVoices() {
const voices = (
typeof speechSynthesis !== 'undefined' ?
[...speechSynthesis.getVoices()].map((voice, index) => ({
voice,
isJapanese: this._languageTagIsJapanese(voice.lang),
index,
})) :
[]
);
voices.sort(this._textToSpeechVoiceCompare.bind(this));
this._voices = voices;
this.trigger('voicesUpdated', {});
}
/**
* @param {import('audio-controller').VoiceInfo} a
* @param {import('audio-controller').VoiceInfo} b
* @returns {number}
*/
_textToSpeechVoiceCompare(a, b) {
if (a.isJapanese) {
if (!b.isJapanese) { return -1; }
} else {
if (b.isJapanese) { return 1; }
}
if (a.voice.default) {
if (!b.voice.default) { return -1; }
} else {
if (b.voice.default) { return 1; }
}
return a.index - b.index;
}
/**
* @param {string} languageTag
* @returns {boolean}
*/
_languageTagIsJapanese(languageTag) {
return (
languageTag.startsWith('ja_') ||
languageTag.startsWith('ja-') ||
languageTag.startsWith('jpn-')
);
}
/**
* @param {number} index
* @param {import('settings').AudioSourceOptions} source
*/
_createAudioSourceEntry(index, source) {
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('audio-source'));
const entry = new AudioSourceEntry(this, index, source, node);
this._audioSourceEntries.push(entry);
/** @type {HTMLElement} */ (this._audioSourceContainer).appendChild(node);
entry.prepare();
}
/**
* @returns {import('settings').AudioSourceType}
*/
_getUnusedAudioSourceType() {
const typesAvailable = this._getAvailableAudioSourceTypes();
for (const type of typesAvailable) {
if (!this._audioSourceEntries.some((entry) => entry.type === type)) {
return type;
}
}
return typesAvailable[0];
}
/**
* @returns {import('settings').AudioSourceType[]}
*/
_getAvailableAudioSourceTypes() {
/** @type {import('settings').AudioSourceType[]} */
const generalAudioSources = ['language-pod-101', 'lingua-libre', 'wiktionary', 'text-to-speech', 'custom'];
if (this._language === 'ja') {
/** @type {import('settings').AudioSourceType[]} */
const japaneseAudioSources = ['jpod101', 'jisho'];
return [...japaneseAudioSources, ...generalAudioSources];
}
return generalAudioSources;
}
/** */
async _addAudioSource() {
const type = this._getUnusedAudioSourceType();
/** @type {import('settings').AudioSourceOptions} */
const source = {type, url: '', voice: ''};
const index = this._audioSourceEntries.length;
this._createAudioSourceEntry(index, source);
await this._settingsController.modifyProfileSettings([{
action: 'splice',
path: 'audio.sources',
start: index,
deleteCount: 0,
items: [source],
}]);
}
/** */
_onAudioSourceMoveButtonClick() {
const modal = /** @type {import('./modal.js').Modal} */ (this._modalController.getModal('audio-source-move-location'));
const index = modal.node.dataset.index ?? '';
const indexNumber = Number.parseInt(index, 10);
if (Number.isNaN(indexNumber)) { return; }
/** @type {HTMLInputElement} */
const targetStringInput = querySelectorNotNull(document, '#audio-source-move-location');
const targetString = targetStringInput.value;
const target = Number.parseInt(targetString, 10) - 1;
if (!Number.isFinite(target) || !Number.isFinite(indexNumber) || indexNumber === target) { return; }
void this.moveAudioSourceOptions(indexNumber, target);
}
}
class AudioSourceEntry {
/**
* @param {AudioController} parent
* @param {number} index
* @param {import('settings').AudioSourceOptions} source
* @param {HTMLElement} node
*/
constructor(parent, index, source, node) {
/** @type {AudioController} */
this._parent = parent;
/** @type {number} */
this._index = index;
/** @type {import('settings').AudioSourceType} */
this._type = source.type;
/** @type {string} */
this._url = source.url;
/** @type {string} */
this._voice = source.voice;
/** @type {HTMLElement} */
this._node = node;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {HTMLSelectElement} */
this._typeSelect = querySelectorNotNull(this._node, '.audio-source-type-select');
/** @type {HTMLInputElement} */
this._urlInput = querySelectorNotNull(this._node, '.audio-source-parameter-container[data-field=url] .audio-source-parameter');
/** @type {HTMLSelectElement} */
this._voiceSelect = querySelectorNotNull(this._node, '.audio-source-parameter-container[data-field=voice] .audio-source-parameter');
/** @type {HTMLButtonElement} */
this._upButton = querySelectorNotNull(this._node, '#audio-source-move-up');
/** @type {HTMLButtonElement} */
this._downButton = querySelectorNotNull(this._node, '#audio-source-move-down');
}
/** @type {number} */
get index() {
return this._index;
}
set index(value) {
this._index = value;
}
/** @type {import('settings').AudioSourceType} */
get type() {
return this._type;
}
/** */
prepare() {
this._updateTypeParameter();
/** @type {HTMLButtonElement} */
const menuButton = querySelectorNotNull(this._node, '.audio-source-menu-button');
this._typeSelect.value = this._type;
this._urlInput.value = this._url;
this._eventListeners.addEventListener(this._typeSelect, 'change', this._onTypeSelectChange.bind(this), false);
this._eventListeners.addEventListener(this._urlInput, 'change', this._onUrlInputChange.bind(this), false);
this._eventListeners.addEventListener(this._voiceSelect, 'change', this._onVoiceSelectChange.bind(this), false);
this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
this._eventListeners.addEventListener(this._upButton, 'click', (() => { this._move(-1); }).bind(this), false);
this._eventListeners.addEventListener(this._downButton, 'click', (() => { this._move(1); }).bind(this), false);
this._eventListeners.on(this._parent, 'voicesUpdated', this._onVoicesUpdated.bind(this));
this._onVoicesUpdated();
}
/** */
cleanup() {
if (this._node.parentNode !== null) {
this._node.parentNode.removeChild(this._node);
}
this._eventListeners.removeAllEventListeners();
}
// Private
/** */
_onVoicesUpdated() {
if (this._voiceSelect === null) { return; }
const voices = this._parent.getVoices();
const fragment = document.createDocumentFragment();
let option = document.createElement('option');
option.value = '';
option.textContent = 'None';
fragment.appendChild(option);
for (const {voice} of voices) {
option = document.createElement('option');
option.value = voice.voiceURI;
option.textContent = `${voice.name} (${voice.lang})`;
fragment.appendChild(option);
}
this._voiceSelect.textContent = '';
this._voiceSelect.appendChild(fragment);
this._voiceSelect.value = this._voice;
}
/**
* @param {number} offset
*/
_move(offset) {
void this._parent.moveAudioSourceOptions(this._index, this._index + offset);
}
/**
* @param {Event} e
*/
_onTypeSelectChange(e) {
const element = /** @type {HTMLSelectElement} */ (e.currentTarget);
const value = this._normalizeAudioSourceType(element.value);
if (value === null) { return; }
void this._setType(value);
}
/**
* @param {Event} e
*/
_onUrlInputChange(e) {
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
void this._setUrl(element.value);
}
/**
* @param {Event} e
*/
_onVoiceSelectChange(e) {
const element = /** @type {HTMLSelectElement} */ (e.currentTarget);
void this._setVoice(element.value);
}
/**
* @param {import('popup-menu').MenuOpenEvent} e
*/
_onMenuOpen(e) {
const {menu} = e.detail;
let hasHelp = false;
switch (this._type) {
case 'custom':
case 'custom-json':
case 'text-to-speech':
case 'text-to-speech-reading':
hasHelp = true;
break;
}
/** @type {?HTMLButtonElement} */
const helpNode = menu.bodyNode.querySelector('.popup-menu-item[data-menu-action=help]');
if (helpNode !== null) {
helpNode.disabled = !hasHelp;
}
}
/**
* @param {import('popup-menu').MenuCloseEvent} e
*/
_onMenuClose(e) {
switch (e.detail.action) {
case 'help':
this._showHelp(this._type);
break;
case 'moveTo':
this._showMoveToModal();
break;
case 'remove':
void this._parent.removeSource(this);
break;
}
}
/**
* @param {import('settings').AudioSourceType} value
*/
async _setType(value) {
this._type = value;
this._updateTypeParameter();
await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].type`, value);
}
/**
* @param {string} value
*/
async _setUrl(value) {
this._url = value;
await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].url`, value);
}
/**
* @param {string} value
*/
async _setVoice(value) {
this._voice = value;
await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].voice`, value);
}
/** */
_updateTypeParameter() {
let field = null;
switch (this._type) {
case 'custom':
case 'custom-json':
field = 'url';
break;
case 'text-to-speech':
case 'text-to-speech-reading':
field = 'voice';
break;
}
for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._node.querySelectorAll('.audio-source-parameter-container'))) {
node.hidden = (field === null || node.dataset.field !== field);
}
}
/**
* @param {import('settings').AudioSourceType} type
*/
_showHelp(type) {
switch (type) {
case 'custom':
this._showModal('audio-source-help-custom');
break;
case 'custom-json':
this._showModal('audio-source-help-custom-json');
break;
case 'text-to-speech':
case 'text-to-speech-reading':
this._parent.setTestVoice(this._voice);
this._showModal('audio-source-help-text-to-speech');
break;
}
}
/** */
_showMoveToModal() {
const modal = this._parent.modalController.getModal('audio-source-move-location');
if (modal === null) { return; }
const count = this._parent.audioSourceCount;
/** @type {HTMLInputElement} */
const input = querySelectorNotNull(modal.node, '#audio-source-move-location');
modal.node.dataset.index = `${this._index}`;
input.value = `${this._index + 1}`;
input.max = `${count}`;
modal.setVisible(true);
}
/**
* @param {string} name
*/
_showModal(name) {
const modal = this._parent.modalController.getModal(name);
if (modal === null) { return; }
modal.setVisible(true);
}
/**
* @param {string} value
* @returns {?import('settings').AudioSourceType}
*/
_normalizeAudioSourceType(value) {
switch (value) {
case 'jpod101':
case 'language-pod-101':
case 'jisho':
case 'lingua-libre':
case 'wiktionary':
case 'text-to-speech':
case 'text-to-speech-reading':
case 'custom':
case 'custom-json':
return value;
default:
return null;
}
}
}

View File

@@ -0,0 +1,717 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2019-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Dexie} from '../../../lib/dexie.js';
import {ThemeController} from '../../app/theme-controller.js';
import {parseJson} from '../../core/json.js';
import {log} from '../../core/log.js';
import {isObjectNotArray} from '../../core/object-utilities.js';
import {toError} from '../../core/to-error.js';
import {arrayBufferUtf8Decode} from '../../data/array-buffer-util.js';
import {OptionsUtil} from '../../data/options-util.js';
import {getAllPermissions} from '../../data/permissions-util.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {DictionaryController} from './dictionary-controller.js';
export class BackupController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
* @param {?import('./modal-controller.js').ModalController} modalController
*/
constructor(settingsController, modalController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {?import('./modal-controller.js').ModalController} */
this._modalController = modalController;
/** @type {?import('core').TokenObject} */
this._settingsExportToken = null;
/** @type {?() => void} */
this._settingsExportRevoke = null;
/** @type {number} */
this._currentVersion = 0;
/** @type {?import('./modal.js').Modal} */
this._settingsResetModal = null;
/** @type {?import('./modal.js').Modal} */
this._settingsImportErrorModal = null;
/** @type {?import('./modal.js').Modal} */
this._settingsImportWarningModal = null;
/** @type {?OptionsUtil} */
this._optionsUtil = null;
/** @type {string} */
this._dictionariesDatabaseName = 'dict';
/** @type {?import('core').TokenObject} */
this._settingsExportDatabaseToken = null;
try {
this._optionsUtil = new OptionsUtil();
} catch (e) {
// NOP
}
/** @type {ThemeController} */
this._themeController = new ThemeController(document.documentElement);
}
/** */
async prepare() {
if (this._optionsUtil !== null) {
await this._optionsUtil.prepare();
}
if (this._modalController !== null) {
this._settingsResetModal = this._modalController.getModal('settings-reset');
this._settingsImportErrorModal = this._modalController.getModal('settings-import-error');
this._settingsImportWarningModal = this._modalController.getModal('settings-import-warning');
}
this._addNodeEventListener('#settings-export-button', 'click', this._onSettingsExportClick.bind(this), false);
this._addNodeEventListener('#settings-import-button', 'click', this._onSettingsImportClick.bind(this), false);
this._addNodeEventListener('#settings-import-file', 'change', this._onSettingsImportFileChange.bind(this), false);
this._addNodeEventListener('#settings-reset-button', 'click', this._onSettingsResetClick.bind(this), false);
this._addNodeEventListener('#settings-reset-confirm-button', 'click', this._onSettingsResetConfirmClick.bind(this), false);
this._addNodeEventListener('#settings-export-db-button', 'click', this._onSettingsExportDatabaseClick.bind(this), false);
this._addNodeEventListener('#settings-import-db-button', 'click', this._onSettingsImportDatabaseClick.bind(this), false);
this._addNodeEventListener('#settings-import-db', 'change', this._onSettingsImportDatabaseChange.bind(this), false);
}
// Private
/**
* @param {string} selector
* @param {string} eventName
* @param {(event: Event) => void} callback
* @param {boolean} capture
*/
_addNodeEventListener(selector, eventName, callback, capture) {
const node = document.querySelector(selector);
if (node === null) { return; }
node.addEventListener(eventName, callback, capture);
}
/**
* @param {Date} date
* @param {string} dateSeparator
* @param {string} dateTimeSeparator
* @param {string} timeSeparator
* @param {number} resolution
* @returns {string}
*/
_getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) {
const values = [
date.getUTCFullYear().toString(),
dateSeparator,
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
dateSeparator,
date.getUTCDate().toString().padStart(2, '0'),
dateTimeSeparator,
date.getUTCHours().toString().padStart(2, '0'),
timeSeparator,
date.getUTCMinutes().toString().padStart(2, '0'),
timeSeparator,
date.getUTCSeconds().toString().padStart(2, '0'),
];
return values.slice(0, resolution * 2 - 1).join('');
}
/**
* @param {Date} date
* @returns {Promise<import('backup-controller').BackupData>}
*/
async _getSettingsExportData(date) {
const optionsFull = await this._settingsController.getOptionsFull();
const environment = await this._settingsController.application.api.getEnvironmentInfo();
const fieldTemplatesDefault = await this._settingsController.application.api.getDefaultAnkiFieldTemplates();
const permissions = await getAllPermissions();
// Format options
for (const {options} of optionsFull.profiles) {
if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) {
options.anki.fieldTemplates = null;
}
}
return {
version: this._currentVersion,
date: this._getSettingsExportDateString(date, '-', ' ', ':', 6),
url: chrome.runtime.getURL('/'),
manifest: chrome.runtime.getManifest(),
environment,
userAgent: navigator.userAgent,
permissions,
options: optionsFull,
};
}
/**
* @param {Blob} blob
* @param {string} fileName
*/
_saveBlob(blob, fileName) {
if (
typeof navigator === 'object' && navigator !== null &&
// @ts-expect-error - call for legacy Edge
typeof navigator.msSaveBlob === 'function' &&
// @ts-expect-error - call for legacy Edge
navigator.msSaveBlob(blob)
) {
return;
}
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
a.rel = 'noopener';
a.target = '_blank';
const revoke = () => {
URL.revokeObjectURL(blobUrl);
a.href = '';
this._settingsExportRevoke = null;
};
this._settingsExportRevoke = revoke;
a.dispatchEvent(new MouseEvent('click'));
setTimeout(revoke, 60000);
}
/** */
async _onSettingsExportClick() {
if (this._settingsExportRevoke !== null) {
this._settingsExportRevoke();
this._settingsExportRevoke = null;
}
const date = new Date(Date.now());
/** @type {?import('core').TokenObject} */
const token = {};
this._settingsExportToken = token;
const data = await this._getSettingsExportData(date);
if (this._settingsExportToken !== token) {
// A new export has been started
return;
}
this._settingsExportToken = null;
const fileName = `yomitan-settings-${this._getSettingsExportDateString(date, '-', '-', '-', 6)}.json`;
const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'});
this._saveBlob(blob, fileName);
}
/**
* @param {File} file
* @returns {Promise<ArrayBuffer>}
*/
_readFileArrayBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(/** @type {ArrayBuffer} */ (reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
// Importing
/**
* @param {import('settings').Options} optionsFull
*/
async _settingsImportSetOptionsFull(optionsFull) {
await this._settingsController.setAllSettings(optionsFull);
}
/**
* @param {Error} error
*/
_showSettingsImportError(error) {
log.error(error);
/** @type {HTMLElement} */
const element = querySelectorNotNull(document, '#settings-import-error-message');
element.textContent = `${error}`;
if (this._settingsImportErrorModal !== null) {
this._settingsImportErrorModal.setVisible(true);
}
}
/**
* @param {Set<string>} warnings
* @returns {Promise<import('backup-controller').ShowSettingsImportWarningsResult>}
*/
async _showSettingsImportWarnings(warnings) {
const modal = this._settingsImportWarningModal;
if (modal === null) { return {result: false}; }
const buttons = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.settings-import-warning-import-button'));
const messageContainer = document.querySelector('#settings-import-warning-message');
if (buttons.length === 0 || messageContainer === null) {
return {result: false};
}
// Set message
const fragment = document.createDocumentFragment();
for (const warning of warnings) {
const node = document.createElement('li');
node.textContent = `${warning}`;
fragment.appendChild(node);
}
messageContainer.textContent = '';
messageContainer.appendChild(fragment);
// Show modal
modal.setVisible(true);
// Wait for modal to close
return new Promise((resolve) => {
/**
* @param {MouseEvent} e
*/
const onButtonClick = (e) => {
const element = /** @type {HTMLElement} */ (e.currentTarget);
e.preventDefault();
complete({
result: true,
sanitize: element.dataset.importSanitize === 'true',
});
modal.setVisible(false);
};
/**
* @param {import('panel-element').EventArgument<'visibilityChanged'>} details
*/
const onModalVisibilityChanged = ({visible}) => {
if (visible) { return; }
complete({result: false});
};
let completed = false;
/**
* @param {import('backup-controller').ShowSettingsImportWarningsResult} result
*/
const complete = (result) => {
if (completed) { return; }
completed = true;
modal.off('visibilityChanged', onModalVisibilityChanged);
for (const button of buttons) {
button.removeEventListener('click', onButtonClick, false);
}
resolve(result);
};
// Hook events
modal.on('visibilityChanged', onModalVisibilityChanged);
for (const button of buttons) {
button.addEventListener('click', onButtonClick, false);
}
});
}
/**
* @param {string} urlString
* @returns {boolean}
*/
_isLocalhostUrl(urlString) {
try {
const url = new URL(urlString);
switch (url.hostname.toLowerCase()) {
case 'localhost':
case '127.0.0.1':
case '[::1]':
switch (url.protocol.toLowerCase()) {
case 'http:':
case 'https:':
return true;
}
break;
}
} catch (e) {
// NOP
}
return false;
}
/**
* @param {import('settings').ProfileOptions} options
* @param {boolean} dryRun
* @returns {string[]}
*/
_settingsImportSanitizeProfileOptions(options, dryRun) {
const warnings = [];
const anki = options.anki;
if (isObjectNotArray(anki)) {
const fieldTemplates = anki.fieldTemplates;
if (typeof fieldTemplates === 'string') {
warnings.push('anki.fieldTemplates contains a non-default value');
if (!dryRun) {
anki.fieldTemplates = null;
}
}
const server = anki.server;
if (typeof server === 'string' && server.length > 0 && !this._isLocalhostUrl(server)) {
warnings.push('anki.server uses a non-localhost URL');
if (!dryRun) {
anki.server = 'http://127.0.0.1:8765';
}
}
}
const audio = options.audio;
if (isObjectNotArray(audio)) {
const sources = audio.sources;
if (Array.isArray(sources)) {
for (let i = 0, ii = sources.length; i < ii; ++i) {
const source = sources[i];
if (!isObjectNotArray(source)) { continue; }
const {url} = source;
if (typeof url === 'string' && url.length > 0 && !this._isLocalhostUrl(url)) {
warnings.push(`audio.sources[${i}].url uses a non-localhost URL`);
if (!dryRun) {
sources[i].url = '';
}
}
}
}
}
return warnings;
}
/**
* @param {import('settings').Options} optionsFull
* @param {boolean} dryRun
* @returns {Set<string>}
*/
_settingsImportSanitizeOptions(optionsFull, dryRun) {
const warnings = new Set();
const profiles = optionsFull.profiles;
if (Array.isArray(profiles)) {
for (const profile of profiles) {
if (!isObjectNotArray(profile)) { continue; }
const options = profile.options;
if (!isObjectNotArray(options)) { continue; }
const warnings2 = this._settingsImportSanitizeProfileOptions(options, dryRun);
for (const warning of warnings2) {
warnings.add(warning);
}
}
}
return warnings;
}
/**
* @param {File} file
*/
async _importSettingsFile(file) {
if (this._optionsUtil === null) { throw new Error('OptionsUtil invalid'); }
const dataString = arrayBufferUtf8Decode(await this._readFileArrayBuffer(file));
/** @type {import('backup-controller').BackupData} */
const data = parseJson(dataString);
// Type check
if (!isObjectNotArray(data)) {
throw new Error(`Invalid data type: ${typeof data}`);
}
// Version check
const version = data.version;
if (!(
typeof version === 'number' &&
Number.isFinite(version) &&
version === Math.floor(version)
)) {
throw new Error(`Invalid version: ${version}`);
}
if (!(
version >= 0 &&
version <= this._currentVersion
)) {
throw new Error(`Unsupported version: ${version}`);
}
// Verify options exists
let optionsFull = data.options;
if (!isObjectNotArray(optionsFull)) {
throw new Error(`Invalid options type: ${typeof optionsFull}`);
}
// Upgrade options
optionsFull = await this._optionsUtil.update(optionsFull);
// Check for warnings
const sanitizationWarnings = this._settingsImportSanitizeOptions(optionsFull, true);
// Show sanitization warnings
if (sanitizationWarnings.size > 0) {
const {result, sanitize} = await this._showSettingsImportWarnings(sanitizationWarnings);
if (!result) { return; }
if (sanitize !== false) {
this._settingsImportSanitizeOptions(optionsFull, false);
}
}
// Update dictionaries
await DictionaryController.ensureDictionarySettings(this._settingsController, void 0, optionsFull, false, false);
// Assign options
await this._settingsImportSetOptionsFull(optionsFull);
}
/** */
_onSettingsImportClick() {
/** @type {HTMLElement} */
const element = querySelectorNotNull(document, '#settings-import-file');
element.click();
}
/**
* @param {Event} e
*/
async _onSettingsImportFileChange(e) {
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
const files = element.files;
if (files === null || files.length === 0) { return; }
const file = files[0];
element.value = '';
try {
await this._importSettingsFile(file);
} catch (error) {
this._showSettingsImportError(toError(error));
}
}
// Resetting
/** */
_onSettingsResetClick() {
if (this._settingsResetModal === null) { return; }
this._settingsResetModal.setVisible(true);
}
/** */
async _onSettingsResetConfirmClick() {
if (this._optionsUtil === null) { throw new Error('OptionsUtil invalid'); }
if (this._settingsResetModal !== null) {
this._settingsResetModal.setVisible(false);
}
// Get default options
const optionsFull = this._optionsUtil.getDefault();
// Update dictionaries
await DictionaryController.ensureDictionarySettings(this._settingsController, void 0, optionsFull, false, false);
// Update display theme
this._themeController.theme = optionsFull.profiles[optionsFull.profileCurrent].options.general.popupTheme;
this._themeController.prepare();
this._themeController.siteOverride = true;
this._themeController.updateTheme();
// Assign options
try {
await this._settingsImportSetOptionsFull(optionsFull);
} catch (e) {
log.error(e);
}
}
// Exporting Dictionaries Database
/**
* @param {string} message
* @param {boolean} [isWarning]
*/
_databaseExportImportErrorMessage(message, isWarning = false) {
/** @type {HTMLElement} */
const errorMessageSettingsContainer = querySelectorNotNull(document, '#db-ops-error-report-container');
errorMessageSettingsContainer.style.display = 'block';
/** @type {HTMLElement} */
const errorMessageContainer = querySelectorNotNull(document, '#db-ops-error-report');
errorMessageContainer.style.display = 'block';
errorMessageContainer.textContent = message;
if (isWarning) { // Hide after 5 seconds (5000 milliseconds)
errorMessageContainer.style.color = '#FFC40C';
setTimeout(function _hideWarningMessage() {
errorMessageContainer.style.display = 'none';
errorMessageContainer.style.color = '#8B0000';
}, 5000);
}
}
/**
* @param {{totalRows: number, completedRows: number, done: boolean}} details
*/
_databaseExportProgressCallback({totalRows, completedRows, done}) {
log.log(`Progress: ${completedRows} of ${totalRows} rows completed`);
/** @type {HTMLElement} */
const messageSettingsContainer = querySelectorNotNull(document, '#db-ops-progress-report-container');
messageSettingsContainer.style.display = 'block';
/** @type {HTMLElement} */
const messageContainer = querySelectorNotNull(document, '#db-ops-progress-report');
messageContainer.style.display = 'block';
messageContainer.textContent = `Export Progress: ${completedRows} of ${totalRows} rows completed`;
if (done) {
log.log('Done exporting.');
messageContainer.style.display = 'none';
}
}
/**
* @param {string} databaseName
* @returns {Promise<Blob>}
*/
async _exportDatabase(databaseName) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const DexieConstructor = /** @type {import('dexie').DexieConstructor} */ (/** @type {unknown} */ (Dexie));
const db = new DexieConstructor(databaseName);
await db.open();
/** @type {unknown} */
// @ts-expect-error - The export function is declared as an extension which has no type information.
const blob = await db.export({
progressCallback: this._databaseExportProgressCallback.bind(this),
});
db.close();
return /** @type {Blob} */ (blob);
}
/** */
async _onSettingsExportDatabaseClick() {
if (this._settingsExportDatabaseToken !== null) {
// An existing import or export is in progress.
this._databaseExportImportErrorMessage('An export or import operation is already in progress. Please wait till it is over.', true);
return;
}
/** @type {HTMLElement} */
const errorMessageContainer = querySelectorNotNull(document, '#db-ops-error-report');
errorMessageContainer.style.display = 'none';
const date = new Date(Date.now());
const pageExitPrevention = this._settingsController.preventPageExit();
try {
/** @type {import('core').TokenObject} */
const token = {};
this._settingsExportDatabaseToken = token;
const fileName = `yomitan-dictionaries-${this._getSettingsExportDateString(date, '-', '-', '-', 6)}.json`;
const data = await this._exportDatabase(this._dictionariesDatabaseName);
const blob = new Blob([data], {type: 'application/json'});
this._saveBlob(blob, fileName);
} catch (error) {
log.log(error);
this._databaseExportImportErrorMessage('Errors encountered while exporting. Please try again. Restart the browser if it continues to fail.');
} finally {
pageExitPrevention.end();
this._settingsExportDatabaseToken = null;
}
}
// Importing Dictionaries Database
/**
* @param {{totalRows: number, completedRows: number, done: boolean}} details
*/
_databaseImportProgressCallback({totalRows, completedRows, done}) {
log.log(`Progress: ${completedRows} of ${totalRows} rows completed`);
/** @type {HTMLElement} */
const messageSettingsContainer = querySelectorNotNull(document, '#db-ops-progress-report-container');
messageSettingsContainer.style.display = 'block';
/** @type {HTMLElement} */
const messageContainer = querySelectorNotNull(document, '#db-ops-progress-report');
messageContainer.style.display = 'block';
messageContainer.style.color = '#4169e1';
messageContainer.textContent = `Import Progress: ${completedRows} of ${totalRows} rows completed`;
if (done) {
log.log('Done importing.');
messageContainer.style.color = '#006633';
messageContainer.textContent = 'Done importing. You will need to re-enable the dictionaries and refresh afterward. If you run into issues, please restart the browser. If it continues to fail, reinstall Yomitan and import dictionaries one-by-one.';
}
}
/**
* @param {string} _databaseName
* @param {File} file
*/
async _importDatabase(_databaseName, file) {
await this._settingsController.application.api.purgeDatabase();
await Dexie.import(file, {
progressCallback: this._databaseImportProgressCallback.bind(this),
});
void this._settingsController.application.api.triggerDatabaseUpdated('dictionary', 'import');
this._settingsController.application.triggerStorageChanged();
}
/** */
_onSettingsImportDatabaseClick() {
/** @type {HTMLElement} */
const element = querySelectorNotNull(document, '#settings-import-db');
element.click();
}
/**
* @param {Event} e
*/
async _onSettingsImportDatabaseChange(e) {
if (this._settingsExportDatabaseToken !== null) {
// An existing import or export is in progress.
this._databaseExportImportErrorMessage('An export or import operation is already in progress. Please wait till it is over.', true);
return;
}
/** @type {HTMLElement} */
const errorMessageContainer = querySelectorNotNull(document, '#db-ops-error-report');
errorMessageContainer.style.display = 'none';
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
const files = element.files;
if (files === null || files.length === 0) { return; }
const pageExitPrevention = this._settingsController.preventPageExit();
const file = files[0];
element.value = '';
try {
/** @type {import('core').TokenObject} */
const token = {};
this._settingsExportDatabaseToken = token;
await this._importDatabase(this._dictionariesDatabaseName, file);
} catch (error) {
log.log(error);
/** @type {HTMLElement} */
const messageContainer = querySelectorNotNull(document, '#db-ops-progress-report');
messageContainer.style.color = 'red';
this._databaseExportImportErrorMessage('Encountered errors when importing. Please restart the browser and try again. If it continues to fail, reinstall Yomitan and import dictionaries one-by-one.');
} finally {
pageExitPrevention.end();
this._settingsExportDatabaseToken = null;
}
}
}

View File

@@ -0,0 +1,224 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class CollapsibleDictionaryController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {?import('core').TokenObject} */
this._getDictionaryInfoToken = null;
/** @type {Map<string, import('dictionary-importer').Summary>} */
this._dictionaryInfoMap = new Map();
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {HTMLElement} */
this._container = querySelectorNotNull(document, '#collapsible-dictionary-list');
/** @type {HTMLSelectElement[]} */
this._selects = [];
/** @type {?HTMLSelectElement} */
this._allSelect = null;
}
/** */
async prepare() {
await this._onDatabaseUpdated();
this._settingsController.application.on('databaseUpdated', this._onDatabaseUpdated.bind(this));
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
this._settingsController.on('dictionarySettingsReordered', this._onDictionarySettingsReordered.bind(this));
}
// Private
/** */
async _onDatabaseUpdated() {
/** @type {?import('core').TokenObject} */
const token = {};
this._getDictionaryInfoToken = token;
const dictionaries = await this._settingsController.getDictionaryInfo();
if (this._getDictionaryInfoToken !== token) { return; }
this._getDictionaryInfoToken = null;
this._dictionaryInfoMap.clear();
for (const entry of dictionaries) {
this._dictionaryInfoMap.set(entry.title, entry);
}
await this._onDictionarySettingsReordered();
}
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
this._eventListeners.removeAllEventListeners();
this._selects = [];
const fragment = document.createDocumentFragment();
this._setupAllSelect(fragment, options);
const {dictionaries} = options;
for (let i = 0, ii = dictionaries.length; i < ii; ++i) {
const {name} = dictionaries[i];
const dictionaryInfo = this._dictionaryInfoMap.get(name);
if (!dictionaryInfo?.counts?.terms?.total && !dictionaryInfo?.counts?.kanji?.total) {
continue;
}
if (typeof dictionaryInfo === 'undefined') { continue; }
const select = this._addSelect(fragment, name, `rev.${dictionaryInfo.revision}`);
select.dataset.setting = `dictionaries[${i}].definitionsCollapsible`;
this._eventListeners.addEventListener(select, 'settingChanged', this._onDefinitionsCollapsibleChange.bind(this), false);
this._selects.push(select);
}
const container = /** @type {HTMLElement} */ (this._container);
container.textContent = '';
container.appendChild(fragment);
}
/** */
_onDefinitionsCollapsibleChange() {
void this._updateAllSelectFresh();
}
/**
* @param {Event} e
*/
_onAllSelectChange(e) {
const {value} = /** @type {HTMLSelectElement} */ (e.currentTarget);
const value2 = this._normalizeDictionaryDefinitionsCollapsible(value);
if (value2 === null) { return; }
void this._setDefinitionsCollapsibleAll(value2);
}
/** */
async _onDictionarySettingsReordered() {
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._onOptionsChanged({options, optionsContext});
}
/**
* @param {DocumentFragment} fragment
* @param {import('settings').ProfileOptions} options
*/
_setupAllSelect(fragment, options) {
const select = this._addSelect(fragment, 'All', '');
const option = document.createElement('option');
option.value = 'varies';
option.textContent = 'Varies';
option.disabled = true;
select.appendChild(option);
this._eventListeners.addEventListener(select, 'change', this._onAllSelectChange.bind(this), false);
this._allSelect = select;
this._updateAllSelect(options);
}
/**
* @param {DocumentFragment} fragment
* @param {string} dictionary
* @param {string} version
* @returns {HTMLSelectElement}
*/
_addSelect(fragment, dictionary, version) {
const node = this._settingsController.instantiateTemplate('collapsible-dictionary-item');
fragment.appendChild(node);
/** @type {HTMLElement} */
const nameNode = querySelectorNotNull(node, '.dictionary-title');
nameNode.textContent = dictionary;
/** @type {HTMLElement} */
const versionNode = querySelectorNotNull(node, '.dictionary-revision');
versionNode.textContent = version;
return querySelectorNotNull(node, '.definitions-collapsible');
}
/** */
async _updateAllSelectFresh() {
this._updateAllSelect(await this._settingsController.getOptions());
}
/**
* @param {import('settings').ProfileOptions} options
*/
_updateAllSelect(options) {
let value = null;
let varies = false;
for (const {definitionsCollapsible} of options.dictionaries) {
if (value === null) {
value = definitionsCollapsible;
} else if (value !== definitionsCollapsible) {
varies = true;
break;
}
}
if (this._allSelect !== null) {
this._allSelect.value = (varies || value === null ? 'varies' : value);
}
}
/**
* @param {import('settings').DictionaryDefinitionsCollapsible} value
*/
async _setDefinitionsCollapsibleAll(value) {
const options = await this._settingsController.getOptions();
/** @type {import('settings-modifications').Modification[]} */
const targets = [];
const {dictionaries} = options;
for (let i = 0, ii = dictionaries.length; i < ii; ++i) {
const path = `dictionaries[${i}].definitionsCollapsible`;
targets.push({action: 'set', path, value});
}
await this._settingsController.modifyProfileSettings(targets);
for (const select of this._selects) {
select.value = value;
}
}
/**
* @param {string} value
* @returns {?import('settings').DictionaryDefinitionsCollapsible}
*/
_normalizeDictionaryDefinitionsCollapsible(value) {
switch (value) {
case 'not-collapsible':
case 'expanded':
case 'collapsed':
case 'force-collapsed':
case 'force-expanded':
return value;
default:
return null;
}
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2025 Yomitan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {ModalController} from './modal-controller.js';
export class DataTransmissionConsentController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
* @param {ModalController} modalController
*/
constructor(settingsController, modalController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {ModalController} */
this._modalController = modalController;
/** @type {?HTMLButtonElement} */
this._acceptDataTransmissionButton = null;
/** @type {?HTMLButtonElement} */
this._declineDataTransmissionButton = null;
}
/** */
async prepare() {
const firefoxDataTransmissionModal = this._modalController.getModal('firefox-data-transmission-consent');
if (firefoxDataTransmissionModal) {
this._acceptDataTransmissionButton = /** @type {HTMLButtonElement} */ (querySelectorNotNull(document, '#accept-data-transmission'));
this._declineDataTransmissionButton = /** @type {HTMLButtonElement} */ (querySelectorNotNull(document, '#decline-data-transmission'));
this._acceptDataTransmissionButton.addEventListener('click', this._onAccept.bind(this));
this._declineDataTransmissionButton.addEventListener('click', this._onDecline.bind(this));
const options = await this._settingsController.getOptionsFull();
firefoxDataTransmissionModal?.setVisible(!options.global.dataTransmissionConsentShown);
}
}
// Private
/** */
async _onAccept() {
await this._settingsController.setGlobalSetting('global.dataTransmissionConsentShown', true);
}
/** */
async _onDecline() {
await this._settingsController.setGlobalSetting('global.dataTransmissionConsentShown', true);
await this._settingsController.setProfileSetting('audio.enabled', false);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,943 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ExtensionError} from '../../core/extension-error.js';
import {readResponseJson} from '../../core/json.js';
import {log} from '../../core/log.js';
import {toError} from '../../core/to-error.js';
import {getKebabCase} from '../../data/anki-template-util.js';
import {DictionaryWorker} from '../../dictionary/dictionary-worker.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {DictionaryController} from './dictionary-controller.js';
export class DictionaryImportController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
* @param {import('./modal-controller.js').ModalController} modalController
* @param {import('./status-footer.js').StatusFooter} statusFooter
*/
constructor(settingsController, modalController, statusFooter) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {import('./modal-controller.js').ModalController} */
this._modalController = modalController;
/** @type {import('./status-footer.js').StatusFooter} */
this._statusFooter = statusFooter;
/** @type {boolean} */
this._modifying = false;
/** @type {HTMLButtonElement} */
this._purgeButton = querySelectorNotNull(document, '#dictionary-delete-all-button');
/** @type {HTMLButtonElement} */
this._purgeConfirmButton = querySelectorNotNull(document, '#dictionary-confirm-delete-all-button');
/** @type {HTMLButtonElement} */
this._importFileInput = querySelectorNotNull(document, '#dictionary-import-file-input');
/** @type {HTMLButtonElement} */
this._importFileDrop = querySelectorNotNull(document, '#dictionary-drop-file-zone');
/** @type {number} */
this._importFileDropItemCount = 0;
/** @type {HTMLInputElement} */
this._importButton = querySelectorNotNull(document, '#dictionary-import-button');
/** @type {HTMLInputElement} */
this._importURLButton = querySelectorNotNull(document, '#dictionary-import-url-button');
/** @type {HTMLInputElement} */
this._importURLText = querySelectorNotNull(document, '#dictionary-import-url-text');
/** @type {?import('./modal.js').Modal} */
this._purgeConfirmModal = null;
/** @type {HTMLElement} */
this._errorContainer = querySelectorNotNull(document, '#dictionary-error');
/** @type {[originalMessage: string, newMessage: string][]} */
this._errorToStringOverrides = [
[
'A mutation operation was attempted on a database that did not allow mutations.',
'Access to IndexedDB appears to be restricted. Firefox seems to require that the history preference is set to "Remember history" before IndexedDB use of any kind is allowed.',
],
[
'The operation failed for reasons unrelated to the database itself and not covered by any other error code.',
'Unable to access IndexedDB due to a possibly corrupt user profile. Try using the "Refresh Firefox" feature to reset your user profile.',
],
];
/** @type {string[]} */
this._recommendedDictionaryQueue = [];
/** @type {boolean} */
this._recommendedDictionaryActiveImport = false;
}
/** */
prepare() {
this._importModal = this._modalController.getModal('dictionary-import');
this._purgeConfirmModal = this._modalController.getModal('dictionary-confirm-delete-all');
this._purgeButton.addEventListener('click', this._onPurgeButtonClick.bind(this), false);
this._purgeConfirmButton.addEventListener('click', this._onPurgeConfirmButtonClick.bind(this), false);
this._importButton.addEventListener('click', this._onImportButtonClick.bind(this), false);
this._importURLButton.addEventListener('click', this._onImportFromURL.bind(this), false);
this._importFileInput.addEventListener('change', this._onImportFileChange.bind(this), false);
this._importFileDrop.addEventListener('click', this._onImportFileButtonClick.bind(this), false);
this._importFileDrop.addEventListener('dragenter', this._onFileDropEnter.bind(this), false);
this._importFileDrop.addEventListener('dragover', this._onFileDropOver.bind(this), false);
this._importFileDrop.addEventListener('dragleave', this._onFileDropLeave.bind(this), false);
this._importFileDrop.addEventListener('drop', this._onFileDrop.bind(this), false);
this._settingsController.on('importDictionaryFromUrl', this._onEventImportDictionaryFromUrl.bind(this));
const recommendedDictionaryButton = document.querySelector('[data-modal-action="show,recommended-dictionaries"]');
if (recommendedDictionaryButton) {
recommendedDictionaryButton.addEventListener('click', this._renderRecommendedDictionaries.bind(this), false);
}
}
// Private
/**
* @param {MouseEvent} e
*/
async _onRecommendedImportClick(e) {
if (!e.target || !(e.target instanceof HTMLButtonElement)) { return; }
const import_url = e.target.attributes.getNamedItem('data-import-url');
if (!import_url) { return; }
this._recommendedDictionaryQueue.push(import_url.value);
e.target.disabled = true;
if (this._recommendedDictionaryActiveImport) { return; }
while (this._recommendedDictionaryQueue.length > 0) {
this._recommendedDictionaryActiveImport = true;
try {
const url = this._recommendedDictionaryQueue[0];
if (!url) { continue; }
const importProgressTracker = new ImportProgressTracker(this._getUrlImportSteps(), 1);
const onProgress = importProgressTracker.onProgress.bind(importProgressTracker);
await this._importDictionaries(
this._generateFilesFromUrls([url], onProgress),
null,
null,
importProgressTracker,
);
void this._recommendedDictionaryQueue.shift();
} catch (error) {
log.error(error);
}
}
this._recommendedDictionaryActiveImport = false;
}
/** */
async _renderRecommendedDictionaries() {
const url = '../../data/recommended-dictionaries.json';
const response = await fetch(url, {
method: 'GET',
mode: 'no-cors',
cache: 'default',
credentials: 'omit',
redirect: 'follow',
referrerPolicy: 'no-referrer',
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`);
}
/** @type {import('dictionary-recommended.js').RecommendedDictionaryElementMap[]} */
const recommendedDictionaryCategories = [
{property: 'terms', element: querySelectorNotNull(querySelectorNotNull(document, '#recommended-term-dictionaries'), '.recommended-dictionary-list')},
{property: 'kanji', element: querySelectorNotNull(querySelectorNotNull(document, '#recommended-kanji-dictionaries'), '.recommended-dictionary-list')},
{property: 'frequency', element: querySelectorNotNull(querySelectorNotNull(document, '#recommended-frequency-dictionaries'), '.recommended-dictionary-list')},
{property: 'grammar', element: querySelectorNotNull(querySelectorNotNull(document, '#recommended-grammar-dictionaries'), '.recommended-dictionary-list')},
{property: 'pronunciation', element: querySelectorNotNull(querySelectorNotNull(document, '#recommended-pronunciation-dictionaries'), '.recommended-dictionary-list')},
];
const language = (await this._settingsController.getOptions()).general.language;
/** @type {import('dictionary-recommended.js').RecommendedDictionaries} */
const recommendedDictionaries = (await readResponseJson(response));
if (!(language in recommendedDictionaries)) {
for (const {element} of recommendedDictionaryCategories) {
const dictionaryCategoryParent = element.parentElement;
if (dictionaryCategoryParent) {
dictionaryCategoryParent.hidden = true;
}
}
return;
}
const installedDictionaries = await this._settingsController.getDictionaryInfo();
/** @type {Set<string>} */
const installedDictionaryNames = new Set();
/** @type {Set<string>} */
const installedDictionaryDownloadUrls = new Set();
for (const dictionary of installedDictionaries) {
installedDictionaryNames.add(dictionary.title);
if (dictionary.downloadUrl) {
installedDictionaryDownloadUrls.add(dictionary.downloadUrl);
}
}
for (const {property, element} of recommendedDictionaryCategories) {
this._renderRecommendedDictionaryGroup(recommendedDictionaries[language][property], element, installedDictionaryNames, installedDictionaryDownloadUrls);
}
/** @type {NodeListOf<HTMLElement>} */
const buttons = document.querySelectorAll('.action-button[data-action=import-recommended-dictionary]');
for (const button of buttons) {
button.addEventListener('click', this._onRecommendedImportClick.bind(this), false);
}
}
/**
*
* @param {import('dictionary-recommended.js').RecommendedDictionary[]} recommendedDictionaries
* @param {HTMLElement} dictionariesList
* @param {Set<string>} installedDictionaryNames
* @param {Set<string>} installedDictionaryDownloadUrls
*/
_renderRecommendedDictionaryGroup(recommendedDictionaries, dictionariesList, installedDictionaryNames, installedDictionaryDownloadUrls) {
const dictionariesListParent = dictionariesList.parentElement;
dictionariesList.innerHTML = '';
// Hide section if no dictionaries are available
if (dictionariesListParent) {
dictionariesListParent.hidden = recommendedDictionaries.length === 0;
}
for (const dictionary of recommendedDictionaries) {
if (dictionariesList) {
if (dictionariesListParent) {
dictionariesListParent.hidden = false;
}
const template = this._settingsController.instantiateTemplate('recommended-dictionaries-list-item');
const label = querySelectorNotNull(template, '.settings-item-label');
const description = querySelectorNotNull(template, '.description');
/** @type {HTMLAnchorElement} */
const homepage = querySelectorNotNull(template, '.homepage');
/** @type {HTMLButtonElement} */
const button = querySelectorNotNull(template, '.action-button[data-action=import-recommended-dictionary]');
button.disabled = (
installedDictionaryNames.has(dictionary.name) ||
installedDictionaryDownloadUrls.has(dictionary.downloadUrl) ||
this._recommendedDictionaryQueue.includes(dictionary.downloadUrl)
);
const urlAttribute = document.createAttribute('data-import-url');
urlAttribute.value = dictionary.downloadUrl;
button.attributes.setNamedItem(urlAttribute);
label.textContent = dictionary.name;
description.textContent = dictionary.description;
if (dictionary.homepage) {
homepage.target = '_blank';
homepage.href = dictionary.homepage;
} else {
homepage.remove();
}
dictionariesList.append(template);
}
}
}
/**
* @param {import('settings-controller').EventArgument<'importDictionaryFromUrl'>} details
*/
_onEventImportDictionaryFromUrl({url, profilesDictionarySettings, onImportDone}) {
void this.importFilesFromURLs(url, profilesDictionarySettings, onImportDone);
}
/** */
_onImportFileButtonClick() {
/** @type {HTMLInputElement} */ (this._importFileInput).click();
}
/**
* @param {DragEvent} e
*/
_onFileDropEnter(e) {
e.preventDefault();
if (!e.dataTransfer) { return; }
for (const item of e.dataTransfer.items) {
// Directories and files with no extension both show as ''
if (item.type === '' || item.type === 'application/zip') {
this._importFileDrop.classList.add('drag-over');
break;
}
}
}
/**
* @param {DragEvent} e
*/
_onFileDropOver(e) {
e.preventDefault();
}
/**
* @param {DragEvent} e
*/
_onFileDropLeave(e) {
e.preventDefault();
this._importFileDrop.classList.remove('drag-over');
}
/**
* @param {DragEvent} e
*/
async _onFileDrop(e) {
e.preventDefault();
this._importFileDrop.classList.remove('drag-over');
if (e.dataTransfer === null) { return; }
/** @type {import('./modal.js').Modal} */ (this._importModal).setVisible(false);
/** @type {File[]} */
const fileArray = [];
for (const fileEntry of await this._getAllFileEntries(e.dataTransfer.items)) {
if (!fileEntry) { return; }
try {
fileArray.push(await new Promise((resolve, reject) => { fileEntry.file(resolve, reject); }));
} catch (error) {
log.error(error);
}
}
const importProgressTracker = new ImportProgressTracker(this._getFileImportSteps(), fileArray.length);
void this._importDictionaries(
this._arrayToAsyncGenerator(fileArray),
null,
null,
importProgressTracker,
);
}
/**
* @param {DataTransferItemList} dataTransferItemList
* @returns {Promise<FileSystemFileEntry[]>}
*/
async _getAllFileEntries(dataTransferItemList) {
/** @type {(FileSystemFileEntry)[]} */
const fileEntries = [];
/** @type {(FileSystemEntry | null)[]} */
const entries = [];
for (let i = 0; i < dataTransferItemList.length; i++) {
entries.push(dataTransferItemList[i].webkitGetAsEntry());
}
this._importFileDropItemCount = entries.length - 1;
while (entries.length > 0) {
this._importFileDropItemCount += 1;
this._validateDirectoryItemCount();
/** @type {(FileSystemEntry | null) | undefined} */
const entry = entries.shift();
if (!entry) { continue; }
if (entry.isFile) {
if (entry.name.substring(entry.name.lastIndexOf('.'), entry.name.length) === '.zip') {
// @ts-expect-error - ts does not recognize `if (entry.isFile)` as verifying `entry` is type `FileSystemFileEntry` and instanceof does not work
fileEntries.push(entry);
}
} else if (entry.isDirectory) {
// @ts-expect-error - ts does not recognize `if (entry.isDirectory)` as verifying `entry` is type `FileSystemDirectoryEntry` and instanceof does not work
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
entries.push(...await this._readAllDirectoryEntries(entry.createReader()));
}
}
return fileEntries;
}
/**
* @param {FileSystemDirectoryReader} directoryReader
* @returns {Promise<(FileSystemEntry)[]>}
*/
async _readAllDirectoryEntries(directoryReader) {
const entries = [];
/** @type {(FileSystemEntry)[]} */
let readEntries = await new Promise((resolve) => { directoryReader.readEntries(resolve); });
while (readEntries.length > 0) {
this._importFileDropItemCount += readEntries.length;
this._validateDirectoryItemCount();
entries.push(...readEntries);
readEntries = await new Promise((resolve) => { directoryReader.readEntries(resolve); });
}
return entries;
}
/**
* @throws
*/
_validateDirectoryItemCount() {
if (this._importFileDropItemCount > 1000) {
this._importFileDropItemCount = 0;
const errorText = 'Directory upload item count too large';
this._showErrors([new Error(errorText)]);
throw new Error(errorText);
}
}
/**
* @param {MouseEvent} e
*/
_onImportButtonClick(e) {
e.preventDefault();
/** @type {import('./modal.js').Modal} */ (this._importModal).setVisible(true);
}
/**
* @param {MouseEvent} e
*/
_onPurgeButtonClick(e) {
e.preventDefault();
/** @type {import('./modal.js').Modal} */ (this._purgeConfirmModal).setVisible(true);
}
/**
* @param {MouseEvent} e
*/
_onPurgeConfirmButtonClick(e) {
e.preventDefault();
/** @type {import('./modal.js').Modal} */ (this._purgeConfirmModal).setVisible(false);
void this._purgeDatabase();
}
/**
* @param {Event} e
*/
async _onImportFileChange(e) {
/** @type {import('./modal.js').Modal} */ (this._importModal).setVisible(false);
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
const {files} = node;
if (files === null) { return; }
const files2 = [...files];
node.value = '';
void this._importDictionaries(
this._arrayToAsyncGenerator(files2),
null,
null,
new ImportProgressTracker(this._getFileImportSteps(), files2.length),
);
}
/** */
async _onImportFromURL() {
const text = this._importURLText.value.trim();
if (!text) { return; }
await this.importFilesFromURLs(text, null, null);
}
/**
* @param {string} text
* @param {import('settings-controller').ProfilesDictionarySettings} profilesDictionarySettings
* @param {import('settings-controller').ImportDictionaryDoneCallback} onImportDone
*/
async importFilesFromURLs(text, profilesDictionarySettings, onImportDone) {
const urls = text.split('\n');
const importProgressTracker = new ImportProgressTracker(this._getUrlImportSteps(), urls.length);
const onProgress = importProgressTracker.onProgress.bind(importProgressTracker);
void this._importDictionaries(
this._generateFilesFromUrls(urls, onProgress),
profilesDictionarySettings,
onImportDone,
importProgressTracker,
);
}
/**
* @param {string[]} urls
* @param {import('dictionary-worker').ImportProgressCallback} onProgress
* @yields {Promise<File>}
* @returns {AsyncGenerator<File, void, void>}
*/
async *_generateFilesFromUrls(urls, onProgress) {
for (const url of urls) {
onProgress({nextStep: true, index: 0, count: 0});
try {
const xhr = new XMLHttpRequest();
xhr.open('GET', url.trim(), true);
xhr.responseType = 'blob';
xhr.onprogress = (event) => {
if (event.lengthComputable) {
onProgress({nextStep: false, index: event.loaded, count: event.total});
}
};
/** @type {Promise<File>} */
const blobPromise = new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.status === 200) {
if (xhr.response instanceof Blob) {
resolve(new File([xhr.response], 'fileFromURL'));
} else {
reject(new Error(`Failed to fetch blob from ${url}`));
}
} else {
reject(new Error(`Failed to fetch the URL: ${url}`));
}
};
xhr.onerror = () => {
reject(new Error(`Error fetching URL: ${url}`));
};
});
xhr.send();
const file = await blobPromise;
yield file;
} catch (error) {
log.error(error);
}
}
}
/** */
async _purgeDatabase() {
if (this._modifying) { return; }
const prevention = this._preventPageExit();
try {
this._setModifying(true);
this._hideErrors();
await this._settingsController.application.api.purgeDatabase();
const errors = await this._clearDictionarySettings();
if (errors.length > 0) {
this._showErrors(errors);
}
} catch (error) {
this._showErrors([toError(error)]);
} finally {
prevention.end();
this._setModifying(false);
this._triggerStorageChanged();
}
}
/**
* @param {AsyncGenerator<File, void, void>} dictionaries
* @param {import('settings-controller').ProfilesDictionarySettings} profilesDictionarySettings
* @param {import('settings-controller').ImportDictionaryDoneCallback} onImportDone
* @param {ImportProgressTracker} importProgressTracker
*/
async _importDictionaries(dictionaries, profilesDictionarySettings, onImportDone, importProgressTracker) {
if (this._modifying) { return; }
const statusFooter = this._statusFooter;
const progressSelector = '.dictionary-import-progress';
const progressContainers = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`#dictionaries-modal ${progressSelector}`));
const recommendedProgressContainers = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`#recommended-dictionaries-modal ${progressSelector}`));
const prevention = this._preventPageExit();
const onProgress = importProgressTracker.onProgress.bind(importProgressTracker);
/** @type {Error[]} */
let errors = [];
try {
this._setModifying(true);
this._hideErrors();
for (const progress of [...progressContainers, ...recommendedProgressContainers]) { progress.hidden = false; }
const optionsFull = await this._settingsController.getOptionsFull();
const importDetails = {
prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported,
yomitanVersion: chrome.runtime.getManifest().version,
};
for (let i = 0; i < importProgressTracker.dictionaryCount; ++i) {
importProgressTracker.onNextDictionary();
if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, true); }
const file = (await dictionaries.next()).value;
if (!file || !(file instanceof File)) {
errors.push(new Error(`Failed to read file ${i + 1} of ${importProgressTracker.dictionaryCount}.`));
continue;
}
errors = [
...errors,
...(await this._importDictionaryFromZip(
file,
profilesDictionarySettings,
importDetails,
onProgress,
) ?? []),
];
}
} catch (error) {
errors.push(toError(error));
} finally {
this._showErrors(errors);
prevention.end();
for (const progress of [...progressContainers, ...recommendedProgressContainers]) { progress.hidden = true; }
if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, false); }
this._setModifying(false);
this._triggerStorageChanged();
if (onImportDone) { onImportDone(); }
}
}
/**
* @returns {import('dictionary-importer').ImportSteps}
*/
_getFileImportSteps() {
return [
{label: '', callback: this._triggerStorageChanged.bind(this)}, // Dictionary import is uninitialized
{label: 'Initializing import'}, // Dictionary import is uninitialized
{label: 'Loading dictionary'}, // Load dictionary archive and validate index
{label: 'Loading schemas'}, // Load schemas and get archive files
{label: 'Validating data'}, // Load and validate dictionary data
{label: 'Importing data'}, // Add dictionary descriptor, load, and import data
{label: 'Finalizing import', callback: this._triggerStorageChanged.bind(this)}, // Update dictionary descriptor
];
}
/**
* @returns {import('dictionary-importer').ImportSteps}
*/
_getUrlImportSteps() {
const urlImportSteps = this._getFileImportSteps();
urlImportSteps.splice(2, 0, {label: 'Downloading dictionary'});
return urlImportSteps;
}
/**
* @template T
* @param {T[]} arr
* @yields {Promise<T>}
* @returns {AsyncGenerator<T, void, void>}
*/
async *_arrayToAsyncGenerator(arr) {
for (const item of arr) {
yield item;
}
}
/**
* @param {File} file
* @param {import('settings-controller').ProfilesDictionarySettings} profilesDictionarySettings
* @param {import('dictionary-importer').ImportDetails} importDetails
* @param {import('dictionary-worker').ImportProgressCallback} onProgress
* @returns {Promise<Error[] | undefined>}
*/
async _importDictionaryFromZip(file, profilesDictionarySettings, importDetails, onProgress) {
const archiveContent = await this._readFile(file);
const {result, errors} = await new DictionaryWorker().importDictionary(archiveContent, importDetails, onProgress);
if (!result) {
return errors;
}
const errors2 = await this._addDictionarySettings(result, profilesDictionarySettings);
await this._settingsController.application.api.triggerDatabaseUpdated('dictionary', 'import');
// Only runs if updating a dictionary
if (profilesDictionarySettings !== null) {
const options = await this._settingsController.getOptionsFull();
const {profiles} = options;
for (const profile of profiles) {
for (const cardFormat of profile.options.anki.cardFormats) {
const ankiTermFields = cardFormat.fields;
const oldFieldSegmentRegex = new RegExp(getKebabCase(profilesDictionarySettings[profile.id].name), 'g');
const newFieldSegment = getKebabCase(result.title);
for (const key of Object.keys(ankiTermFields)) {
ankiTermFields[key].value = ankiTermFields[key].value.replace(oldFieldSegmentRegex, newFieldSegment);
}
}
}
await this._settingsController.setAllSettings(options);
}
if (errors.length > 0) {
errors.push(new Error(`Dictionary may not have been imported properly: ${errors.length} error${errors.length === 1 ? '' : 's'} reported.`));
this._showErrors([...errors, ...errors2]);
} else if (errors2.length > 0) {
this._showErrors(errors2);
}
}
/**
* @param {import('dictionary-importer').Summary} summary
* @param {import('settings-controller').ProfilesDictionarySettings} profilesDictionarySettings
* @returns {Promise<Error[]>}
*/
async _addDictionarySettings(summary, profilesDictionarySettings) {
const {title, sequenced, styles} = summary;
let optionsFull;
// Workaround Firefox bug sometimes causing getOptionsFull to fail
for (let i = 0, success = false; (i < 10) && (success === false); i++) {
try {
optionsFull = await this._settingsController.getOptionsFull();
success = true;
} catch (error) {
log.error(error);
}
}
if (!optionsFull) { return [new Error('Failed to automatically set dictionary settings. A page refresh and manual enabling of the dictionary may be required.')]; }
const profileIndex = this._settingsController.profileIndex;
/** @type {import('settings-modifications').Modification[]} */
const targets = [];
const profileCount = optionsFull.profiles.length;
for (let i = 0; i < profileCount; ++i) {
const {options, id: profileId} = optionsFull.profiles[i];
const enabled = profileIndex === i;
const defaultSettings = DictionaryController.createDefaultDictionarySettings(title, enabled, styles);
const path1 = `profiles[${i}].options.dictionaries`;
if (profilesDictionarySettings === null || typeof profilesDictionarySettings[profileId] === 'undefined') {
targets.push({action: 'push', path: path1, items: [defaultSettings]});
} else {
const {index, alias, name, ...currentSettings} = profilesDictionarySettings[profileId];
const newAlias = alias === name ? title : alias;
targets.push({
action: 'splice',
path: path1,
start: index,
items: [{
...currentSettings,
styles,
name: title,
alias: newAlias,
}],
deleteCount: 0,
});
}
if (sequenced && options.general.mainDictionary === '') {
const path2 = `profiles[${i}].options.general.mainDictionary`;
targets.push({action: 'set', path: path2, value: title});
}
}
return await this._modifyGlobalSettings(targets);
}
/**
* @returns {Promise<Error[]>}
*/
async _clearDictionarySettings() {
const optionsFull = await this._settingsController.getOptionsFull();
/** @type {import('settings-modifications').Modification[]} */
const targets = [];
const profileCount = optionsFull.profiles.length;
for (let i = 0; i < profileCount; ++i) {
const path1 = `profiles[${i}].options.dictionaries`;
targets.push({action: 'set', path: path1, value: []});
const path2 = `profiles[${i}].options.general.mainDictionary`;
targets.push({action: 'set', path: path2, value: ''});
}
return await this._modifyGlobalSettings(targets);
}
/**
* @returns {import('settings-controller').PageExitPrevention}
*/
_preventPageExit() {
return this._settingsController.preventPageExit();
}
/**
* @param {Error[]} errors
*/
_showErrors(errors) {
/** @type {Map<string, number>} */
const uniqueErrors = new Map();
for (const error of errors) {
log.error(error);
const errorString = this._errorToString(error);
let count = uniqueErrors.get(errorString);
if (typeof count === 'undefined') {
count = 0;
}
uniqueErrors.set(errorString, count + 1);
}
const fragment = document.createDocumentFragment();
for (const [e, count] of uniqueErrors.entries()) {
const div = document.createElement('p');
if (count > 1) {
div.textContent = `${e} `;
const em = document.createElement('em');
em.textContent = `(${count})`;
div.appendChild(em);
} else {
div.textContent = `${e}`;
}
fragment.appendChild(div);
}
const errorContainer = /** @type {HTMLElement} */ (this._errorContainer);
errorContainer.appendChild(fragment);
errorContainer.hidden = false;
}
/** */
_hideErrors() {
const errorContainer = /** @type {HTMLElement} */ (this._errorContainer);
errorContainer.textContent = '';
errorContainer.hidden = true;
}
/**
* @param {File} file
* @returns {Promise<ArrayBuffer>}
*/
_readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(/** @type {ArrayBuffer} */ (reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
/**
* @param {Error} error
* @returns {string}
*/
_errorToString(error) {
const errorMessage = error.toString();
for (const [match, newErrorString] of this._errorToStringOverrides) {
if (errorMessage.includes(match)) {
return newErrorString;
}
}
return errorMessage;
}
/**
* @param {boolean} value
*/
_setModifying(value) {
this._modifying = value;
this._setButtonsEnabled(!value);
}
/**
* @param {boolean} value
*/
_setButtonsEnabled(value) {
value = !value;
for (const node of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.dictionary-database-mutating-input'))) {
node.disabled = value;
}
}
/**
* @param {import('settings-modifications').Modification[]} targets
* @returns {Promise<Error[]>}
*/
async _modifyGlobalSettings(targets) {
const results = await this._settingsController.modifyGlobalSettings(targets);
const errors = [];
for (const {error} of results) {
if (typeof error !== 'undefined') {
errors.push(ExtensionError.deserialize(error));
}
}
return errors;
}
/** */
_triggerStorageChanged() {
this._settingsController.application.triggerStorageChanged();
}
}
export class ImportProgressTracker {
/**
* @param {import('dictionary-importer').ImportSteps} steps
* @param {number} dictionaryCount
*/
constructor(steps, dictionaryCount) {
/** @type {import('dictionary-importer').ImportSteps} */
this._steps = steps;
/** @type {number} */
this._dictionaryCount = dictionaryCount;
/** @type {number} */
this._stepIndex = 0;
/** @type {number} */
this._dictionaryIndex = 0;
const progressSelector = '.dictionary-import-progress';
/** @type {NodeListOf<HTMLElement>} */
this._progressBars = (document.querySelectorAll(`${progressSelector} .progress-bar`));
/** @type {NodeListOf<HTMLElement>} */
this._infoLabels = (document.querySelectorAll(`${progressSelector} .progress-info`));
/** @type {NodeListOf<HTMLElement>} */
this._statusLabels = (document.querySelectorAll(`${progressSelector} .progress-status`));
this.onProgress({nextStep: false, index: 0, count: 0});
}
/** @type {string} */
get statusPrefix() {
return `Importing dictionary${this._dictionaryCount > 1 ? ` (${this._dictionaryIndex} of ${this._dictionaryCount})` : ''}`;
}
/** @type {import('dictionary-importer').ImportStep} */
get currentStep() {
return this._steps[this._stepIndex];
}
/** @type {number} */
get stepCount() {
return this._steps.length;
}
/** @type {number} */
get dictionaryCount() {
return this._dictionaryCount;
}
/** @type {import('dictionary-worker').ImportProgressCallback} */
onProgress(data) {
const {nextStep, index, count} = data;
if (nextStep) {
this._stepIndex++;
}
const labelText = `${this.statusPrefix} - Step ${this._stepIndex + 1} of ${this.stepCount}: ${this.currentStep.label}...`;
for (const label of this._infoLabels) { label.textContent = labelText; }
const percent = count > 0 ? (index / count * 100) : 0;
const cssString = `${percent}%`;
const statusString = `${Math.floor(percent).toFixed(0)}%`;
for (const progressBar of this._progressBars) { progressBar.style.width = cssString; }
for (const label of this._statusLabels) { label.textContent = statusString; }
const callback = this.currentStep?.callback;
if (typeof callback === 'function') {
callback();
}
}
/**
*
*/
onNextDictionary() {
this._dictionaryIndex += 1;
this._stepIndex = 0;
this.onProgress({
nextStep: true,
index: 0,
count: 0,
});
}
}

View File

@@ -0,0 +1,389 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {isObjectNotArray} from '../../core/object-utilities.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {HotkeyUtil} from '../../input/hotkey-util.js';
import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js';
export class ExtensionKeyboardShortcutController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {HTMLButtonElement} */
this._resetButton = querySelectorNotNull(document, '#extension-hotkey-list-reset-all');
/** @type {HTMLButtonElement} */
this._clearButton = querySelectorNotNull(document, '#extension-hotkey-list-clear-all');
/** @type {HTMLElement} */
this._listContainer = querySelectorNotNull(document, '#extension-hotkey-list');
/** @type {HotkeyUtil} */
this._hotkeyUtil = new HotkeyUtil();
/** @type {?import('environment').OperatingSystem} */
this._os = null;
/** @type {ExtensionKeyboardShortcutHotkeyEntry[]} */
this._entries = [];
}
/** @type {HotkeyUtil} */
get hotkeyUtil() {
return this._hotkeyUtil;
}
/** */
async prepare() {
const canResetCommands = this.canResetCommands();
const canModifyCommands = this.canModifyCommands();
this._resetButton.hidden = !canResetCommands;
this._clearButton.hidden = !canModifyCommands;
if (canResetCommands) {
this._resetButton.addEventListener('click', this._onResetClick.bind(this));
}
if (canModifyCommands) {
this._clearButton.addEventListener('click', this._onClearClick.bind(this));
}
const {platform: {os}} = await this._settingsController.application.api.getEnvironmentInfo();
this._os = os;
this._hotkeyUtil.os = os;
const commands = await this._getCommands();
this._setupCommands(commands);
}
/**
* @param {string} name
* @returns {Promise<{key: ?string, modifiers: import('input').Modifier[]}>}
*/
async resetCommand(name) {
await this._resetCommand(name);
/** @type {?string} */
let key = null;
/** @type {import('input').Modifier[]} */
let modifiers = [];
const commands = await this._getCommands();
for (const {name: name2, shortcut} of commands) {
if (name === name2) {
({key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut));
break;
}
}
return {key, modifiers};
}
/**
* @param {string} name
* @param {?string} key
* @param {import('input').Modifier[]} modifiers
*/
async updateCommand(name, key, modifiers) {
// Firefox-only; uses Promise API
const shortcut = this._hotkeyUtil.convertInputToCommand(key, modifiers);
await browser.commands.update({name, shortcut});
}
/**
* @returns {boolean}
*/
canResetCommands() {
return (
typeof browser === 'object' && browser !== null &&
typeof browser.commands === 'object' && browser.commands !== null &&
typeof browser.commands.reset === 'function'
);
}
/**
* @returns {boolean}
*/
canModifyCommands() {
return (
typeof browser === 'object' && browser !== null &&
typeof browser.commands === 'object' && browser.commands !== null &&
typeof browser.commands.update === 'function'
);
}
// Add
/**
* @param {MouseEvent} e
*/
_onResetClick(e) {
e.preventDefault();
void this._resetAllCommands();
}
/**
* @param {MouseEvent} e
*/
_onClearClick(e) {
e.preventDefault();
void this._clearAllCommands();
}
/**
* @returns {Promise<chrome.commands.Command[]>}
*/
_getCommands() {
return new Promise((resolve, reject) => {
if (!(isObjectNotArray(chrome.commands) && typeof chrome.commands.getAll === 'function')) {
resolve([]);
return;
}
chrome.commands.getAll((result) => {
const e = chrome.runtime.lastError;
if (e) {
reject(new Error(e.message));
} else {
resolve(result);
}
});
});
}
/**
* @param {chrome.commands.Command[]} commands
*/
_setupCommands(commands) {
for (const entry of this._entries) {
entry.cleanup();
}
this._entries = [];
const fragment = document.createDocumentFragment();
for (const {name, description, shortcut} of commands) {
if (typeof name !== 'string' || name.startsWith('_')) { continue; }
const {key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut);
const node = this._settingsController.instantiateTemplate('extension-hotkey-list-item');
fragment.appendChild(node);
const entry = new ExtensionKeyboardShortcutHotkeyEntry(this, node, name, description, key, modifiers, this._os);
entry.prepare();
this._entries.push(entry);
}
const listContainer = /** @type {HTMLElement} */ (this._listContainer);
listContainer.textContent = '';
listContainer.appendChild(fragment);
}
/** */
async _resetAllCommands() {
if (!this.canModifyCommands()) { return; }
let commands = await this._getCommands();
const promises = [];
for (const {name} of commands) {
if (typeof name !== 'string' || name.startsWith('_')) { continue; }
promises.push(this._resetCommand(name));
}
await Promise.all(promises);
commands = await this._getCommands();
this._setupCommands(commands);
}
/** */
async _clearAllCommands() {
if (!this.canModifyCommands()) { return; }
let commands = await this._getCommands();
const promises = [];
for (const {name} of commands) {
if (typeof name !== 'string' || name.startsWith('_')) { continue; }
promises.push(this.updateCommand(name, null, []));
}
await Promise.all(promises);
commands = await this._getCommands();
this._setupCommands(commands);
}
/**
* @param {string} name
*/
async _resetCommand(name) {
// Firefox-only; uses Promise API
await browser.commands.reset(name);
}
}
class ExtensionKeyboardShortcutHotkeyEntry {
/**
* @param {ExtensionKeyboardShortcutController} parent
* @param {Element} node
* @param {string} name
* @param {string|undefined} description
* @param {?string} key
* @param {import('input').Modifier[]} modifiers
* @param {?import('environment').OperatingSystem} os
*/
constructor(parent, node, name, description, key, modifiers, os) {
/** @type {ExtensionKeyboardShortcutController} */
this._parent = parent;
/** @type {Element} */
this._node = node;
/** @type {string} */
this._name = name;
/** @type {string|undefined} */
this._description = description;
/** @type {?string} */
this._key = key;
/** @type {import('input').Modifier[]} */
this._modifiers = modifiers;
/** @type {?import('environment').OperatingSystem} */
this._os = os;
/** @type {?HTMLInputElement} */
this._input = null;
/** @type {?KeyboardMouseInputField} */
this._inputField = null;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
}
/** */
prepare() {
/** @type {HTMLElement} */
const label = querySelectorNotNull(this._node, '.settings-item-label');
label.textContent = this._description || this._name;
/** @type {HTMLButtonElement} */
const button = querySelectorNotNull(this._node, '.extension-hotkey-list-item-button');
/** @type {HTMLInputElement} */
const input = querySelectorNotNull(this._node, 'input');
this._input = input;
if (this._parent.canModifyCommands()) {
this._inputField = new KeyboardMouseInputField(input, null, this._os);
this._inputField.prepare(this._key, this._modifiers, false, true);
this._eventListeners.on(this._inputField, 'change', this._onInputFieldChange.bind(this));
this._eventListeners.addEventListener(button, 'menuClose', this._onMenuClose.bind(this));
this._eventListeners.addEventListener(input, 'blur', this._onInputFieldBlur.bind(this));
} else {
input.readOnly = true;
input.value = this._parent.hotkeyUtil.getInputDisplayValue(this._key, this._modifiers);
button.hidden = true;
}
}
/** */
cleanup() {
this._eventListeners.removeAllEventListeners();
if (this._node.parentNode !== null) {
this._node.parentNode.removeChild(this._node);
}
if (this._inputField !== null) {
this._inputField.cleanup();
this._inputField = null;
}
}
// Private
/**
* @param {import('keyboard-mouse-input-field').EventArgument<'change'>} e
*/
_onInputFieldChange(e) {
const {key, modifiers} = e;
void this._tryUpdateInput(key, modifiers, false);
}
/** */
_onInputFieldBlur() {
this._updateInput();
}
/**
* @param {import('popup-menu').MenuCloseEvent} e
*/
_onMenuClose(e) {
switch (e.detail.action) {
case 'clearInput':
void this._tryUpdateInput(null, [], true);
break;
case 'resetInput':
void this._resetInput();
break;
}
}
/** */
_updateInput() {
/** @type {KeyboardMouseInputField} */ (this._inputField).setInput(this._key, this._modifiers);
if (this._input !== null) {
delete this._input.dataset.invalid;
}
}
/**
* @param {?string} key
* @param {import('input').Modifier[]} modifiers
* @param {boolean} updateInput
*/
async _tryUpdateInput(key, modifiers, updateInput) {
let okay = (key === null ? (modifiers.length === 0) : (modifiers.length > 0));
if (okay) {
try {
await this._parent.updateCommand(this._name, key, modifiers);
} catch (e) {
okay = false;
}
}
if (okay) {
this._key = key;
this._modifiers = modifiers;
if (this._input !== null) {
delete this._input.dataset.invalid;
}
} else {
if (this._input !== null) {
this._input.dataset.invalid = 'true';
}
}
if (updateInput) {
this._updateInput();
}
}
/** */
async _resetInput() {
const {key, modifiers} = await this._parent.resetCommand(this._name);
this._key = key;
this._modifiers = modifiers;
this._updateInput();
}
}

View File

@@ -0,0 +1,370 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ExtensionError} from '../../core/extension-error.js';
import {parseJson} from '../../core/json.js';
import {convertElementValueToNumber} from '../../dom/document-util.js';
import {DOMDataBinder} from '../../dom/dom-data-binder.js';
export class GenericSettingController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {import('settings-modifications').OptionsScopeType} */
this._defaultScope = 'profile';
/** @type {DOMDataBinder<import('generic-setting-controller').ElementMetadata>} */
this._dataBinder = new DOMDataBinder(
['[data-setting]', '[data-permissions-setting]'],
this._createElementMetadata.bind(this),
this._compareElementMetadata.bind(this),
this._getValues.bind(this),
this._setValues.bind(this),
);
/** @type {Map<import('generic-setting-controller').TransformType, import('generic-setting-controller').TransformFunction>} */
this._transforms = new Map(/** @type {[key: import('generic-setting-controller').TransformType, value: import('generic-setting-controller').TransformFunction][]} */ ([
['setAttribute', this._setAttribute.bind(this)],
['setVisibility', this._setVisibility.bind(this)],
['splitTags', this._splitTags.bind(this)],
['joinTags', this._joinTags.bind(this)],
['toNumber', this._toNumber.bind(this)],
['toBoolean', this._toBoolean.bind(this)],
['toString', this._toString.bind(this)],
['conditionalConvert', this._conditionalConvert.bind(this)],
]));
}
/** */
async prepare() {
this._dataBinder.observe(document.body);
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
}
/** */
async refresh() {
await this._dataBinder.refresh();
}
// Private
/** */
_onOptionsChanged() {
void this._dataBinder.refresh();
}
/**
* @param {Element} element
* @returns {import('generic-setting-controller').ElementMetadata|undefined}
*/
_createElementMetadata(element) {
if (!(element instanceof HTMLElement)) { return void 0; }
const {scope, transform: transformRaw} = element.dataset;
const path = element.dataset.setting ?? element.dataset.permissionsSetting;
if (typeof path !== 'string') { return void 0; }
const scope2 = this._normalizeScope(scope);
return {
path,
scope: scope2 !== null ? scope2 : this._defaultScope,
transforms: this._getTransformDataArray(transformRaw),
transformRaw,
};
}
/**
* @param {import('generic-setting-controller').ElementMetadata} metadata1
* @param {import('generic-setting-controller').ElementMetadata} metadata2
* @returns {boolean}
*/
_compareElementMetadata(metadata1, metadata2) {
return (
metadata1.path === metadata2.path &&
metadata1.scope === metadata2.scope &&
metadata1.transformRaw === metadata2.transformRaw
);
}
/**
* @param {import('dom-data-binder').GetValuesDetails<import('generic-setting-controller').ElementMetadata>[]} targets
* @returns {Promise<import('dom-data-binder').TaskResult[]>}
*/
async _getValues(targets) {
const defaultScope = this._defaultScope;
/** @type {import('settings-modifications').ScopedRead[]} */
const settingsTargets = [];
for (const {metadata: {path, scope}} of targets) {
/** @type {import('settings-modifications').ScopedRead} */
const target = {
path,
scope: typeof scope === 'string' ? scope : defaultScope,
optionsContext: null,
};
settingsTargets.push(target);
}
return this._transformResults(await this._settingsController.getSettings(settingsTargets), targets);
}
/**
* @param {import('dom-data-binder').SetValuesDetails<import('generic-setting-controller').ElementMetadata>[]} targets
* @returns {Promise<import('dom-data-binder').TaskResult[]>}
*/
async _setValues(targets) {
const defaultScope = this._defaultScope;
/** @type {import('settings-modifications').ScopedModification[]} */
const settingsTargets = [];
for (const {metadata: {path, scope, transforms}, value, element} of targets) {
const transformedValue = this._applyTransforms(value, transforms, 'pre', element);
/** @type {import('settings-modifications').ScopedModification} */
const target = {
path,
scope: typeof scope === 'string' ? scope : defaultScope,
action: 'set',
value: transformedValue,
optionsContext: null,
};
settingsTargets.push(target);
}
return this._transformResults(await this._settingsController.modifySettings(settingsTargets), targets);
}
/**
* @param {import('settings-controller').ModifyResult[]} values
* @param {import('dom-data-binder').GetValuesDetails<import('generic-setting-controller').ElementMetadata>[]|import('dom-data-binder').SetValuesDetails<import('generic-setting-controller').ElementMetadata>[]} targets
* @returns {import('dom-data-binder').TaskResult[]}
*/
_transformResults(values, targets) {
return values.map((value, i) => {
const error = value.error;
if (error) { return {error: ExtensionError.deserialize(error)}; }
const {metadata: {transforms}, element} = targets[i];
const result = this._applyTransforms(value.result, transforms, 'post', element);
return {result};
});
}
/**
* @param {unknown} value
* @param {import('generic-setting-controller').TransformData[]} transforms
* @param {import('generic-setting-controller').TransformStep} step
* @param {Element} element
* @returns {unknown}
*/
_applyTransforms(value, transforms, step, element) {
for (const transform of transforms) {
const transformStep = transform.step;
if (typeof transformStep !== 'undefined' && transformStep !== step) { continue; }
const transformFunction = this._transforms.get(transform.type);
if (typeof transformFunction === 'undefined') { continue; }
value = transformFunction(value, transform, element);
}
return value;
}
/**
* @param {?Node} node
* @param {number} ancestorDistance
* @returns {?Node}
*/
_getAncestor(node, ancestorDistance) {
if (ancestorDistance < 0) {
return document.documentElement;
}
for (let i = 0; i < ancestorDistance && node !== null; ++i) {
node = node.parentNode;
}
return node;
}
/**
* @param {?Node} node
* @param {number|undefined} ancestorDistance
* @param {string|undefined} selector
* @returns {?Node}
*/
_getRelativeElement(node, ancestorDistance, selector) {
const selectorRoot = (
typeof ancestorDistance === 'number' ?
this._getAncestor(node, ancestorDistance) :
document
);
if (selectorRoot === null) { return null; }
return (
typeof selector === 'string' && (selectorRoot instanceof Element || selectorRoot instanceof Document) ?
selectorRoot.querySelector(selector) :
(selectorRoot === document ? document.documentElement : selectorRoot)
);
}
/**
* @param {import('generic-setting-controller').OperationData} operationData
* @param {unknown} lhs
* @returns {unknown}
*/
_evaluateSimpleOperation(operationData, lhs) {
const {op: operation, value: rhs} = operationData;
switch (operation) {
case '!': return !lhs;
case '!!': return !!lhs;
case '===': return lhs === rhs;
case '!==': return lhs !== rhs;
case '>=': return /** @type {number} */ (lhs) >= /** @type {number} */ (rhs);
case '<=': return /** @type {number} */ (lhs) <= /** @type {number} */ (rhs);
case '>': return /** @type {number} */ (lhs) > /** @type {number} */ (rhs);
case '<': return /** @type {number} */ (lhs) < /** @type {number} */ (rhs);
case '&&':
for (const operationData2 of /** @type {import('generic-setting-controller').OperationData[]} */ (rhs)) {
const result = this._evaluateSimpleOperation(operationData2, lhs);
if (!result) { return result; }
}
return true;
case '||':
for (const operationData2 of /** @type {import('generic-setting-controller').OperationData[]} */ (rhs)) {
const result = this._evaluateSimpleOperation(operationData2, lhs);
if (result) { return result; }
}
return false;
default:
return false;
}
}
/**
* @param {string|undefined} value
* @returns {?import('settings-modifications').OptionsScopeType}
*/
_normalizeScope(value) {
switch (value) {
case 'profile':
case 'global':
return value;
default:
return null;
}
}
/**
* @param {string|undefined} transformRaw
* @returns {import('generic-setting-controller').TransformData[]}
*/
_getTransformDataArray(transformRaw) {
if (typeof transformRaw === 'string') {
const transforms = parseJson(transformRaw);
return Array.isArray(transforms) ? transforms : [transforms];
}
return [];
}
// Transforms
/**
* @param {unknown} value
* @param {import('generic-setting-controller').SetAttributeTransformData} data
* @param {Element} element
* @returns {unknown}
*/
_setAttribute(value, data, element) {
const {ancestorDistance, selector, attribute} = data;
const relativeElement = this._getRelativeElement(element, ancestorDistance, selector);
if (relativeElement !== null && relativeElement instanceof Element) {
relativeElement.setAttribute(attribute, `${value}`);
}
return value;
}
/**
* @param {unknown} value
* @param {import('generic-setting-controller').SetVisibilityTransformData} data
* @param {Element} element
* @returns {unknown}
*/
_setVisibility(value, data, element) {
const {ancestorDistance, selector, condition} = data;
const relativeElement = this._getRelativeElement(element, ancestorDistance, selector);
if (relativeElement !== null && relativeElement instanceof HTMLElement) {
relativeElement.hidden = !this._evaluateSimpleOperation(condition, value);
}
return value;
}
/**
* @param {unknown} value
* @returns {string[]}
*/
_splitTags(value) {
return `${value}`.split(/[,; ]+/).filter((v) => !!v);
}
/**
* @param {unknown} value
* @returns {string}
*/
_joinTags(value) {
return Array.isArray(value) ? value.join(' ') : '';
}
/**
* @param {unknown} value
* @param {import('generic-setting-controller').ToNumberConstraintsTransformData} data
* @returns {number}
*/
_toNumber(value, data) {
/** @type {import('document-util').ToNumberConstraints} */
const constraints = typeof data.constraints === 'object' && data.constraints !== null ? data.constraints : {};
return typeof value === 'string' ? convertElementValueToNumber(value, constraints) : 0;
}
/**
* @param {string} value
* @returns {boolean}
*/
_toBoolean(value) {
return (value === 'true');
}
/**
* @param {unknown} value
* @returns {string}
*/
_toString(value) {
return `${value}`;
}
/**
* @param {unknown} value
* @param {import('generic-setting-controller').ConditionalConvertTransformData} data
* @returns {unknown}
*/
_conditionalConvert(value, data) {
const {cases} = data;
if (Array.isArray(cases)) {
for (const caseData of cases) {
if (caseData.default === true) {
value = caseData.result;
} else if (this._evaluateSimpleOperation(caseData, value)) {
value = caseData.result;
break;
}
}
}
return value;
}
}

View File

@@ -0,0 +1,332 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventDispatcher} from '../../core/event-dispatcher.js';
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {getActiveButtons, getActiveModifiers} from '../../dom/document-util.js';
import {HotkeyUtil} from '../../input/hotkey-util.js';
/**
* @augments EventDispatcher<import('keyboard-mouse-input-field').Events>
*/
export class KeyboardMouseInputField extends EventDispatcher {
/**
* @param {HTMLInputElement} inputNode
* @param {?HTMLButtonElement} mouseButton
* @param {?import('environment').OperatingSystem} os
* @param {?(pointerType: string) => boolean} [isPointerTypeSupported]
*/
constructor(inputNode, mouseButton, os, isPointerTypeSupported = null) {
super();
/** @type {HTMLInputElement} */
this._inputNode = inputNode;
/** @type {?HTMLButtonElement} */
this._mouseButton = mouseButton;
/** @type {?(pointerType: string) => boolean} */
this._isPointerTypeSupported = isPointerTypeSupported;
/** @type {HotkeyUtil} */
this._hotkeyUtil = new HotkeyUtil(os);
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {?string} */
this._key = null;
/** @type {import('input').Modifier[]} */
this._modifiers = [];
/** @type {Set<number>} */
this._penPointerIds = new Set();
/** @type {boolean} */
this._mouseModifiersSupported = false;
/** @type {boolean} */
this._keySupported = false;
}
/** @type {import('input').Modifier[]} */
get modifiers() {
return this._modifiers;
}
/**
* @param {?string} key
* @param {import('input').Modifier[]} modifiers
* @param {boolean} [mouseModifiersSupported]
* @param {boolean} [keySupported]
*/
prepare(key, modifiers, mouseModifiersSupported = false, keySupported = false) {
this.cleanup();
this._mouseModifiersSupported = mouseModifiersSupported;
this._keySupported = keySupported;
this.setInput(key, modifiers);
/** @type {import('event-listener-collection').AddEventListenerArgs[]} */
const events = [
[this._inputNode, 'keydown', this._onModifierKeyDown.bind(this), false],
[this._inputNode, 'keyup', this._onModifierKeyUp.bind(this), false],
];
if (mouseModifiersSupported && this._mouseButton !== null) {
events.push(
[this._mouseButton, 'mousedown', this._onMouseButtonMouseDown.bind(this), false],
[this._mouseButton, 'pointerdown', this._onMouseButtonPointerDown.bind(this), false],
[this._mouseButton, 'pointerover', this._onMouseButtonPointerOver.bind(this), false],
[this._mouseButton, 'pointerout', this._onMouseButtonPointerOut.bind(this), false],
[this._mouseButton, 'pointercancel', this._onMouseButtonPointerCancel.bind(this), false],
[this._mouseButton, 'mouseup', this._onMouseButtonMouseUp.bind(this), false],
[this._mouseButton, 'contextmenu', this._onMouseButtonContextMenu.bind(this), false],
);
}
for (const args of events) {
this._eventListeners.addEventListener(...args);
}
}
/**
* @param {?string} key
* @param {import('input').Modifier[]} modifiers
*/
setInput(key, modifiers) {
this._key = key;
this._modifiers = this._sortModifiers(modifiers);
this._updateDisplayString();
}
/** */
cleanup() {
this._eventListeners.removeAllEventListeners();
this._modifiers = [];
this._key = null;
this._mouseModifiersSupported = false;
this._keySupported = false;
this._penPointerIds.clear();
}
/** */
clearInputs() {
this._updateModifiers([], null);
}
// Private
/**
* @param {import('input').Modifier[]} modifiers
* @returns {import('input').Modifier[]}
*/
_sortModifiers(modifiers) {
return this._hotkeyUtil.sortModifiers(modifiers);
}
/** */
_updateDisplayString() {
const displayValue = this._hotkeyUtil.getInputDisplayValue(this._key, this._modifiers);
this._inputNode.value = displayValue;
}
/**
* @param {KeyboardEvent} e
* @returns {Set<import('input').ModifierKey>}
*/
_getModifierKeys(e) {
const modifiers = new Set(getActiveModifiers(e));
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey
// https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta
// It works with mouse events on some platforms, so try to determine if metaKey is pressed.
// This is a hack and only works when both Shift and Alt are not pressed.
if (
!modifiers.has('meta') &&
e.key === 'Meta' &&
!(
modifiers.size === 2 &&
modifiers.has('shift') &&
modifiers.has('alt')
)
) {
modifiers.add('meta');
}
return modifiers;
}
/**
* @param {string|undefined} keyName
* @returns {boolean}
*/
_isModifierKey(keyName) {
switch (keyName) {
case 'AltLeft':
case 'AltRight':
case 'ControlLeft':
case 'ControlRight':
case 'MetaLeft':
case 'MetaRight':
case 'ShiftLeft':
case 'ShiftRight':
case 'OSLeft':
case 'OSRight':
return true;
default:
return false;
}
}
/**
* @param {KeyboardEvent} e
*/
_onModifierKeyDown(e) {
e.preventDefault();
/** @type {string|undefined} */
let key = e.code;
if (key === 'Unidentified' || key === '') { key = void 0; }
if (this._keySupported) {
this._updateModifiers([...this._getModifierKeys(e)], this._isModifierKey(key) ? void 0 : key);
} else {
switch (key) {
case 'Escape':
case 'Backspace':
this.clearInputs();
break;
default:
this._addModifiers(this._getModifierKeys(e));
break;
}
}
}
/**
* @param {KeyboardEvent} e
*/
_onModifierKeyUp(e) {
e.preventDefault();
}
/**
* @param {MouseEvent} e
*/
_onMouseButtonMouseDown(e) {
e.preventDefault();
this._addModifiers(getActiveButtons(e));
}
/**
* @param {PointerEvent} e
*/
_onMouseButtonPointerDown(e) {
if (!e.isPrimary) { return; }
let {pointerType, pointerId} = e;
// Workaround for Firefox bug not detecting certain 'touch' events as 'pen' events.
if (this._penPointerIds.has(pointerId)) { pointerType = 'pen'; }
if (
typeof this._isPointerTypeSupported !== 'function' ||
!this._isPointerTypeSupported(pointerType)
) {
return;
}
e.preventDefault();
this._addModifiers(getActiveButtons(e));
}
/**
* @param {PointerEvent} e
*/
_onMouseButtonPointerOver(e) {
const {pointerType, pointerId} = e;
if (pointerType === 'pen') {
this._penPointerIds.add(pointerId);
}
}
/**
* @param {PointerEvent} e
*/
_onMouseButtonPointerOut(e) {
const {pointerId} = e;
this._penPointerIds.delete(pointerId);
}
/**
* @param {PointerEvent} e
*/
_onMouseButtonPointerCancel(e) {
this._onMouseButtonPointerOut(e);
}
/**
* @param {MouseEvent} e
*/
_onMouseButtonMouseUp(e) {
e.preventDefault();
}
/**
* @param {MouseEvent} e
*/
_onMouseButtonContextMenu(e) {
e.preventDefault();
}
/**
* @param {Iterable<import('input').Modifier>} newModifiers
* @param {?string} [newKey]
*/
_addModifiers(newModifiers, newKey) {
const modifiers = new Set(this._modifiers);
for (const modifier of newModifiers) {
modifiers.add(modifier);
}
this._updateModifiers([...modifiers], newKey);
}
/**
* @param {import('input').Modifier[]} modifiers
* @param {?string} [newKey]
*/
_updateModifiers(modifiers, newKey) {
modifiers = this._sortModifiers(modifiers);
let changed = false;
if (typeof newKey !== 'undefined' && this._key !== newKey) {
this._key = newKey;
changed = true;
}
if (!this._areArraysEqual(this._modifiers, modifiers)) {
this._modifiers = modifiers;
changed = true;
}
this._updateDisplayString();
if (changed) {
this.trigger('change', {modifiers: this._modifiers, key: this._key});
}
}
/**
* @template [T=unknown]
* @param {T[]} array1
* @param {T[]} array2
* @returns {boolean}
*/
_areArraysEqual(array1, array2) {
const length = array1.length;
if (length !== array2.length) { return false; }
for (let i = 0; i < length; ++i) {
if (array1[i] !== array2[i]) { return false; }
}
return true;
}
}

View File

@@ -0,0 +1,814 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {convertElementValueToNumber, normalizeModifierKey} from '../../dom/document-util.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {ObjectPropertyAccessor} from '../../general/object-property-accessor.js';
import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js';
export class KeyboardShortcutController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {KeyboardShortcutHotkeyEntry[]} */
this._entries = [];
/** @type {?import('environment').OperatingSystem} */
this._os = null;
/** @type {HTMLButtonElement} */
this._addButton = querySelectorNotNull(document, '#hotkey-list-add');
/** @type {HTMLButtonElement} */
this._resetButton = querySelectorNotNull(document, '#hotkey-list-reset');
/** @type {HTMLElement} */
this._listContainer = querySelectorNotNull(document, '#hotkey-list');
/** @type {HTMLElement} */
this._emptyIndicator = querySelectorNotNull(document, '#hotkey-list-empty');
/** @type {Intl.Collator} */
this._stringComparer = new Intl.Collator('en-US'); // Invariant locale
/** @type {HTMLElement} */
this._scrollContainer = querySelectorNotNull(document, '#keyboard-shortcuts-modal .modal-body');
/* eslint-disable @stylistic/no-multi-spaces */
/** @type {Map<string, import('keyboard-shortcut-controller').ActionDetails>} */
this._actionDetails = new Map([
['', {scopes: new Set()}],
['close', {scopes: new Set(['popup', 'search'])}],
['focusSearchBox', {scopes: new Set(['search'])}],
['nextEntry', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-move-offset', default: '1'}}],
['previousEntry', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-move-offset', default: '1'}}],
['lastEntry', {scopes: new Set(['popup', 'search'])}],
['firstEntry', {scopes: new Set(['popup', 'search'])}],
['nextEntryDifferentDictionary', {scopes: new Set(['popup', 'search'])}],
['previousEntryDifferentDictionary', {scopes: new Set(['popup', 'search'])}],
['historyBackward', {scopes: new Set(['popup', 'search'])}],
['historyForward', {scopes: new Set(['popup', 'search'])}],
['profilePrevious', {scopes: new Set(['popup', 'search', 'web'])}],
['profileNext', {scopes: new Set(['popup', 'search', 'web'])}],
['addNote', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-anki-card-format', default: '0'}}],
['viewNotes', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-anki-card-format', default: '0'}}],
['playAudio', {scopes: new Set(['popup', 'search'])}],
['playAudioFromSource', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-audio-source', default: 'jpod101'}}],
['copyHostSelection', {scopes: new Set(['popup'])}],
['scanSelectedText', {scopes: new Set(['web'])}],
['scanTextAtSelection', {scopes: new Set(['web'])}],
['scanTextAtCaret', {scopes: new Set(['web'])}],
['toggleOption', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-setting-path', default: ''}}],
]);
/* eslint-enable @stylistic/no-multi-spaces */
}
/** @type {import('./settings-controller.js').SettingsController} */
get settingsController() {
return this._settingsController;
}
/** */
async prepare() {
const {platform: {os}} = await this._settingsController.application.api.getEnvironmentInfo();
this._os = os;
this._addButton.addEventListener('click', this._onAddClick.bind(this));
this._resetButton.addEventListener('click', this._onResetClick.bind(this));
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
await this._updateOptions();
}
/**
* @param {import('settings').InputsHotkeyOptions} terminationCharacterEntry
*/
async addEntry(terminationCharacterEntry) {
const options = await this._settingsController.getOptions();
const {inputs: {hotkeys}} = options;
await this._settingsController.modifyProfileSettings([{
action: 'splice',
path: 'inputs.hotkeys',
start: hotkeys.length,
deleteCount: 0,
items: [terminationCharacterEntry],
}]);
await this._updateOptions();
const scrollContainer = /** @type {HTMLElement} */ (this._scrollContainer);
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
/**
* @param {number} index
* @returns {Promise<boolean>}
*/
async deleteEntry(index) {
const options = await this._settingsController.getOptions();
const {inputs: {hotkeys}} = options;
if (index < 0 || index >= hotkeys.length) { return false; }
await this._settingsController.modifyProfileSettings([{
action: 'splice',
path: 'inputs.hotkeys',
start: index,
deleteCount: 1,
items: [],
}]);
await this._updateOptions();
return true;
}
/**
* @param {import('settings-modifications').Modification[]} targets
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async modifyProfileSettings(targets) {
return await this._settingsController.modifyProfileSettings(targets);
}
/**
* @returns {Promise<import('settings').InputsHotkeyOptions[]>}
*/
async getDefaultHotkeys() {
const defaultOptions = await this._settingsController.getDefaultOptions();
return defaultOptions.profiles[0].options.inputs.hotkeys;
}
/**
* @param {string} action
* @returns {import('keyboard-shortcut-controller').ActionDetails|undefined}
*/
getActionDetails(action) {
return this._actionDetails.get(action);
}
/**
* @returns {Promise<string[]>}
*/
async getAnkiCardFormats() {
const options = await this._settingsController.getOptions();
const {anki} = options;
return anki.cardFormats.map((cardFormat) => cardFormat.name);
}
// Private
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
async _onOptionsChanged({options}) {
for (const entry of this._entries) {
entry.cleanup();
}
this._entries = [];
const os = /** @type {import('environment').OperatingSystem} */ (this._os);
const {inputs: {hotkeys}} = options;
const fragment = document.createDocumentFragment();
for (let i = 0, ii = hotkeys.length; i < ii; ++i) {
const hotkeyEntry = hotkeys[i];
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('hotkey-list-item'));
fragment.appendChild(node);
const entry = new KeyboardShortcutHotkeyEntry(this, hotkeyEntry, i, node, os, this._stringComparer);
this._entries.push(entry);
await entry.prepare();
}
const listContainer = /** @type {HTMLElement} */ (this._listContainer);
listContainer.appendChild(fragment);
listContainer.hidden = (hotkeys.length === 0);
/** @type {HTMLElement} */ (this._emptyIndicator).hidden = (hotkeys.length > 0);
}
/**
* @param {MouseEvent} e
*/
_onAddClick(e) {
e.preventDefault();
void this._addNewEntry();
}
/**
* @param {MouseEvent} e
*/
_onResetClick(e) {
e.preventDefault();
void this._reset();
}
/** */
async _addNewEntry() {
/** @type {import('settings').InputsHotkeyOptions} */
const newEntry = {
action: '',
argument: '',
key: null,
modifiers: [],
scopes: ['popup', 'search'],
enabled: true,
};
await this.addEntry(newEntry);
}
/** */
async _updateOptions() {
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
await this._onOptionsChanged({options, optionsContext});
}
/** */
async _reset() {
const value = await this.getDefaultHotkeys();
await this._settingsController.setProfileSetting('inputs.hotkeys', value);
await this._updateOptions();
}
}
class KeyboardShortcutHotkeyEntry {
/**
* @param {KeyboardShortcutController} parent
* @param {import('settings').InputsHotkeyOptions} data
* @param {number} index
* @param {HTMLElement} node
* @param {import('environment').OperatingSystem} os
* @param {Intl.Collator} stringComparer
*/
constructor(parent, data, index, node, os, stringComparer) {
/** @type {KeyboardShortcutController} */
this._parent = parent;
/** @type {import('settings').InputsHotkeyOptions} */
this._data = data;
/** @type {number} */
this._index = index;
/** @type {HTMLElement} */
this._node = node;
/** @type {import('environment').OperatingSystem} */
this._os = os;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {?KeyboardMouseInputField} */
this._inputField = null;
/** @type {?HTMLSelectElement} */
this._actionSelect = null;
/** @type {string} */
this._basePath = `inputs.hotkeys[${this._index}]`;
/** @type {Intl.Collator} */
this._stringComparer = stringComparer;
/** @type {?HTMLButtonElement} */
this._enabledButton = null;
/** @type {?import('../../dom/popup-menu.js').PopupMenu} */
this._scopeMenu = null;
/** @type {EventListenerCollection} */
this._scopeMenuEventListeners = new EventListenerCollection();
/** @type {?HTMLElement} */
this._argumentContainer = null;
/** @type {?HTMLInputElement} */
this._argumentInput = null;
/** @type {EventListenerCollection} */
this._argumentEventListeners = new EventListenerCollection();
}
/** */
async prepare() {
const node = this._node;
/** @type {HTMLButtonElement} */
const menuButton = querySelectorNotNull(node, '.hotkey-list-item-button');
/** @type {HTMLInputElement} */
const input = querySelectorNotNull(node, '.hotkey-list-item-input');
/** @type {HTMLSelectElement} */
const action = querySelectorNotNull(node, '.hotkey-list-item-action');
/** @type {HTMLInputElement} */
const enabledToggle = querySelectorNotNull(node, '.hotkey-list-item-enabled');
/** @type {HTMLButtonElement} */
const scopesButton = querySelectorNotNull(node, '.hotkey-list-item-scopes-button');
/** @type {HTMLButtonElement} */
const enabledButton = querySelectorNotNull(node, '.hotkey-list-item-enabled-button');
this._actionSelect = action;
this._enabledButton = enabledButton;
this._argumentContainer = node.querySelector('.hotkey-list-item-action-argument-container');
this._inputField = new KeyboardMouseInputField(input, null, this._os);
this._inputField.prepare(this._data.key, this._data.modifiers, false, true);
action.value = this._data.action;
enabledToggle.checked = this._data.enabled;
enabledToggle.dataset.setting = `${this._basePath}.enabled`;
this._updateScopesButton();
await this._updateActionArgument();
this._eventListeners.addEventListener(scopesButton, 'menuOpen', this._onScopesMenuOpen.bind(this));
this._eventListeners.addEventListener(scopesButton, 'menuClose', this._onScopesMenuClose.bind(this));
this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
this._eventListeners.addEventListener(this._actionSelect, 'change', this._onActionSelectChange.bind(this), false);
this._eventListeners.on(this._inputField, 'change', this._onInputFieldChange.bind(this));
}
/** */
cleanup() {
this._eventListeners.removeAllEventListeners();
/** @type {KeyboardMouseInputField} */ (this._inputField).cleanup();
this._clearScopeMenu();
this._clearArgumentEventListeners();
if (this._node.parentNode !== null) {
this._node.parentNode.removeChild(this._node);
}
}
// Private
/**
* @param {import('popup-menu').MenuOpenEvent} e
*/
_onMenuOpen(e) {
const {action} = this._data;
const {menu} = e.detail;
/** @type {HTMLElement} */
const resetArgument = querySelectorNotNull(menu.bodyNode, '.popup-menu-item[data-menu-action="resetArgument"]');
const details = this._parent.getActionDetails(action);
const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0;
resetArgument.hidden = (typeof argumentDetails === 'undefined');
}
/**
* @param {import('popup-menu').MenuCloseEvent} e
*/
_onMenuClose(e) {
switch (e.detail.action) {
case 'delete':
void this._delete();
break;
case 'clearInputs':
/** @type {KeyboardMouseInputField} */ (this._inputField).clearInputs();
break;
case 'resetInput':
void this._resetInput();
break;
case 'resetArgument':
void this._resetArgument();
break;
}
}
/**
* @param {import('popup-menu').MenuOpenEvent} e
*/
_onScopesMenuOpen(e) {
const {menu} = e.detail;
const validScopes = this._getValidScopesForAction(this._data.action);
if (validScopes === null || validScopes.size === 0) {
menu.close();
return;
}
this._scopeMenu = menu;
this._updateScopeMenuItems(menu);
this._updateDisplay(menu.containerNode); // Fix a animation issue due to changing checkbox values
}
/**
* @param {import('popup-menu').MenuCloseEvent} e
*/
_onScopesMenuClose(e) {
const {menu, action} = e.detail;
if (action === 'toggleScope') {
e.preventDefault();
return;
}
if (this._scopeMenu === menu) {
this._clearScopeMenu();
}
}
/**
* @param {import('keyboard-mouse-input-field').EventArgument<'change'>} details
*/
_onInputFieldChange({key, modifiers}) {
/** @type {import('input').ModifierKey[]} */
const modifiers2 = [];
for (const modifier of modifiers) {
const modifier2 = normalizeModifierKey(modifier);
if (modifier2 === null) { continue; }
modifiers2.push(modifier2);
}
void this._setKeyAndModifiers(key, modifiers2);
}
/**
* @param {MouseEvent} e
*/
_onScopeCheckboxChange(e) {
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
const scope = this._normalizeScope(node.dataset.scope);
if (scope === null) { return; }
void this._setScopeEnabled(scope, node.checked);
}
/**
* @param {MouseEvent} e
*/
_onActionSelectChange(e) {
const node = /** @type {HTMLSelectElement} */ (e.currentTarget);
const value = node.value;
void this._setAction(value);
}
/**
* @param {string} template
* @param {Event} e
*/
_onArgumentValueChange(template, e) {
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
let value = this._getArgumentInputValue(node);
switch (template) {
case 'hotkey-argument-move-offset':
value = `${convertElementValueToNumber(value, node)}`;
break;
}
void this._setArgument(value);
}
/** */
async _delete() {
void this._parent.deleteEntry(this._index);
}
/**
* @param {?string} key
* @param {import('input').ModifierKey[]} modifiers
*/
async _setKeyAndModifiers(key, modifiers) {
this._data.key = key;
this._data.modifiers = modifiers;
await this._modifyProfileSettings([
{
action: 'set',
path: `${this._basePath}.key`,
value: key,
},
{
action: 'set',
path: `${this._basePath}.modifiers`,
value: modifiers,
},
]);
}
/**
* @param {import('settings').InputsHotkeyScope} scope
* @param {boolean} enabled
*/
async _setScopeEnabled(scope, enabled) {
const scopes = this._data.scopes;
const index = scopes.indexOf(scope);
if ((index >= 0) === enabled) { return; }
if (enabled) {
scopes.push(scope);
const stringComparer = this._stringComparer;
scopes.sort((scope1, scope2) => stringComparer.compare(scope1, scope2));
} else {
scopes.splice(index, 1);
}
await this._modifyProfileSettings([{
action: 'set',
path: `${this._basePath}.scopes`,
value: scopes,
}]);
this._updateScopesButton();
}
/**
* @param {import('settings-modifications').Modification[]} targets
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async _modifyProfileSettings(targets) {
return await this._parent.settingsController.modifyProfileSettings(targets);
}
/** */
async _resetInput() {
const defaultHotkeys = await this._parent.getDefaultHotkeys();
const defaultValue = this._getDefaultKeyAndModifiers(defaultHotkeys, this._data.action);
if (defaultValue === null) { return; }
const {key, modifiers} = defaultValue;
await this._setKeyAndModifiers(key, modifiers);
/** @type {KeyboardMouseInputField} */ (this._inputField).setInput(key, modifiers);
}
/** */
async _resetArgument() {
const {action} = this._data;
const details = this._parent.getActionDetails(action);
const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0;
let argumentDefault = typeof argumentDetails !== 'undefined' ? argumentDetails.default : void 0;
if (typeof argumentDefault !== 'string') { argumentDefault = ''; }
await this._setArgument(argumentDefault);
}
/**
* @param {import('settings').InputsHotkeyOptions[]} defaultHotkeys
* @param {string} action
* @returns {?{modifiers: import('settings').InputsHotkeyModifier[], key: ?string}}
*/
_getDefaultKeyAndModifiers(defaultHotkeys, action) {
for (const {action: action2, key, modifiers} of defaultHotkeys) {
if (action2 !== action) { continue; }
return {modifiers, key};
}
return null;
}
/**
* @param {string} value
*/
async _setAction(value) {
const validScopesOld = this._getValidScopesForAction(this._data.action);
const scopes = this._data.scopes;
let details = this._parent.getActionDetails(value);
if (typeof details === 'undefined') { details = {scopes: new Set()}; }
const validScopes = details.scopes;
const {argument: argumentDetails} = details;
let defaultArgument = typeof argumentDetails !== 'undefined' ? argumentDetails.default : '';
if (typeof defaultArgument !== 'string') { defaultArgument = ''; }
this._data.action = value;
this._data.argument = defaultArgument;
let scopesChanged = false;
if ((validScopesOld !== null ? validScopesOld.size : 0) === scopes.length) {
scopes.length = 0;
scopesChanged = true;
} else {
for (let i = 0, ii = scopes.length; i < ii; ++i) {
if (!validScopes.has(scopes[i])) {
scopes.splice(i, 1);
--i;
--ii;
scopesChanged = true;
}
}
}
if (scopesChanged && scopes.length === 0) {
scopes.push(...validScopes);
}
await this._modifyProfileSettings([
{
action: 'set',
path: `${this._basePath}.action`,
value: this._data.action,
},
{
action: 'set',
path: `${this._basePath}.argument`,
value: this._data.argument,
},
{
action: 'set',
path: `${this._basePath}.scopes`,
value: this._data.scopes,
},
]);
this._updateScopesButton();
this._updateScopesMenu();
await this._updateActionArgument();
}
/**
* @param {string} value
*/
async _setArgument(value) {
this._data.argument = value;
const node = this._argumentInput;
if (node !== null && this._getArgumentInputValue(node) !== value) {
this._setArgumentInputValue(node, value);
}
void this._updateArgumentInputValidity();
await this._modifyProfileSettings([{
action: 'set',
path: `${this._basePath}.argument`,
value,
}]);
}
/** */
_updateScopesMenu() {
if (this._scopeMenu === null) { return; }
this._updateScopeMenuItems(this._scopeMenu);
}
/**
* @param {string} action
* @returns {?Set<import('settings').InputsHotkeyScope>}
*/
_getValidScopesForAction(action) {
const details = this._parent.getActionDetails(action);
return typeof details !== 'undefined' ? details.scopes : null;
}
/**
* @param {import('../../dom/popup-menu.js').PopupMenu} menu
*/
_updateScopeMenuItems(menu) {
this._scopeMenuEventListeners.removeAllEventListeners();
const scopes = this._data.scopes;
const validScopes = this._getValidScopesForAction(this._data.action);
const bodyNode = menu.bodyNode;
const menuItems = /** @type {NodeListOf<HTMLElement>} */ (bodyNode.querySelectorAll('.popup-menu-item'));
for (const menuItem of menuItems) {
if (menuItem.dataset.menuAction !== 'toggleScope') { continue; }
const scope = this._normalizeScope(menuItem.dataset.scope);
if (scope === null) { continue; }
menuItem.hidden = !(validScopes === null || validScopes.has(scope));
/** @type {HTMLInputElement} */
const checkbox = querySelectorNotNull(menuItem, '.hotkey-scope-checkbox');
if (checkbox !== null) {
checkbox.checked = scopes.includes(scope);
this._scopeMenuEventListeners.addEventListener(checkbox, 'change', this._onScopeCheckboxChange.bind(this), false);
}
}
}
/** */
_clearScopeMenu() {
this._scopeMenuEventListeners.removeAllEventListeners();
this._scopeMenu = null;
}
/** */
_updateScopesButton() {
const {scopes} = this._data;
if (this._enabledButton !== null) {
this._enabledButton.dataset.scopeCount = `${scopes.length}`;
}
}
/**
* @param {HTMLElement} node
*/
_updateDisplay(node) {
const {style} = node;
const {display} = style;
style.display = 'none';
getComputedStyle(node).getPropertyValue('display');
style.display = display;
}
/** */
async _updateActionArgument() {
this._clearArgumentEventListeners();
const {action, argument} = this._data;
const details = this._parent.getActionDetails(action);
const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0;
if (this._argumentContainer !== null) {
this._argumentContainer.textContent = '';
}
if (typeof argumentDetails === 'undefined') {
return;
}
const {template} = argumentDetails;
const node = this._parent.settingsController.instantiateTemplate(template);
const inputSelector = '.hotkey-argument-input';
const inputNode = /** @type {HTMLInputElement} */ (node.matches(inputSelector) ? node : node.querySelector(inputSelector));
if (inputNode !== null) {
this._setArgumentInputValue(inputNode, argument);
this._argumentInput = inputNode;
void this._updateArgumentInputValidity();
this._argumentEventListeners.addEventListener(inputNode, 'change', this._onArgumentValueChange.bind(this, template), false);
}
if (template === 'hotkey-argument-anki-card-format') {
const ankiCardFormats = await this._parent.getAnkiCardFormats();
const selectNode = /** @type {HTMLSelectElement} */ (node.querySelector('.anki-card-format-select'));
for (const [index, format] of ankiCardFormats.entries()) {
const option = document.createElement('option');
option.value = `${index}`;
option.textContent = format;
selectNode.appendChild(option);
}
selectNode.value = argument;
}
if (this._argumentContainer !== null) {
this._argumentContainer.appendChild(node);
}
}
/** */
_clearArgumentEventListeners() {
this._argumentEventListeners.removeAllEventListeners();
this._argumentInput = null;
}
/**
* @param {HTMLInputElement} node
* @returns {string}
*/
_getArgumentInputValue(node) {
return node.value;
}
/**
* @param {HTMLInputElement} node
* @param {string} value
*/
_setArgumentInputValue(node, value) {
node.value = value;
}
/** */
async _updateArgumentInputValidity() {
if (this._argumentInput === null) { return; }
let okay = true;
const {action, argument} = this._data;
const details = this._parent.getActionDetails(action);
const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0;
if (typeof argumentDetails !== 'undefined') {
const {template} = argumentDetails;
switch (template) {
case 'hotkey-argument-setting-path':
okay = await this._isHotkeyArgumentSettingPathValid(argument);
break;
}
}
this._argumentInput.dataset.invalid = `${!okay}`;
}
/**
* @param {string} path
* @returns {Promise<boolean>}
*/
async _isHotkeyArgumentSettingPathValid(path) {
if (path.length === 0) { return true; }
const options = await this._parent.settingsController.getOptions();
const accessor = new ObjectPropertyAccessor(options);
const pathArray = ObjectPropertyAccessor.getPathArray(path);
try {
const value = accessor.get(pathArray, pathArray.length);
if (typeof value === 'boolean') {
return true;
}
} catch (e) {
// NOP
}
return false;
}
/**
* @param {string|undefined} value
* @returns {?import('settings').InputsHotkeyScope}
*/
_normalizeScope(value) {
switch (value) {
case 'popup':
case 'search':
case 'web':
return value;
default:
return null;
}
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class LanguagesController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
}
/** */
async prepare() {
const languages = await this._settingsController.application.api.getLanguageSummaries();
languages.sort((a, b) => a.name.localeCompare(b.name, 'en'));
this._fillSelect(languages);
}
/**
* @param {import('language').LanguageSummary[]} languages
*/
_fillSelect(languages) {
const selectElement = querySelectorNotNull(document, '#language-select');
for (const {iso, name} of languages) {
const option = document.createElement('option');
option.value = iso;
option.text = `${name} (${iso})`;
selectElement.appendChild(option);
}
}
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {toError} from '../../core/to-error.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class MecabController {
/**
* @param {import('../../comm/api.js').API} api
*/
constructor(api) {
/** @type {import('../../comm/api.js').API} */
this._api = api;
/** @type {HTMLButtonElement} */
this._testButton = querySelectorNotNull(document, '#test-mecab-button');
/** @type {HTMLElement} */
this._resultsContainer = querySelectorNotNull(document, '#test-mecab-results');
/** @type {boolean} */
this._testActive = false;
}
/** */
prepare() {
this._testButton.addEventListener('click', this._onTestButtonClick.bind(this), false);
}
// Private
/**
* @param {MouseEvent} e
*/
_onTestButtonClick(e) {
e.preventDefault();
void this._testMecab();
}
/** */
async _testMecab() {
if (this._testActive) { return; }
try {
this._testActive = true;
const resultsContainer = /** @type {HTMLElement} */ (this._resultsContainer);
/** @type {HTMLButtonElement} */ (this._testButton).disabled = true;
resultsContainer.textContent = '';
resultsContainer.hidden = true;
await this._api.testMecab();
this._setStatus('Connection was successful', false);
} catch (e) {
this._setStatus(toError(e).message, true);
} finally {
this._testActive = false;
/** @type {HTMLButtonElement} */ (this._testButton).disabled = false;
}
}
/**
* @param {string} message
* @param {boolean} isError
*/
_setStatus(message, isError) {
const resultsContainer = /** @type {HTMLElement} */ (this._resultsContainer);
resultsContainer.textContent = message;
resultsContainer.hidden = false;
resultsContainer.classList.toggle('danger-text', isError);
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {HtmlTemplateCollection} from '../../dom/html-template-collection.js';
import {Modal} from './modal.js';
export class ModalController {
/**
* @param {string[]} templateNames
*/
constructor(templateNames) {
/** @type {Modal[]} */
this._modals = [];
/** @type {Map<string|Element, Modal>} */
this._modalMap = new Map();
/** @type {HtmlTemplateCollection} */
this._templates = new HtmlTemplateCollection();
/** @type {string[]} */
this._templateNames = templateNames;
}
/** */
async prepare() {
if (this._templateNames.length > 0) {
await this._templates.loadFromFiles(['/templates-modals.html']);
for (const name of this._templateNames) {
const template = this._templates.getTemplateContent(name);
document.body.appendChild(template);
}
}
const idSuffix = '-modal';
for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.modal'))) {
let {id} = node;
if (typeof id !== 'string') { continue; }
if (id.endsWith(idSuffix)) {
id = id.substring(0, id.length - idSuffix.length);
}
const modal = new Modal(node);
modal.prepare();
this._modalMap.set(id, modal);
this._modalMap.set(node, modal);
this._modals.push(modal);
}
}
/**
* @param {string|Element} nameOrNode
* @returns {?Modal}
*/
getModal(nameOrNode) {
const modal = this._modalMap.get(nameOrNode);
return (typeof modal !== 'undefined' ? modal : null);
}
/**
* @returns {?Modal}
*/
getTopVisibleModal() {
for (let i = this._modals.length - 1; i >= 0; --i) {
const modal = this._modals[i];
if (modal.isVisible()) {
return modal;
}
}
return null;
}
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {PanelElement} from '../../dom/panel-element.js';
export class Modal extends PanelElement {
/**
* @param {HTMLElement} node
*/
constructor(node) {
super(node, 375); // Milliseconds; includes buffer
/** @type {?Element} */
this._contentNode = null;
/** @type {boolean} */
this._canCloseOnClick = false;
/** @type {boolean} */
this.forceInteract = node.classList.contains('force-interact');
}
/** */
prepare() {
const node = this.node;
this._contentNode = node.querySelector('.modal-content');
/** @type {?HTMLElement} */
let dimmerNode = node.querySelector('.modal-content-dimmer');
if (dimmerNode === null) { dimmerNode = node; }
dimmerNode.addEventListener('mousedown', this._onModalContainerMouseDown.bind(this), false);
dimmerNode.addEventListener('mouseup', this._onModalContainerMouseUp.bind(this), false);
dimmerNode.addEventListener('click', this._onModalContainerClick.bind(this), false);
for (const actionNode of /** @type {NodeListOf<HTMLElement>} */ (node.querySelectorAll('[data-modal-action]'))) {
actionNode.addEventListener('click', this._onActionNodeClick.bind(this), false);
}
}
// Private
/**
* @param {MouseEvent} e
*/
_onModalContainerMouseDown(e) {
this._canCloseOnClick = (e.currentTarget === e.target) && !this.forceInteract;
}
/**
* @param {MouseEvent} e
*/
_onModalContainerMouseUp(e) {
if (!this._canCloseOnClick) { return; }
this._canCloseOnClick = (e.currentTarget === e.target);
}
/**
* @param {MouseEvent} e
*/
_onModalContainerClick(e) {
if (!this._canCloseOnClick) { return; }
this._canCloseOnClick = false;
if (e.currentTarget !== e.target) { return; }
this.setVisible(false);
}
/**
* @param {MouseEvent} e
*/
_onActionNodeClick(e) {
const element = /** @type {HTMLElement} */ (e.currentTarget);
const {modalAction} = element.dataset;
switch (modalAction) {
case 'expand':
this._setExpanded(true);
break;
case 'collapse':
this._setExpanded(false);
break;
}
}
/**
* @param {boolean} expanded
*/
_setExpanded(expanded) {
if (this._contentNode === null) { return; }
this._contentNode.classList.toggle('modal-content-full', expanded);
}
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {convertElementValueToNumber} from '../../dom/document-util.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class NestedPopupsController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {number} */
this._popupNestingMaxDepth = 0;
/** @type {HTMLInputElement} */
this._nestedPopupsEnabled = querySelectorNotNull(document, '#nested-popups-enabled');
/** @type {HTMLInputElement} */
this._nestedPopupsCount = querySelectorNotNull(document, '#nested-popups-count');
/** @type {HTMLElement} */
this._nestedPopupsEnabledMoreOptions = querySelectorNotNull(document, '#nested-popups-enabled-more-options');
}
/** */
async prepare() {
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._nestedPopupsEnabled.addEventListener('change', this._onNestedPopupsEnabledChange.bind(this), false);
this._nestedPopupsCount.addEventListener('change', this._onNestedPopupsCountChange.bind(this), false);
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
this._onOptionsChanged({options, optionsContext});
}
// Private
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
this._updatePopupNestingMaxDepth(options.scanning.popupNestingMaxDepth);
}
/**
* @param {Event} e
*/
_onNestedPopupsEnabledChange(e) {
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
const value = node.checked;
if (value && this._popupNestingMaxDepth > 0) { return; }
void this._setPopupNestingMaxDepth(value ? 1 : 0);
}
/**
* @param {Event} e
*/
_onNestedPopupsCountChange(e) {
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
const value = Math.max(1, convertElementValueToNumber(node.value, node));
void this._setPopupNestingMaxDepth(value);
}
/**
* @param {number} value
*/
_updatePopupNestingMaxDepth(value) {
const enabled = (value > 0);
this._popupNestingMaxDepth = value;
/** @type {HTMLInputElement} */ (this._nestedPopupsEnabled).checked = enabled;
/** @type {HTMLInputElement} */ (this._nestedPopupsCount).value = `${value}`;
/** @type {HTMLElement} */ (this._nestedPopupsEnabledMoreOptions).hidden = !enabled;
}
/**
* @param {number} value
*/
async _setPopupNestingMaxDepth(value) {
this._updatePopupNestingMaxDepth(value);
await this._settingsController.setProfileSetting('scanning.popupNestingMaxDepth', value);
}
}

View File

@@ -0,0 +1,166 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {toError} from '../../core/to-error.js';
import {getAllPermissions, setPermissionsGranted} from '../../data/permissions-util.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class PermissionsOriginController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {HTMLElement} */
this._originContainer = querySelectorNotNull(document, '#permissions-origin-list');
/** @type {HTMLElement} */
this._originEmpty = querySelectorNotNull(document, '#permissions-origin-list-empty');
/** @type {?NodeListOf<HTMLInputElement>} */
this._originToggleNodes = null;
/** @type {HTMLInputElement} */
this._addOriginInput = querySelectorNotNull(document, '#permissions-origin-new-input');
/** @type {HTMLElement} */
this._errorContainer = querySelectorNotNull(document, '#permissions-origin-list-error');
/** @type {ChildNode[]} */
this._originContainerChildren = [];
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
}
/** */
async prepare() {
this._originToggleNodes = /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.permissions-origin-toggle'));
/** @type {HTMLButtonElement} */
const addButton = querySelectorNotNull(document, '#permissions-origin-add');
for (const node of this._originToggleNodes) {
node.addEventListener('change', this._onOriginToggleChange.bind(this), false);
}
addButton.addEventListener('click', this._onAddButtonClick.bind(this), false);
this._settingsController.on('permissionsChanged', this._onPermissionsChanged.bind(this));
await this._updatePermissions();
}
// Private
/**
* @param {import('settings-controller').EventArgument<'permissionsChanged'>} details
*/
_onPermissionsChanged({permissions}) {
this._eventListeners.removeAllEventListeners();
for (const node of this._originContainerChildren) {
if (node.parentNode === null) { continue; }
node.parentNode.removeChild(node);
}
this._originContainerChildren = [];
/** @type {Set<string>} */
const originsSet = new Set(permissions.origins);
for (const node of /** @type {NodeListOf<HTMLInputElement>} */ (this._originToggleNodes)) {
const {origin} = node.dataset;
node.checked = typeof origin === 'string' && originsSet.has(origin);
}
let any = false;
const excludeOrigins = new Set([
'<all_urls>',
]);
const fragment = document.createDocumentFragment();
for (const origin of originsSet) {
if (excludeOrigins.has(origin)) { continue; }
const node = this._settingsController.instantiateTemplateFragment('permissions-origin');
/** @type {HTMLInputElement} */
const input = querySelectorNotNull(node, '.permissions-origin-input');
/** @type {HTMLElement} */
const menuButton = querySelectorNotNull(node, '.permissions-origin-button');
input.value = origin;
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onOriginMenuClose.bind(this, origin), false);
this._originContainerChildren.push(...node.childNodes);
fragment.appendChild(node);
any = true;
}
const container = /** @type {HTMLElement} */ (this._originContainer);
container.insertBefore(fragment, container.firstChild);
/** @type {HTMLElement} */ (this._originEmpty).hidden = any;
/** @type {HTMLElement} */ (this._errorContainer).hidden = true;
}
/**
* @param {Event} e
*/
_onOriginToggleChange(e) {
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
const value = node.checked;
node.checked = !value;
const {origin} = node.dataset;
if (typeof origin !== 'string') { return; }
void this._setOriginPermissionEnabled(origin, value);
}
/**
* @param {string} origin
*/
_onOriginMenuClose(origin) {
void this._setOriginPermissionEnabled(origin, false);
}
/** */
_onAddButtonClick() {
void this._addOrigin();
}
/** */
async _addOrigin() {
const input = /** @type {HTMLInputElement} */ (this._addOriginInput);
const origin = input.value;
const added = await this._setOriginPermissionEnabled(origin, true);
if (added) {
input.value = '';
}
}
/** */
async _updatePermissions() {
const permissions = await getAllPermissions();
this._onPermissionsChanged({permissions});
}
/**
* @param {string} origin
* @param {boolean} enabled
* @returns {Promise<boolean>}
*/
async _setOriginPermissionEnabled(origin, enabled) {
let added = false;
try {
added = await setPermissionsGranted({origins: [origin]}, enabled);
} catch (e) {
const errorContainer = /** @type {HTMLElement} */ (this._errorContainer);
errorContainer.hidden = false;
errorContainer.textContent = toError(e).message;
}
if (!added) { return false; }
await this._updatePermissions();
return true;
}
}

View File

@@ -0,0 +1,162 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {getAllPermissions, hasPermissions, setPermissionsGranted} from '../../data/permissions-util.js';
import {ObjectPropertyAccessor} from '../../general/object-property-accessor.js';
export class PermissionsToggleController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {?NodeListOf<HTMLInputElement>} */
this._toggles = null;
}
/** */
async prepare() {
this._toggles = document.querySelectorAll('.permissions-toggle');
for (const toggle of this._toggles) {
toggle.addEventListener('change', this._onPermissionsToggleChange.bind(this), false);
}
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
this._settingsController.on('permissionsChanged', this._onPermissionsChanged.bind(this));
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._onOptionsChanged({options, optionsContext});
}
// Private
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
let accessor = null;
for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (this._toggles)) {
const {permissionsSetting} = toggle.dataset;
if (typeof permissionsSetting !== 'string') { continue; }
if (accessor === null) {
accessor = new ObjectPropertyAccessor(options);
}
const path = ObjectPropertyAccessor.getPathArray(permissionsSetting);
let value;
try {
value = accessor.get(path, path.length);
} catch (e) {
continue;
}
toggle.checked = !!value;
}
void this._updateValidity();
}
/**
* @param {Event} e
*/
async _onPermissionsToggleChange(e) {
const toggle = /** @type {HTMLInputElement} */ (e.currentTarget);
let value = toggle.checked;
const valuePre = !value;
const {permissionsSetting} = toggle.dataset;
const hasPermissionsSetting = typeof permissionsSetting === 'string';
if (value || !hasPermissionsSetting) {
toggle.checked = valuePre;
const permissions = this._getRequiredPermissions(toggle);
try {
value = await setPermissionsGranted({permissions}, value);
} catch (error) {
value = valuePre;
try {
value = await hasPermissions({permissions});
} catch (error2) {
// NOP
}
}
toggle.checked = value;
}
if (hasPermissionsSetting) {
this._setToggleValid(toggle, true);
await this._settingsController.setProfileSetting(permissionsSetting, value);
}
}
/**
* @param {import('settings-controller').EventArgument<'permissionsChanged'>} details
*/
_onPermissionsChanged({permissions}) {
const permissions2 = permissions.permissions;
const permissionsSet = new Set(typeof permissions2 !== 'undefined' ? permissions2 : []);
for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (this._toggles)) {
const {permissionsSetting} = toggle.dataset;
const hasPermissions2 = this._hasAll(permissionsSet, this._getRequiredPermissions(toggle));
if (typeof permissionsSetting === 'string') {
const valid = !toggle.checked || hasPermissions2;
this._setToggleValid(toggle, valid);
} else {
toggle.checked = hasPermissions2;
}
}
}
/**
* @param {HTMLInputElement} toggle
* @param {boolean} valid
*/
_setToggleValid(toggle, valid) {
const relative = /** @type {?HTMLElement} */ (toggle.closest('.settings-item'));
if (relative === null) { return; }
relative.dataset.invalid = `${!valid}`;
}
/** */
async _updateValidity() {
const permissions = await getAllPermissions();
this._onPermissionsChanged({permissions});
}
/**
* @param {Set<string>} set
* @param {string[]} values
* @returns {boolean}
*/
_hasAll(set, values) {
for (const value of values) {
if (!set.has(value)) { return false; }
}
return true;
}
/**
* @param {HTMLInputElement} toggle
* @returns {string[]}
*/
_getRequiredPermissions(toggle) {
const requiredPermissions = toggle.dataset.requiredPermissions;
return (typeof requiredPermissions === 'string' && requiredPermissions.length > 0 ? requiredPermissions.split(' ') : []);
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {isObjectNotArray} from '../../core/object-utilities.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class PersistentStorageController {
/**
* @param {import('../../application.js').Application} application
*/
constructor(application) {
/** @type {import('../../application.js').Application} */
this._application = application;
/** @type {HTMLInputElement} */
this._persistentStorageCheckbox = querySelectorNotNull(document, '#storage-persistent-checkbox');
}
/** @type {import('../../application.js').Application} */
get application() {
return this._application;
}
/** */
async prepare() {
this._persistentStorageCheckbox.addEventListener('change', this._onPersistentStorageCheckboxChange.bind(this), false);
if (!this._isPersistentStorageSupported()) { return; }
/** @type {?HTMLElement} */
const info = document.querySelector('#storage-persistent-info');
if (info !== null) { info.hidden = false; }
const isStoragePeristent = await this.isStoragePeristent();
this._updateCheckbox(isStoragePeristent);
}
/**
* @returns {Promise<boolean>}
*/
async isStoragePeristent() {
try {
return await navigator.storage.persisted();
} catch (e) {
// NOP
}
return false;
}
// Private
/**
* @param {Event} e
*/
_onPersistentStorageCheckboxChange(e) {
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
if (node.checked) {
node.checked = false;
void this._attemptPersistStorage();
} else {
node.checked = true;
}
}
/** */
async _attemptPersistStorage() {
let isStoragePeristent = false;
try {
isStoragePeristent = await navigator.storage.persist();
} catch (e) {
// NOP
}
this._updateCheckbox(isStoragePeristent);
/** @type {?HTMLElement} */
const node = document.querySelector('#storage-persistent-fail-warning');
if (node !== null) { node.hidden = isStoragePeristent; }
this._application.triggerStorageChanged();
}
/**
* @returns {boolean}
*/
_isPersistentStorageSupported() {
return isObjectNotArray(navigator.storage) && typeof navigator.storage.persist === 'function';
}
/**
* @param {boolean} isStoragePeristent
*/
_updateCheckbox(isStoragePeristent) {
/** @type {HTMLInputElement} */ (this._persistentStorageCheckbox).checked = isStoragePeristent;
/** @type {HTMLInputElement} */ (this._persistentStorageCheckbox).readOnly = isStoragePeristent;
}
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2019-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class PopupPreviewController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {string} */
this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
/** @type {HTMLIFrameElement} */
this._frame = querySelectorNotNull(document, '#popup-preview-frame');
/** @type {HTMLTextAreaElement} */
this._customCss = querySelectorNotNull(document, '#custom-popup-css');
/** @type {HTMLTextAreaElement} */
this._customOuterCss = querySelectorNotNull(document, '#custom-popup-outer-css');
/** @type {HTMLElement} */
this._previewFrameContainer = querySelectorNotNull(document, '.preview-frame-container');
}
/** */
prepare() {
if (new URLSearchParams(location.search).get('popup-preview') === 'false') { return; }
this._customCss.addEventListener('input', this._onCustomCssChange.bind(this), false);
this._customCss.addEventListener('settingChanged', this._onCustomCssChange.bind(this), false);
this._customOuterCss.addEventListener('input', this._onCustomOuterCssChange.bind(this), false);
this._customOuterCss.addEventListener('settingChanged', this._onCustomOuterCssChange.bind(this), false);
this._frame.addEventListener('load', this._onFrameLoad.bind(this), false);
this._settingsController.on('optionsContextChanged', this._onOptionsContextChange.bind(this));
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
this._settingsController.on('dictionaryEnabled', this._onOptionsContextChange.bind(this));
const languageSelect = querySelectorNotNull(document, '#language-select');
languageSelect.addEventListener(
/** @type {string} */ ('settingChanged'),
/** @type {EventListener} */ (this._onLanguageSelectChanged.bind(this)),
false,
);
this._frame.src = '/popup-preview.html';
}
// Private
/** */
_onFrameLoad() {
this._onOptionsContextChange();
this._onCustomCssChange();
this._onCustomOuterCssChange();
}
/** */
_onCustomCssChange() {
const css = /** @type {HTMLTextAreaElement} */ (this._customCss).value;
this._invoke('setCustomCss', {css});
}
/** */
_onCustomOuterCssChange() {
const css = /** @type {HTMLTextAreaElement} */ (this._customOuterCss).value;
this._invoke('setCustomOuterCss', {css});
}
/** */
_onOptionsContextChange() {
const optionsContext = this._settingsController.getOptionsContext();
this._invoke('updateOptionsContext', {optionsContext});
}
/** */
_onDictionaryEnabled() {
this._invoke('updateSearch', {});
}
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
this._invoke('setLanguageExampleText', {language: options.general.language});
}
/**
* @param {import('dom-data-binder').SettingChangedEvent} settingChangedEvent
*/
_onLanguageSelectChanged(settingChangedEvent) {
const {value} = settingChangedEvent.detail;
if (typeof value !== 'string') { return; }
this._invoke('setLanguageExampleText', {language: value});
}
/**
* @template {import('popup-preview-frame').ApiNames} TName
* @param {TName} action
* @param {import('popup-preview-frame').ApiParams<TName>} params
*/
_invoke(action, params) {
if (this._frame === null || this._frame.contentWindow === null) { return; }
this._frame.contentWindow.postMessage({action, params}, this._targetOrigin);
}
}
/**
* @param {string | undefined} url
* @returns {boolean}
*/
export function checkPopupPreviewURL(url) {
return !!(url && url.includes('popup-preview.html') && !['http:', 'https:', 'ws:', 'wss:', 'ftp:', 'data:', 'file:'].includes(new URL(url).protocol));
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2019-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {PopupFactory} from '../../app/popup-factory.js';
import {Application} from '../../application.js';
import {HotkeyHandler} from '../../input/hotkey-handler.js';
import {PopupPreviewFrame} from './popup-preview-frame.js';
await Application.main(true, async (application) => {
const hotkeyHandler = new HotkeyHandler();
hotkeyHandler.prepare(application.crossFrame);
const popupFactory = new PopupFactory(application);
popupFactory.prepare();
const preview = new PopupPreviewFrame(application, popupFactory, hotkeyHandler);
await preview.prepare();
document.documentElement.dataset.loaded = 'true';
});

View File

@@ -0,0 +1,322 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2019-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Frontend} from '../../app/frontend.js';
import {ThemeController} from '../../app/theme-controller.js';
import {createApiMap, invokeApiMapHandler} from '../../core/api-map.js';
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {TextSourceRange} from '../../dom/text-source-range.js';
import {isComposing} from '../../language/ime-utilities.js';
import {convertToKanaIME} from '../../language/ja/japanese-wanakana.js';
export class PopupPreviewFrame {
/**
* @param {import('../../application.js').Application} application
* @param {import('../../app/popup-factory.js').PopupFactory} popupFactory
* @param {import('../../input/hotkey-handler.js').HotkeyHandler} hotkeyHandler
*/
constructor(application, popupFactory, hotkeyHandler) {
/** @type {import('../../application.js').Application} */
this._application = application;
/** @type {import('../../app/popup-factory.js').PopupFactory} */
this._popupFactory = popupFactory;
/** @type {import('../../input/hotkey-handler.js').HotkeyHandler} */
this._hotkeyHandler = hotkeyHandler;
/** @type {?Frontend} */
this._frontend = null;
/** @type {?(optionsContext: import('settings').OptionsContext) => Promise<import('settings').ProfileOptions>} */
this._apiOptionsGetOld = null;
/** @type {boolean} */
this._popupShown = false;
/** @type {?import('core').Timeout} */
this._themeChangeTimeout = null;
/** @type {?import('text-source').TextSource} */
this._textSource = null;
/** @type {?import('settings').OptionsContext} */
this._optionsContext = null;
/** @type {HTMLElement} */
this._exampleText = querySelectorNotNull(document, '#example-text');
/** @type {HTMLInputElement} */
this._exampleTextInput = querySelectorNotNull(document, '#example-text-input');
/** @type {EventListenerCollection} */
this._exampleTextInputEvents = new EventListenerCollection();
/** @type {string} */
this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
/** @type {import('language').LanguageSummary[]} */
this._languageSummaries = [];
/** @type {ThemeController} */
this._themeController = new ThemeController(document.documentElement);
/* eslint-disable @stylistic/no-multi-spaces */
/** @type {import('popup-preview-frame').ApiMap} */
this._windowMessageHandlers = createApiMap([
['setText', this._onSetText.bind(this)],
['setCustomCss', this._setCustomCss.bind(this)],
['setCustomOuterCss', this._setCustomOuterCss.bind(this)],
['updateOptionsContext', this._updateOptionsContext.bind(this)],
['setLanguageExampleText', this._setLanguageExampleText.bind(this)],
['updateSearch', this._updateSearch.bind(this)],
]);
/* eslint-enable @stylistic/no-multi-spaces */
}
/** */
async prepare() {
window.addEventListener('message', this._onMessage.bind(this), false);
this._themeController.prepare();
// Setup events
this._exampleText.addEventListener('click', this._onExampleTextClick.bind(this), false);
this._exampleTextInput.addEventListener('blur', this._onExampleTextInputBlur.bind(this), false);
this._exampleTextInput.addEventListener('input', this._onExampleTextInputInput.bind(this), false);
// Overwrite API functions
/** @type {?(optionsContext: import('settings').OptionsContext) => Promise<import('settings').ProfileOptions>} */
this._apiOptionsGetOld = this._application.api.optionsGet.bind(this._application.api);
this._application.api.optionsGet = this._apiOptionsGet.bind(this);
this._languageSummaries = await this._application.api.getLanguageSummaries();
const options = await this._application.api.optionsGet({current: true});
void this._setLanguageExampleText({language: options.general.language});
// Overwrite frontend
this._frontend = new Frontend({
application: this._application,
popupFactory: this._popupFactory,
depth: 0,
parentPopupId: null,
parentFrameId: null,
useProxyPopup: false,
canUseWindowPopup: false,
pageType: 'web',
allowRootFramePopupProxy: false,
childrenSupported: false,
hotkeyHandler: this._hotkeyHandler,
});
this._frontend.setOptionsContextOverride(this._optionsContext);
await this._frontend.prepare();
this._frontend.setDisabledOverride(true);
this._frontend.canClearSelection = false;
const {popup} = this._frontend;
if (popup !== null) {
popup.on('customOuterCssChanged', this._onCustomOuterCssChanged.bind(this));
}
// Update search
void this._updateSearch();
}
// Private
/**
* @param {import('settings').OptionsContext} optionsContext
* @returns {Promise<import('settings').ProfileOptions>}
*/
async _apiOptionsGet(optionsContext) {
const options = await /** @type {(optionsContext: import('settings').OptionsContext) => Promise<import('settings').ProfileOptions>} */ (this._apiOptionsGetOld)(optionsContext);
options.general.enable = true;
options.general.debugInfo = false;
options.general.popupWidth = 400;
options.general.popupHeight = 250;
options.general.popupHorizontalOffset = 0;
options.general.popupVerticalOffset = 10;
options.general.popupHorizontalOffset2 = 10;
options.general.popupVerticalOffset2 = 0;
options.general.popupHorizontalTextPosition = 'below';
options.general.popupVerticalTextPosition = 'before';
options.scanning.selectText = false;
this._themeController.theme = options.general.popupTheme;
this._themeController.siteOverride = true;
this._themeController.updateTheme();
return options;
}
/**
* @param {import('popup').EventArgument<'customOuterCssChanged'>} details
*/
_onCustomOuterCssChanged({node, inShadow}) {
if (node === null || inShadow) { return; }
const node2 = document.querySelector('#popup-outer-css');
if (node2 === null) { return; }
const {parentNode} = node2;
if (parentNode === null) { return; }
// This simulates the stylesheet priorities when injecting using the web extension API.
parentNode.insertBefore(node, node2);
}
/**
* @param {MessageEvent<import('popup-preview-frame.js').ApiMessageAny>} event
*/
_onMessage(event) {
if (event.origin !== this._targetOrigin) { return; }
const {action, params} = event.data;
const callback = () => {}; // NOP
invokeApiMapHandler(this._windowMessageHandlers, action, params, [], callback);
}
/** */
_onExampleTextClick() {
if (this._exampleTextInput === null) { return; }
const visible = this._exampleTextInput.hidden;
this._exampleTextInput.hidden = !visible;
if (!visible) { return; }
this._exampleTextInput.focus();
this._exampleTextInput.select();
}
/** */
_onExampleTextInputBlur() {
if (this._exampleTextInput === null) { return; }
this._exampleTextInput.hidden = true;
}
/**
* @param {Event} e
*/
_onExampleTextInputInput(e) {
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
this._setText(element.value, false);
}
/** @type {import('popup-preview-frame').ApiHandler<'setText'>} */
_onSetText({text}) {
this._setText(text, true);
}
/**
* @param {string} text
* @param {boolean} setInput
*/
_setText(text, setInput) {
if (setInput && this._exampleTextInput !== null) {
this._exampleTextInput.value = text;
}
if (this._exampleText === null) { return; }
this._exampleText.textContent = text;
if (this._frontend === null) { return; }
void this._updateSearch();
}
/**
* @param {boolean} visible
*/
_setInfoVisible(visible) {
const node = document.querySelector('.placeholder-info');
if (node === null) { return; }
node.classList.toggle('placeholder-info-visible', visible);
}
/** @type {import('popup-preview-frame').ApiHandler<'setCustomCss'>} */
_setCustomCss({css}) {
if (this._frontend === null) { return; }
const popup = this._frontend.popup;
if (popup === null) { return; }
void popup.setCustomCss(css);
}
/** @type {import('popup-preview-frame').ApiHandler<'setCustomOuterCss'>} */
_setCustomOuterCss({css}) {
if (this._frontend === null) { return; }
const popup = this._frontend.popup;
if (popup === null) { return; }
void popup.setCustomOuterCss(css, false);
}
/** @type {import('popup-preview-frame').ApiHandler<'updateOptionsContext'>} */
async _updateOptionsContext(details) {
const {optionsContext} = details;
this._optionsContext = optionsContext;
if (this._frontend === null) { return; }
this._frontend.setOptionsContextOverride(optionsContext);
await this._frontend.updateOptions();
await this._updateSearch();
}
/** @type {import('popup-preview-frame').ApiHandler<'setLanguageExampleText'>} */
_setLanguageExampleText({language}) {
const activeLanguage = /** @type {import('language').LanguageSummary} */ (this._languageSummaries.find(({iso}) => iso === language));
this._exampleTextInputEvents.removeAllEventListeners();
if (this._exampleTextInput !== null && language === 'ja') {
this._exampleTextInputEvents.addEventListener(this._exampleTextInput, 'input', this._onSearchInput.bind(this), false);
}
this._exampleTextInput.lang = language;
this._exampleTextInput.value = activeLanguage.exampleText;
this._exampleTextInput.dispatchEvent(new Event('input'));
}
/**
* @param {InputEvent} e
*/
_onSearchInput(e) {
const element = /** @type {HTMLTextAreaElement} */ (e.currentTarget);
this._searchTextKanaConversion(element, e);
}
/**
* @param {HTMLTextAreaElement} element
* @param {InputEvent} event
*/
_searchTextKanaConversion(element, event) {
const platform = document.documentElement.dataset.platform ?? 'unknown';
const browser = document.documentElement.dataset.browser ?? 'unknown';
if (isComposing(event, platform, browser)) { return; }
const {kanaString, newSelectionStart} = convertToKanaIME(element.value, element.selectionStart);
element.value = kanaString;
element.setSelectionRange(newSelectionStart, newSelectionStart);
}
/** */
async _updateSearch() {
if (this._exampleText === null) { return; }
const textNode = this._exampleText.firstChild;
if (textNode === null) { return; }
const range = document.createRange();
range.selectNodeContents(textNode);
const source = TextSourceRange.create(range);
const frontend = /** @type {Frontend} */ (this._frontend);
try {
await frontend.setTextSource(source);
} finally {
source.cleanup();
}
this._textSource = source;
await frontend.showContentCompleted();
const popup = frontend.popup;
if (popup !== null && popup.isVisibleSync()) {
this._popupShown = true;
}
this._setInfoVisible(!this._popupShown);
this._themeController.updateTheme();
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class PopupWindowController {
/**
* @param {import('../../comm/api.js').API} api
*/
constructor(api) {
/** @type {import('../../comm/api.js').API} */
this._api = api;
}
/** */
prepare() {
/** @type {HTMLElement} */
const testLink = querySelectorNotNull(document, '#test-window-open-link');
testLink.addEventListener('click', this._onTestWindowOpenLinkClick.bind(this), false);
}
// Private
/**
* @param {MouseEvent} e
*/
_onTestWindowOpenLinkClick(e) {
e.preventDefault();
void this._testWindowOpen();
}
/** */
async _testWindowOpen() {
await this._api.getOrCreateSearchPopup({focus: true});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,848 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {clone, generateId} from '../../core/utilities.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {ProfileConditionsUI} from './profile-conditions-ui.js';
export class ProfileController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
* @param {import('./modal-controller.js').ModalController} modalController
*/
constructor(settingsController, modalController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {import('./modal-controller.js').ModalController} */
this._modalController = modalController;
/** @type {ProfileConditionsUI} */
this._profileConditionsUI = new ProfileConditionsUI(settingsController);
/** @type {?number} */
this._profileConditionsIndex = null;
/** @type {HTMLSelectElement} */
this._profileActiveSelect = querySelectorNotNull(document, '#profile-active-select');
/** @type {HTMLSelectElement} */
this._profileCopySourceSelect = querySelectorNotNull(document, '#profile-copy-source-select');
/** @type {HTMLElement} */
this._resetProfileNameElement = querySelectorNotNull(document, '#profile-reset-name');
/** @type {HTMLElement} */
this._removeProfileNameElement = querySelectorNotNull(document, '#profile-remove-name');
/** @type {HTMLButtonElement} */
this._profileAddButton = querySelectorNotNull(document, '#profile-add-button');
/** @type {HTMLButtonElement} */
this._profileResetConfirmButton = querySelectorNotNull(document, '#profile-reset-confirm-button');
/** @type {HTMLButtonElement} */
this._profileRemoveConfirmButton = querySelectorNotNull(document, '#profile-remove-confirm-button');
/** @type {HTMLButtonElement} */
this._profileCopyConfirmButton = querySelectorNotNull(document, '#profile-copy-confirm-button');
/** @type {HTMLElement} */
this._profileEntryListContainer = querySelectorNotNull(document, '#profile-entry-list');
/** @type {HTMLElement} */
this._profileConditionsProfileName = querySelectorNotNull(document, '#profile-conditions-profile-name');
/** @type {?import('./modal.js').Modal} */
this._profileRemoveModal = null;
/** @type {?import('./modal.js').Modal} */
this._profileCopyModal = null;
/** @type {?import('./modal.js').Modal} */
this._profileConditionsModal = null;
/** @type {boolean} */
this._profileEntriesSupported = false;
/** @type {ProfileEntry[]} */
this._profileEntryList = [];
/** @type {import('settings').Profile[]} */
this._profiles = [];
/** @type {number} */
this._profileCurrent = 0;
}
/** @type {number} */
get profileCount() {
return this._profiles.length;
}
/** @type {number} */
get profileCurrentIndex() {
return this._profileCurrent;
}
/** */
async prepare() {
const {platform: {os}} = await this._settingsController.application.api.getEnvironmentInfo();
this._profileConditionsUI.os = os;
this._profileResetModal = this._modalController.getModal('profile-reset');
this._profileRemoveModal = this._modalController.getModal('profile-remove');
this._profileCopyModal = this._modalController.getModal('profile-copy');
this._profileConditionsModal = this._modalController.getModal('profile-conditions');
this._profileEntriesSupported = (this._profileEntryListContainer !== null);
if (this._profileActiveSelect !== null) { this._profileActiveSelect.addEventListener('change', this._onProfileActiveChange.bind(this), false); }
if (this._profileAddButton !== null) { this._profileAddButton.addEventListener('click', this._onAdd.bind(this), false); }
if (this._profileResetConfirmButton !== null) { this._profileResetConfirmButton.addEventListener('click', this._onResetConfirm.bind(this), false); }
if (this._profileRemoveConfirmButton !== null) { this._profileRemoveConfirmButton.addEventListener('click', this._onDeleteConfirm.bind(this), false); }
if (this._profileCopyConfirmButton !== null) { this._profileCopyConfirmButton.addEventListener('click', this._onCopyConfirm.bind(this), false); }
this._profileConditionsUI.on('conditionGroupCountChanged', this._onConditionGroupCountChanged.bind(this));
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
void this._onOptionsChanged();
}
/**
* @param {number} profileIndex
* @param {number} offset
*/
async moveProfile(profileIndex, offset) {
if (this._getProfile(profileIndex) === null) { return; }
const profileIndexNew = Math.max(0, Math.min(this._profiles.length - 1, profileIndex + offset));
if (profileIndex === profileIndexNew) { return; }
await this.swapProfiles(profileIndex, profileIndexNew);
}
/**
* @param {number} profileIndex
* @param {string} value
*/
async setProfileName(profileIndex, value) {
const profile = this._getProfile(profileIndex);
if (profile === null) { return; }
profile.name = value;
this._updateSelectName(profileIndex, value);
const profileEntry = this._getProfileEntry(profileIndex);
if (profileEntry !== null) { profileEntry.setName(value); }
await this._settingsController.setGlobalSetting(`profiles[${profileIndex}].name`, value);
}
/**
* @param {number} profileIndex
*/
async setDefaultProfile(profileIndex) {
const profile = this._getProfile(profileIndex);
if (profile === null) { return; }
/** @type {HTMLSelectElement} */ (this._profileActiveSelect).value = `${profileIndex}`;
this._profileCurrent = profileIndex;
const profileEntry = this._getProfileEntry(profileIndex);
if (profileEntry !== null) { profileEntry.setIsDefault(true); }
this._settingsController.profileIndex = profileIndex;
await this._settingsController.setGlobalSetting('profileCurrent', profileIndex);
}
/**
* @param {number} sourceProfileIndex
* @param {number} destinationProfileIndex
*/
async copyProfile(sourceProfileIndex, destinationProfileIndex) {
const sourceProfile = this._getProfile(sourceProfileIndex);
if (sourceProfile === null || !this._getProfile(destinationProfileIndex)) { return; }
const options = clone(sourceProfile.options);
this._profiles[destinationProfileIndex].options = options;
this._updateProfileSelectOptions();
const destinationProfileEntry = this._getProfileEntry(destinationProfileIndex);
if (destinationProfileEntry !== null) {
destinationProfileEntry.updateState();
}
await this._settingsController.modifyGlobalSettings([{
action: 'set',
path: `profiles[${destinationProfileIndex}].options`,
value: options,
}]);
await this._settingsController.refresh();
}
/**
* @param {number} profileIndex
*/
async duplicateProfile(profileIndex) {
const profile = this._getProfile(profileIndex);
if (profile === null) { return; }
// Create new profile
const newProfile = clone(profile);
newProfile.name = this._createCopyName(profile.name, this._profiles, 100);
newProfile.id = generateId(16);
// Update state
const index = this._profiles.length;
this._profiles.push(newProfile);
if (this._profileEntriesSupported) {
this._addProfileEntry(index);
}
this._updateProfileSelectOptions();
// Modify settings
await this._settingsController.modifyGlobalSettings([{
action: 'splice',
path: 'profiles',
start: index,
deleteCount: 0,
items: [newProfile],
}]);
}
/**
* @param {number} profileIndex
*/
async resetProfile(profileIndex) {
const profile = this._getProfile(profileIndex);
if (profile === null) { return; }
const defaultOptions = await this._settingsController.getDefaultOptions();
const defaultProfileOptions = defaultOptions.profiles[0];
defaultProfileOptions.name = profile.name;
await this._settingsController.modifyGlobalSettings([{
action: 'set',
path: `profiles[${profileIndex}]`,
value: defaultProfileOptions,
}]);
await this._settingsController.refresh();
}
/**
* @param {number} profileIndex
*/
async deleteProfile(profileIndex) {
const profile = this._getProfile(profileIndex);
if (profile === null || this.profileCount <= 1) { return; }
// Get indices
let profileCurrentNew = this._profileCurrent;
const settingsProfileIndex = this._profileCurrent;
// Construct settings modifications
/** @type {import('settings-modifications').Modification[]} */
const modifications = [{
action: 'splice',
path: 'profiles',
start: profileIndex,
deleteCount: 1,
items: [],
}];
if (profileCurrentNew >= profileIndex) {
profileCurrentNew = Math.min(profileCurrentNew - 1, this._profiles.length - 1);
modifications.push({
action: 'set',
path: 'profileCurrent',
value: profileCurrentNew,
});
}
// Update state
this._profileCurrent = profileCurrentNew;
this._profiles.splice(profileIndex, 1);
if (profileIndex < this._profileEntryList.length) {
const profileEntry = this._profileEntryList[profileIndex];
profileEntry.cleanup();
this._profileEntryList.splice(profileIndex, 1);
for (let i = profileIndex, ii = this._profileEntryList.length; i < ii; ++i) {
this._profileEntryList[i].index = i;
}
}
const profileEntry2 = this._getProfileEntry(profileCurrentNew);
if (profileEntry2 !== null) {
profileEntry2.setIsDefault(true);
}
this._updateProfileSelectOptions();
// Update profile index
if (settingsProfileIndex >= profileIndex) {
this._settingsController.profileIndex = settingsProfileIndex - 1;
} else {
this._settingsController.refreshProfileIndex();
}
// Modify settings
await this._settingsController.modifyGlobalSettings(modifications);
}
/**
* @param {number} index1
* @param {number} index2
*/
async swapProfiles(index1, index2) {
const profile1 = this._getProfile(index1);
const profile2 = this._getProfile(index2);
if (profile1 === null || profile2 === null || index1 === index2) { return; }
// Get swapped indices
const profileCurrent = this._profileCurrent;
const profileCurrentNew = this._getSwappedValue(profileCurrent, index1, index2);
const settingsProfileIndex = this._settingsController.profileIndex;
const settingsProfileIndexNew = this._getSwappedValue(settingsProfileIndex, index1, index2);
// Construct settings modifications
/** @type {import('settings-modifications').Modification[]} */
const modifications = [{
action: 'swap',
path1: `profiles[${index1}]`,
path2: `profiles[${index2}]`,
}];
if (profileCurrentNew !== profileCurrent) {
modifications.push({
action: 'set',
path: 'profileCurrent',
value: profileCurrentNew,
});
}
// Update state
this._profileCurrent = profileCurrentNew;
this._profiles[index1] = profile2;
this._profiles[index2] = profile1;
const entry1 = this._getProfileEntry(index1);
const entry2 = this._getProfileEntry(index2);
if (entry1 !== null && entry2 !== null) {
entry1.index = index2;
entry2.index = index1;
this._swapDomNodes(entry1.node, entry2.node);
this._profileEntryList[index1] = entry2;
this._profileEntryList[index2] = entry1;
}
this._updateProfileSelectOptions();
// Modify settings
await this._settingsController.modifyGlobalSettings(modifications);
// Update profile index
if (settingsProfileIndex !== settingsProfileIndexNew) {
this._settingsController.profileIndex = settingsProfileIndexNew;
}
}
/**
* @param {number} profileIndex
*/
openResetProfileModal(profileIndex) {
const profile = this._getProfile(profileIndex);
if (profile === null || this.profileCount <= 1) { return; }
/** @type {HTMLElement} */ (this._resetProfileNameElement).textContent = profile.name;
/** @type {import('./modal.js').Modal} */ (this._profileResetModal).node.dataset.profileIndex = `${profileIndex}`;
/** @type {import('./modal.js').Modal} */ (this._profileResetModal).setVisible(true);
}
/**
* @param {number} profileIndex
*/
openDeleteProfileModal(profileIndex) {
const profile = this._getProfile(profileIndex);
if (profile === null || this.profileCount <= 1) { return; }
/** @type {HTMLElement} */ (this._removeProfileNameElement).textContent = profile.name;
/** @type {import('./modal.js').Modal} */ (this._profileRemoveModal).node.dataset.profileIndex = `${profileIndex}`;
/** @type {import('./modal.js').Modal} */ (this._profileRemoveModal).setVisible(true);
}
/**
* @param {number} profileIndex
*/
openCopyProfileModal(profileIndex) {
const profile = this._getProfile(profileIndex);
if (profile === null || this.profileCount <= 1) { return; }
let copyFromIndex = this._profileCurrent;
if (copyFromIndex === profileIndex) {
if (profileIndex !== 0) {
copyFromIndex = 0;
} else if (this.profileCount > 1) {
copyFromIndex = 1;
}
}
const profileIndexString = `${profileIndex}`;
const select = /** @type {HTMLSelectElement} */ (this._profileCopySourceSelect);
for (const option of select.querySelectorAll('option')) {
const {value} = option;
option.disabled = (value === profileIndexString);
}
select.value = `${copyFromIndex}`;
/** @type {import('./modal.js').Modal} */ (this._profileCopyModal).node.dataset.profileIndex = `${profileIndex}`;
/** @type {import('./modal.js').Modal} */ (this._profileCopyModal).setVisible(true);
}
/**
* @param {number} profileIndex
*/
openProfileConditionsModal(profileIndex) {
const profile = this._getProfile(profileIndex);
if (profile === null) { return; }
if (this._profileConditionsModal === null) { return; }
this._profileConditionsModal.setVisible(true);
this._profileConditionsUI.cleanup();
this._profileConditionsIndex = profileIndex;
void this._profileConditionsUI.prepare(profileIndex);
if (this._profileConditionsProfileName !== null) {
this._profileConditionsProfileName.textContent = profile.name;
}
}
// Private
/** */
async _onOptionsChanged() {
// Update state
const {profiles, profileCurrent} = await this._settingsController.getOptionsFull();
this._profiles = profiles;
this._profileCurrent = profileCurrent;
const settingsProfileIndex = this._settingsController.profileIndex;
// Update UI
this._updateProfileSelectOptions();
void this.setDefaultProfile(profileCurrent);
/** @type {HTMLSelectElement} */ (this._profileActiveSelect).value = `${profileCurrent}`;
// Update profile conditions
this._profileConditionsUI.cleanup();
const conditionsProfile = this._getProfile(this._profileConditionsIndex !== null ? this._profileConditionsIndex : settingsProfileIndex);
if (conditionsProfile !== null) {
void this._profileConditionsUI.prepare(settingsProfileIndex);
}
// Update profile entries
for (const entry of this._profileEntryList) {
entry.cleanup();
}
this._profileEntryList = [];
if (this._profileEntriesSupported) {
for (let i = 0, ii = profiles.length; i < ii; ++i) {
this._addProfileEntry(i);
}
}
}
/**
* @param {Event} e
*/
_onProfileActiveChange(e) {
const element = /** @type {HTMLSelectElement} */ (e.currentTarget);
const value = this._tryGetValidProfileIndex(element.value);
if (value === null) { return; }
void this.setDefaultProfile(value);
}
/** */
_onAdd() {
void this.duplicateProfile(this._settingsController.profileIndex);
}
/** */
_onResetConfirm() {
const modal = /** @type {import('./modal.js').Modal} */ (this._profileResetModal);
modal.setVisible(false);
const {node} = modal;
const profileIndex = node.dataset.profileIndex;
delete node.dataset.profileIndex;
const validProfileIndex = this._tryGetValidProfileIndex(profileIndex);
if (validProfileIndex === null) { return; }
void this.resetProfile(validProfileIndex);
}
/** */
_onDeleteConfirm() {
const modal = /** @type {import('./modal.js').Modal} */ (this._profileRemoveModal);
modal.setVisible(false);
const {node} = modal;
const profileIndex = node.dataset.profileIndex;
delete node.dataset.profileIndex;
const validProfileIndex = this._tryGetValidProfileIndex(profileIndex);
if (validProfileIndex === null) { return; }
void this.deleteProfile(validProfileIndex);
}
/** */
_onCopyConfirm() {
const modal = /** @type {import('./modal.js').Modal} */ (this._profileCopyModal);
modal.setVisible(false);
const {node} = modal;
const destinationProfileIndex = node.dataset.profileIndex;
delete node.dataset.profileIndex;
const validDestinationProfileIndex = this._tryGetValidProfileIndex(destinationProfileIndex);
if (validDestinationProfileIndex === null) { return; }
const sourceProfileIndex = this._tryGetValidProfileIndex(/** @type {HTMLSelectElement} */ (this._profileCopySourceSelect).value);
if (sourceProfileIndex === null) { return; }
void this.copyProfile(sourceProfileIndex, validDestinationProfileIndex);
}
/**
* @param {import('profile-conditions-ui').EventArgument<'conditionGroupCountChanged'>} details
*/
_onConditionGroupCountChanged({count, profileIndex}) {
if (profileIndex >= 0 && profileIndex < this._profileEntryList.length) {
const profileEntry = this._profileEntryList[profileIndex];
profileEntry.setConditionGroupsCount(count);
}
}
/**
* @param {number} profileIndex
*/
_addProfileEntry(profileIndex) {
const profile = this._profiles[profileIndex];
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('profile-entry'));
const entry = new ProfileEntry(this, node, profile, profileIndex);
this._profileEntryList.push(entry);
entry.prepare();
/** @type {HTMLElement} */ (this._profileEntryListContainer).appendChild(node);
}
/** */
_updateProfileSelectOptions() {
for (const select of this._getAllProfileSelects()) {
const fragment = document.createDocumentFragment();
for (let i = 0; i < this._profiles.length; ++i) {
const profile = this._profiles[i];
const option = document.createElement('option');
option.value = `${i}`;
option.textContent = profile.name;
fragment.appendChild(option);
}
select.textContent = '';
select.appendChild(fragment);
}
}
/**
* @param {number} index
* @param {string} name
*/
_updateSelectName(index, name) {
const optionValue = `${index}`;
for (const select of this._getAllProfileSelects()) {
for (const option of select.querySelectorAll('option')) {
if (option.value === optionValue) {
option.textContent = name;
}
}
}
}
/**
* @returns {HTMLSelectElement[]}
*/
_getAllProfileSelects() {
return [
/** @type {HTMLSelectElement} */ (this._profileActiveSelect),
/** @type {HTMLSelectElement} */ (this._profileCopySourceSelect),
];
}
/**
* @param {string|undefined} stringValue
* @returns {?number}
*/
_tryGetValidProfileIndex(stringValue) {
if (typeof stringValue !== 'string') { return null; }
const intValue = Number.parseInt(stringValue, 10);
return (
Number.isFinite(intValue) &&
intValue >= 0 &&
intValue < this.profileCount ?
intValue :
null
);
}
/**
* @param {string} name
* @param {import('settings').Profile[]} profiles
* @param {number} maxUniqueAttempts
* @returns {string}
*/
_createCopyName(name, profiles, maxUniqueAttempts) {
let space, index, prefix, suffix;
const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name);
if (match === null) {
prefix = `${name} (Copy`;
space = '';
index = '';
suffix = ')';
} else {
prefix = match[1];
suffix = match[5];
if (typeof match[2] === 'string') {
space = match[3];
index = Number.parseInt(match[4], 10) + 1;
} else {
space = ' ';
index = 2;
}
}
let i = 0;
while (true) {
const newName = `${prefix}${space}${index}${suffix}`;
if (i++ >= maxUniqueAttempts || !profiles.some((profile) => profile.name === newName)) {
return newName;
}
if (typeof index !== 'number') {
index = 2;
space = ' ';
} else {
++index;
}
}
}
/**
* @template [T=unknown]
* @param {T} currentValue
* @param {T} value1
* @param {T} value2
* @returns {T}
*/
_getSwappedValue(currentValue, value1, value2) {
if (currentValue === value1) { return value2; }
if (currentValue === value2) { return value1; }
return currentValue;
}
/**
* @param {number} profileIndex
* @returns {?import('settings').Profile}
*/
_getProfile(profileIndex) {
return (profileIndex >= 0 && profileIndex < this._profiles.length ? this._profiles[profileIndex] : null);
}
/**
* @param {number} profileIndex
* @returns {?ProfileEntry}
*/
_getProfileEntry(profileIndex) {
return (profileIndex >= 0 && profileIndex < this._profileEntryList.length ? this._profileEntryList[profileIndex] : null);
}
/**
* @param {Element} node1
* @param {Element} node2
*/
_swapDomNodes(node1, node2) {
const parent1 = node1.parentNode;
const parent2 = node2.parentNode;
const next1 = node1.nextSibling;
const next2 = node2.nextSibling;
if (node2 !== next1 && parent1 !== null) { parent1.insertBefore(node2, next1); }
if (node1 !== next2 && parent2 !== null) { parent2.insertBefore(node1, next2); }
}
}
class ProfileEntry {
/**
* @param {ProfileController} profileController
* @param {HTMLElement} node
* @param {import('settings').Profile} profile
* @param {number} index
*/
constructor(profileController, node, profile, index) {
/** @type {ProfileController} */
this._profileController = profileController;
/** @type {HTMLElement} */
this._node = node;
/** @type {import('settings').Profile} */
this._profile = profile;
/** @type {number} */
this._index = index;
/** @type {HTMLInputElement} */
this._isDefaultRadio = querySelectorNotNull(node, '.profile-entry-is-default-radio');
/** @type {HTMLInputElement} */
this._nameInput = querySelectorNotNull(node, '.profile-entry-name-input');
/** @type {HTMLElement} */
this._countLink = querySelectorNotNull(node, '.profile-entry-condition-count-link');
/** @type {HTMLElement} */
this._countText = querySelectorNotNull(node, '.profile-entry-condition-count');
/** @type {HTMLButtonElement} */
this._menuButton = querySelectorNotNull(node, '.profile-entry-menu-button');
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
}
/** @type {number} */
get index() {
return this._index;
}
set index(value) {
this._index = value;
}
/** @type {HTMLElement} */
get node() {
return this._node;
}
/** */
prepare() {
this.updateState();
this._eventListeners.addEventListener(this._isDefaultRadio, 'change', this._onIsDefaultRadioChange.bind(this), false);
this._eventListeners.addEventListener(this._nameInput, 'input', this._onNameInputInput.bind(this), false);
this._eventListeners.addEventListener(this._countLink, 'click', this._onConditionsCountLinkClick.bind(this), false);
this._eventListeners.addEventListener(this._menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
this._eventListeners.addEventListener(this._menuButton, 'menuClose', this._onMenuClose.bind(this), false);
}
/** */
cleanup() {
this._eventListeners.removeAllEventListeners();
if (this._node.parentNode !== null) {
this._node.parentNode.removeChild(this._node);
}
}
/**
* @param {string} value
*/
setName(value) {
if (this._nameInput.value === value) { return; }
this._nameInput.value = value;
}
/**
* @param {boolean} value
*/
setIsDefault(value) {
this._isDefaultRadio.checked = value;
}
/** */
updateState() {
this._nameInput.value = this._profile.name;
this._countText.textContent = `${this._profile.conditionGroups.length}`;
this._isDefaultRadio.checked = (this._index === this._profileController.profileCurrentIndex);
}
/**
* @param {number} count
*/
setConditionGroupsCount(count) {
this._countText.textContent = `${count}`;
}
// Private
/**
* @param {Event} e
*/
_onIsDefaultRadioChange(e) {
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
if (!element.checked) { return; }
void this._profileController.setDefaultProfile(this._index);
}
/**
* @param {Event} e
*/
_onNameInputInput(e) {
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
const name = element.value;
void this._profileController.setProfileName(this._index, name);
}
/** */
_onConditionsCountLinkClick() {
this._profileController.openProfileConditionsModal(this._index);
}
/**
* @param {import('popup-menu').MenuOpenEvent} e
*/
_onMenuOpen(e) {
const bodyNode = e.detail.menu.bodyNode;
const count = this._profileController.profileCount;
this._setMenuActionEnabled(bodyNode, 'moveUp', this._index > 0);
this._setMenuActionEnabled(bodyNode, 'moveDown', this._index < count - 1);
this._setMenuActionEnabled(bodyNode, 'copyFrom', count > 1);
this._setMenuActionEnabled(bodyNode, 'delete', count > 1);
}
/**
* @param {import('popup-menu').MenuCloseEvent} e
*/
_onMenuClose(e) {
switch (e.detail.action) {
case 'moveUp':
void this._profileController.moveProfile(this._index, -1);
break;
case 'moveDown':
void this._profileController.moveProfile(this._index, 1);
break;
case 'copyFrom':
this._profileController.openCopyProfileModal(this._index);
break;
case 'editConditions':
this._profileController.openProfileConditionsModal(this._index);
break;
case 'duplicate':
void this._profileController.duplicateProfile(this._index);
break;
case 'reset':
this._profileController.openResetProfileModal(this._index);
break;
case 'delete':
this._profileController.openDeleteProfileModal(this._index);
break;
}
}
/**
* @param {Element} menu
* @param {string} action
* @param {boolean} enabled
*/
_setMenuActionEnabled(menu, action, enabled) {
const element = /** @type {HTMLButtonElement} */ (menu.querySelector(`[data-menu-action="${action}"]`));
if (element === null) { return; }
element.disabled = !enabled;
}
}

View File

@@ -0,0 +1,141 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {toError} from '../../core/to-error.js';
import {getAllPermissions, setPermissionsGranted} from '../../data/permissions-util.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class RecommendedPermissionsController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {HTMLInputElement} */
this._originToggleNode = querySelectorNotNull(document, '#recommended-permissions-toggle');
/** @type {HTMLInputElement} */
this._optionalPermissionsToggleNode = querySelectorNotNull(document, '#optional-permissions-toggle');
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {?HTMLElement} */
this._errorContainer = null;
}
/** */
async prepare() {
this._errorContainer = document.querySelector('#recommended-permissions-error');
this._originToggleNode.addEventListener('change', this._onOriginToggleChange.bind(this), false);
this._optionalPermissionsToggleNode.addEventListener('change', this._onOptionalPermissionsToggleChange.bind(this), false);
this._settingsController.on('permissionsChanged', this._onPermissionsChanged.bind(this));
await this._updatePermissions();
}
// Private
/**
* @param {import('settings-controller').EventArgument<'permissionsChanged'>} details
*/
_onPermissionsChanged({permissions}) {
this._eventListeners.removeAllEventListeners();
const originsSet = new Set(permissions.origins);
const {origin} = this._originToggleNode.dataset;
this._originToggleNode.checked = typeof origin === 'string' && originsSet.has(origin);
this._optionalPermissionsToggleNode.checked = Array.isArray(permissions.permissions) && permissions.permissions.includes('clipboardRead') && permissions.permissions.includes('nativeMessaging');
}
/**
* @param {Event} e
*/
_onOriginToggleChange(e) {
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
const value = node.checked;
node.checked = !value;
const {origin} = node.dataset;
if (typeof origin !== 'string') { return; }
void this._setOriginPermissionEnabled(origin, value);
}
/**
* @param {Event} e
*/
async _onOptionalPermissionsToggleChange(e) {
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
const value = node.checked;
const permissions = ['clipboardRead', 'nativeMessaging'];
await setPermissionsGranted({permissions}, value);
await this._updatePermissions();
}
/** */
async _updatePermissions() {
const permissions = await getAllPermissions();
this._onPermissionsChanged({permissions});
void this._setWelcomePageText();
}
/**
* @param {string} origin
* @param {boolean} enabled
* @returns {Promise<boolean>}
*/
async _setOriginPermissionEnabled(origin, enabled) {
let added = false;
try {
added = await setPermissionsGranted({origins: [origin]}, enabled);
} catch (e) {
if (this._errorContainer !== null) {
this._errorContainer.hidden = false;
this._errorContainer.textContent = toError(e).message;
}
}
await this._updatePermissions();
return added;
}
/** */
async _setWelcomePageText() {
const permissions = await getAllPermissions();
const recommendedPermissions = permissions.origins?.includes('<all_urls>');
const optionalPermissions = permissions.permissions?.includes('clipboardRead') && permissions.permissions?.includes('nativeMessaging');
/** @type {HTMLElement | null} */
this._textIfFullEnabled = document.querySelector('#full-permissions-enabled');
/** @type {HTMLElement | null} */
this._textIfRecommendedEnabled = document.querySelector('#recommended-permissions-enabled');
/** @type {HTMLElement | null} */
this._textIfDisabled = document.querySelector('#permissions-disabled');
if (this._textIfFullEnabled && this._textIfRecommendedEnabled && this._textIfDisabled) {
this._textIfFullEnabled.hidden = true;
this._textIfRecommendedEnabled.hidden = true;
this._textIfDisabled.hidden = true;
if (optionalPermissions && recommendedPermissions) {
this._textIfFullEnabled.hidden = false;
} else if (recommendedPermissions) {
this._textIfRecommendedEnabled.hidden = false;
} else {
this._textIfDisabled.hidden = false;
}
}
}
}

View File

@@ -0,0 +1,194 @@
/*
* Copyright (C) 2024-2025 Yomitan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {fetchJson} from '../../core/fetch-utilities.js';
import {log} from '../../core/log.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class RecommendedSettingsController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {HTMLElement} */
this._recommendedSettingsModal = querySelectorNotNull(document, '#recommended-settings-modal');
/** @type {HTMLInputElement} */
this._languageSelect = querySelectorNotNull(document, '#language-select');
/** @type {HTMLInputElement} */
this._applyButton = querySelectorNotNull(document, '#recommended-settings-apply-button');
/** @type {Map<string, import('settings-controller').RecommendedSetting>} */
this._recommendedSettings = new Map();
}
/** */
async prepare() {
this._languageSelect.addEventListener('change', this._onLanguageSelectChanged.bind(this), false);
this._applyButton.addEventListener('click', this._onApplyButtonClicked.bind(this), false);
}
/**
* @param {Event} _e
*/
async _onLanguageSelectChanged(_e) {
const setLanguage = this._languageSelect.value;
if (typeof setLanguage !== 'string') { return; }
const recommendedSettings = await this._getRecommendedSettings(setLanguage);
if (typeof recommendedSettings !== 'undefined') {
const settingsList = querySelectorNotNull(document, '#recommended-settings-list');
settingsList.innerHTML = '';
this._recommendedSettings = new Map();
for (const [index, setting] of recommendedSettings.entries()) {
this._recommendedSettings.set(index.toString(), setting);
const {description} = setting;
const template = this._settingsController.instantiateTemplate('recommended-settings-list-item');
// Render label
this._renderLabel(template, setting);
// Render description
const descriptionElement = querySelectorNotNull(template, '.settings-item-description');
if (description !== 'undefined') {
descriptionElement.textContent = description;
}
// Render checkbox
const checkbox = /** @type {HTMLInputElement} */ (querySelectorNotNull(template, 'input[type="checkbox"]'));
checkbox.value = index.toString();
settingsList.append(template);
}
this._recommendedSettingsModal.hidden = false;
}
}
/**
*
* @param {string} language
* @returns {Promise<import('settings-controller').RecommendedSetting[]>}
*/
async _getRecommendedSettings(language) {
if (typeof this._recommendedSettingsByLanguage === 'undefined') {
/** @type {import('settings-controller').RecommendedSettingsByLanguage} */
this._recommendedSettingsByLanguage = await fetchJson('/data/recommended-settings.json');
}
return this._recommendedSettingsByLanguage[language];
}
/**
* @param {MouseEvent} e
*/
_onApplyButtonClicked(e) {
e.preventDefault();
/** @type {NodeListOf<HTMLInputElement>} */
const enabledCheckboxes = querySelectorNotNull(document, '#recommended-settings-list').querySelectorAll('input[type="checkbox"]:checked');
if (enabledCheckboxes.length > 0) {
const modifications = [];
for (const checkbox of enabledCheckboxes) {
const index = checkbox.value;
const setting = this._recommendedSettings.get(index);
if (typeof setting === 'undefined') { continue; }
modifications.push(setting.modification);
}
void this._settingsController.modifyProfileSettings(modifications).then(
(results) => {
for (const result of results) {
if (Object.hasOwn(result, 'error')) {
log.error(new Error(`Failed to apply recommended setting: ${JSON.stringify(result)}`));
}
}
},
);
void this._settingsController.refresh();
}
this._recommendedSettingsModal.hidden = true;
}
/**
* @param {Element} template
* @param {import('settings-controller').RecommendedSetting} setting
*/
_renderLabel(template, setting) {
const label = querySelectorNotNull(template, '.settings-item-label');
const {modification} = setting;
switch (modification.action) {
case 'set': {
const {path, value} = modification;
const pathCodeElement = document.createElement('code');
pathCodeElement.textContent = path;
const valueCodeElement = document.createElement('code');
valueCodeElement.textContent = JSON.stringify(value, null, 2);
label.appendChild(document.createTextNode('Setting '));
label.appendChild(pathCodeElement);
label.appendChild(document.createTextNode(' = '));
label.appendChild(valueCodeElement);
break;
}
case 'delete': {
const {path} = modification;
const pathCodeElement = document.createElement('code');
pathCodeElement.textContent = path;
label.appendChild(document.createTextNode('Deleting '));
label.appendChild(pathCodeElement);
break;
}
case 'swap': {
const {path1, path2} = modification;
const path1CodeElement = document.createElement('code');
path1CodeElement.textContent = path1;
const path2CodeElement = document.createElement('code');
path2CodeElement.textContent = path2;
label.appendChild(document.createTextNode('Swapping '));
label.appendChild(path1CodeElement);
label.appendChild(document.createTextNode(' and '));
label.appendChild(path2CodeElement);
break;
}
case 'splice': {
const {path, start, deleteCount, items} = modification;
const pathCodeElement = document.createElement('code');
pathCodeElement.textContent = path;
label.appendChild(document.createTextNode('Splicing '));
label.appendChild(pathCodeElement);
label.appendChild(document.createTextNode(` at ${start} deleting ${deleteCount} items and inserting ${items.length} items`));
break;
}
case 'push': {
const {path, items} = modification;
const pathCodeElement = document.createElement('code');
pathCodeElement.textContent = path;
label.appendChild(document.createTextNode(`Pushing ${items.length} items to `));
label.appendChild(pathCodeElement);
break;
}
default: {
log.error(new Error(`Unknown modification: ${modification}`));
}
}
}
}

View File

@@ -0,0 +1,440 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {normalizeModifier} from '../../dom/document-util.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js';
export class ScanInputsController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {?import('environment').OperatingSystem} */
this._os = null;
/** @type {HTMLElement} */
this._container = querySelectorNotNull(document, '#scan-input-list');
/** @type {HTMLButtonElement} */
this._addButton = querySelectorNotNull(document, '#scan-input-add');
/** @type {?NodeListOf<HTMLElement>} */
this._scanningInputCountNodes = null;
/** @type {ScanInputField[]} */
this._entries = [];
}
/** */
async prepare() {
const {platform: {os}} = await this._settingsController.application.api.getEnvironmentInfo();
this._os = os;
this._scanningInputCountNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.scanning-input-count'));
this._addButton.addEventListener('click', this._onAddButtonClick.bind(this), false);
this._settingsController.on('scanInputsChanged', this._onScanInputsChanged.bind(this));
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
await this.refresh();
}
/**
* @param {number} index
* @returns {boolean}
*/
removeInput(index) {
if (index < 0 || index >= this._entries.length) { return false; }
const input = this._entries[index];
input.cleanup();
this._entries.splice(index, 1);
for (let i = index, ii = this._entries.length; i < ii; ++i) {
this._entries[i].index = i;
}
this._updateCounts();
void this._modifyProfileSettings([{
action: 'splice',
path: 'scanning.inputs',
start: index,
deleteCount: 1,
items: [],
}]);
return true;
}
/**
* @param {number} index
* @param {string} property
* @param {unknown} value
* @param {boolean} event
*/
async setProperty(index, property, value, event) {
const path = `scanning.inputs[${index}].${property}`;
await this._settingsController.setProfileSetting(path, value);
if (event) {
this._triggerScanInputsChanged();
}
}
/**
* @param {string} name
* @returns {Element}
*/
instantiateTemplate(name) {
return this._settingsController.instantiateTemplate(name);
}
/** */
async refresh() {
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._onOptionsChanged({options, optionsContext});
}
// Private
/**
* @param {import('settings-controller').EventArgument<'scanInputsChanged'>} details
*/
_onScanInputsChanged({source}) {
if (source === this) { return; }
void this.refresh();
}
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
const {inputs} = options.scanning;
for (let i = this._entries.length - 1; i >= 0; --i) {
this._entries[i].cleanup();
}
this._entries.length = 0;
for (let i = 0, ii = inputs.length; i < ii; ++i) {
this._addOption(i, inputs[i]);
}
this._updateCounts();
}
/**
* @param {MouseEvent} e
*/
_onAddButtonClick(e) {
e.preventDefault();
const index = this._entries.length;
const scanningInput = ScanInputsController.createDefaultMouseInput('', '');
this._addOption(index, scanningInput);
this._updateCounts();
void this._modifyProfileSettings([{
action: 'splice',
path: 'scanning.inputs',
start: index,
deleteCount: 0,
items: [scanningInput],
}]);
// Scroll to bottom
const button = /** @type {HTMLElement} */ (e.currentTarget);
const modalContainer = /** @type {HTMLElement} */ (button.closest('.modal'));
/** @type {HTMLElement} */
const scrollContainer = querySelectorNotNull(modalContainer, '.modal-body');
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
/**
* @param {number} index
* @param {import('settings').ScanningInput} scanningInput
*/
_addOption(index, scanningInput) {
if (this._os === null || this._container === null) { return; }
const field = new ScanInputField(this, index, this._os);
this._entries.push(field);
field.prepare(this._container, scanningInput);
}
/** */
_updateCounts() {
const stringValue = `${this._entries.length}`;
for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._scanningInputCountNodes)) {
node.textContent = stringValue;
}
}
/**
* @param {import('settings-modifications').Modification[]} targets
*/
async _modifyProfileSettings(targets) {
await this._settingsController.modifyProfileSettings(targets);
this._triggerScanInputsChanged();
}
/** */
_triggerScanInputsChanged() {
/** @type {import('settings-controller').EventArgument<'scanInputsChanged'>} */
const event = {source: this};
this._settingsController.trigger('scanInputsChanged', event);
}
/**
* @param {string} include
* @param {string} exclude
* @returns {import('settings').ScanningInput}
*/
static createDefaultMouseInput(include, exclude) {
return {
include,
exclude,
types: {mouse: true, touch: false, pen: false},
options: {
showAdvanced: false,
searchTerms: true,
searchKanji: true,
scanOnTouchTap: true,
scanOnTouchMove: false,
scanOnTouchPress: false,
scanOnTouchRelease: false,
scanOnPenMove: true,
scanOnPenHover: false,
scanOnPenReleaseHover: false,
scanOnPenPress: true,
scanOnPenRelease: false,
preventTouchScrolling: true,
preventPenScrolling: true,
minimumTouchTime: 0,
},
};
}
}
class ScanInputField {
/**
* @param {ScanInputsController} parent
* @param {number} index
* @param {import('environment').OperatingSystem} os
*/
constructor(parent, index, os) {
/** @type {ScanInputsController} */
this._parent = parent;
/** @type {number} */
this._index = index;
/** @type {import('environment').OperatingSystem} */
this._os = os;
/** @type {?HTMLElement} */
this._node = null;
/** @type {?KeyboardMouseInputField} */
this._includeInputField = null;
/** @type {?KeyboardMouseInputField} */
this._excludeInputField = null;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
}
/** @type {number} */
get index() {
return this._index;
}
set index(value) {
this._index = value;
this._updateDataSettingTargets();
}
/**
* @param {HTMLElement} container
* @param {import('settings').ScanningInput} scanningInput
*/
prepare(container, scanningInput) {
const {include, exclude, options: {showAdvanced}} = scanningInput;
const node = /** @type {HTMLElement} */ (this._parent.instantiateTemplate('scan-input'));
/** @type {HTMLInputElement} */
const includeInputNode = querySelectorNotNull(node, '.scan-input-field[data-property=include]');
/** @type {HTMLButtonElement} */
const includeMouseButton = querySelectorNotNull(node, '.mouse-button[data-property=include]');
/** @type {HTMLInputElement} */
const excludeInputNode = querySelectorNotNull(node, '.scan-input-field[data-property=exclude]');
/** @type {HTMLButtonElement} */
const excludeMouseButton = querySelectorNotNull(node, '.mouse-button[data-property=exclude]');
/** @type {HTMLButtonElement} */
const menuButton = querySelectorNotNull(node, '.scanning-input-menu-button');
node.dataset.showAdvanced = `${showAdvanced}`;
this._node = node;
container.appendChild(node);
const isPointerTypeSupported = this._isPointerTypeSupported.bind(this);
this._includeInputField = new KeyboardMouseInputField(includeInputNode, includeMouseButton, this._os, isPointerTypeSupported);
this._excludeInputField = new KeyboardMouseInputField(excludeInputNode, excludeMouseButton, this._os, isPointerTypeSupported);
this._includeInputField.prepare(null, this._splitModifiers(include), true, false);
this._excludeInputField.prepare(null, this._splitModifiers(exclude), true, false);
this._eventListeners.on(this._includeInputField, 'change', this._onIncludeValueChange.bind(this));
this._eventListeners.on(this._excludeInputField, 'change', this._onExcludeValueChange.bind(this));
this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this));
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this));
this._updateDataSettingTargets();
}
/** */
cleanup() {
this._eventListeners.removeAllEventListeners();
if (this._includeInputField !== null) {
this._includeInputField.cleanup();
this._includeInputField = null;
}
if (this._node !== null) {
const parent = this._node.parentNode;
if (parent !== null) { parent.removeChild(this._node); }
this._node = null;
}
}
// Private
/**
* @param {import('keyboard-mouse-input-field').EventArgument<'change'>} details
*/
_onIncludeValueChange({modifiers}) {
const modifiers2 = this._joinModifiers(modifiers);
void this._parent.setProperty(this._index, 'include', modifiers2, true);
}
/**
* @param {import('keyboard-mouse-input-field').EventArgument<'change'>} details
*/
_onExcludeValueChange({modifiers}) {
const modifiers2 = this._joinModifiers(modifiers);
void this._parent.setProperty(this._index, 'exclude', modifiers2, true);
}
/**
* @param {MouseEvent} e
*/
_onRemoveClick(e) {
e.preventDefault();
this._removeSelf();
}
/**
* @param {import('popup-menu').MenuOpenEvent} e
*/
_onMenuOpen(e) {
const bodyNode = e.detail.menu.bodyNode;
/** @type {?HTMLElement} */
const showAdvanced = bodyNode.querySelector('.popup-menu-item[data-menu-action="showAdvanced"]');
/** @type {?HTMLElement} */
const hideAdvanced = bodyNode.querySelector('.popup-menu-item[data-menu-action="hideAdvanced"]');
const advancedVisible = (this._node !== null && this._node.dataset.showAdvanced === 'true');
if (showAdvanced !== null) {
showAdvanced.hidden = advancedVisible;
}
if (hideAdvanced !== null) {
hideAdvanced.hidden = !advancedVisible;
}
}
/**
* @param {import('popup-menu').MenuCloseEvent} e
*/
_onMenuClose(e) {
switch (e.detail.action) {
case 'remove':
this._removeSelf();
break;
case 'showAdvanced':
this._setAdvancedOptionsVisible(true);
break;
case 'hideAdvanced':
this._setAdvancedOptionsVisible(false);
break;
case 'clearInputs':
/** @type {KeyboardMouseInputField} */ (this._includeInputField).clearInputs();
/** @type {KeyboardMouseInputField} */ (this._excludeInputField).clearInputs();
break;
}
}
/**
* @param {string} pointerType
* @returns {boolean}
*/
_isPointerTypeSupported(pointerType) {
if (this._node === null) { return false; }
const node = /** @type {?HTMLInputElement} */ (this._node.querySelector(`input.scan-input-settings-checkbox[data-property="types.${pointerType}"]`));
return node !== null && node.checked;
}
/** */
_updateDataSettingTargets() {
if (this._node === null) { return; }
const index = this._index;
for (const typeCheckbox of /** @type {NodeListOf<HTMLElement>} */ (this._node.querySelectorAll('.scan-input-settings-checkbox'))) {
const {property} = typeCheckbox.dataset;
typeCheckbox.dataset.setting = `scanning.inputs[${index}].${property}`;
}
for (const typeInput of /** @type {NodeListOf<HTMLElement>} */ (this._node.querySelectorAll('.scan-input-settings-input'))) {
const {property} = typeInput.dataset;
typeInput.dataset.setting = `scanning.inputs[${index}].${property}`;
}
}
/** */
_removeSelf() {
this._parent.removeInput(this._index);
}
/**
* @param {boolean} showAdvanced
*/
_setAdvancedOptionsVisible(showAdvanced) {
showAdvanced = !!showAdvanced;
if (this._node !== null) {
this._node.dataset.showAdvanced = `${showAdvanced}`;
}
void this._parent.setProperty(this._index, 'options.showAdvanced', showAdvanced, false);
}
/**
* @param {string} modifiersString
* @returns {import('input').Modifier[]}
*/
_splitModifiers(modifiersString) {
/** @type {import('input').Modifier[]} */
const results = [];
for (const modifier of modifiersString.split(/[,;\s]+/)) {
const modifier2 = normalizeModifier(modifier.trim().toLowerCase());
if (modifier2 === null) { continue; }
results.push(modifier2);
}
return results;
}
/**
* @param {import('input').Modifier[]} modifiersArray
* @returns {string}
*/
_joinModifiers(modifiersArray) {
return modifiersArray.join(', ');
}
}

View File

@@ -0,0 +1,305 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {HotkeyUtil} from '../../input/hotkey-util.js';
import {ScanInputsController} from './scan-inputs-controller.js';
export class ScanInputsSimpleController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {HTMLInputElement} */
this._middleMouseButtonScan = querySelectorNotNull(document, '#middle-mouse-button-scan');
/** @type {HTMLSelectElement} */
this._mainScanModifierKeyInput = querySelectorNotNull(document, '#main-scan-modifier-key');
/** @type {boolean} */
this._mainScanModifierKeyInputHasOther = false;
/** @type {HotkeyUtil} */
this._hotkeyUtil = new HotkeyUtil();
}
/** */
async prepare() {
const {platform: {os}} = await this._settingsController.application.api.getEnvironmentInfo();
this._hotkeyUtil.os = os;
this._mainScanModifierKeyInputHasOther = false;
this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther);
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._middleMouseButtonScan.addEventListener('change', this.onMiddleMouseButtonScanChange.bind(this), false);
this._mainScanModifierKeyInput.addEventListener('change', this._onMainScanModifierKeyInputChange.bind(this), false);
this._settingsController.on('scanInputsChanged', this._onScanInputsChanged.bind(this));
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
this._onOptionsChanged({options, optionsContext});
}
/** */
async refresh() {
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._onOptionsChanged({options, optionsContext});
}
// Private
/**
* @param {import('settings-controller').EventArgument<'scanInputsChanged'>} details
*/
_onScanInputsChanged({source}) {
if (source === this) { return; }
void this.refresh();
}
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
const {scanning: {inputs}} = options;
const middleMouseSupportedIndex = this._getIndexOfMiddleMouseButtonScanInput(inputs);
const mainScanInputIndex = this._getIndexOfMainScanInput(inputs);
const hasMainScanInput = (mainScanInputIndex >= 0);
let middleMouseSupported = false;
if (middleMouseSupportedIndex >= 0) {
const includeValues = this._splitValue(inputs[middleMouseSupportedIndex].include);
if (includeValues.includes('mouse2')) {
middleMouseSupported = true;
}
}
let mainScanInput = 'none';
if (hasMainScanInput) {
const includeValues = this._splitValue(inputs[mainScanInputIndex].include);
if (includeValues.length > 0) {
mainScanInput = includeValues[0];
}
} else {
mainScanInput = 'other';
}
this._setHasMainScanInput(hasMainScanInput);
/** @type {HTMLInputElement} */ (this._middleMouseButtonScan).checked = middleMouseSupported;
/** @type {HTMLSelectElement} */ (this._mainScanModifierKeyInput).value = mainScanInput;
}
/**
* @param {Event} e
*/
onMiddleMouseButtonScanChange(e) {
const element = /** @type {HTMLInputElement} */ (e.currentTarget);
const middleMouseSupported = element.checked;
void this._setMiddleMouseSuppported(middleMouseSupported);
}
/**
* @param {Event} e
*/
_onMainScanModifierKeyInputChange(e) {
const element = /** @type {HTMLSelectElement} */ (e.currentTarget);
const mainScanKey = element.value;
if (mainScanKey === 'other') { return; }
const mainScanInputs = (mainScanKey === 'none' ? [] : [mainScanKey]);
void this._setMainScanInputs(mainScanInputs);
}
/**
* @param {HTMLSelectElement} select
* @param {boolean} hasOther
*/
_populateSelect(select, hasOther) {
const modifierKeys = [
{value: 'none', name: 'No key'},
];
for (const value of /** @type {import('input').ModifierKey[]} */ (['alt', 'ctrl', 'shift', 'meta'])) {
const name = this._hotkeyUtil.getModifierDisplayValue(value);
modifierKeys.push({value, name});
}
if (hasOther) {
modifierKeys.push({value: 'other', name: 'Other'});
}
const fragment = document.createDocumentFragment();
for (const {value, name} of modifierKeys) {
const option = document.createElement('option');
option.value = value;
option.textContent = name;
fragment.appendChild(option);
}
select.textContent = '';
select.appendChild(fragment);
}
/**
* @param {string} value
* @returns {string[]}
*/
_splitValue(value) {
return value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
}
/**
* @param {boolean} value
*/
async _setMiddleMouseSuppported(value) {
// Find target index
const options = await this._settingsController.getOptions();
const {scanning: {inputs}} = options;
const index = this._getIndexOfMiddleMouseButtonScanInput(inputs);
if (value) {
// Add new
if (index >= 0) { return; }
let insertionPosition = this._getIndexOfMainScanInput(inputs);
insertionPosition = (insertionPosition >= 0 ? insertionPosition + 1 : inputs.length);
const input = ScanInputsController.createDefaultMouseInput('mouse2', '');
await this._modifyProfileSettings([{
action: 'splice',
path: 'scanning.inputs',
start: insertionPosition,
deleteCount: 0,
items: [input],
}]);
} else {
// Modify existing
if (index < 0) { return; }
await this._modifyProfileSettings([{
action: 'splice',
path: 'scanning.inputs',
start: index,
deleteCount: 1,
items: [],
}]);
}
}
/**
* @param {string[]} value
*/
async _setMainScanInputs(value) {
const value2 = value.join(', ');
// Find target index
const options = await this._settingsController.getOptions();
const {scanning: {inputs}} = options;
const index = this._getIndexOfMainScanInput(inputs);
this._setHasMainScanInput(true);
if (index < 0) {
// Add new
const input = ScanInputsController.createDefaultMouseInput(value2, 'mouse0');
await this._modifyProfileSettings([{
action: 'splice',
path: 'scanning.inputs',
start: inputs.length,
deleteCount: 0,
items: [input],
}]);
} else {
// Modify existing
await this._modifyProfileSettings([{
action: 'set',
path: `scanning.inputs[${index}].include`,
value: value2,
}]);
}
}
/**
* @param {import('settings-modifications').Modification[]} targets
*/
async _modifyProfileSettings(targets) {
await this._settingsController.modifyProfileSettings(targets);
/** @type {import('settings-controller').EventArgument<'scanInputsChanged'>} */
const event = {source: this};
this._settingsController.trigger('scanInputsChanged', event);
}
/**
* @param {import('settings').ScanningInput[]} inputs
* @returns {number}
*/
_getIndexOfMainScanInput(inputs) {
for (let i = 0, ii = inputs.length; i < ii; ++i) {
const {include, exclude, types: {mouse}} = inputs[i];
if (!mouse) { continue; }
const includeValues = this._splitValue(include);
const excludeValues = this._splitValue(exclude);
if (
(
includeValues.length === 0 ||
(includeValues.length === 1 && !this._isMouseInput(includeValues[0]))
) &&
excludeValues.length === 1 &&
excludeValues[0] === 'mouse0'
) {
return i;
}
}
return -1;
}
/**
* @param {import('settings').ScanningInput[]} inputs
* @returns {number}
*/
_getIndexOfMiddleMouseButtonScanInput(inputs) {
for (let i = 0, ii = inputs.length; i < ii; ++i) {
const {include, exclude, types: {mouse}} = inputs[i];
if (!mouse) { continue; }
const includeValues = this._splitValue(include);
const excludeValues = this._splitValue(exclude);
if (
(includeValues.length === 1 && includeValues[0] === 'mouse2') &&
excludeValues.length === 0
) {
return i;
}
}
return -1;
}
/**
* @param {string} input
* @returns {boolean}
*/
_isMouseInput(input) {
return /^mouse\d+$/.test(input);
}
/**
* @param {boolean} hasMainScanInput
*/
_setHasMainScanInput(hasMainScanInput) {
if (this._mainScanModifierKeyInputHasOther !== hasMainScanInput) { return; }
this._mainScanModifierKeyInputHasOther = !hasMainScanInput;
if (this._mainScanModifierKeyInput !== null) {
this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther);
}
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class SecondarySearchDictionaryController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {?import('core').TokenObject} */
this._getDictionaryInfoToken = null;
/** @type {Map<string, import('dictionary-importer').Summary>} */
this._dictionaryInfoMap = new Map();
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {HTMLElement} */
this._container = querySelectorNotNull(document, '#secondary-search-dictionary-list');
}
/** */
async prepare() {
await this._onDatabaseUpdated();
this._settingsController.application.on('databaseUpdated', this._onDatabaseUpdated.bind(this));
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
this._settingsController.on('dictionarySettingsReordered', this._onDictionarySettingsReordered.bind(this));
}
// Private
/** */
async _onDatabaseUpdated() {
/** @type {?import('core').TokenObject} */
const token = {};
this._getDictionaryInfoToken = token;
const dictionaries = await this._settingsController.getDictionaryInfo();
if (this._getDictionaryInfoToken !== token) { return; }
this._getDictionaryInfoToken = null;
this._dictionaryInfoMap.clear();
for (const entry of dictionaries) {
this._dictionaryInfoMap.set(entry.title, entry);
}
await this._onDictionarySettingsReordered();
}
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
this._eventListeners.removeAllEventListeners();
const fragment = document.createDocumentFragment();
const {dictionaries} = options;
for (let i = 0, ii = dictionaries.length; i < ii; ++i) {
const {name} = dictionaries[i];
const dictionaryInfo = this._dictionaryInfoMap.get(name);
if (typeof dictionaryInfo === 'undefined') { continue; }
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('secondary-search-dictionary'));
fragment.appendChild(node);
/** @type {HTMLElement} */
const nameNode = querySelectorNotNull(node, '.dictionary-title');
nameNode.textContent = name;
/** @type {HTMLElement} */
const versionNode = querySelectorNotNull(node, '.dictionary-revision');
versionNode.textContent = `rev.${dictionaryInfo.revision}`;
/** @type {HTMLElement} */
const toggle = querySelectorNotNull(node, '.dictionary-allow-secondary-searches');
toggle.dataset.setting = `dictionaries[${i}].allowSecondarySearches`;
this._eventListeners.addEventListener(toggle, 'settingChanged', this._onEnabledChanged.bind(this, node), false);
}
const container = /** @type {HTMLElement} */ (this._container);
container.textContent = '';
container.appendChild(fragment);
}
/**
* @param {HTMLElement} node
* @param {import('dom-data-binder').SettingChangedEvent} e
*/
_onEnabledChanged(node, e) {
const {detail: {value}} = e;
node.dataset.enabled = `${value}`;
}
/** */
async _onDictionarySettingsReordered() {
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._onOptionsChanged({options, optionsContext});
}
}

View File

@@ -0,0 +1,328 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class SentenceTerminationCharactersController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {SentenceTerminationCharacterEntry[]} */
this._entries = [];
/** @type {HTMLButtonElement} */
this._addButton = querySelectorNotNull(document, '#sentence-termination-character-list-add');
/** @type {HTMLButtonElement} */
this._resetButton = querySelectorNotNull(document, '#sentence-termination-character-list-reset');
/** @type {HTMLElement} */
this._listTable = querySelectorNotNull(document, '#sentence-termination-character-list-table');
/** @type {HTMLElement} */
this._listContainer = querySelectorNotNull(document, '#sentence-termination-character-list');
/** @type {HTMLElement} */
this._emptyIndicator = querySelectorNotNull(document, '#sentence-termination-character-list-empty');
}
/** @type {import('./settings-controller.js').SettingsController} */
get settingsController() {
return this._settingsController;
}
/** */
async prepare() {
this._addButton.addEventListener('click', this._onAddClick.bind(this));
this._resetButton.addEventListener('click', this._onResetClick.bind(this));
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
await this._updateOptions();
}
/**
* @param {import('settings').SentenceParsingTerminationCharacterOption} terminationCharacterEntry
*/
async addEntry(terminationCharacterEntry) {
const options = await this._settingsController.getOptions();
const {sentenceParsing: {terminationCharacters}} = options;
await this._settingsController.modifyProfileSettings([{
action: 'splice',
path: 'sentenceParsing.terminationCharacters',
start: terminationCharacters.length,
deleteCount: 0,
items: [terminationCharacterEntry],
}]);
await this._updateOptions();
}
/**
* @param {number} index
* @returns {Promise<boolean>}
*/
async deleteEntry(index) {
const options = await this._settingsController.getOptions();
const {sentenceParsing: {terminationCharacters}} = options;
if (index < 0 || index >= terminationCharacters.length) { return false; }
await this._settingsController.modifyProfileSettings([{
action: 'splice',
path: 'sentenceParsing.terminationCharacters',
start: index,
deleteCount: 1,
items: [],
}]);
await this._updateOptions();
return true;
}
/**
* @param {import('settings-modifications').Modification[]} targets
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async modifyProfileSettings(targets) {
return await this._settingsController.modifyProfileSettings(targets);
}
// Private
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
for (const entry of this._entries) {
entry.cleanup();
}
this._entries = [];
const {sentenceParsing: {terminationCharacters}} = options;
const listContainer = /** @type {HTMLElement} */ (this._listContainer);
for (let i = 0, ii = terminationCharacters.length; i < ii; ++i) {
const terminationCharacterEntry = terminationCharacters[i];
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('sentence-termination-character-entry'));
listContainer.appendChild(node);
const entry = new SentenceTerminationCharacterEntry(this, terminationCharacterEntry, i, node);
this._entries.push(entry);
entry.prepare();
}
const empty = terminationCharacters.length === 0;
/** @type {HTMLElement} */ (this._listTable).hidden = empty;
/** @type {HTMLElement} */ (this._emptyIndicator).hidden = !empty;
}
/**
* @param {MouseEvent} e
*/
_onAddClick(e) {
e.preventDefault();
void this._addNewEntry();
}
/**
* @param {MouseEvent} e
*/
_onResetClick(e) {
e.preventDefault();
void this._reset();
}
/** */
async _addNewEntry() {
const newEntry = {
enabled: true,
character1: '"',
character2: '"',
includeCharacterAtStart: false,
includeCharacterAtEnd: false,
};
await this.addEntry(newEntry);
}
/** */
async _updateOptions() {
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._onOptionsChanged({options, optionsContext});
}
/** */
async _reset() {
const defaultOptions = await this._settingsController.getDefaultOptions();
const value = defaultOptions.profiles[0].options.sentenceParsing.terminationCharacters;
await this._settingsController.setProfileSetting('sentenceParsing.terminationCharacters', value);
await this._updateOptions();
}
}
class SentenceTerminationCharacterEntry {
/**
* @param {SentenceTerminationCharactersController} parent
* @param {import('settings').SentenceParsingTerminationCharacterOption} data
* @param {number} index
* @param {HTMLElement} node
*/
constructor(parent, data, index, node) {
/** @type {SentenceTerminationCharactersController} */
this._parent = parent;
/** @type {import('settings').SentenceParsingTerminationCharacterOption} */
this._data = data;
/** @type {number} */
this._index = index;
/** @type {HTMLElement} */
this._node = node;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {?HTMLInputElement} */
this._character1Input = null;
/** @type {?HTMLInputElement} */
this._character2Input = null;
/** @type {string} */
this._basePath = `sentenceParsing.terminationCharacters[${this._index}]`;
}
/** */
prepare() {
const {enabled, character1, character2, includeCharacterAtStart, includeCharacterAtEnd} = this._data;
const node = this._node;
/** @type {HTMLInputElement} */
const enabledToggle = querySelectorNotNull(node, '.sentence-termination-character-enabled');
/** @type {HTMLSelectElement} */
const typeSelect = querySelectorNotNull(node, '.sentence-termination-character-type');
/** @type {HTMLInputElement} */
const character1Input = querySelectorNotNull(node, '.sentence-termination-character-input1');
/** @type {HTMLInputElement} */
const character2Input = querySelectorNotNull(node, '.sentence-termination-character-input2');
/** @type {HTMLInputElement} */
const includeAtStartCheckbox = querySelectorNotNull(node, '.sentence-termination-character-include-at-start');
/** @type {HTMLInputElement} */
const includeAtEndheckbox = querySelectorNotNull(node, '.sentence-termination-character-include-at-end');
/** @type {HTMLButtonElement} */
const menuButton = querySelectorNotNull(node, '.sentence-termination-character-entry-button');
this._character1Input = character1Input;
this._character2Input = character2Input;
const type = (character2 === null ? 'terminator' : 'quote');
node.dataset.type = type;
enabledToggle.checked = enabled;
typeSelect.value = type;
character1Input.value = character1;
character2Input.value = (character2 !== null ? character2 : '');
includeAtStartCheckbox.checked = includeCharacterAtStart;
includeAtEndheckbox.checked = includeCharacterAtEnd;
enabledToggle.dataset.setting = `${this._basePath}.enabled`;
includeAtStartCheckbox.dataset.setting = `${this._basePath}.includeCharacterAtStart`;
includeAtEndheckbox.dataset.setting = `${this._basePath}.includeCharacterAtEnd`;
this._eventListeners.addEventListener(typeSelect, 'change', this._onTypeSelectChange.bind(this), false);
this._eventListeners.addEventListener(character1Input, 'change', this._onCharacterChange.bind(this, 1), false);
this._eventListeners.addEventListener(character2Input, 'change', this._onCharacterChange.bind(this, 2), false);
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
}
/** */
cleanup() {
this._eventListeners.removeAllEventListeners();
if (this._node.parentNode !== null) {
this._node.parentNode.removeChild(this._node);
}
}
// Private
/**
* @param {Event} e
*/
_onTypeSelectChange(e) {
const element = /** @type {HTMLSelectElement} */ (e.currentTarget);
void this._setHasCharacter2(element.value === 'quote');
}
/**
* @param {1|2} characterNumber
* @param {Event} e
*/
_onCharacterChange(characterNumber, e) {
const node = /** @type {HTMLInputElement} */ (e.currentTarget);
if (characterNumber === 2 && this._data.character2 === null) {
node.value = '';
}
const value = node.value.substring(0, 1);
void this._setCharacterValue(node, characterNumber, value);
}
/**
* @param {import('popup-menu').MenuCloseEvent} e
*/
_onMenuClose(e) {
switch (e.detail.action) {
case 'delete':
void this._delete();
break;
}
}
/** */
async _delete() {
void this._parent.deleteEntry(this._index);
}
/**
* @param {boolean} has
*/
async _setHasCharacter2(has) {
if (this._character2Input === null) { return; }
const okay = await this._setCharacterValue(this._character2Input, 2, has ? this._data.character1 : null);
if (okay) {
const type = (!has ? 'terminator' : 'quote');
this._node.dataset.type = type;
}
}
/**
* @param {HTMLInputElement} inputNode
* @param {1|2} characterNumber
* @param {?string} value
* @returns {Promise<boolean>}
*/
async _setCharacterValue(inputNode, characterNumber, value) {
if (characterNumber === 1 && typeof value !== 'string') { value = ''; }
const r = await this._parent.settingsController.setProfileSetting(`${this._basePath}.character${characterNumber}`, value);
const okay = !r[0].error;
if (okay) {
if (characterNumber === 1) {
this._data.character1 = /** @type {string} */ (value);
} else {
this._data.character2 = value;
}
} else {
value = characterNumber === 1 ? this._data.character1 : this._data.character2;
}
inputNode.value = (value !== null ? value : '');
return okay;
}
}

View File

@@ -0,0 +1,364 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventDispatcher} from '../../core/event-dispatcher.js';
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {isObjectNotArray} from '../../core/object-utilities.js';
import {generateId} from '../../core/utilities.js';
import {OptionsUtil} from '../../data/options-util.js';
import {getAllPermissions} from '../../data/permissions-util.js';
import {HtmlTemplateCollection} from '../../dom/html-template-collection.js';
/**
* @augments EventDispatcher<import('settings-controller').Events>
*/
export class SettingsController extends EventDispatcher {
/**
* @param {import('../../application.js').Application} application
*/
constructor(application) {
super();
/** @type {import('../../application.js').Application} */
this._application = application;
/** @type {number} */
this._profileIndex = 0;
/** @type {string} */
this._source = generateId(16);
/** @type {Set<import('settings-controller').PageExitPrevention>} */
this._pageExitPreventions = new Set();
/** @type {EventListenerCollection} */
this._pageExitPreventionEventListeners = new EventListenerCollection();
/** @type {HtmlTemplateCollection} */
this._templates = new HtmlTemplateCollection();
}
/** @type {import('../../application.js').Application} */
get application() {
return this._application;
}
/** @type {string} */
get source() {
return this._source;
}
/** @type {number} */
get profileIndex() {
return this._profileIndex;
}
set profileIndex(value) {
if (this._profileIndex === value) { return; }
this._setProfileIndex(value, true);
}
/** */
refreshProfileIndex() {
this._setProfileIndex(this._profileIndex, true);
}
/** @type {HtmlTemplateCollection} */
get templates() {
return this._templates;
}
/** */
async prepare() {
await this._templates.loadFromFiles(['/templates-settings.html']);
this._application.on('optionsUpdated', this._onOptionsUpdated.bind(this));
if (this._canObservePermissionsChanges()) {
chrome.permissions.onAdded.addListener(this._onPermissionsChanged.bind(this));
chrome.permissions.onRemoved.addListener(this._onPermissionsChanged.bind(this));
}
const optionsFull = await this.getOptionsFull();
const {profiles, profileCurrent} = optionsFull;
if (profileCurrent >= 0 && profileCurrent < profiles.length) {
this._profileIndex = profileCurrent;
}
}
/** */
async refresh() {
await this._onOptionsUpdatedInternal(true);
}
/**
* @returns {Promise<import('settings').ProfileOptions>}
*/
async getOptions() {
const optionsContext = this.getOptionsContext();
return await this._application.api.optionsGet(optionsContext);
}
/**
* @returns {Promise<import('settings').Options>}
*/
async getOptionsFull() {
return await this._application.api.optionsGetFull();
}
/**
* @param {import('settings').Options} value
*/
async setAllSettings(value) {
const profileIndex = value.profileCurrent;
await this._application.api.setAllSettings(value, this._source);
this._setProfileIndex(profileIndex, true);
}
/**
* @param {import('settings-modifications').ScopedRead[]} targets
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async getSettings(targets) {
return await this._getSettings(targets, null);
}
/**
* @param {import('settings-modifications').Read[]} targets
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async getGlobalSettings(targets) {
return await this._getSettings(targets, {scope: 'global', optionsContext: null});
}
/**
* @param {import('settings-modifications').Read[]} targets
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async getProfileSettings(targets) {
return await this._getSettings(targets, {scope: 'profile', optionsContext: null});
}
/**
* @param {import('settings-modifications').ScopedModification[]} targets
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async modifySettings(targets) {
return await this._modifySettings(targets, null);
}
/**
* @param {import('settings-modifications').Modification[]} targets
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async modifyGlobalSettings(targets) {
return await this._modifySettings(targets, {scope: 'global', optionsContext: null});
}
/**
* @param {import('settings-modifications').Modification[]} targets
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async modifyProfileSettings(targets) {
return await this._modifySettings(targets, {scope: 'profile', optionsContext: null});
}
/**
* @param {string} path
* @param {unknown} value
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async setGlobalSetting(path, value) {
return await this.modifyGlobalSettings([{action: 'set', path, value}]);
}
/**
* @param {string} path
* @param {unknown} value
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async setProfileSetting(path, value) {
return await this.modifyProfileSettings([{action: 'set', path, value}]);
}
/**
* @returns {Promise<import('dictionary-importer').Summary[]>}
*/
async getDictionaryInfo() {
return await this._application.api.getDictionaryInfo();
}
/**
* @returns {import('settings').OptionsContext}
*/
getOptionsContext() {
return {index: this._profileIndex};
}
/**
* @returns {import('settings-controller').PageExitPrevention}
*/
preventPageExit() {
/** @type {import('settings-controller').PageExitPrevention} */
// eslint-disable-next-line sonarjs/prefer-object-literal
const obj = {};
obj.end = this._endPreventPageExit.bind(this, obj);
if (this._pageExitPreventionEventListeners.size === 0) {
this._pageExitPreventionEventListeners.addEventListener(window, 'beforeunload', this._onBeforeUnload.bind(this), false);
}
this._pageExitPreventions.add(obj);
return obj;
}
/**
* @param {string} name
* @returns {Element}
*/
instantiateTemplate(name) {
return this._templates.instantiate(name);
}
/**
* @param {string} name
* @returns {DocumentFragment}
*/
instantiateTemplateFragment(name) {
return this._templates.instantiateFragment(name);
}
/**
* @returns {Promise<import('settings').Options>}
*/
async getDefaultOptions() {
const optionsUtil = new OptionsUtil();
await optionsUtil.prepare();
return optionsUtil.getDefault();
}
// Private
/**
* @param {number} value
* @param {boolean} canUpdateProfileIndex
*/
_setProfileIndex(value, canUpdateProfileIndex) {
this._profileIndex = value;
this.trigger('optionsContextChanged', {});
void this._onOptionsUpdatedInternal(canUpdateProfileIndex);
}
/**
* @param {{source: string}} details
*/
_onOptionsUpdated({source}) {
if (source === this._source) { return; }
void this._onOptionsUpdatedInternal(true);
}
/**
* @param {boolean} canUpdateProfileIndex
*/
async _onOptionsUpdatedInternal(canUpdateProfileIndex) {
const optionsContext = this.getOptionsContext();
try {
const options = await this.getOptions();
this.trigger('optionsChanged', {options, optionsContext});
} catch (e) {
if (canUpdateProfileIndex) {
this._setProfileIndex(0, false);
return;
}
throw e;
}
}
/**
* @param {import('settings-modifications').OptionsScope} target
*/
_modifyOptionsScope(target) {
if (target.scope === 'profile') {
target.optionsContext = this.getOptionsContext();
}
}
/**
* @template {boolean} THasScope
* @param {import('settings-controller').SettingsRead<THasScope>[]} targets
* @param {import('settings-controller').SettingsExtraFields<THasScope>} extraFields
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async _getSettings(targets, extraFields) {
const targets2 = targets.map((target) => {
const target2 = /** @type {import('settings-controller').SettingsRead<true>} */ (Object.assign({}, extraFields, target));
this._modifyOptionsScope(target2);
return target2;
});
return await this._application.api.getSettings(targets2);
}
/**
* @template {boolean} THasScope
* @param {import('settings-controller').SettingsModification<THasScope>[]} targets
* @param {import('settings-controller').SettingsExtraFields<THasScope>} extraFields
* @returns {Promise<import('settings-controller').ModifyResult[]>}
*/
async _modifySettings(targets, extraFields) {
const targets2 = targets.map((target) => {
const target2 = /** @type {import('settings-controller').SettingsModification<true>} */ (Object.assign({}, extraFields, target));
this._modifyOptionsScope(target2);
return target2;
});
return await this._application.api.modifySettings(targets2, this._source);
}
/**
* @param {BeforeUnloadEvent} e
* @returns {string|undefined}
*/
_onBeforeUnload(e) {
if (this._pageExitPreventions.size === 0) {
return;
}
e.preventDefault();
e.returnValue = '';
return '';
}
/**
* @param {import('settings-controller').PageExitPrevention} obj
*/
_endPreventPageExit(obj) {
this._pageExitPreventions.delete(obj);
if (this._pageExitPreventions.size === 0) {
this._pageExitPreventionEventListeners.removeAllEventListeners();
}
}
/** */
_onPermissionsChanged() {
void this._triggerPermissionsChanged();
}
/** */
async _triggerPermissionsChanged() {
const eventName = 'permissionsChanged';
if (!this.hasListeners(eventName)) { return; }
const permissions = await getAllPermissions();
this.trigger(eventName, {permissions});
}
/**
* @returns {boolean}
*/
_canObservePermissionsChanges() {
return isObjectNotArray(chrome.permissions) && isObjectNotArray(chrome.permissions.onAdded) && isObjectNotArray(chrome.permissions.onRemoved);
}
}

View File

@@ -0,0 +1,416 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ThemeController} from '../../app/theme-controller.js';
import {isInputElementFocused} from '../../dom/document-util.js';
import {PopupMenu} from '../../dom/popup-menu.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {SelectorObserver} from '../../dom/selector-observer.js';
export class SettingsDisplayController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
* @param {import('./modal-controller.js').ModalController} modalController
*/
constructor(settingsController, modalController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {import('./modal-controller.js').ModalController} */
this._modalController = modalController;
/** @type {HTMLElement} */
this._contentNode = querySelectorNotNull(document, '.content');
/** @type {HTMLElement} */
this._menuContainer = querySelectorNotNull(document, '#popup-menus');
/** @type {(event: MouseEvent) => void} */
this._onMoreToggleClickBind = this._onMoreToggleClick.bind(this);
/** @type {(event: MouseEvent) => void} */
this._onMenuButtonClickBind = this._onMenuButtonClick.bind(this);
/** @type {ThemeController} */
this._themeController = new ThemeController(document.documentElement);
/** @type {HTMLSelectElement | null}*/
this._themeDropdown = document.querySelector('[data-setting="general.popupTheme"]');
}
/** */
async prepare() {
this._themeController.prepare();
await this._setTheme();
const onFabButtonClick = this._onFabButtonClick.bind(this);
for (const fabButton of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.fab-button'))) {
fabButton.addEventListener('click', onFabButtonClick, false);
}
const onModalAction = this._onModalAction.bind(this);
for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-modal-action]'))) {
node.addEventListener('click', onModalAction, false);
}
const onSelectOnClickElementClick = this._onSelectOnClickElementClick.bind(this);
for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-select-on-click]'))) {
node.addEventListener('click', onSelectOnClickElementClick, false);
}
const onInputTabActionKeyDown = this._onInputTabActionKeyDown.bind(this);
for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-tab-action]'))) {
node.addEventListener('keydown', onInputTabActionKeyDown, false);
}
for (const node of /** @type {NodeListOf<HTMLIFrameElement>} */ (document.querySelectorAll('.defer-load-iframe'))) {
this._setupDeferLoadIframe(node);
}
const moreSelectorObserver = new SelectorObserver({
selector: '.more-toggle',
onAdded: this._onMoreSetup.bind(this),
onRemoved: this._onMoreCleanup.bind(this),
});
moreSelectorObserver.observe(document.documentElement, false);
const menuSelectorObserver = new SelectorObserver({
selector: '[data-menu]',
onAdded: this._onMenuSetup.bind(this),
onRemoved: this._onMenuCleanup.bind(this),
});
menuSelectorObserver.observe(document.documentElement, false);
window.addEventListener('keydown', this._onKeyDown.bind(this), false);
if (this._themeDropdown) {
this._themeDropdown.addEventListener('change', this._updateTheme.bind(this), false);
}
}
/** */
async _setTheme() {
this._themeController.theme = (await this._settingsController.getOptions()).general.popupTheme;
this._themeController.siteOverride = true;
this._themeController.updateTheme();
}
/** */
async _updateTheme() {
const theme = this._themeDropdown?.value;
if (theme === 'site' || theme === 'light' || theme === 'dark' || theme === 'browser') {
this._themeController.theme = theme;
}
this._themeController.siteOverride = true;
this._themeController.updateTheme();
}
// Private
/**
* @param {Element} element
* @returns {null}
*/
_onMoreSetup(element) {
/** @type {HTMLElement} */ (element).addEventListener('click', this._onMoreToggleClickBind, false);
return null;
}
/**
* @param {Element} element
*/
_onMoreCleanup(element) {
/** @type {HTMLElement} */ (element).removeEventListener('click', this._onMoreToggleClickBind, false);
}
/**
* @param {Element} element
* @returns {null}
*/
_onMenuSetup(element) {
/** @type {HTMLElement} */ (element).addEventListener('click', this._onMenuButtonClickBind, false);
return null;
}
/**
* @param {Element} element
*/
_onMenuCleanup(element) {
/** @type {HTMLElement} */ (element).removeEventListener('click', this._onMenuButtonClickBind, false);
}
/**
* @param {MouseEvent} e
*/
_onMenuButtonClick(e) {
const element = /** @type {HTMLElement} */ (e.currentTarget);
const {menu} = element.dataset;
if (typeof menu === 'undefined') { return; }
this._showMenu(element, menu);
}
/**
* @param {MouseEvent} e
*/
_onFabButtonClick(e) {
const element = /** @type {HTMLElement} */ (e.currentTarget);
const action = element.dataset.action;
switch (action) {
case 'toggle-sidebar':
document.body.classList.toggle('sidebar-visible');
break;
case 'toggle-preview-sidebar':
document.body.classList.toggle('preview-sidebar-visible');
break;
}
}
/**
* @param {MouseEvent} e
*/
_onMoreToggleClick(e) {
const node = /** @type {HTMLElement} */ (e.currentTarget);
const container = this._getMoreContainer(node);
if (container === null) { return; }
/** @type {?HTMLElement} */
const more = container.querySelector('.more');
if (more === null) { return; }
const moreVisible = more.hidden;
more.hidden = !moreVisible;
for (const moreToggle of /** @type {NodeListOf<HTMLElement>} */ (container.querySelectorAll('.more-toggle'))) {
const container2 = this._getMoreContainer(moreToggle);
if (container2 === null) { continue; }
const more2 = container2.querySelector('.more');
if (more2 === null || more2 !== more) { continue; }
moreToggle.dataset.expanded = `${moreVisible}`;
}
e.preventDefault();
}
/**
* @param {KeyboardEvent} e
*/
_onKeyDown(e) {
switch (e.code) {
case 'Escape':
if (!isInputElementFocused()) {
this._closeTopMenuOrModal();
e.preventDefault();
}
break;
}
}
/**
* @param {MouseEvent} e
*/
_onModalAction(e) {
const node = /** @type {HTMLElement} */ (e.currentTarget);
const {modalAction} = node.dataset;
if (typeof modalAction !== 'string') { return; }
const modalActionArray = modalAction.split(',');
const action = modalActionArray[0];
/** @type {string|Element|undefined} */
let target = modalActionArray[1];
if (typeof target === 'undefined') {
const currentModal = node.closest('.modal');
if (currentModal === null) { return; }
target = currentModal;
}
const modal = this._modalController.getModal(target);
if (modal === null) { return; }
switch (action) {
case 'show':
modal.setVisible(true);
break;
case 'hide':
modal.setVisible(false);
break;
case 'toggle':
modal.setVisible(!modal.isVisible());
break;
}
e.preventDefault();
}
/**
* @param {MouseEvent} e
*/
_onSelectOnClickElementClick(e) {
if (e.button !== 0) { return; }
const node = /** @type {HTMLElement} */ (e.currentTarget);
const range = document.createRange();
range.selectNode(node);
const selection = window.getSelection();
if (selection !== null) {
selection.removeAllRanges();
selection.addRange(range);
}
e.preventDefault();
e.stopPropagation();
}
/**
* @param {KeyboardEvent} e
*/
_onInputTabActionKeyDown(e) {
if (e.key !== 'Tab' || e.ctrlKey) { return; }
const node = /** @type {HTMLElement} */ (e.currentTarget);
const {tabAction} = node.dataset;
if (typeof tabAction !== 'string') { return; }
const args = tabAction.split(',');
switch (args[0]) {
case 'ignore':
e.preventDefault();
break;
case 'indent':
e.preventDefault();
this._indentInput(e, node, args);
break;
}
}
/**
* @param {HTMLElement} link
* @returns {?Element}
*/
_getMoreContainer(link) {
const v = link.dataset.parentDistance;
const distance = v ? Number.parseInt(v, 10) : 1;
if (Number.isNaN(distance)) { return null; }
/** @type {?Element} */
let result = link;
for (let i = 0; i < distance; ++i) {
if (result === null) { break; }
result = /** @type {?Element} */ (result.parentNode);
}
return result;
}
/** */
_closeTopMenuOrModal() {
for (const popupMenu of PopupMenu.openMenus) {
popupMenu.close();
return;
}
const modal = this._modalController.getTopVisibleModal();
if (modal !== null && !modal.forceInteract) {
modal.setVisible(false);
}
}
/**
* @param {HTMLElement} element
* @param {string} menuName
*/
_showMenu(element, menuName) {
const menu = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate(menuName));
/** @type {HTMLElement} */ (this._menuContainer).appendChild(menu);
const popupMenu = new PopupMenu(element, menu);
popupMenu.prepare();
}
/**
* @param {KeyboardEvent} e
* @param {HTMLElement} node
* @param {string[]} args
*/
_indentInput(e, node, args) {
if (!(node instanceof HTMLTextAreaElement)) { return; }
let indent = '\t';
if (args.length > 1) {
const count = Number.parseInt(args[1], 10);
indent = (Number.isFinite(count) && count >= 0 ? ' '.repeat(count) : args[1]);
}
const {selectionStart: start, selectionEnd: end, value} = node;
const lineStart = value.substring(0, start).lastIndexOf('\n') + 1;
const lineWhitespaceMatch = /^[ \t]*/.exec(value.substring(lineStart));
const lineWhitespace = lineWhitespaceMatch !== null ? lineWhitespaceMatch[0] : '';
if (e.shiftKey) {
const whitespaceLength = Math.max(0, Math.floor((lineWhitespace.length - 1) / 4) * 4);
const selectionStartNew = lineStart + whitespaceLength;
const selectionEndNew = lineStart + lineWhitespace.length;
const removeCount = selectionEndNew - selectionStartNew;
if (removeCount > 0) {
node.selectionStart = selectionStartNew;
node.selectionEnd = selectionEndNew;
document.execCommand('delete', false);
node.selectionStart = Math.max(lineStart, start - removeCount);
node.selectionEnd = Math.max(lineStart, end - removeCount);
}
} else {
if (indent.length > 0) {
const indentLength = (Math.ceil((start - lineStart + 1) / indent.length) * indent.length - (start - lineStart));
document.execCommand('insertText', false, indent.substring(0, indentLength));
}
}
}
/**
* @param {HTMLIFrameElement} element
*/
_setupDeferLoadIframe(element) {
const parent = this._getMoreContainer(element);
if (parent === null) { return; }
/** @type {?MutationObserver} */
let mutationObserver = null;
const callback = () => {
if (!this._isElementVisible(element)) { return false; }
const src = element.dataset.src;
delete element.dataset.src;
if (typeof src === 'string') {
element.src = src;
}
if (mutationObserver === null) { return true; }
mutationObserver.disconnect();
mutationObserver = null;
return true;
};
if (callback()) { return; }
mutationObserver = new MutationObserver(callback);
mutationObserver.observe(parent, {attributes: true});
}
/**
* @param {HTMLElement} element
* @returns {boolean}
*/
_isElementVisible(element) {
return (element.offsetParent !== null);
}
}

View File

@@ -0,0 +1,188 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Application} from '../../application.js';
import {DocumentFocusController} from '../../dom/document-focus-controller.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {ExtensionContentController} from '../common/extension-content-controller.js';
import {AnkiController} from './anki-controller.js';
import {AnkiDeckGeneratorController} from './anki-deck-generator-controller.js';
import {AnkiTemplatesController} from './anki-templates-controller.js';
import {AudioController} from './audio-controller.js';
import {BackupController} from './backup-controller.js';
import {CollapsibleDictionaryController} from './collapsible-dictionary-controller.js';
import {DictionaryController} from './dictionary-controller.js';
import {DictionaryImportController} from './dictionary-import-controller.js';
import {ExtensionKeyboardShortcutController} from './extension-keyboard-shortcuts-controller.js';
import {GenericSettingController} from './generic-setting-controller.js';
import {KeyboardShortcutController} from './keyboard-shortcuts-controller.js';
import {LanguagesController} from './languages-controller.js';
import {MecabController} from './mecab-controller.js';
import {ModalController} from './modal-controller.js';
import {NestedPopupsController} from './nested-popups-controller.js';
import {PermissionsToggleController} from './permissions-toggle-controller.js';
import {PersistentStorageController} from './persistent-storage-controller.js';
import {PopupPreviewController} from './popup-preview-controller.js';
import {PopupWindowController} from './popup-window-controller.js';
import {ProfileController} from './profile-controller.js';
import {RecommendedSettingsController} from './recommended-settings-controller.js';
import {ScanInputsController} from './scan-inputs-controller.js';
import {ScanInputsSimpleController} from './scan-inputs-simple-controller.js';
import {SecondarySearchDictionaryController} from './secondary-search-dictionary-controller.js';
import {SentenceTerminationCharactersController} from './sentence-termination-characters-controller.js';
import {SettingsController} from './settings-controller.js';
import {SettingsDisplayController} from './settings-display-controller.js';
import {SortFrequencyDictionaryController} from './sort-frequency-dictionary-controller.js';
import {StatusFooter} from './status-footer.js';
import {StorageController} from './storage-controller.js';
import {TranslationTextReplacementsController} from './translation-text-replacements-controller.js';
import {YomitanApiController} from './yomitan-api-controller.js';
/**
* @param {GenericSettingController} genericSettingController
*/
async function setupGenericSettingController(genericSettingController) {
await genericSettingController.prepare();
await genericSettingController.refresh();
}
await Application.main(true, async (application) => {
const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
const extensionContentController = new ExtensionContentController();
extensionContentController.prepare();
/** @type {HTMLElement} */
const statusFooterElement = querySelectorNotNull(document, '.status-footer-container');
const statusFooter = new StatusFooter(statusFooterElement);
statusFooter.prepare();
/** @type {?number} */
let prepareTimer = window.setTimeout(() => {
prepareTimer = null;
document.documentElement.dataset.loadingStalled = 'true';
}, 1000);
if (prepareTimer !== null) {
clearTimeout(prepareTimer);
prepareTimer = null;
}
delete document.documentElement.dataset.loadingStalled;
const preparePromises = [];
const modalController = new ModalController(['shared-modals', 'settings-modals']);
await modalController.prepare();
const settingsController = new SettingsController(application);
await settingsController.prepare();
const settingsDisplayController = new SettingsDisplayController(settingsController, modalController);
await settingsDisplayController.prepare();
document.body.hidden = false;
const popupPreviewController = new PopupPreviewController(settingsController);
popupPreviewController.prepare();
const persistentStorageController = new PersistentStorageController(application);
preparePromises.push(persistentStorageController.prepare());
const storageController = new StorageController(persistentStorageController);
storageController.prepare();
const dictionaryController = new DictionaryController(settingsController, modalController, statusFooter);
preparePromises.push(dictionaryController.prepare());
const dictionaryImportController = new DictionaryImportController(settingsController, modalController, statusFooter);
dictionaryImportController.prepare();
const genericSettingController = new GenericSettingController(settingsController);
preparePromises.push(setupGenericSettingController(genericSettingController));
const audioController = new AudioController(settingsController, modalController);
preparePromises.push(audioController.prepare());
const profileController = new ProfileController(settingsController, modalController);
preparePromises.push(profileController.prepare());
const settingsBackup = new BackupController(settingsController, modalController);
preparePromises.push(settingsBackup.prepare());
const ankiController = new AnkiController(settingsController, application, modalController);
preparePromises.push(ankiController.prepare());
const ankiDeckGeneratorController = new AnkiDeckGeneratorController(application, settingsController, modalController, ankiController);
preparePromises.push(ankiDeckGeneratorController.prepare());
const ankiTemplatesController = new AnkiTemplatesController(application, settingsController, modalController, ankiController);
preparePromises.push(ankiTemplatesController.prepare());
const scanInputsController = new ScanInputsController(settingsController);
preparePromises.push(scanInputsController.prepare());
const simpleScanningInputController = new ScanInputsSimpleController(settingsController);
preparePromises.push(simpleScanningInputController.prepare());
const nestedPopupsController = new NestedPopupsController(settingsController);
preparePromises.push(nestedPopupsController.prepare());
const permissionsToggleController = new PermissionsToggleController(settingsController);
preparePromises.push(permissionsToggleController.prepare());
const secondarySearchDictionaryController = new SecondarySearchDictionaryController(settingsController);
preparePromises.push(secondarySearchDictionaryController.prepare());
const languagesController = new LanguagesController(settingsController);
preparePromises.push(languagesController.prepare());
const translationTextReplacementsController = new TranslationTextReplacementsController(settingsController);
preparePromises.push(translationTextReplacementsController.prepare());
const sentenceTerminationCharactersController = new SentenceTerminationCharactersController(settingsController);
preparePromises.push(sentenceTerminationCharactersController.prepare());
const keyboardShortcutController = new KeyboardShortcutController(settingsController);
preparePromises.push(keyboardShortcutController.prepare());
const extensionKeyboardShortcutController = new ExtensionKeyboardShortcutController(settingsController);
preparePromises.push(extensionKeyboardShortcutController.prepare());
const popupWindowController = new PopupWindowController(application.api);
popupWindowController.prepare();
const mecabController = new MecabController(application.api);
mecabController.prepare();
const yomitanApiController = new YomitanApiController(application.api);
yomitanApiController.prepare();
const collapsibleDictionaryController = new CollapsibleDictionaryController(settingsController);
preparePromises.push(collapsibleDictionaryController.prepare());
const sortFrequencyDictionaryController = new SortFrequencyDictionaryController(settingsController);
preparePromises.push(sortFrequencyDictionaryController.prepare());
const recommendedSettingsController = new RecommendedSettingsController(settingsController);
preparePromises.push(recommendedSettingsController.prepare());
await Promise.all(preparePromises);
document.documentElement.dataset.loaded = 'true';
});

View File

@@ -0,0 +1,238 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class SortFrequencyDictionaryController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {HTMLSelectElement} */
this._sortFrequencyDictionarySelect = querySelectorNotNull(document, '#sort-frequency-dictionary');
/** @type {HTMLSelectElement} */
this._sortFrequencyDictionaryOrderSelect = querySelectorNotNull(document, '#sort-frequency-dictionary-order');
/** @type {HTMLButtonElement} */
this._sortFrequencyDictionaryOrderAutoButton = querySelectorNotNull(document, '#sort-frequency-dictionary-order-auto');
/** @type {HTMLElement} */
this._sortFrequencyDictionaryOrderContainerNode = querySelectorNotNull(document, '#sort-frequency-dictionary-order-container');
/** @type {?import('core').TokenObject} */
this._getDictionaryInfoToken = null;
}
/** */
async prepare() {
await this._onDatabaseUpdated();
this._settingsController.application.on('databaseUpdated', this._onDatabaseUpdated.bind(this));
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
this._sortFrequencyDictionarySelect.addEventListener('change', this._onSortFrequencyDictionarySelectChange.bind(this));
this._sortFrequencyDictionaryOrderSelect.addEventListener('change', this._onSortFrequencyDictionaryOrderSelectChange.bind(this));
this._sortFrequencyDictionaryOrderAutoButton.addEventListener('click', this._onSortFrequencyDictionaryOrderAutoButtonClick.bind(this));
}
// Private
/** */
async _onDatabaseUpdated() {
/** @type {?import('core').TokenObject} */
const token = {};
this._getDictionaryInfoToken = token;
const dictionaries = await this._settingsController.getDictionaryInfo();
if (this._getDictionaryInfoToken !== token) { return; }
this._getDictionaryInfoToken = null;
this._updateDictionaryOptions(dictionaries);
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._onOptionsChanged({options, optionsContext});
}
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
const {sortFrequencyDictionary, sortFrequencyDictionaryOrder} = options.general;
/** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect).value = (sortFrequencyDictionary !== null ? sortFrequencyDictionary : '');
/** @type {HTMLSelectElement} */ (this._sortFrequencyDictionaryOrderSelect).value = sortFrequencyDictionaryOrder;
/** @type {HTMLElement} */ (this._sortFrequencyDictionaryOrderContainerNode).hidden = (sortFrequencyDictionary === null);
}
/** */
_onSortFrequencyDictionarySelectChange() {
const {value} = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect);
void this._setSortFrequencyDictionaryValue(value !== '' ? value : null);
}
/** */
_onSortFrequencyDictionaryOrderSelectChange() {
const {value} = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionaryOrderSelect);
const value2 = this._normalizeSortFrequencyDictionaryOrder(value);
if (value2 === null) { return; }
void this._setSortFrequencyDictionaryOrderValue(value2);
}
/** */
_onSortFrequencyDictionaryOrderAutoButtonClick() {
const {value} = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect);
if (value === '') { return; }
void this._autoUpdateOrder(value);
}
/**
* @param {import('dictionary-importer').Summary[]} dictionaries
*/
_updateDictionaryOptions(dictionaries) {
const fragment = document.createDocumentFragment();
let option = document.createElement('option');
option.value = '';
option.textContent = 'None';
fragment.appendChild(option);
for (const {title, counts} of dictionaries) {
if (counts && counts.termMeta && counts.termMeta.freq > 0) {
option = document.createElement('option');
option.value = title;
option.textContent = title;
fragment.appendChild(option);
}
}
const select = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect);
select.textContent = '';
select.appendChild(fragment);
}
/**
* @param {?string} value
*/
async _setSortFrequencyDictionaryValue(value) {
/** @type {HTMLElement} */ (this._sortFrequencyDictionaryOrderContainerNode).hidden = (value === null);
await this._settingsController.setProfileSetting('general.sortFrequencyDictionary', value);
if (value !== null) {
await this._autoUpdateOrder(value);
}
}
/**
* @param {import('settings').SortFrequencyDictionaryOrder} value
*/
async _setSortFrequencyDictionaryOrderValue(value) {
await this._settingsController.setProfileSetting('general.sortFrequencyDictionaryOrder', value);
}
/**
* @param {string} dictionary
*/
async _autoUpdateOrder(dictionary) {
const order = await this._getFrequencyOrder(dictionary);
if (order === 0) { return; }
const value = (order > 0 ? 'descending' : 'ascending');
/** @type {HTMLSelectElement} */ (this._sortFrequencyDictionaryOrderSelect).value = value;
await this._setSortFrequencyDictionaryOrderValue(value);
}
/**
* @param {string} dictionary
* @returns {Promise<number>}
*/
async _getFrequencyOrder(dictionary) {
const dictionaryInfo = await this._settingsController.application.api.getDictionaryInfo();
const dictionaryLang = dictionaryInfo.find(({title}) => title === dictionary)?.sourceLanguage ?? '';
/** @type {Record<string, string[]>} */
const moreCommonTerms = {
ja: ['来る', '言う', '出る', '入る', '方', '男', '女', '今', '何', '時'],
};
/** @type {Record<string, string[]>} */
const lessCommonTerms = {
ja: ['行なう', '論じる', '過す', '行方', '人口', '猫', '犬', '滝', '理', '暁'],
};
let langMoreCommonTerms = moreCommonTerms[dictionaryLang];
let langLessCommonTerms = lessCommonTerms[dictionaryLang];
if (dictionaryLang === '') {
langMoreCommonTerms = [];
for (const key in moreCommonTerms) {
if (Object.hasOwn(moreCommonTerms, key)) {
langMoreCommonTerms.push(...moreCommonTerms[key]);
}
}
langLessCommonTerms = [];
for (const key in lessCommonTerms) {
if (Object.hasOwn(lessCommonTerms, key)) {
langLessCommonTerms.push(...lessCommonTerms[key]);
}
}
}
const terms = [...langMoreCommonTerms, ...langLessCommonTerms];
const frequencies = await this._settingsController.application.api.getTermFrequencies(
terms.map((term) => ({term, reading: null})),
[dictionary],
);
/** @type {Map<string, {hasValue: boolean, minValue: number, maxValue: number}>} */
const termDetails = new Map();
const moreCommonTermDetails = [];
const lessCommonTermDetails = [];
for (const term of langMoreCommonTerms) {
const details = {hasValue: false, minValue: Number.MAX_SAFE_INTEGER, maxValue: Number.MIN_SAFE_INTEGER};
termDetails.set(term, details);
moreCommonTermDetails.push(details);
}
for (const term of langLessCommonTerms) {
const details = {hasValue: false, minValue: Number.MAX_SAFE_INTEGER, maxValue: Number.MIN_SAFE_INTEGER};
termDetails.set(term, details);
lessCommonTermDetails.push(details);
}
for (const {term, frequency} of frequencies) {
const details = termDetails.get(term);
if (typeof details === 'undefined') { continue; }
details.minValue = Math.min(details.minValue, frequency);
details.maxValue = Math.max(details.maxValue, frequency);
details.hasValue = true;
}
let result = 0;
for (const details1 of moreCommonTermDetails) {
if (!details1.hasValue) { continue; }
for (const details2 of lessCommonTermDetails) {
if (!details2.hasValue) { continue; }
result += Math.sign(details1.maxValue - details2.minValue) + Math.sign(details1.minValue - details2.maxValue);
}
}
return Math.sign(result);
}
/**
* @param {string} value
* @returns {?import('settings').SortFrequencyDictionaryOrder}
*/
_normalizeSortFrequencyDictionaryOrder(value) {
switch (value) {
case 'ascending':
case 'descending':
return value;
default:
return null;
}
}
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2020-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {PanelElement} from '../../dom/panel-element.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class StatusFooter extends PanelElement {
/**
* @param {HTMLElement} node
*/
constructor(node) {
super(node, 375); // Milliseconds; includes buffer
/** @type {HTMLElement} */
this._body = querySelectorNotNull(node, '.status-footer');
}
/** */
prepare() {
/** @type {HTMLElement} */
const closeButton = querySelectorNotNull(this._body, '.status-footer-header-close');
this.on('closeCompleted', this._onCloseCompleted.bind(this));
closeButton.addEventListener('click', this._onCloseClick.bind(this), false);
}
/**
* @param {string} selector
* @returns {?HTMLElement}
*/
getTaskContainer(selector) {
return this._body.querySelector(selector);
}
/**
* @param {string} selector
* @returns {boolean}
*/
isTaskActive(selector) {
const target = this.getTaskContainer(selector);
return (target !== null && !!target.dataset.active);
}
/**
* @param {string} selector
* @param {boolean} active
*/
setTaskActive(selector, active) {
const target = this.getTaskContainer(selector);
if (target === null) { return; }
const activeElements = new Set();
for (const element of /** @type {NodeListOf<HTMLElement>} */ (this._body.querySelectorAll('.status-footer-item'))) {
if (element.dataset.active) {
activeElements.add(element);
}
}
if (active) {
target.dataset.active = 'true';
if (!this.isVisible()) {
this.setVisible(true);
}
target.hidden = false;
} else {
delete target.dataset.active;
if (activeElements.size <= 1) {
this.setVisible(false);
}
}
}
// Private
/**
* @param {MouseEvent} e
*/
_onCloseClick(e) {
e.preventDefault();
this.setVisible(false);
}
/** */
_onCloseCompleted() {
for (const element of /** @type {NodeListOf<HTMLElement>} */ (this._body.querySelectorAll('.status-footer-item'))) {
if (!element.dataset.active) {
element.hidden = true;
}
}
}
}

View File

@@ -0,0 +1,169 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2019-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class StorageController {
/**
* @param {import('./persistent-storage-controller.js').PersistentStorageController} persistentStorageController
*/
constructor(persistentStorageController) {
/** @type {import('./persistent-storage-controller.js').PersistentStorageController} */
this._persistentStorageController = persistentStorageController;
/** @type {?StorageEstimate} */
this._mostRecentStorageEstimate = null;
/** @type {boolean} */
this._storageEstimateFailed = false;
/** @type {boolean} */
this._isUpdating = false;
/** @type {?NodeListOf<HTMLElement>} */
this._storageUsageNodes = null;
/** @type {?NodeListOf<HTMLElement>} */
this._storageQuotaNodes = null;
/** @type {?NodeListOf<HTMLElement>} */
this._storageUseFiniteNodes = null;
/** @type {?NodeListOf<HTMLElement>} */
this._storageUseExhaustWarnNodes = null;
/** @type {?NodeListOf<HTMLElement>} */
this._storageUseInfiniteNodes = null;
/** @type {?NodeListOf<HTMLElement>} */
this._storageUseValidNodes = null;
/** @type {?NodeListOf<HTMLElement>} */
this._storageUseInvalidNodes = null;
}
/** */
prepare() {
this._storageUsageNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-usage'));
this._storageQuotaNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-quota'));
this._storageUseFiniteNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-finite'));
this._storageUseExhaustWarnNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-exhaustion-alert'));
this._storageUseInfiniteNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-infinite'));
this._storageUseValidNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-valid'));
this._storageUseInvalidNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-invalid'));
/** @type {HTMLButtonElement} */
const storageRefreshButton = querySelectorNotNull(document, '#storage-refresh');
storageRefreshButton.addEventListener('click', this._onStorageRefreshButtonClick.bind(this), false);
this._persistentStorageController.application.on('storageChanged', this._onStorageChanged.bind(this));
void this._updateStats();
}
// Private
/** */
_onStorageRefreshButtonClick() {
void this._updateStats();
}
/** */
_onStorageChanged() {
void this._updateStats();
}
/** */
async _updateStats() {
if (this._isUpdating) { return; }
try {
this._isUpdating = true;
const estimate = await this._storageEstimate();
const valid = (estimate !== null);
let storageIsLow = false;
// Firefox reports usage as 0 when persistent storage is enabled.
const finite = valid && ((typeof estimate.usage === 'number' && estimate.usage > 0) || !(await this._persistentStorageController.isStoragePeristent()));
if (finite) {
let {usage, quota} = estimate;
if (typeof usage !== 'number') { usage = 0; }
if (typeof quota !== 'number') {
quota = 0;
} else {
storageIsLow = quota <= (3 * 1000000000);
}
const usageString = this._bytesToLabeledString(usage);
const quotaString = this._bytesToLabeledString(quota);
for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._storageUsageNodes)) {
node.textContent = usageString;
}
for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._storageQuotaNodes)) {
node.textContent = quotaString;
}
}
this._setElementsVisible(this._storageUseFiniteNodes, valid && finite);
this._setElementsVisible(this._storageUseInfiniteNodes, valid && !finite);
this._setElementsVisible(this._storageUseValidNodes, valid);
this._setElementsVisible(this._storageUseInvalidNodes, !valid);
this._setElementsVisible(this._storageUseExhaustWarnNodes, storageIsLow);
} finally {
this._isUpdating = false;
}
}
// Private
/**
* @returns {Promise<?StorageEstimate>}
*/
async _storageEstimate() {
if (this._storageEstimateFailed && this._mostRecentStorageEstimate === null) {
return null;
}
try {
const value = await navigator.storage.estimate();
this._mostRecentStorageEstimate = value;
return value;
} catch (e) {
this._storageEstimateFailed = true;
}
return null;
}
/**
* @param {number} size
* @returns {string}
*/
_bytesToLabeledString(size) {
const base = 1000;
const labels = [' bytes', 'KB', 'MB', 'GB', 'TB'];
const maxLabelIndex = labels.length - 1;
let labelIndex = 0;
while (size >= base && labelIndex < maxLabelIndex) {
size /= base;
++labelIndex;
}
const label = labelIndex === 0 ? `${size}` : size.toFixed(1);
return `${label}${labels[labelIndex]}`;
}
/**
* @param {?NodeListOf<HTMLElement>} elements
* @param {boolean} visible
*/
_setElementsVisible(elements, visible) {
if (elements === null) { return; }
visible = !visible;
for (const element of elements) {
element.hidden = visible;
}
}
}

View File

@@ -0,0 +1,324 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class TranslationTextReplacementsController {
/**
* @param {import('./settings-controller.js').SettingsController} settingsController
*/
constructor(settingsController) {
/** @type {import('./settings-controller.js').SettingsController} */
this._settingsController = settingsController;
/** @type {HTMLElement} */
this._entryContainer = querySelectorNotNull(document, '#translation-text-replacement-list');
/** @type {TranslationTextReplacementsEntry[]} */
this._entries = [];
}
/** */
async prepare() {
/** @type {HTMLButtonElement} */
const addButton = querySelectorNotNull(document, '#translation-text-replacement-add');
addButton.addEventListener('click', this._onAdd.bind(this), false);
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
await this._updateOptions();
}
/** */
async addGroup() {
const options = await this._settingsController.getOptions();
const {groups} = options.translation.textReplacements;
const newEntry = this._createNewEntry();
/** @type {import('settings-modifications').Modification} */
const target = (
(groups.length === 0) ?
{
action: 'splice',
path: 'translation.textReplacements.groups',
start: 0,
deleteCount: 0,
items: [[newEntry]],
} :
{
action: 'splice',
path: 'translation.textReplacements.groups[0]',
start: groups[0].length,
deleteCount: 0,
items: [newEntry],
}
);
await this._settingsController.modifyProfileSettings([target]);
await this._updateOptions();
}
/**
* @param {number} index
* @returns {Promise<boolean>}
*/
async deleteGroup(index) {
const options = await this._settingsController.getOptions();
const {groups} = options.translation.textReplacements;
if (groups.length === 0) { return false; }
const group0 = groups[0];
if (index < 0 || index >= group0.length) { return false; }
/** @type {import('settings-modifications').Modification} */
const target = (
(group0.length > 1) ?
{
action: 'splice',
path: 'translation.textReplacements.groups[0]',
start: index,
deleteCount: 1,
items: [],
} :
{
action: 'splice',
path: 'translation.textReplacements.groups',
start: 0,
deleteCount: group0.length,
items: [],
}
);
await this._settingsController.modifyProfileSettings([target]);
await this._updateOptions();
return true;
}
// Private
/**
* @param {import('settings-controller').EventArgument<'optionsChanged'>} details
*/
_onOptionsChanged({options}) {
for (const entry of this._entries) {
entry.cleanup();
}
this._entries = [];
const {groups} = options.translation.textReplacements;
if (groups.length > 0) {
const group0 = groups[0];
for (let i = 0, ii = group0.length; i < ii; ++i) {
const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('translation-text-replacement-entry'));
/** @type {HTMLElement} */ (this._entryContainer).appendChild(node);
const entry = new TranslationTextReplacementsEntry(this, node, i);
this._entries.push(entry);
entry.prepare();
}
}
}
/** */
_onAdd() {
void this.addGroup();
}
/** */
async _updateOptions() {
const options = await this._settingsController.getOptions();
const optionsContext = this._settingsController.getOptionsContext();
this._onOptionsChanged({options, optionsContext});
}
/**
* @returns {import('settings').TranslationTextReplacementGroup}
*/
_createNewEntry() {
return {pattern: '', ignoreCase: false, replacement: ''};
}
}
class TranslationTextReplacementsEntry {
/**
* @param {TranslationTextReplacementsController} parent
* @param {HTMLElement} node
* @param {number} index
*/
constructor(parent, node, index) {
/** @type {TranslationTextReplacementsController} */
this._parent = parent;
/** @type {HTMLElement} */
this._node = node;
/** @type {number} */
this._index = index;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {?HTMLInputElement} */
this._patternInput = null;
/** @type {?HTMLInputElement} */
this._replacementInput = null;
/** @type {?HTMLInputElement} */
this._ignoreCaseToggle = null;
/** @type {?HTMLInputElement} */
this._testInput = null;
/** @type {?HTMLInputElement} */
this._testOutput = null;
}
/** */
prepare() {
/** @type {HTMLInputElement} */
const patternInput = querySelectorNotNull(this._node, '.translation-text-replacement-pattern');
/** @type {HTMLInputElement} */
const replacementInput = querySelectorNotNull(this._node, '.translation-text-replacement-replacement');
/** @type {HTMLInputElement} */
const ignoreCaseToggle = querySelectorNotNull(this._node, '.translation-text-replacement-pattern-ignore-case');
/** @type {HTMLInputElement} */
const menuButton = querySelectorNotNull(this._node, '.translation-text-replacement-button');
/** @type {HTMLInputElement} */
const testInput = querySelectorNotNull(this._node, '.translation-text-replacement-test-input');
/** @type {HTMLInputElement} */
const testOutput = querySelectorNotNull(this._node, '.translation-text-replacement-test-output');
this._patternInput = patternInput;
this._replacementInput = replacementInput;
this._ignoreCaseToggle = ignoreCaseToggle;
this._testInput = testInput;
this._testOutput = testOutput;
const pathBase = `translation.textReplacements.groups[0][${this._index}]`;
patternInput.dataset.setting = `${pathBase}.pattern`;
replacementInput.dataset.setting = `${pathBase}.replacement`;
ignoreCaseToggle.dataset.setting = `${pathBase}.ignoreCase`;
this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false);
this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false);
this._eventListeners.addEventListener(patternInput, 'settingChanged', this._onPatternChanged.bind(this), false);
this._eventListeners.addEventListener(ignoreCaseToggle, 'settingChanged', this._updateTestInput.bind(this), false);
this._eventListeners.addEventListener(replacementInput, 'settingChanged', this._updateTestInput.bind(this), false);
this._eventListeners.addEventListener(testInput, 'input', this._updateTestInput.bind(this), false);
}
/** */
cleanup() {
this._eventListeners.removeAllEventListeners();
if (this._node.parentNode !== null) {
this._node.parentNode.removeChild(this._node);
}
}
// Private
/**
* @param {import('popup-menu').MenuOpenEvent} e
*/
_onMenuOpen(e) {
const bodyNode = e.detail.menu.bodyNode;
const testVisible = this._isTestVisible();
/** @type {HTMLElement} */
const element1 = querySelectorNotNull(bodyNode, '[data-menu-action=showTest]');
/** @type {HTMLElement} */
const element2 = querySelectorNotNull(bodyNode, '[data-menu-action=hideTest]');
element1.hidden = testVisible;
element2.hidden = !testVisible;
}
/**
* @param {import('popup-menu').MenuCloseEvent} e
*/
_onMenuClose(e) {
switch (e.detail.action) {
case 'remove':
void this._parent.deleteGroup(this._index);
break;
case 'showTest':
this._setTestVisible(true);
break;
case 'hideTest':
this._setTestVisible(false);
break;
}
}
/**
* @param {import('dom-data-binder').SettingChangedEvent} deatils
*/
_onPatternChanged({detail: {value}}) {
this._validatePattern(value);
this._updateTestInput();
}
/**
* @param {unknown} value
*/
_validatePattern(value) {
let okay = false;
try {
if (typeof value === 'string') {
// eslint-disable-next-line no-new
new RegExp(value, 'g');
okay = true;
}
} catch (e) {
// NOP
}
if (this._patternInput !== null) {
this._patternInput.dataset.invalid = `${!okay}`;
}
}
/**
* @returns {boolean}
*/
_isTestVisible() {
return this._node.dataset.testVisible === 'true';
}
/**
* @param {boolean} visible
*/
_setTestVisible(visible) {
this._node.dataset.testVisible = `${visible}`;
this._updateTestInput();
}
/** */
_updateTestInput() {
if (
!this._isTestVisible() ||
this._ignoreCaseToggle === null ||
this._patternInput === null ||
this._replacementInput === null ||
this._testInput === null ||
this._testOutput === null
) { return; }
const ignoreCase = this._ignoreCaseToggle.checked;
const pattern = this._patternInput.value;
let regex;
try {
regex = new RegExp(pattern, ignoreCase ? 'gi' : 'g');
} catch (e) {
return;
}
const replacement = this._replacementInput.value;
const input = this._testInput.value;
const output = input.replace(regex, replacement);
this._testOutput.value = output;
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright (C) 2025 Yomitan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {toError} from '../../core/to-error.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
export class YomitanApiController {
/**
* @param {import('../../comm/api.js').API} api
*/
constructor(api) {
/** @type {import('../../comm/api.js').API} */
this._api = api;
/** @type {HTMLButtonElement} */
this._testButton = querySelectorNotNull(document, '#test-yomitan-api-button');
/** @type {HTMLElement} */
this._resultsContainer = querySelectorNotNull(document, '#test-yomitan-api-results');
/** @type {HTMLInputElement} */
this._urlInput = querySelectorNotNull(document, '#test-yomitan-url-input');
/** @type {boolean} */
this._testActive = false;
}
/** */
prepare() {
this._testButton.addEventListener('click', this._onTestButtonClick.bind(this), false);
}
// Private
/**
* @param {MouseEvent} e
*/
_onTestButtonClick(e) {
e.preventDefault();
void this._testYomitanApi();
}
/** */
async _testYomitanApi() {
if (this._testActive) { return; }
try {
this._testActive = true;
const resultsContainer = /** @type {HTMLElement} */ (this._resultsContainer);
/** @type {HTMLButtonElement} */ (this._testButton).disabled = true;
resultsContainer.textContent = '';
resultsContainer.hidden = true;
await this._api.testYomitanApi(this._urlInput.value);
this._setStatus('Connection was successful', false);
} catch (e) {
this._setStatus(toError(e).message, true);
} finally {
this._testActive = false;
/** @type {HTMLButtonElement} */ (this._testButton).disabled = false;
}
}
/**
* @param {string} message
* @param {boolean} isError
*/
_setStatus(message, isError) {
const resultsContainer = /** @type {HTMLElement} */ (this._resultsContainer);
resultsContainer.textContent = message;
resultsContainer.hidden = false;
resultsContainer.classList.toggle('danger-text', isError);
}
}

41
vendor/yomitan/js/pages/support-main.js vendored Normal file
View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2024-2025 Yomitan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {ThemeController} from '../app/theme-controller.js';
import {Application} from '../application.js';
import {SettingsController} from './settings/settings-controller.js';
await Application.main(true, async (application) => {
const settingsController = new SettingsController(application);
await settingsController.prepare();
/** @type {ThemeController} */
const themeController = new ThemeController(document.documentElement);
themeController.prepare();
const optionsFull = await application.api.optionsGetFull();
const {profiles, profileCurrent} = optionsFull;
const defaultProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null;
if (defaultProfile !== null) {
themeController.theme = defaultProfile.options.general.popupTheme;
themeController.siteOverride = true;
themeController.updateTheme();
}
document.body.hidden = false;
document.documentElement.dataset.loaded = 'true';
});

119
vendor/yomitan/js/pages/welcome-main.js vendored Normal file
View File

@@ -0,0 +1,119 @@
/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2019-2022 Yomichan Authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Application} from '../application.js';
import {DocumentFocusController} from '../dom/document-focus-controller.js';
import {querySelectorNotNull} from '../dom/query-selector.js';
import {ExtensionContentController} from './common/extension-content-controller.js';
import {DataTransmissionConsentController} from './settings/data-transmission-consent-controller.js';
import {DictionaryController} from './settings/dictionary-controller.js';
import {DictionaryImportController} from './settings/dictionary-import-controller.js';
import {GenericSettingController} from './settings/generic-setting-controller.js';
import {LanguagesController} from './settings/languages-controller.js';
import {ModalController} from './settings/modal-controller.js';
import {RecommendedPermissionsController} from './settings/recommended-permissions-controller.js';
import {RecommendedSettingsController} from './settings/recommended-settings-controller.js';
import {ScanInputsSimpleController} from './settings/scan-inputs-simple-controller.js';
import {SettingsController} from './settings/settings-controller.js';
import {SettingsDisplayController} from './settings/settings-display-controller.js';
import {StatusFooter} from './settings/status-footer.js';
/**
* @param {import('../comm/api.js').API} api
*/
async function setupEnvironmentInfo(api) {
const {manifest_version: manifestVersion} = chrome.runtime.getManifest();
const {browser, platform} = await api.getEnvironmentInfo();
document.documentElement.dataset.browser = browser;
document.documentElement.dataset.os = platform.os;
document.documentElement.dataset.manifestVersion = `${manifestVersion}`;
}
/**
* @param {GenericSettingController} genericSettingController
*/
async function setupGenericSettingsController(genericSettingController) {
await genericSettingController.prepare();
await genericSettingController.refresh();
}
/** */
async function checkNeedsCustomTemplatesWarning() {
const key = 'needsCustomTemplatesWarning';
const result = await chrome.storage.session.get({[key]: false});
if (!result[key]) { return; }
document.documentElement.dataset.warnCustomTemplates = 'true';
await chrome.storage.session.remove([key]);
}
await Application.main(true, async (application) => {
const modalController = new ModalController(['shared-modals', 'settings-modals']);
await modalController.prepare();
const settingsController = new SettingsController(application);
await settingsController.prepare();
const settingsDisplayController = new SettingsDisplayController(settingsController, modalController);
await settingsDisplayController.prepare();
document.body.hidden = false;
const documentFocusController = new DocumentFocusController();
documentFocusController.prepare();
const extensionContentController = new ExtensionContentController();
extensionContentController.prepare();
/** @type {HTMLElement} */
const statusFooterElement = querySelectorNotNull(document, '.status-footer-container');
const statusFooter = new StatusFooter(statusFooterElement);
statusFooter.prepare();
void setupEnvironmentInfo(application.api);
void checkNeedsCustomTemplatesWarning();
const preparePromises = [];
const genericSettingController = new GenericSettingController(settingsController);
preparePromises.push(setupGenericSettingsController(genericSettingController));
const dictionaryController = new DictionaryController(settingsController, modalController, statusFooter);
preparePromises.push(dictionaryController.prepare());
const dictionaryImportController = new DictionaryImportController(settingsController, modalController, statusFooter);
preparePromises.push(dictionaryImportController.prepare());
const simpleScanningInputController = new ScanInputsSimpleController(settingsController);
preparePromises.push(simpleScanningInputController.prepare());
const recommendedPermissionsController = new RecommendedPermissionsController(settingsController);
preparePromises.push(recommendedPermissionsController.prepare());
const languagesController = new LanguagesController(settingsController);
preparePromises.push(languagesController.prepare());
const recommendedSettingsController = new RecommendedSettingsController(settingsController);
preparePromises.push(recommendedSettingsController.prepare());
const dataTransmissionConsentController = new DataTransmissionConsentController(settingsController, modalController);
preparePromises.push(dataTransmissionConsentController.prepare());
await Promise.all(preparePromises);
document.documentElement.dataset.loaded = 'true';
});