diff --git a/.eslintrc.json b/.eslintrc.json index 86da3d48da..bdb05baf7a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -694,6 +694,25 @@ } }] } + }, + { + "files": [ + "ext/data/recommended-settings.json" + ], + "rules": { + "jsonc/sort-keys": ["error", { + "pathPattern": ".*", + "order": [ + "modification", + "description" + ] + }, { + "pathPattern": ".*", + "order": { + "type": "asc" + } + }] + } } ] } diff --git a/ext/data/recommended-settings.json b/ext/data/recommended-settings.json new file mode 100644 index 0000000000..c492a2540a --- /dev/null +++ b/ext/data/recommended-settings.json @@ -0,0 +1,406 @@ +{ + "da": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "de": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "el": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "en": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "es": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "fi": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "fr": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + }, + { + "modification": { + "action": "set", + "path": "translation.textReplacements.groups", + "value": [ + [ + { + "pattern": "l'", + "ignoreCase": true, + "replacement": "" + }, + { + "pattern": "j'", + "ignoreCase": true, + "replacement": "" + }, + { + "pattern": "d'", + "ignoreCase": true, + "replacement": "" + } + ] + ] + }, + "description": "Separating the l', j', d' from the word." + } + ], + "hu": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "id": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "it": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "mn": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "nl": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "pl": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "pt": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "ro": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "ru": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "sh": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "sq": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "sv": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "tr": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ], + "vi": [ + { + "modification": { + "action": "set", + "path": "scanning.scanResolution", + "value": "word" + }, + "description": "Scan text one word at a time (as opposed to one character)." + }, + { + "modification": { + "action": "set", + "path": "translation.searchResolution", + "value": "word" + }, + "description": "Lookup whole words in the dictionary." + } + ] +} diff --git a/ext/data/schemas/recommended-settings-schema.json b/ext/data/schemas/recommended-settings-schema.json new file mode 100644 index 0000000000..2c8054244b --- /dev/null +++ b/ext/data/schemas/recommended-settings-schema.json @@ -0,0 +1,180 @@ +{ + "$id": "recommendedSetttings", + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Contains data for recommended default options overrides by language.", + "type": "object", + "$defs": { + "path": { + "type": "string", + "minLength": 2 + }, + "value": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array" + }, + { + "type": "object" + } + ] + }, + "description": { + "type": "string", + "minLength": 2 + } + }, + "patternProperties": { + "^.{2,}$": { + "title": "Language", + "type": "array", + "items": { + "title": "Modification", + "type": "object", + "oneOf": [ + { + "type": "object", + "title": "ModificationSet", + "properties": { + "modification": { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "set" + }, + "path": { + "$ref": "#/$defs/path" + }, + "value": { + "$ref": "#/$defs/value" + } + } + }, + "description": { + "$ref": "#/$defs/description" + } + } + }, + { + "type": "object", + "title": "ModificationDelete", + "properties": { + "modification": { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "delete" + }, + "path": { + "$ref": "#/$defs/path" + }, + "value": { + "$ref": "#/$defs/value" + } + } + }, + "description": { + "$ref": "#/$defs/description" + } + } + }, + { + "type": "object", + "title": "ModificationSwap", + "properties": { + "modification": { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "swap" + }, + "path1": { + "$ref": "#/$defs/path" + }, + "path2": { + "$ref": "#/$defs/path" + } + } + }, + "description": { + "$ref": "#/$defs/description" + } + } + }, + { + "type": "object", + "title": "ModificationSplice", + "properties": { + "modification": { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "splice" + }, + "path": { + "$ref": "#/$defs/path" + }, + "start": { + "type": "number" + }, + "deleteCount": { + "type": "number" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/value" + } + } + } + }, + "description": { + "$ref": "#/$defs/description" + } + } + }, + { + "type": "object", + "title": "ModificationPush", + "properties": { + "modification": { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "push" + }, + "path": { + "$ref": "#/$defs/path" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/value" + } + } + } + }, + "description": { + "$ref": "#/$defs/description" + } + } + } + ] + } + } + } +} diff --git a/ext/js/pages/settings/recommended-settings-controller.js b/ext/js/pages/settings/recommended-settings-controller.js new file mode 100644 index 0000000000..c9b540b614 --- /dev/null +++ b/ext/js/pages/settings/recommended-settings-controller.js @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2024 Ajatt-Tools and contributors + * + * 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 . + */ + +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} */ + 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} + */ + 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} */ + 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) => { + results.map((result) => { + 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}`)); + } + } + } +} diff --git a/ext/js/pages/settings/settings-main.js b/ext/js/pages/settings/settings-main.js index 38b1fe0b4e..66537c9b04 100644 --- a/ext/js/pages/settings/settings-main.js +++ b/ext/js/pages/settings/settings-main.js @@ -40,6 +40,7 @@ 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'; @@ -174,6 +175,8 @@ await Application.main(true, async (application) => { const sortFrequencyDictionaryController = new SortFrequencyDictionaryController(settingsController); preparePromises.push(sortFrequencyDictionaryController.prepare()); + const recommendedSettingsController = new RecommendedSettingsController(settingsController); + preparePromises.push(recommendedSettingsController.prepare()); await Promise.all(preparePromises); diff --git a/ext/js/pages/welcome-main.js b/ext/js/pages/welcome-main.js index 65a6c4aecb..6dc3f2a551 100644 --- a/ext/js/pages/welcome-main.js +++ b/ext/js/pages/welcome-main.js @@ -26,6 +26,7 @@ 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'; @@ -105,6 +106,9 @@ await Application.main(true, async (application) => { const languagesController = new LanguagesController(settingsController); preparePromises.push(languagesController.prepare()); + const recommendedSettingsController = new RecommendedSettingsController(settingsController); + preparePromises.push(recommendedSettingsController.prepare()); + await Promise.all(preparePromises); document.documentElement.dataset.loaded = 'true'; diff --git a/ext/templates-modals.html b/ext/templates-modals.html index da36ed83d6..905fd01393 100644 --- a/ext/templates-modals.html +++ b/ext/templates-modals.html @@ -346,6 +346,30 @@

Pronunciation Dictionaries

+ + + + + + diff --git a/test/data/json.json b/test/data/json.json index b2e0908887..36bfd174ad 100644 --- a/test/data/json.json +++ b/test/data/json.json @@ -99,6 +99,11 @@ "typeFile": "types/test/json.d.ts", "type": "AjvSchema" }, + { + "path": "ext/data/schemas/recommended-settings-schema.json", + "typeFile": "types/test/json.d.ts", + "type": "AjvSchema" + }, { "path": "test/data/translator-test-inputs.json", "typeFile": "types/test/translator.d.ts", @@ -193,6 +198,12 @@ "typeFile": "types/ext/dictionary-recommended.d.ts", "type": "RecommendedDictionaries", "schema": "ext/data/schemas/recommended-dictionaries-schema.json" + }, + { + "path": "ext/data/recommended-settings.json", + "typeFile": "types/ext/settings-controller.d.ts", + "type": "RecommendedSettingsByLanguage", + "schema": "ext/data/schemas/recommended-settings-schema.json" } ] } diff --git a/types/ext/settings-controller.d.ts b/types/ext/settings-controller.d.ts index 8aadb75f48..e37b3c89a1 100644 --- a/types/ext/settings-controller.d.ts +++ b/types/ext/settings-controller.d.ts @@ -22,6 +22,7 @@ import type * as Core from './core'; import type * as Settings from './settings'; import type * as SettingsModifications from './settings-modifications'; import type {EventNames, EventArgument as BaseEventArgument} from './core'; +import type {Modification} from './settings-modifications'; export type PageExitPrevention = { end: () => void; @@ -57,3 +58,12 @@ export type SettingsModification = THasScope extends export type SettingsExtraFields = THasScope extends true ? null : SettingsModifications.OptionsScope; export type ModifyResult = Core.Response; + +export type RecommendedSetting = { + modification: Modification; + description: string; +}; + +export type RecommendedSettingsByLanguage = { + [key: string]: RecommendedSetting[]; +};