diff options
author | Alex Yatskov <alex@foosoft.net> | 2019-09-23 17:35:36 -0700 |
---|---|---|
committer | Alex Yatskov <alex@foosoft.net> | 2019-09-23 17:35:36 -0700 |
commit | f4b6527ed6ed1f0f4f5a63b94766b20f3b90e6ec (patch) | |
tree | 0d2f733c13597dd4067d3dc01e6da27f96bfe81b | |
parent | cfc6363a01ee00e89866c54709006d6f55d093de (diff) | |
parent | f5afe590ad0730a695614b32032b7ea70b46c7b0 (diff) |
Merge branch 'master' into testing
-rw-r--r-- | README.md | 24 | ||||
-rw-r--r-- | ext/bg/background.html | 2 | ||||
-rw-r--r-- | ext/bg/guide.html | 2 | ||||
-rw-r--r-- | ext/bg/js/api.js | 44 | ||||
-rw-r--r-- | ext/bg/js/backend.js | 139 | ||||
-rw-r--r-- | ext/bg/js/conditions-ui.js | 326 | ||||
-rw-r--r-- | ext/bg/js/conditions.js | 117 | ||||
-rw-r--r-- | ext/bg/js/context.js | 6 | ||||
-rw-r--r-- | ext/bg/js/options.js | 226 | ||||
-rw-r--r-- | ext/bg/js/profile-conditions.js | 85 | ||||
-rw-r--r-- | ext/bg/js/search-frontend.js | 6 | ||||
-rw-r--r-- | ext/bg/js/search.js | 9 | ||||
-rw-r--r-- | ext/bg/js/settings-profiles.js | 281 | ||||
-rw-r--r-- | ext/bg/js/settings.js | 338 | ||||
-rw-r--r-- | ext/bg/js/translator.js | 6 | ||||
-rw-r--r-- | ext/bg/js/util.js | 9 | ||||
-rw-r--r-- | ext/bg/settings.html | 285 | ||||
-rw-r--r-- | ext/fg/js/api.js | 24 | ||||
-rw-r--r-- | ext/fg/js/document.js | 21 | ||||
-rw-r--r-- | ext/fg/js/float.js | 11 | ||||
-rw-r--r-- | ext/fg/js/frontend-api-sender.js | 14 | ||||
-rw-r--r-- | ext/fg/js/frontend.js | 126 | ||||
-rw-r--r-- | ext/fg/js/popup-nested.js | 7 | ||||
-rw-r--r-- | ext/fg/js/popup-proxy-host.js | 24 | ||||
-rw-r--r-- | ext/fg/js/popup-proxy.js | 17 | ||||
-rw-r--r-- | ext/fg/js/popup.js | 16 | ||||
-rw-r--r-- | ext/fg/js/source.js | 4 | ||||
-rw-r--r-- | ext/manifest.json | 2 | ||||
-rw-r--r-- | ext/mixed/js/display.js | 11 |
29 files changed, 1750 insertions, 432 deletions
@@ -61,7 +61,7 @@ Import](https://foosoft.net/projects/yomichan-import) page to learn how to conve Please be aware that the non-English dictionaries contain fewer entries than their English counterparts. Even if your primary language is not English, you may consider also importing the English version for better coverage. -* **[JMdict](http://www.edrdg.org/enamdict/enamdict_doc.html)** (Japanese vocabulary) +* **[JMdict](https://www.edrdg.org/enamdict/enamdict_doc.html)** (Japanese vocabulary) * [jmdict\_dutch.zip](https://foosoft.net/projects/yomichan/dl/dict/jmdict_dutch.zip) * [jmdict\_english.zip](https://foosoft.net/projects/yomichan/dl/dict/jmdict_english.zip) * [jmdict\_french.zip](https://foosoft.net/projects/yomichan/dl/dict/jmdict_french.zip) @@ -71,7 +71,7 @@ primary language is not English, you may consider also importing the English ver * [jmdict\_slovenian.zip](https://foosoft.net/projects/yomichan/dl/dict/jmdict_slovenian.zip) * [jmdict\_spanish.zip](https://foosoft.net/projects/yomichan/dl/dict/jmdict_spanish.zip) * [jmdict\_swedish.zip](https://foosoft.net/projects/yomichan/dl/dict/jmdict_swedish.zip) -* **[JMnedict](http://www.edrdg.org/enamdict/enamdict_doc.html)** (Japanese names) +* **[JMnedict](https://www.edrdg.org/enamdict/enamdict_doc.html)** (Japanese names) * [jmnedict.zip](https://foosoft.net/projects/yomichan/dl/dict/jmnedict.zip) * **[KireiCake](https://kireicake.com/rikaicakes/)** (Japanese slang) * [kireicake.zip](https://foosoft.net/projects/yomichan/dl/dict/kireicake.zip) @@ -127,7 +127,7 @@ Import](https://foosoft.net/projects/yomichan-import). Please see the project pa ## Anki Integration ## -Yomichan features automatic flashcard creation for [Anki](http://ankisrs.net/), a free application designed to help you +Yomichan features automatic flashcard creation for [Anki](https://apps.ankiweb.net/), a free application designed to help you retain knowledge. This feature requires the prior installation of an Anki plugin called [AnkiConnect](https://foosoft.net/projects/anki-connect). Please see the respective project page for more information about how to set up this software. @@ -135,7 +135,7 @@ Please see the respective project page for more information about how to set up Before flashcards can be automatically created, you must configure the templates used to create term and/or Kanji notes. If you are unfamiliar with Anki deck and model management, this would be a good time to reference the [Anki -Manual](http://ankisrs.net/docs/manual.html). In short, you must specify what information should be included in the +Manual](https://apps.ankiweb.net/docs/manual.html). In short, you must specify what information should be included in the flashcards that Yomichan creates through AnkiConnect. Flashcard fields can be configured with the following steps: @@ -145,7 +145,7 @@ Flashcard fields can be configured with the following steps: 3. Select the type of template to configure by clicking on either the *Terms* or *Kanji* tabs. 4. Select the Anki deck and model to use for new creating new flashcards of this type. 5. Fill the model fields with markers corresponding to the information you wish to include (several can be used at - once). Advanced users can also configure the actual [Handlebars](http://handlebarsjs.com/) templates used to create + once). Advanced users can also configure the actual [Handlebars](https://handlebarsjs.com/) templates used to create the flashcard contents (this is strictly optional). #### Markers for Term Cards #### @@ -243,7 +243,7 @@ following basic guidelines when creating pull requests: ### Templates ### -Yomichan uses [Handlebars](http://handlebarsjs.com/) templates for user interface generation. The source templates are +Yomichan uses [Handlebars](https://handlebarsjs.com/) templates for user interface generation. The source templates are found in the `tmpl` directory and the compiled version is stored in the `ext/bg/js/templates.js` file. If you modify the source templates, you will need to also recompile them. If you are developing on Linux or Mac OS X, you can use the included `build_tmpl.sh` and `build_tmpl_auto.sh` shell scripts to do this for you @@ -255,13 +255,13 @@ tmpl/*.html -f ext/bg/js/templates.js` from the project's base directory to comp Yomichan uses several third-party libraries to function. Below are links to homepages and snapshots of the exact versions packaged. -* Bootstrap Toggle: [homepage](http://www.bootstraptoggle.com/) - [snapshot](https://github.com/minhur/bootstrap-toggle/archive/b76c094.zip) -* Bootstrap: [homepage](http://getbootstrap.com/) - [snapshot](https://github.com/twbs/bootstrap/releases/download/v3.3.7/bootstrap-3.3.7-dist.zip) -* Dexie: [homepage](http://dexie.org/) - [snapshot](https://github.com/dfahlander/Dexie.js/archive/v2.0.0-beta.10.zip) -* Handlebars: [homepage](http://handlebarsjs.com/) - [snapshot](http://builds.handlebarsjs.com.s3.amazonaws.com/handlebars.min-714a4c4.js) +* Bootstrap Toggle: [homepage](https://www.bootstraptoggle.com/) - [snapshot](https://github.com/minhur/bootstrap-toggle/archive/b76c094.zip) +* Bootstrap: [homepage](https://getbootstrap.com/) - [snapshot](https://github.com/twbs/bootstrap/releases/download/v3.3.7/bootstrap-3.3.7-dist.zip) +* Dexie: [homepage](https://dexie.org/) - [snapshot](https://github.com/dfahlander/Dexie.js/archive/v2.0.0-beta.10.zip) +* Handlebars: [homepage](https://handlebarsjs.com/) - [snapshot](http://builds.handlebarsjs.com.s3.amazonaws.com/handlebars.min-714a4c4.js) * JQuery: [homepage](https://blog.jquery.com/) - [snapshot](https://code.jquery.com/jquery-3.2.1.min.js) -* JSZip: [homepage](http://stuk.github.io/jszip/) - [snapshot](https://raw.githubusercontent.com/Stuk/jszip/de7f52fbcba485737bef7923a83f0fad92d9f5bc/dist/jszip.min.js) -* WanaKana: [homepage](http://wanakana.com/) - [snapshot](https://raw.githubusercontent.com/WaniKani/WanaKana/7c4a052/gh-pages/assets/js/wanakana.min.js) +* JSZip: [homepage](https://stuk.github.io/jszip/) - [snapshot](https://raw.githubusercontent.com/Stuk/jszip/de7f52fbcba485737bef7923a83f0fad92d9f5bc/dist/jszip.min.js) +* WanaKana: [homepage](https://wanakana.com/) - [snapshot](https://raw.githubusercontent.com/WaniKani/WanaKana/7c4a052/gh-pages/assets/js/wanakana.min.js) ## Frequently Asked Questions ## diff --git a/ext/bg/background.html b/ext/bg/background.html index 90a56024..3b37db87 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -16,11 +16,13 @@ <script src="/bg/js/api.js"></script> <script src="/bg/js/audio.js"></script> <script src="/bg/js/backend-api-forwarder.js"></script> + <script src="/bg/js/conditions.js"></script> <script src="/bg/js/database.js"></script> <script src="/bg/js/deinflector.js"></script> <script src="/bg/js/dictionary.js"></script> <script src="/bg/js/handlebars.js"></script> <script src="/bg/js/options.js"></script> + <script src="/bg/js/profile-conditions.js"></script> <script src="/bg/js/request.js"></script> <script src="/bg/js/templates.js"></script> <script src="/bg/js/translator.js"></script> diff --git a/ext/bg/guide.html b/ext/bg/guide.html index 6f98d264..7ec1d8d9 100644 --- a/ext/bg/guide.html +++ b/ext/bg/guide.html @@ -15,7 +15,7 @@ <p> Read the steps below to get up and running with Yomichan. For complete documentation, - visit the <a href="https://foosoft.net/projects/yomichan/" target="_blank">official homepage</a>. + visit the <a href="https://foosoft.net/projects/yomichan/" target="_blank" rel="noopener">official homepage</a>. </p> <ol> diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js index b8ef4362..474fe604 100644 --- a/ext/bg/js/api.js +++ b/ext/bg/js/api.js @@ -17,16 +17,23 @@ */ -async function apiOptionsSet(options) { - utilBackend().onOptionsUpdated(options); +function apiOptionsGet(optionsContext) { + return utilBackend().getOptions(optionsContext); } -async function apiOptionsGet() { - return utilBackend().options; +function apiOptionsGetFull() { + return utilBackend().getFullOptions(); } -async function apiTermsFind(text) { - const options = utilBackend().options; +async function apiOptionsSave(source) { + const backend = utilBackend(); + const options = await apiOptionsGetFull(); + await optionsSave(options); + backend.onOptionsUpdated(source); +} + +async function apiTermsFind(text, optionsContext) { + const options = await apiOptionsGet(optionsContext); const translator = utilBackend().translator; const searcher = { @@ -38,7 +45,8 @@ async function apiTermsFind(text) { const {definitions, length} = await searcher( text, dictEnabledSet(options), - options.scanning.alphanumeric + options.scanning.alphanumeric, + options ); return { @@ -47,14 +55,14 @@ async function apiTermsFind(text) { }; } -async function apiKanjiFind(text) { - const options = utilBackend().options; +async function apiKanjiFind(text, optionsContext) { + const options = await apiOptionsGet(optionsContext); const definitions = await utilBackend().translator.findKanji(text, dictEnabledSet(options)); return definitions.slice(0, options.general.maxResults); } -async function apiDefinitionAdd(definition, mode, context) { - const options = utilBackend().options; +async function apiDefinitionAdd(definition, mode, context, optionsContext) { + const options = await apiOptionsGet(optionsContext); if (mode !== 'kanji') { await audioInject( @@ -76,14 +84,15 @@ async function apiDefinitionAdd(definition, mode, context) { return utilBackend().anki.addNote(note); } -async function apiDefinitionsAddable(definitions, modes) { +async function apiDefinitionsAddable(definitions, modes, optionsContext) { + const options = await apiOptionsGet(optionsContext); const states = []; try { const notes = []; for (const definition of definitions) { for (const mode of modes) { - const note = await dictNoteFormat(definition, mode, utilBackend().options); + const note = await dictNoteFormat(definition, mode, options); notes.push(note); } } @@ -131,10 +140,13 @@ async function apiCommandExec(command) { }, toggle: async () => { - const options = utilBackend().options; + const optionsContext = { + depth: 0, + url: window.location.href + }; + const options = await apiOptionsGet(optionsContext); options.general.enable = !options.general.enable; - await optionsSave(options); - await apiOptionsSet(options); + await apiOptionsSave('popup'); } }; diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 39fd4288..4068b760 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -22,47 +22,44 @@ class Backend { this.translator = new Translator(); this.anki = new AnkiNull(); this.options = null; + this.optionsContext = { + depth: 0, + url: window.location.href + }; + + this.isPreparedResolve = null; + this.isPreparedPromise = new Promise((resolve) => (this.isPreparedResolve = resolve)); this.apiForwarder = new BackendApiForwarder(); } async prepare() { await this.translator.prepare(); - await apiOptionsSet(await optionsLoad()); + this.options = await optionsLoad(); + this.onOptionsUpdated('background'); if (chrome.commands !== null && typeof chrome.commands === 'object') { chrome.commands.onCommand.addListener(this.onCommand.bind(this)); } chrome.runtime.onMessage.addListener(this.onMessage.bind(this)); - if (this.options.general.showGuide) { + const options = this.getOptionsSync(this.optionsContext); + if (options.general.showGuide) { chrome.tabs.create({url: chrome.extension.getURL('/bg/guide.html')}); } + + this.isPreparedResolve(); + this.isPreparedResolve = null; + this.isPreparedPromise = null; } - onOptionsUpdated(options) { - this.options = utilIsolate(options); - - if (!options.general.enable) { - this.setExtensionBadgeBackgroundColor('#555555'); - this.setExtensionBadgeText('off'); - } else if (!dictConfigured(options)) { - this.setExtensionBadgeBackgroundColor('#f0ad4e'); - this.setExtensionBadgeText('!'); - } else { - this.setExtensionBadgeText(''); - } - - if (options.anki.enable) { - this.anki = new AnkiConnect(options.anki.server); - } else { - this.anki = new AnkiNull(); - } + onOptionsUpdated(source) { + this.applyOptions(); const callback = () => this.checkLastError(chrome.runtime.lastError); chrome.tabs.query({}, tabs => { for (const tab of tabs) { - chrome.tabs.sendMessage(tab.id, {action: 'optionsSet', params: options}, callback); + chrome.tabs.sendMessage(tab.id, {action: 'optionsUpdate', params: {source}}, callback); } }); } @@ -81,28 +78,24 @@ class Backend { }; const handlers = { - optionsGet: ({callback}) => { - forward(apiOptionsGet(), callback); - }, - - optionsSet: ({options, callback}) => { - forward(apiOptionsSet(options), callback); + optionsGet: ({optionsContext, callback}) => { + forward(apiOptionsGet(optionsContext), callback); }, - kanjiFind: ({text, callback}) => { - forward(apiKanjiFind(text), callback); + kanjiFind: ({text, optionsContext, callback}) => { + forward(apiKanjiFind(text, optionsContext), callback); }, - termsFind: ({text, callback}) => { - forward(apiTermsFind(text), callback); + termsFind: ({text, optionsContext, callback}) => { + forward(apiTermsFind(text, optionsContext), callback); }, - definitionAdd: ({definition, mode, context, callback}) => { - forward(apiDefinitionAdd(definition, mode, context), callback); + definitionAdd: ({definition, mode, context, optionsContext, callback}) => { + forward(apiDefinitionAdd(definition, mode, context, optionsContext), callback); }, - definitionsAddable: ({definitions, modes, callback}) => { - forward(apiDefinitionsAddable(definitions, modes), callback); + definitionsAddable: ({definitions, modes, optionsContext, callback}) => { + forward(apiDefinitionsAddable(definitions, modes, optionsContext), callback); }, noteView: ({noteId}) => { @@ -143,12 +136,86 @@ class Backend { return true; } + applyOptions() { + const options = this.getOptionsSync(this.optionsContext); + if (!options.general.enable) { + this.setExtensionBadgeBackgroundColor('#555555'); + this.setExtensionBadgeText('off'); + } else if (!dictConfigured(options)) { + this.setExtensionBadgeBackgroundColor('#f0ad4e'); + this.setExtensionBadgeText('!'); + } else { + this.setExtensionBadgeText(''); + } + + this.anki = options.anki.enable ? new AnkiConnect(options.anki.server) : new AnkiNull(); + } + + async getFullOptions() { + if (this.isPreparedPromise !== null) { + await this.isPreparedPromise; + } + return this.options; + } + + async getOptions(optionsContext) { + if (this.isPreparedPromise !== null) { + await this.isPreparedPromise; + } + return this.getOptionsSync(optionsContext); + } + + getOptionsSync(optionsContext) { + return this.getProfileSync(optionsContext).options; + } + + getProfileSync(optionsContext) { + const profiles = this.options.profiles; + if (typeof optionsContext.index === 'number') { + return profiles[optionsContext.index]; + } + const profile = this.getProfileFromContext(optionsContext); + return profile !== null ? profile : this.options.profiles[this.options.profileCurrent]; + } + + getProfileFromContext(optionsContext) { + for (const profile of this.options.profiles) { + const conditionGroups = profile.conditionGroups; + if (conditionGroups.length > 0 && Backend.testConditionGroups(conditionGroups, optionsContext)) { + return profile; + } + } + return null; + } + + static testConditionGroups(conditionGroups, data) { + if (conditionGroups.length === 0) { return false; } + + for (const conditionGroup of conditionGroups) { + const conditions = conditionGroup.conditions; + if (conditions.length > 0 && Backend.testConditions(conditions, data)) { + return true; + } + } + + return false; + } + + static testConditions(conditions, data) { + for (const condition of conditions) { + if (!conditionsTestValue(profileConditionsDescriptor, condition.type, condition.operator, condition.value, data)) { + return false; + } + } + return true; + } + setExtensionBadgeBackgroundColor(color) { if (typeof chrome.browserAction.setBadgeBackgroundColor === 'function') { chrome.browserAction.setBadgeBackgroundColor({color}); } } - + setExtensionBadgeText(text) { if (typeof chrome.browserAction.setBadgeText === 'function') { chrome.browserAction.setBadgeText({text}); diff --git a/ext/bg/js/conditions-ui.js b/ext/bg/js/conditions-ui.js new file mode 100644 index 00000000..a6f54a1c --- /dev/null +++ b/ext/bg/js/conditions-ui.js @@ -0,0 +1,326 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 <http://www.gnu.org/licenses/>. + */ + + +class ConditionsUI { + static instantiateTemplate(templateSelector) { + const template = document.querySelector(templateSelector); + const content = document.importNode(template.content, true); + return $(content.firstChild); + } +} + +ConditionsUI.Container = class Container { + constructor(conditionDescriptors, conditionNameDefault, conditionGroups, container, addButton) { + this.children = []; + this.conditionDescriptors = conditionDescriptors; + this.conditionNameDefault = conditionNameDefault; + this.conditionGroups = conditionGroups; + this.container = container; + this.addButton = addButton; + + this.container.empty(); + + for (const conditionGroup of conditionGroups) { + this.children.push(new ConditionsUI.ConditionGroup(this, conditionGroup)); + } + + this.addButton.on('click', () => this.onAddConditionGroup()); + } + + cleanup() { + for (const child of this.children) { + child.cleanup(); + } + + this.addButton.off('click'); + this.container.empty(); + } + + save() { + // Override + } + + isolate(object) { + // Override + return object; + } + + remove(child) { + const index = this.children.indexOf(child); + if (index < 0) { + return; + } + + child.cleanup(); + this.children.splice(index, 1); + this.conditionGroups.splice(index, 1); + } + + onAddConditionGroup() { + const conditionGroup = this.isolate({ + conditions: [this.createDefaultCondition(this.conditionNameDefault)] + }); + this.conditionGroups.push(conditionGroup); + this.save(); + this.children.push(new ConditionsUI.ConditionGroup(this, conditionGroup)); + } + + createDefaultCondition(type) { + let operator = ''; + let value = ''; + if (this.conditionDescriptors.hasOwnProperty(type)) { + const conditionDescriptor = this.conditionDescriptors[type]; + operator = conditionDescriptor.defaultOperator; + ({value} = this.getOperatorDefaultValue(type, operator)); + if (typeof value === 'undefined') { + value = ''; + } + } + return {type, operator, value}; + } + + getOperatorDefaultValue(type, operator) { + if (this.conditionDescriptors.hasOwnProperty(type)) { + const conditionDescriptor = this.conditionDescriptors[type]; + if (conditionDescriptor.operators.hasOwnProperty(operator)) { + const operatorDescriptor = conditionDescriptor.operators[operator]; + if (operatorDescriptor.hasOwnProperty('defaultValue')) { + return {value: operatorDescriptor.defaultValue, fromOperator: true}; + } + } + if (conditionDescriptor.hasOwnProperty('defaultValue')) { + return {value: conditionDescriptor.defaultValue, fromOperator: false}; + } + } + return {fromOperator: false}; + } +}; + +ConditionsUI.ConditionGroup = class ConditionGroup { + constructor(parent, conditionGroup) { + this.parent = parent; + this.children = []; + this.conditionGroup = conditionGroup; + this.container = $('<div>').addClass('condition-group').appendTo(parent.container); + this.options = ConditionsUI.instantiateTemplate('#condition-group-options-template').appendTo(parent.container); + this.separator = ConditionsUI.instantiateTemplate('#condition-group-separator-template').appendTo(parent.container); + this.addButton = this.options.find('.condition-add'); + + for (const condition of conditionGroup.conditions) { + this.children.push(new ConditionsUI.Condition(this, condition)); + } + + this.addButton.on('click', () => this.onAddCondition()); + } + + cleanup() { + for (const child of this.children) { + child.cleanup(); + } + + this.addButton.off('click'); + this.container.remove(); + this.options.remove(); + this.separator.remove(); + } + + save() { + this.parent.save(); + } + + isolate(object) { + return this.parent.isolate(object); + } + + remove(child) { + const index = this.children.indexOf(child); + if (index < 0) { + return; + } + + child.cleanup(); + this.children.splice(index, 1); + this.conditionGroup.conditions.splice(index, 1); + + if (this.children.length === 0) { + this.parent.remove(this, false); + } + } + + onAddCondition() { + const condition = this.isolate(this.parent.createDefaultCondition(this.parent.conditionNameDefault)); + this.conditionGroup.conditions.push(condition); + this.children.push(new ConditionsUI.Condition(this, condition)); + } +}; + +ConditionsUI.Condition = class Condition { + constructor(parent, condition) { + this.parent = parent; + this.condition = condition; + this.container = ConditionsUI.instantiateTemplate('#condition-template').appendTo(parent.container); + this.input = this.container.find('input'); + this.typeSelect = this.container.find('.condition-type'); + this.operatorSelect = this.container.find('.condition-operator'); + this.removeButton = this.container.find('.condition-remove'); + + this.updateTypes(); + this.updateOperators(); + this.updateInput(); + + this.input.on('change', () => this.onInputChanged()); + this.typeSelect.on('change', () => this.onConditionTypeChanged()); + this.operatorSelect.on('change', () => this.onConditionOperatorChanged()); + this.removeButton.on('click', () => this.onRemoveClicked()); + } + + cleanup() { + this.input.off('change'); + this.typeSelect.off('change'); + this.operatorSelect.off('change'); + this.removeButton.off('click'); + this.container.remove(); + } + + save() { + this.parent.save(); + } + + updateTypes() { + const conditionDescriptors = this.parent.parent.conditionDescriptors; + const optionGroup = this.typeSelect.find('optgroup'); + optionGroup.empty(); + for (const type of Object.keys(conditionDescriptors)) { + const conditionDescriptor = conditionDescriptors[type]; + $('<option>').val(type).text(conditionDescriptor.name).appendTo(optionGroup); + } + this.typeSelect.val(this.condition.type); + } + + updateOperators() { + const conditionDescriptors = this.parent.parent.conditionDescriptors; + const optionGroup = this.operatorSelect.find('optgroup'); + optionGroup.empty(); + + const type = this.condition.type; + if (conditionDescriptors.hasOwnProperty(type)) { + const conditionDescriptor = conditionDescriptors[type]; + const operators = conditionDescriptor.operators; + for (const operatorName of Object.keys(operators)) { + const operatorDescriptor = operators[operatorName]; + $('<option>').val(operatorName).text(operatorDescriptor.name).appendTo(optionGroup); + } + } + + this.operatorSelect.val(this.condition.operator); + } + + updateInput() { + const conditionDescriptors = this.parent.parent.conditionDescriptors; + const {type, operator} = this.condition; + const props = { + placeholder: '', + type: 'text' + }; + + const objects = []; + if (conditionDescriptors.hasOwnProperty(type)) { + const conditionDescriptor = conditionDescriptors[type]; + objects.push(conditionDescriptor); + if (conditionDescriptor.operators.hasOwnProperty(operator)) { + const operatorDescriptor = conditionDescriptor.operators[operator]; + objects.push(operatorDescriptor); + } + } + + for (const object of objects) { + if (object.hasOwnProperty('placeholder')) { + props.placeholder = object.placeholder; + } + if (object.type === 'number') { + props.type = 'number'; + for (const prop of ['step', 'min', 'max']) { + if (object.hasOwnProperty(prop)) { + props[prop] = object[prop]; + } + } + } + } + + for (const prop in props) { + this.input.prop(prop, props[prop]); + } + + const {valid} = this.validateValue(this.condition.value); + this.input.toggleClass('is-invalid', !valid); + this.input.val(this.condition.value); + } + + validateValue(value) { + const conditionDescriptors = this.parent.parent.conditionDescriptors; + let valid = true; + try { + value = conditionsNormalizeOptionValue( + conditionDescriptors, + this.condition.type, + this.condition.operator, + value + ); + } catch (e) { + valid = false; + } + return {valid, value}; + } + + onInputChanged() { + const {valid, value} = this.validateValue(this.input.val()); + this.input.toggleClass('is-invalid', !valid); + this.input.val(value); + this.condition.value = value; + this.save(); + } + + onConditionTypeChanged() { + const type = this.typeSelect.val(); + const {operator, value} = this.parent.parent.createDefaultCondition(type); + this.condition.type = type; + this.condition.operator = operator; + this.condition.value = value; + this.save(); + this.updateOperators(); + this.updateInput(); + } + + onConditionOperatorChanged() { + const type = this.condition.type; + const operator = this.operatorSelect.val(); + const {value, fromOperator} = this.parent.parent.getOperatorDefaultValue(type, operator); + this.condition.operator = operator; + if (fromOperator) { + this.condition.value = value; + } + this.save(); + this.updateInput(); + } + + onRemoveClicked() { + this.parent.remove(this); + this.save(); + } +}; diff --git a/ext/bg/js/conditions.js b/ext/bg/js/conditions.js new file mode 100644 index 00000000..ed4b14f5 --- /dev/null +++ b/ext/bg/js/conditions.js @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 <http://www.gnu.org/licenses/>. + */ + + +function conditionsValidateOptionValue(object, value) { + if (object.hasOwnProperty('validate') && !object.validate(value)) { + throw new Error('Invalid value for condition'); + } + + if (object.hasOwnProperty('transform')) { + value = object.transform(value); + + if (object.hasOwnProperty('validateTransformed') && !object.validateTransformed(value)) { + throw new Error('Invalid value for condition'); + } + } + + return value; +} + +function conditionsNormalizeOptionValue(descriptors, type, operator, optionValue) { + if (!descriptors.hasOwnProperty(type)) { + throw new Error('Invalid type'); + } + + const conditionDescriptor = descriptors[type]; + if (!conditionDescriptor.operators.hasOwnProperty(operator)) { + throw new Error('Invalid operator'); + } + + const operatorDescriptor = conditionDescriptor.operators[operator]; + + let transformedValue = conditionsValidateOptionValue(conditionDescriptor, optionValue); + transformedValue = conditionsValidateOptionValue(operatorDescriptor, transformedValue); + + if (operatorDescriptor.hasOwnProperty('transformReverse')) { + transformedValue = operatorDescriptor.transformReverse(transformedValue); + } + return transformedValue; +} + +function conditionsTestValueThrowing(descriptors, type, operator, optionValue, value) { + if (!descriptors.hasOwnProperty(type)) { + throw new Error('Invalid type'); + } + + const conditionDescriptor = descriptors[type]; + if (!conditionDescriptor.operators.hasOwnProperty(operator)) { + throw new Error('Invalid operator'); + } + + const operatorDescriptor = conditionDescriptor.operators[operator]; + if (operatorDescriptor.hasOwnProperty('transform')) { + if (operatorDescriptor.hasOwnProperty('transformCache')) { + const key = `${optionValue}`; + const transformCache = operatorDescriptor.transformCache; + if (transformCache.hasOwnProperty(key)) { + optionValue = transformCache[key]; + } else { + optionValue = operatorDescriptor.transform(optionValue); + transformCache[key] = optionValue; + } + } else { + optionValue = operatorDescriptor.transform(optionValue); + } + } + + return operatorDescriptor.test(value, optionValue); +} + +function conditionsTestValue(descriptors, type, operator, optionValue, value) { + try { + return conditionsTestValueThrowing(descriptors, type, operator, optionValue, value); + } catch (e) { + return false; + } +} + +function conditionsClearCaches(descriptors) { + for (const type in descriptors) { + if (!descriptors.hasOwnProperty(type)) { + continue; + } + + const conditionDescriptor = descriptors[type]; + if (conditionDescriptor.hasOwnProperty('transformCache')) { + conditionDescriptor.transformCache = {}; + } + + const operatorDescriptors = conditionDescriptor.operators; + for (const operator in operatorDescriptors) { + if (!operatorDescriptors.hasOwnProperty(operator)) { + continue; + } + + const operatorDescriptor = operatorDescriptors[operator]; + if (operatorDescriptor.hasOwnProperty('transformCache')) { + operatorDescriptor.transformCache = {}; + } + } + } +} diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js index 689d6863..0f88e9c0 100644 --- a/ext/bg/js/context.js +++ b/ext/bg/js/context.js @@ -22,7 +22,11 @@ $(document).ready(utilAsync(() => { $('#open-options').click(() => apiCommandExec('options')); $('#open-help').click(() => apiCommandExec('help')); - optionsLoad().then(options => { + const optionsContext = { + depth: 0, + url: window.location.href + }; + apiOptionsGet(optionsContext).then(options => { const toggle = $('#enable-search'); toggle.prop('checked', options.general.enable).change(); toggle.bootstrapToggle(); diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index df95aae9..e9e321df 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -17,7 +17,67 @@ */ -function optionsFieldTemplates() { +/* + * Generic options functions + */ + +function optionsGenericApplyUpdates(options, updates) { + const targetVersion = updates.length; + const currentVersion = options.version; + if (typeof currentVersion === 'number' && Number.isFinite(currentVersion)) { + for (let i = Math.max(0, Math.floor(currentVersion)); i < targetVersion; ++i) { + const update = updates[i]; + if (update !== null) { + update(options); + } + } + } + + options.version = targetVersion; + return options; +} + + +/* + * Per-profile options + */ + +const profileOptionsVersionUpdates = [ + null, + null, + null, + null, + (options) => { + options.general.audioSource = options.general.audioPlayback ? 'jpod101' : 'disabled'; + }, + (options) => { + options.general.showGuide = false; + }, + (options) => { + options.scanning.modifier = options.scanning.requireShift ? 'shift' : 'none'; + }, + (options) => { + const fieldTemplatesDefault = profileOptionsGetDefaultFieldTemplates(); + options.general.resultOutputMode = options.general.groupResults ? 'group' : 'split'; + options.anki.fieldTemplates = ( + (utilStringHashCode(options.anki.fieldTemplates) !== -805327496) ? + `{{#if merge}}${fieldTemplatesDefault}{{else}}${options.anki.fieldTemplates}{{/if}}` : + fieldTemplatesDefault + ); + }, + (options) => { + if (utilStringHashCode(options.anki.fieldTemplates) === 1285806040) { + options.anki.fieldTemplates = profileOptionsGetDefaultFieldTemplates(); + } + }, + (options) => { + if (utilStringHashCode(options.anki.fieldTemplates) === -250091611) { + options.anki.fieldTemplates = profileOptionsGetDefaultFieldTemplates(); + } + } +]; + +function profileOptionsGetDefaultFieldTemplates() { return ` {{#*inline "glossary-single"}} {{~#unless brief~}} @@ -183,8 +243,8 @@ function optionsFieldTemplates() { `.trim(); } -function optionsSetDefaults(options) { - const defaults = { +function profileOptionsCreateDefaults() { + return { general: { enable: true, audioSource: 'jpod101', @@ -235,9 +295,13 @@ function optionsSetDefaults(options) { screenshot: {format: 'png', quality: 92}, terms: {deck: '', model: '', fields: {}}, kanji: {deck: '', model: '', fields: {}}, - fieldTemplates: optionsFieldTemplates() + fieldTemplates: profileOptionsGetDefaultFieldTemplates() } }; +} + +function profileOptionsSetDefaults(options) { + const defaults = profileOptionsCreateDefaults(); const combine = (target, source) => { for (const key in source) { @@ -257,81 +321,119 @@ function optionsSetDefaults(options) { return options; } -function optionsVersion(options) { - const fixups = [ - () => {}, - () => {}, - () => {}, - () => {}, - () => { - if (options.general.audioPlayback) { - options.general.audioSource = 'jpod101'; - } else { - options.general.audioSource = 'disabled'; - } - }, - () => { - options.general.showGuide = false; - }, - () => { - if (options.scanning.requireShift) { - options.scanning.modifier = 'shift'; - } else { - options.scanning.modifier = 'none'; - } - }, - () => { - if (options.general.groupResults) { - options.general.resultOutputMode = 'group'; - } else { - options.general.resultOutputMode = 'split'; - } - if (utilStringHashCode(options.anki.fieldTemplates) !== -805327496) { - options.anki.fieldTemplates = `{{#if merge}}${optionsFieldTemplates()}{{else}}${options.anki.fieldTemplates}{{/if}}`; - } else { - options.anki.fieldTemplates = optionsFieldTemplates(); - } - }, - () => { - if (utilStringHashCode(options.anki.fieldTemplates) === 1285806040) { - options.anki.fieldTemplates = optionsFieldTemplates(); - } - }, - () => { - if (utilStringHashCode(options.anki.fieldTemplates) === -250091611) { - options.anki.fieldTemplates = optionsFieldTemplates(); - } +function profileOptionsUpdateVersion(options) { + profileOptionsSetDefaults(options); + return optionsGenericApplyUpdates(options, profileOptionsVersionUpdates); +} + + +/* + * Global options + * + * Each profile has an array named "conditionGroups", which is an array of condition groups + * which enable the contextual selection of profiles. The structure of the array is as follows: + * [ + * { + * conditions: [ + * { + * type: "string", + * operator: "string", + * value: "string" + * }, + * // ... + * ] + * }, + * // ... + * ] + */ + +const optionsVersionUpdates = []; + +function optionsUpdateVersion(options, defaultProfileOptions) { + // Ensure profiles is an array + if (!Array.isArray(options.profiles)) { + options.profiles = []; + } + + // Remove invalid + const profiles = options.profiles; + for (let i = profiles.length - 1; i >= 0; --i) { + if (!utilIsObject(profiles[i])) { + profiles.splice(i, 1); } - ]; + } - optionsSetDefaults(options); - if (!options.hasOwnProperty('version')) { - options.version = fixups.length; + // Require at least one profile + if (profiles.length === 0) { + profiles.push({ + name: 'Default', + options: defaultProfileOptions, + conditionGroups: [] + }); } - while (options.version < fixups.length) { - fixups[options.version++](); + // Ensure profileCurrent is valid + const profileCurrent = options.profileCurrent; + if (!( + typeof profileCurrent === 'number' && + Number.isFinite(profileCurrent) && + Math.floor(profileCurrent) === profileCurrent && + profileCurrent >= 0 && + profileCurrent < profiles.length + )) { + options.profileCurrent = 0; } - return options; + // Update profile options + for (const profile of profiles) { + if (!Array.isArray(profile.conditionGroups)) { + profile.conditionGroups = []; + } + profile.options = profileOptionsUpdateVersion(profile.options); + } + + // Generic updates + return optionsGenericApplyUpdates(options, optionsVersionUpdates); } function optionsLoad() { return new Promise((resolve, reject) => { - chrome.storage.local.get(null, store => resolve(store.options)); + chrome.storage.local.get(['options'], store => { + const error = chrome.runtime.lastError; + if (error) { + reject(error); + } else { + resolve(store.options); + } + }); }).then(optionsStr => { - return optionsStr ? JSON.parse(optionsStr) : {}; - }).catch(error => { + if (typeof optionsStr === 'string') { + const options = JSON.parse(optionsStr); + if (utilIsObject(options)) { + return options; + } + } + return {}; + }).catch(() => { return {}; }).then(options => { - return optionsVersion(options); + return ( + Array.isArray(options.profiles) ? + optionsUpdateVersion(options, {}) : + optionsUpdateVersion({}, options) + ); }); } function optionsSave(options) { return new Promise((resolve, reject) => { - chrome.storage.local.set({options: JSON.stringify(options)}, resolve); - }).then(() => { - apiOptionsSet(options); + chrome.storage.local.set({options: JSON.stringify(options)}, () => { + const error = chrome.runtime.lastError; + if (error) { + reject(error); + } else { + resolve(); + } + }); }); } diff --git a/ext/bg/js/profile-conditions.js b/ext/bg/js/profile-conditions.js new file mode 100644 index 00000000..5daa904e --- /dev/null +++ b/ext/bg/js/profile-conditions.js @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 <http://www.gnu.org/licenses/>. + */ + + +const profileConditionsDescriptor = { + popupLevel: { + name: 'Popup Level', + description: 'Use profile depending on the level of the popup.', + placeholder: 'Number', + type: 'number', + step: 1, + defaultValue: 0, + defaultOperator: 'equal', + transform: (optionValue) => parseInt(optionValue, 10), + transformReverse: (transformedOptionValue) => `${transformedOptionValue}`, + validateTransformed: (transformedOptionValue) => Number.isFinite(transformedOptionValue), + operators: { + equal: { + name: '=', + test: ({depth}, optionValue) => (depth === optionValue) + }, + notEqual: { + name: '\u2260', + test: ({depth}, optionValue) => (depth !== optionValue) + }, + lessThan: { + name: '<', + test: ({depth}, optionValue) => (depth < optionValue) + }, + greaterThan: { + name: '>', + test: ({depth}, optionValue) => (depth > optionValue) + }, + lessThanOrEqual: { + name: '\u2264', + test: ({depth}, optionValue) => (depth <= optionValue) + }, + greaterThanOrEqual: { + name: '\u2265', + test: ({depth}, optionValue) => (depth >= optionValue) + } + } + }, + url: { + name: 'URL', + description: 'Use profile depending on the URL of the current website.', + defaultOperator: 'matchDomain', + operators: { + matchDomain: { + name: 'Matches Domain', + placeholder: 'Comma separated list of domains', + defaultValue: 'example.com', + transformCache: {}, + transform: (optionValue) => optionValue.split(/[,;\s]+/).map(v => v.trim().toLowerCase()).filter(v => v.length > 0), + transformReverse: (transformedOptionValue) => transformedOptionValue.join(', '), + validateTransformed: (transformedOptionValue) => (transformedOptionValue.length > 0), + test: ({url}, transformedOptionValue) => (transformedOptionValue.indexOf(new URL(url).hostname.toLowerCase()) >= 0) + }, + matchRegExp: { + name: 'Matches RegExp', + placeholder: 'Regular expression', + defaultValue: 'example\\.com', + transformCache: {}, + transform: (optionValue) => new RegExp(optionValue, 'i'), + transformReverse: (transformedOptionValue) => transformedOptionValue.source, + test: ({url}, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(url)) + } + } + } +}; diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js index 840a1ea8..faec29ef 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-frontend.js @@ -18,7 +18,11 @@ async function searchFrontendSetup() { - const options = await apiOptionsGet(); + const optionsContext = { + depth: 0, + url: window.location.href + }; + const options = await apiOptionsGet(optionsContext); if (!options.scanning.enableOnSearchPage) { return; } const scriptSrcs = [ diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index a3382398..6ff710f0 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -21,6 +21,11 @@ class DisplaySearch extends Display { constructor() { super($('#spinner'), $('#content')); + this.optionsContext = { + depth: 0, + url: window.location.href + }; + this.search = $('#search').click(this.onSearch.bind(this)); this.query = $('#query').on('input', this.onSearchInput.bind(this)); this.intro = $('#intro'); @@ -46,8 +51,8 @@ class DisplaySearch extends Display { try { e.preventDefault(); this.intro.slideUp(); - const {length, definitions} = await apiTermsFind(this.query.val()); - super.termsShow(definitions, await apiOptionsGet()); + const {length, definitions} = await apiTermsFind(this.query.val(), this.optionsContext); + super.termsShow(definitions, await apiOptionsGet(this.optionsContext)); } catch (e) { this.onError(e); } diff --git a/ext/bg/js/settings-profiles.js b/ext/bg/js/settings-profiles.js new file mode 100644 index 00000000..ededc998 --- /dev/null +++ b/ext/bg/js/settings-profiles.js @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2019 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 <http://www.gnu.org/licenses/>. + */ + +let currentProfileIndex = 0; +let profileConditionsContainer = null; + +function getOptionsContext() { + return { + index: currentProfileIndex + }; +} + + +async function profileOptionsSetup() { + const optionsFull = await apiOptionsGetFull(); + currentProfileIndex = optionsFull.profileCurrent; + + profileOptionsSetupEventListeners(); + await profileOptionsUpdateTarget(optionsFull); +} + +function profileOptionsSetupEventListeners() { + $('#profile-target').change(utilAsync(onTargetProfileChanged)); + $('#profile-name').change(onProfileNameChanged); + $('#profile-add').click(utilAsync(onProfileAdd)); + $('#profile-remove').click(utilAsync(onProfileRemove)); + $('#profile-remove-confirm').click(utilAsync(onProfileRemoveConfirm)); + $('#profile-copy').click(utilAsync(onProfileCopy)); + $('#profile-copy-confirm').click(utilAsync(onProfileCopyConfirm)); + $('#profile-move-up').click(() => onProfileMove(-1)); + $('#profile-move-down').click(() => onProfileMove(1)); + $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change(utilAsync(onProfileOptionsChanged)); +} + +function tryGetIntegerValue(selector, min, max) { + const value = parseInt($(selector).val(), 10); + return ( + typeof value === 'number' && + Number.isFinite(value) && + Math.floor(value) === value && + value >= min && + value < max + ) ? value : null; +} + +async function profileFormRead(optionsFull) { + const profile = optionsFull.profiles[currentProfileIndex]; + + // Current profile + const index = tryGetIntegerValue('#profile-active', 0, optionsFull.profiles.length); + if (index !== null) { + optionsFull.profileCurrent = index; + } + + // Profile name + profile.name = $('#profile-name').val(); +} + +async function profileFormWrite(optionsFull) { + const profile = optionsFull.profiles[currentProfileIndex]; + + profileOptionsPopulateSelect($('#profile-active'), optionsFull.profiles, optionsFull.profileCurrent, null); + profileOptionsPopulateSelect($('#profile-target'), optionsFull.profiles, currentProfileIndex, null); + $('#profile-remove').prop('disabled', optionsFull.profiles.length <= 1); + $('#profile-copy').prop('disabled', optionsFull.profiles.length <= 1); + $('#profile-move-up').prop('disabled', currentProfileIndex <= 0); + $('#profile-move-down').prop('disabled', currentProfileIndex >= optionsFull.profiles.length - 1); + + $('#profile-name').val(profile.name); + + if (profileConditionsContainer !== null) { + profileConditionsContainer.cleanup(); + } + + profileConditionsContainer = new ConditionsUI.Container( + profileConditionsDescriptor, + 'popupLevel', + profile.conditionGroups, + $('#profile-condition-groups'), + $('#profile-add-condition-group') + ); + profileConditionsContainer.save = () => { + apiOptionsSave(); + conditionsClearCaches(profileConditionsDescriptor); + }; + profileConditionsContainer.isolate = utilBackgroundIsolate; +} + +function profileOptionsPopulateSelect(select, profiles, currentValue, ignoreIndices) { + select.empty(); + + + for (let i = 0; i < profiles.length; ++i) { + if (ignoreIndices !== null && ignoreIndices.indexOf(i) >= 0) { + continue; + } + const profile = profiles[i]; + select.append($(`<option value="${i}">${profile.name}</option>`)); + } + + select.val(`${currentValue}`); +} + +async function profileOptionsUpdateTarget(optionsFull) { + profileFormWrite(optionsFull); + + const optionsContext = getOptionsContext(); + const options = await apiOptionsGet(optionsContext); + await formWrite(options); +} + +function profileOptionsCreateCopyName(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 = parseInt(match[4], 10) + 1; + } else { + space = ' '; + index = 2; + } + } + + let i = 0; + while (true) { + const newName = `${prefix}${space}${index}${suffix}`; + if (i++ >= maxUniqueAttempts || profiles.findIndex(profile => profile.name === newName) < 0) { + return newName; + } + if (typeof index !== 'number') { + index = 2; + space = ' '; + } else { + ++index; + } + } +} + +async function onProfileOptionsChanged(e) { + if (!e.originalEvent && !e.isTrigger) { + return; + } + + const optionsFull = await apiOptionsGetFull(); + await profileFormRead(optionsFull); + await apiOptionsSave(); +} + +async function onTargetProfileChanged() { + const optionsFull = await apiOptionsGetFull(); + const index = tryGetIntegerValue('#profile-target', 0, optionsFull.profiles.length); + if (index === null || currentProfileIndex === index) { + return; + } + + currentProfileIndex = index; + + await profileOptionsUpdateTarget(optionsFull); +} + +async function onProfileAdd() { + const optionsFull = await apiOptionsGetFull(); + const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]); + profile.name = profileOptionsCreateCopyName(profile.name, optionsFull.profiles, 100); + optionsFull.profiles.push(profile); + currentProfileIndex = optionsFull.profiles.length - 1; + await profileOptionsUpdateTarget(optionsFull); + await apiOptionsSave(); +} + +async function onProfileRemove(e) { + if (e.shiftKey) { + return await onProfileRemoveConfirm(); + } + + const optionsFull = await apiOptionsGetFull(); + if (optionsFull.profiles.length <= 1) { + return; + } + + const profile = optionsFull.profiles[currentProfileIndex]; + + $('#profile-remove-modal-profile-name').text(profile.name); + $('#profile-remove-modal').modal('show'); +} + +async function onProfileRemoveConfirm() { + $('#profile-remove-modal').modal('hide'); + + const optionsFull = await apiOptionsGetFull(); + if (optionsFull.profiles.length <= 1) { + return; + } + + optionsFull.profiles.splice(currentProfileIndex, 1); + + if (currentProfileIndex >= optionsFull.profiles.length) { + --currentProfileIndex; + } + + if (optionsFull.profileCurrent >= optionsFull.profiles.length) { + optionsFull.profileCurrent = optionsFull.profiles.length - 1; + } + + await profileOptionsUpdateTarget(optionsFull); + await apiOptionsSave(); +} + +function onProfileNameChanged() { + $('#profile-active, #profile-target').find(`[value="${currentProfileIndex}"]`).text(this.value); +} + +async function onProfileMove(offset) { + const optionsFull = await apiOptionsGetFull(); + const index = currentProfileIndex + offset; + if (index < 0 || index >= optionsFull.profiles.length) { + return; + } + + const profile = optionsFull.profiles[currentProfileIndex]; + optionsFull.profiles.splice(currentProfileIndex, 1); + optionsFull.profiles.splice(index, 0, profile); + + if (optionsFull.profileCurrent === currentProfileIndex) { + optionsFull.profileCurrent = index; + } + + currentProfileIndex = index; + + await profileOptionsUpdateTarget(optionsFull); + await settingsSaveOptions(); +} + +async function onProfileCopy() { + const optionsFull = await apiOptionsGetFull(); + if (optionsFull.profiles.length <= 1) { + return; + } + + profileOptionsPopulateSelect($('#profile-copy-source'), optionsFull.profiles, currentProfileIndex === 0 ? 1 : 0, [currentProfileIndex]); + $('#profile-copy-modal').modal('show'); +} + +async function onProfileCopyConfirm() { + $('#profile-copy-modal').modal('hide'); + + const optionsFull = await apiOptionsGetFull(); + const index = tryGetIntegerValue('#profile-copy-source', 0, optionsFull.profiles.length); + if (index === null || index === currentProfileIndex) { + return; + } + + const profileOptions = utilBackgroundIsolate(optionsFull.profiles[index].options); + optionsFull.profiles[currentProfileIndex].options = profileOptions; + + await profileOptionsUpdateTarget(optionsFull); + await settingsSaveOptions(); +} diff --git a/ext/bg/js/settings.js b/ext/bg/js/settings.js index 83f4528c..cb3ddd4e 100644 --- a/ext/bg/js/settings.js +++ b/ext/bg/js/settings.js @@ -16,73 +16,143 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. */ +async function getOptionsArray() { + const optionsFull = await apiOptionsGetFull(); + return optionsFull.profiles.map(profile => profile.options); +} -async function formRead() { - const optionsOld = await optionsLoad(); - const optionsNew = $.extend(true, {}, optionsOld); - - optionsNew.general.showGuide = $('#show-usage-guide').prop('checked'); - optionsNew.general.compactTags = $('#compact-tags').prop('checked'); - optionsNew.general.compactGlossaries = $('#compact-glossaries').prop('checked'); - optionsNew.general.autoPlayAudio = $('#auto-play-audio').prop('checked'); - optionsNew.general.resultOutputMode = $('#result-output-mode').val(); - optionsNew.general.audioSource = $('#audio-playback-source').val(); - optionsNew.general.audioVolume = parseFloat($('#audio-playback-volume').val()); - optionsNew.general.debugInfo = $('#show-debug-info').prop('checked'); - optionsNew.general.showAdvanced = $('#show-advanced-options').prop('checked'); - optionsNew.general.maxResults = parseInt($('#max-displayed-results').val(), 10); - optionsNew.general.popupDisplayMode = $('#popup-display-mode').val(); - optionsNew.general.popupHorizontalTextPosition = $('#popup-horizontal-text-position').val(); - optionsNew.general.popupVerticalTextPosition = $('#popup-vertical-text-position').val(); - optionsNew.general.popupWidth = parseInt($('#popup-width').val(), 10); - optionsNew.general.popupHeight = parseInt($('#popup-height').val(), 10); - optionsNew.general.popupHorizontalOffset = parseInt($('#popup-horizontal-offset').val(), 0); - optionsNew.general.popupVerticalOffset = parseInt($('#popup-vertical-offset').val(), 10); - optionsNew.general.popupHorizontalOffset2 = parseInt($('#popup-horizontal-offset2').val(), 0); - optionsNew.general.popupVerticalOffset2 = parseInt($('#popup-vertical-offset2').val(), 10); - optionsNew.general.customPopupCss = $('#custom-popup-css').val(); - - optionsNew.scanning.middleMouse = $('#middle-mouse-button-scan').prop('checked'); - optionsNew.scanning.touchInputEnabled = $('#touch-input-enabled').prop('checked'); - optionsNew.scanning.selectText = $('#select-matched-text').prop('checked'); - optionsNew.scanning.alphanumeric = $('#search-alphanumeric').prop('checked'); - optionsNew.scanning.autoHideResults = $('#auto-hide-results').prop('checked'); - optionsNew.scanning.deepDomScan = $('#deep-dom-scan').prop('checked'); - optionsNew.scanning.enableOnPopupExpressions = $('#enable-scanning-of-popup-expressions').prop('checked'); - optionsNew.scanning.enableOnSearchPage = $('#enable-scanning-on-search-page').prop('checked'); - optionsNew.scanning.delay = parseInt($('#scan-delay').val(), 10); - optionsNew.scanning.length = parseInt($('#scan-length').val(), 10); - optionsNew.scanning.modifier = $('#scan-modifier-key').val(); - optionsNew.scanning.popupNestingMaxDepth = parseInt($('#popup-nesting-max-depth').val(), 10); - - optionsNew.anki.enable = $('#anki-enable').prop('checked'); - optionsNew.anki.tags = $('#card-tags').val().split(/[,; ]+/); - optionsNew.anki.sentenceExt = parseInt($('#sentence-detection-extent').val(), 10); - optionsNew.anki.server = $('#interface-server').val(); - optionsNew.anki.screenshot.format = $('#screenshot-format').val(); - optionsNew.anki.screenshot.quality = parseInt($('#screenshot-quality').val(), 10); - optionsNew.anki.fieldTemplates = $('#field-templates').val(); - - if (optionsOld.anki.enable && !ankiErrorShown()) { - optionsNew.anki.terms.deck = $('#anki-terms-deck').val(); - optionsNew.anki.terms.model = $('#anki-terms-model').val(); - optionsNew.anki.terms.fields = ankiFieldsToDict($('#terms .anki-field-value')); - optionsNew.anki.kanji.deck = $('#anki-kanji-deck').val(); - optionsNew.anki.kanji.model = $('#anki-kanji-model').val(); - optionsNew.anki.kanji.fields = ankiFieldsToDict($('#kanji .anki-field-value')); +async function formRead(options) { + options.general.enable = $('#enable').prop('checked'); + options.general.showGuide = $('#show-usage-guide').prop('checked'); + options.general.compactTags = $('#compact-tags').prop('checked'); + options.general.compactGlossaries = $('#compact-glossaries').prop('checked'); + options.general.autoPlayAudio = $('#auto-play-audio').prop('checked'); + options.general.resultOutputMode = $('#result-output-mode').val(); + options.general.audioSource = $('#audio-playback-source').val(); + options.general.audioVolume = parseFloat($('#audio-playback-volume').val()); + options.general.debugInfo = $('#show-debug-info').prop('checked'); + options.general.showAdvanced = $('#show-advanced-options').prop('checked'); + options.general.maxResults = parseInt($('#max-displayed-results').val(), 10); + options.general.popupDisplayMode = $('#popup-display-mode').val(); + options.general.popupHorizontalTextPosition = $('#popup-horizontal-text-position').val(); + options.general.popupVerticalTextPosition = $('#popup-vertical-text-position').val(); + options.general.popupWidth = parseInt($('#popup-width').val(), 10); + options.general.popupHeight = parseInt($('#popup-height').val(), 10); + options.general.popupHorizontalOffset = parseInt($('#popup-horizontal-offset').val(), 0); + options.general.popupVerticalOffset = parseInt($('#popup-vertical-offset').val(), 10); + options.general.popupHorizontalOffset2 = parseInt($('#popup-horizontal-offset2').val(), 0); + options.general.popupVerticalOffset2 = parseInt($('#popup-vertical-offset2').val(), 10); + options.general.customPopupCss = $('#custom-popup-css').val(); + + options.scanning.middleMouse = $('#middle-mouse-button-scan').prop('checked'); + options.scanning.touchInputEnabled = $('#touch-input-enabled').prop('checked'); + options.scanning.selectText = $('#select-matched-text').prop('checked'); + options.scanning.alphanumeric = $('#search-alphanumeric').prop('checked'); + options.scanning.autoHideResults = $('#auto-hide-results').prop('checked'); + options.scanning.deepDomScan = $('#deep-dom-scan').prop('checked'); + options.scanning.enableOnPopupExpressions = $('#enable-scanning-of-popup-expressions').prop('checked'); + options.scanning.enableOnSearchPage = $('#enable-scanning-on-search-page').prop('checked'); + options.scanning.delay = parseInt($('#scan-delay').val(), 10); + options.scanning.length = parseInt($('#scan-length').val(), 10); + options.scanning.modifier = $('#scan-modifier-key').val(); + options.scanning.popupNestingMaxDepth = parseInt($('#popup-nesting-max-depth').val(), 10); + + const optionsAnkiEnableOld = options.anki.enable; + options.anki.enable = $('#anki-enable').prop('checked'); + options.anki.tags = utilBackgroundIsolate($('#card-tags').val().split(/[,; ]+/)); + options.anki.sentenceExt = parseInt($('#sentence-detection-extent').val(), 10); + options.anki.server = $('#interface-server').val(); + options.anki.screenshot.format = $('#screenshot-format').val(); + options.anki.screenshot.quality = parseInt($('#screenshot-quality').val(), 10); + options.anki.fieldTemplates = $('#field-templates').val(); + + if (optionsAnkiEnableOld && !ankiErrorShown()) { + options.anki.terms.deck = $('#anki-terms-deck').val(); + options.anki.terms.model = $('#anki-terms-model').val(); + options.anki.terms.fields = utilBackgroundIsolate(ankiFieldsToDict($('#terms .anki-field-value'))); + options.anki.kanji.deck = $('#anki-kanji-deck').val(); + options.anki.kanji.model = $('#anki-kanji-model').val(); + options.anki.kanji.fields = utilBackgroundIsolate(ankiFieldsToDict($('#kanji .anki-field-value'))); } - optionsNew.general.mainDictionary = $('#dict-main').val(); + options.general.mainDictionary = $('#dict-main').val(); $('.dict-group').each((index, element) => { const dictionary = $(element); - optionsNew.dictionaries[dictionary.data('title')] = { + options.dictionaries[dictionary.data('title')] = utilBackgroundIsolate({ priority: parseInt(dictionary.find('.dict-priority').val(), 10), enabled: dictionary.find('.dict-enabled').prop('checked'), allowSecondarySearches: dictionary.find('.dict-allow-secondary-searches').prop('checked') - }; + }); }); +} + +async function formWrite(options) { + $('#enable').prop('checked', options.general.enable); + $('#show-usage-guide').prop('checked', options.general.showGuide); + $('#compact-tags').prop('checked', options.general.compactTags); + $('#compact-glossaries').prop('checked', options.general.compactGlossaries); + $('#auto-play-audio').prop('checked', options.general.autoPlayAudio); + $('#result-output-mode').val(options.general.resultOutputMode); + $('#audio-playback-source').val(options.general.audioSource); + $('#audio-playback-volume').val(options.general.audioVolume); + $('#show-debug-info').prop('checked', options.general.debugInfo); + $('#show-advanced-options').prop('checked', options.general.showAdvanced); + $('#max-displayed-results').val(options.general.maxResults); + $('#popup-display-mode').val(options.general.popupDisplayMode); + $('#popup-horizontal-text-position').val(options.general.popupHorizontalTextPosition); + $('#popup-vertical-text-position').val(options.general.popupVerticalTextPosition); + $('#popup-width').val(options.general.popupWidth); + $('#popup-height').val(options.general.popupHeight); + $('#popup-horizontal-offset').val(options.general.popupHorizontalOffset); + $('#popup-vertical-offset').val(options.general.popupVerticalOffset); + $('#popup-horizontal-offset2').val(options.general.popupHorizontalOffset2); + $('#popup-vertical-offset2').val(options.general.popupVerticalOffset2); + $('#custom-popup-css').val(options.general.customPopupCss); + + $('#middle-mouse-button-scan').prop('checked', options.scanning.middleMouse); + $('#touch-input-enabled').prop('checked', options.scanning.touchInputEnabled); + $('#select-matched-text').prop('checked', options.scanning.selectText); + $('#search-alphanumeric').prop('checked', options.scanning.alphanumeric); + $('#auto-hide-results').prop('checked', options.scanning.autoHideResults); + $('#deep-dom-scan').prop('checked', options.scanning.deepDomScan); + $('#enable-scanning-of-popup-expressions').prop('checked', options.scanning.enableOnPopupExpressions); + $('#enable-scanning-on-search-page').prop('checked', options.scanning.enableOnSearchPage); + $('#scan-delay').val(options.scanning.delay); + $('#scan-length').val(options.scanning.length); + $('#scan-modifier-key').val(options.scanning.modifier); + $('#popup-nesting-max-depth').val(options.scanning.popupNestingMaxDepth); + + $('#anki-enable').prop('checked', options.anki.enable); + $('#card-tags').val(options.anki.tags.join(' ')); + $('#sentence-detection-extent').val(options.anki.sentenceExt); + $('#interface-server').val(options.anki.server); + $('#screenshot-format').val(options.anki.screenshot.format); + $('#screenshot-quality').val(options.anki.screenshot.quality); + $('#field-templates').val(options.anki.fieldTemplates); + + try { + await dictionaryGroupsPopulate(options); + await formMainDictionaryOptionsPopulate(options); + } catch (e) { + dictionaryErrorsShow([e]); + } - return {optionsNew, optionsOld}; + try { + await ankiDeckAndModelPopulate(options); + } catch (e) { + ankiErrorShow(e); + } + + formUpdateVisibility(options); +} + +function formSetupEventListeners() { + $('#dict-purge-link').click(utilAsync(onDictionaryPurge)); + $('#dict-file').change(utilAsync(onDictionaryImport)); + + $('#field-templates-reset').click(utilAsync(onAnkiFieldTemplatesReset)); + $('input, select, textarea').not('.anki-model').not('.profile-form *').change(utilAsync(onFormOptionsChanged)); + $('.anki-model').change(utilAsync(onAnkiModelChanged)); } function formUpdateVisibility(options) { @@ -141,18 +211,23 @@ async function onFormOptionsChanged(e) { return; } - const {optionsNew, optionsOld} = await formRead(); - await optionsSave(optionsNew); - formUpdateVisibility(optionsNew); + const optionsContext = getOptionsContext(); + const options = await apiOptionsGet(optionsContext); + const optionsAnkiEnableOld = options.anki.enable; + const optionsAnkiServerOld = options.anki.server; + + await formRead(options); + await settingsSaveOptions(); + formUpdateVisibility(options); try { const ankiUpdated = - optionsNew.anki.enable !== optionsOld.anki.enable || - optionsNew.anki.server !== optionsOld.anki.server; + options.anki.enable !== optionsAnkiEnableOld || + options.anki.server !== optionsAnkiServerOld; if (ankiUpdated) { ankiSpinnerShow(true); - await ankiDeckAndModelPopulate(optionsNew); + await ankiDeckAndModelPopulate(options); ankiErrorShow(); } } catch (e) { @@ -163,75 +238,46 @@ async function onFormOptionsChanged(e) { } async function onReady() { - const options = await optionsLoad(); + formSetupEventListeners(); + await profileOptionsSetup(); - $('#show-usage-guide').prop('checked', options.general.showGuide); - $('#compact-tags').prop('checked', options.general.compactTags); - $('#compact-glossaries').prop('checked', options.general.compactGlossaries); - $('#auto-play-audio').prop('checked', options.general.autoPlayAudio); - $('#result-output-mode').val(options.general.resultOutputMode); - $('#audio-playback-source').val(options.general.audioSource); - $('#audio-playback-volume').val(options.general.audioVolume); - $('#show-debug-info').prop('checked', options.general.debugInfo); - $('#show-advanced-options').prop('checked', options.general.showAdvanced); - $('#max-displayed-results').val(options.general.maxResults); - $('#popup-display-mode').val(options.general.popupDisplayMode); - $('#popup-horizontal-text-position').val(options.general.popupHorizontalTextPosition); - $('#popup-vertical-text-position').val(options.general.popupVerticalTextPosition); - $('#popup-width').val(options.general.popupWidth); - $('#popup-height').val(options.general.popupHeight); - $('#popup-horizontal-offset').val(options.general.popupHorizontalOffset); - $('#popup-vertical-offset').val(options.general.popupVerticalOffset); - $('#popup-horizontal-offset2').val(options.general.popupHorizontalOffset2); - $('#popup-vertical-offset2').val(options.general.popupVerticalOffset2); - $('#custom-popup-css').val(options.general.customPopupCss); + storageInfoInitialize(); - $('#middle-mouse-button-scan').prop('checked', options.scanning.middleMouse); - $('#touch-input-enabled').prop('checked', options.scanning.touchInputEnabled); - $('#select-matched-text').prop('checked', options.scanning.selectText); - $('#search-alphanumeric').prop('checked', options.scanning.alphanumeric); - $('#auto-hide-results').prop('checked', options.scanning.autoHideResults); - $('#deep-dom-scan').prop('checked', options.scanning.deepDomScan); - $('#enable-scanning-of-popup-expressions').prop('checked', options.scanning.enableOnPopupExpressions); - $('#enable-scanning-on-search-page').prop('checked', options.scanning.enableOnSearchPage); - $('#scan-delay').val(options.scanning.delay); - $('#scan-length').val(options.scanning.length); - $('#scan-modifier-key').val(options.scanning.modifier); - $('#popup-nesting-max-depth').val(options.scanning.popupNestingMaxDepth); + chrome.runtime.onMessage.addListener(onMessage); +} - $('#dict-purge-link').click(utilAsync(onDictionaryPurge)); - $('#dict-file').change(utilAsync(onDictionaryImport)); +$(document).ready(utilAsync(onReady)); - $('#anki-enable').prop('checked', options.anki.enable); - $('#card-tags').val(options.anki.tags.join(' ')); - $('#sentence-detection-extent').val(options.anki.sentenceExt); - $('#interface-server').val(options.anki.server); - $('#screenshot-format').val(options.anki.screenshot.format); - $('#screenshot-quality').val(options.anki.screenshot.quality); - $('#field-templates').val(options.anki.fieldTemplates); - $('#field-templates-reset').click(utilAsync(onAnkiFieldTemplatesReset)); - $('input, select, textarea').not('.anki-model').change(utilAsync(onFormOptionsChanged)); - $('.anki-model').change(utilAsync(onAnkiModelChanged)); - try { - await dictionaryGroupsPopulate(options); - await formMainDictionaryOptionsPopulate(options); - } catch (e) { - dictionaryErrorsShow([e]); - } +/* + * Remote options updates + */ - try { - await ankiDeckAndModelPopulate(options); - } catch (e) { - ankiErrorShow(e); - } +function settingsGetSource() { + return new Promise((resolve) => { + chrome.tabs.getCurrent((tab) => resolve(`settings${tab ? tab.id : ''}`)); + }); +} - formUpdateVisibility(options); +async function settingsSaveOptions() { + const source = await settingsGetSource(); + await apiOptionsSave(source); +} - storageInfoInitialize(); +async function onOptionsUpdate({source}) { + const thisSource = await settingsGetSource(); + if (source === thisSource) { return; } + + const optionsContext = getOptionsContext(); + const options = await apiOptionsGet(optionsContext); + await formWrite(options); } -$(document).ready(utilAsync(onReady)); +function onMessage({action, params}) { + if (action === 'optionsUpdate') { + onOptionsUpdate(params); + } +} /* @@ -374,11 +420,14 @@ async function onDictionaryPurge(e) { dictionarySpinnerShow(true); await utilDatabasePurge(); - const options = await optionsLoad(); - options.dictionaries = {}; - options.general.mainDictionary = ''; - await optionsSave(options); + for (const options of await getOptionsArray()) { + options.dictionaries = utilBackgroundIsolate({}); + options.general.mainDictionary = ''; + } + await settingsSaveOptions(); + const optionsContext = getOptionsContext(); + const options = await apiOptionsGet(optionsContext); await dictionaryGroupsPopulate(options); await formMainDictionaryOptionsPopulate(options); } catch (e) { @@ -414,20 +463,26 @@ async function onDictionaryImport(e) { setProgress(0.0); const exceptions = []; - const options = await optionsLoad(); const summary = await utilDatabaseImport(e.target.files[0], updateProgress, exceptions); - options.dictionaries[summary.title] = {enabled: true, priority: 0, allowSecondarySearches: false}; - if (summary.sequenced && options.general.mainDictionary === '') { - options.general.mainDictionary = summary.title; + for (const options of await getOptionsArray()) { + options.dictionaries[summary.title] = utilBackgroundIsolate({ + enabled: true, + priority: 0, + allowSecondarySearches: false + }); + if (summary.sequenced && options.general.mainDictionary === '') { + options.general.mainDictionary = summary.title; + } } + await settingsSaveOptions(); if (exceptions.length > 0) { exceptions.push(`Dictionary may not have been imported properly: ${exceptions.length} error${exceptions.length === 1 ? '' : 's'} reported.`); dictionaryErrorsShow(exceptions); } - await optionsSave(options); - + const optionsContext = getOptionsContext(); + const options = await apiOptionsGet(optionsContext); await dictionaryGroupsPopulate(options); await formMainDictionaryOptionsPopulate(options); } catch (e) { @@ -566,12 +621,14 @@ async function onAnkiModelChanged(e) { const tab = element.closest('.tab-pane'); const tabId = tab.attr('id'); - const {optionsNew, optionsOld} = await formRead(); - optionsNew.anki[tabId].fields = {}; - await optionsSave(optionsNew); + const optionsContext = getOptionsContext(); + const options = await apiOptionsGet(optionsContext); + await formRead(options); + options.anki[tabId].fields = utilBackgroundIsolate({}); + await settingsSaveOptions(); ankiSpinnerShow(true); - await ankiFieldsPopulate(element, optionsNew); + await ankiFieldsPopulate(element, options); ankiErrorShow(); } catch (e) { ankiErrorShow(e); @@ -583,9 +640,12 @@ async function onAnkiModelChanged(e) { async function onAnkiFieldTemplatesReset(e) { try { e.preventDefault(); - const options = await optionsLoad(); - $('#field-templates').val(options.anki.fieldTemplates = optionsFieldTemplates()); - await optionsSave(options); + const optionsContext = getOptionsContext(); + const options = await apiOptionsGet(optionsContext); + const fieldTemplates = profileOptionsGetDefaultFieldTemplates(); + options.anki.fieldTemplates = fieldTemplates; + $('#field-templates').val(fieldTemplates); + await settingsSaveOptions(); } catch (e) { ankiErrorShow(e); } diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js index c89b43ff..7b952622 100644 --- a/ext/bg/js/translator.js +++ b/ext/bg/js/translator.js @@ -36,8 +36,7 @@ class Translator { } } - async findTermsGrouped(text, dictionaries, alphanumeric) { - const options = await apiOptionsGet(); + async findTermsGrouped(text, dictionaries, alphanumeric, options) { const titles = Object.keys(dictionaries); const {length, definitions} = await this.findTerms(text, dictionaries, alphanumeric); @@ -55,8 +54,7 @@ class Translator { return {length, definitions: definitionsGrouped}; } - async findTermsMerged(text, dictionaries, alphanumeric) { - const options = await apiOptionsGet(); + async findTermsMerged(text, dictionaries, alphanumeric, options) { const secondarySearchTitles = Object.keys(options.dictionaries).filter(dict => options.dictionaries[dict].allowSecondarySearches); const titles = Object.keys(dictionaries); const {length, definitions} = await this.findTerms(text, dictionaries, alphanumeric); diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 3dc7c900..73a8396f 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.js @@ -26,6 +26,11 @@ function utilIsolate(data) { return JSON.parse(JSON.stringify(data)); } +function utilBackgroundIsolate(data) { + const backgroundPage = chrome.extension.getBackgroundPage(); + return backgroundPage.utilIsolate(data); +} + function utilSetEqual(setA, setB) { if (setA.size !== setB.size) { return false; @@ -104,3 +109,7 @@ function utilReadFile(file) { reader.readAsBinaryString(file); }); } + +function utilIsObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/ext/bg/settings.html b/ext/bg/settings.html index 85b7ee5f..d38aa090 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -30,6 +30,59 @@ padding-bottom: 1em; } + .label-light { + font-weight: normal; + } + + .form-control.is-invalid { + border-color: #f00000; + } + + .condition>.condition-prefix:after { + content: "IF"; + } + .condition:nth-child(n+2)>.condition-prefix:after { + content: "AND"; + } + + .input-group .condition-prefix, + .input-group .condition-group-separator-label { + width: 60px; + text-align: center; + } + .input-group .condition-group-separator-label { + padding: 6px 12px; + font-weight: bold; + display: inline-block; + } + .input-group .condition-type, + .input-group .condition-operator { + width: auto; + text-align: center; + text-align-last: center; + } + + .condition-group>.condition>div:first-child { + border-bottom-left-radius: 0; + } + .condition-group>.condition:nth-child(n+2)>div:first-child { + border-top-left-radius: 0; + } + .condition-group>.condition:nth-child(n+2)>div:last-child>button { + border-top-right-radius: 0; + } + .condition-group>.condition:nth-last-child(n+2)>div:last-child>button { + border-bottom-right-radius: 0; + } + .condition-group-options>.condition-add { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + .condition-groups>*:last-of-type { + display: none; + } + #custom-popup-css { width: 100%; min-height: 34px; @@ -49,14 +102,134 @@ [data-browser=firefox-mobile] [data-show-for-browser~=firefox-mobile] { display: initial; } + + @media screen and (max-width: 740px) { + .col-xs-6 { + float: none; + width: 100%; + } + .col-xs-6+.col-xs-6 { + margin-top: 15px; + } + } </style> </head> <body> <div class="container-fluid"> + <div class="profile-form"> + <h3>Profiles</h3> + + <p class="help-block"> + Profiles allow you to create multiple configurations and quickly switch between them or use them in different contexts. + </p> + + <div class="form-group"> + <label for="profile-active">Active profile</label> + <select class="form-control" id="profile-active"></select> + </div> + + <div class="form-group"> + <label for="profile-target">Modifying profile</label> + <div class="input-group"> + <div class="input-group-btn"> + <button class="btn btn-default" id="profile-add" title="Add"><span class="glyphicon glyphicon-plus"></span></button> + <button class="btn btn-default" id="profile-move-up" title="Move up"><span class="glyphicon glyphicon-arrow-up"></span></button> + <button class="btn btn-default" id="profile-move-down" title="Move down"><span class="glyphicon glyphicon-arrow-down"></span></button> + <button class="btn btn-default" id="profile-copy" title="Copy"><span class="glyphicon glyphicon-copy"></span></button> + </div> + <select class="form-control profile-form-manual" id="profile-target"></select> + <div class="input-group-btn"> + <button class="btn btn-danger" id="profile-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button> + </div> + </div> + </div> + + <div class="form-group"> + <label for="profile-name">Profile name</label> + <input type="text" id="profile-name" class="form-control"> + </div> + + <div class="form-group"> + <label>Usage conditions</label> + + <p class="help-block"> + Usage conditions can be assigned such that certain profiles are automatically used in different contexts. + For example, when <a href="#popup-content-scanning">Popup Content Scanning</a> is enabled, different profiles can be used + depending on the level of the popup. + </p> + + <p class="help-block"> + Conditions are organized into groups which represent how the conditions are checked. + If all of the conditions in any group are met, then the profile will automatically be used for that context. + If no conditions are specified, the profile will only be used if it is selected as the <strong>Active profile</strong>. + </p> + + <div class="condition-groups" id="profile-condition-groups"></div> + </div> + <div class="form-group"> + <button class="btn btn-default" id="profile-add-condition-group">Add Condition Group</button> + </div> + + <div class="modal fade" tabindex="-1" role="dialog" id="profile-copy-modal"> + <div class="modal-dialog modal-dialog-centered"> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> + <h4 class="modal-title">Copy Profile</h4> + </div> + <div class="modal-body"> + <p>Select which profile to copy options from:</p> + <select class="form-control" id="profile-copy-source"></select> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> + <button type="button" class="btn btn-primary" id="profile-copy-confirm">Copy Profile</button> + </div> + </div> + </div> + </div> + + <div class="modal fade" tabindex="-1" role="dialog" id="profile-remove-modal"> + <div class="modal-dialog modal-dialog-centered"> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> + <h4 class="modal-title">Confirm profile removal</h4> + </div> + <div class="modal-body"> + Are you sure you want to delete the profile <em id="profile-remove-modal-profile-name"></em>? + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> + <button type="button" class="btn btn-danger" id="profile-remove-confirm">Remove Profile</button> + </div> + </div> + </div> + </div> + + <template id="condition-template"><div class="input-group condition"> + <div class="input-group-addon condition-prefix"></div> + <div class="input-group-btn"><select class="form-control btn btn-default condition-type"><optgroup label="Type"></optgroup></select></div> + <div class="input-group-btn"><select class="form-control btn btn-default condition-operator"><optgroup label="Operator"></optgroup></select></div> + <input type="text" class="form-control" /> + <div class="input-group-btn"><button class="btn btn-danger condition-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button></div> + </div></template> + <template id="condition-group-separator-template"><div class="input-group"> + <div class="condition-group-separator-label">OR</div> + </div></template> + <template id="condition-group-options-template"><div class="condition-group-options"> + <button class="btn btn-default condition-add"><span class="glyphicon glyphicon-plus"></span></button> + </div></template> + </div> + <div> <h3>General Options</h3> <div class="checkbox"> + <label><input type="checkbox" id="enable"> Enable content scanning</label> + </div> + + <div class="checkbox"> <label><input type="checkbox" id="show-usage-guide"> Show usage guide on startup</label> </div> @@ -107,6 +280,16 @@ </select> </div> + <div class="form-group options-advanced"> + <label for="audio-playback-volume">Audio playback volume <span class="label-light">(percent)</span></label> + <input type="number" min="0" max="100" id="audio-playback-volume" class="form-control"> + </div> + + <div class="form-group options-advanced"> + <label for="max-displayed-results">Maximum displayed results</label> + <input type="number" min="1" id="max-displayed-results" class="form-control"> + </div> + <div class="form-group"> <div class="row"> <div class="col-xs-6"> @@ -130,36 +313,41 @@ </div> <div class="form-group options-advanced"> - <label for="audio-playback-volume">Audio playback volume (percent)</label> - <input type="number" min="0" max="100" id="audio-playback-volume" class="form-control"> - </div> - - <div class="form-group options-advanced"> - <label for="max-displayed-results">Maximum displayed results</label> - <input type="number" min="1" id="max-displayed-results" class="form-control"> - </div> - - <div class="form-group options-advanced"> - <label>Popup size (width × height, in pixels)</label> <div class="row"> - <div class="col-xs-6"><input type="number" min="1" id="popup-width" class="form-control"></div> - <div class="col-xs-6"><input type="number" min="1" id="popup-height" class="form-control"></div> + <div class="col-xs-6"> + <label for="popup-display-mode">Popup width <span class="label-light">(in pixels)</span></label> + <input type="number" min="1" id="popup-width" class="form-control"> + </div> + <div class="col-xs-6"> + <label for="popup-display-mode">Popup height <span class="label-light">(in pixels)</span></label> + <input type="number" min="1" id="popup-height" class="form-control"> + </div> </div> </div> <div class="form-group options-advanced"> - <label>Popup offset (horizontal, vertical; in pixels)</label> <div class="row"> - <div class="col-xs-6"><input type="number" min="0" id="popup-horizontal-offset" class="form-control"></div> - <div class="col-xs-6"><input type="number" min="0" id="popup-vertical-offset" class="form-control"></div> + <div class="col-xs-6"> + <label for="popup-display-mode">Horizontal popup offset <span class="label-light">(in pixels)</span></label> + <input type="number" min="0" id="popup-horizontal-offset" class="form-control"> + </div> + <div class="col-xs-6"> + <label for="popup-display-mode">Vertical popup offset <span class="label-light">(in pixels)</span></label> + <input type="number" min="0" id="popup-vertical-offset" class="form-control"> + </div> </div> </div> <div class="form-group options-advanced"> - <label>Popup offset for vertical text (horizontal, vertical; in pixels)</label> <div class="row"> - <div class="col-xs-6"><input type="number" min="0" id="popup-horizontal-offset2" class="form-control"></div> - <div class="col-xs-6"><input type="number" min="0" id="popup-vertical-offset2" class="form-control"></div> + <div class="col-xs-6"> + <label for="popup-display-mode">Horizontal popup offset for vertical text <span class="label-light">(in pixels)</span></label> + <input type="number" min="0" id="popup-horizontal-offset2" class="form-control"> + </div> + <div class="col-xs-6"> + <label for="popup-display-mode">Vertical popup offset for vertical text <span class="label-light">(in pixels)</span></label> + <input type="number" min="0" id="popup-vertical-offset2" class="form-control"> + </div> </div> </div> @@ -193,24 +381,16 @@ </div> <div class="checkbox options-advanced"> - <label><input type="checkbox" id="enable-scanning-of-popup-expressions"> Enable scanning of popup expressions</label> - </div> - - <div class="checkbox"> - <label><input type="checkbox" id="enable-scanning-on-search-page"> Enable scanning on search page</label> - </div> - - <div class="checkbox options-advanced"> <label><input type="checkbox" id="deep-dom-scan"> Deep DOM scan</label> </div> <div class="form-group options-advanced"> - <label for="scan-delay">Scan delay (in milliseconds)</label> + <label for="scan-delay">Scan delay <span class="label-light">(in milliseconds)</span></label> <input type="number" min="1" id="scan-delay" class="form-control"> </div> <div class="form-group options-advanced"> - <label for="scan-length">Scan length (in characters)</label> + <label for="scan-length">Scan length <span class="label-light">(in characters)</span></label> <input type="number" min="1" id="scan-length" class="form-control"> </div> @@ -223,9 +403,26 @@ <option value="shift">Shift</option> </select> </div> + </div> - <div class="form-group options-advanced"> - <label for="popup-nesting-max-depth">Maximum nested popup depth</label> + <div id="popup-content-scanning"> + <h3>Popup Content Scanning Options</h3> + + <p class="help-block"> + Yomichan is able to create additional popups in order to scan the content of other popups. + This feature can be enabled if the <strong>Maximum number of additional popups</strong> option is set to a value greater than 0. + </p> + + <div class="checkbox"> + <label><input type="checkbox" id="enable-scanning-on-search-page"> Enable scanning on search page</label> + </div> + + <div class="checkbox"> + <label><input type="checkbox" id="enable-scanning-of-popup-expressions"> Enable scanning of popup expressions</label> + </div> + + <div class="form-group"> + <label for="popup-nesting-max-depth">Maximum number of additional popups</label> <input type="number" min="0" id="popup-nesting-max-depth" class="form-control"> </div> </div> @@ -265,7 +462,7 @@ <div id="dict-importer"> <p class="help-block"> Select a dictionary to import for use below. Please visit the Yomichan homepage to - <a href="https://foosoft.net/projects/yomichan" target="_blank">download free dictionaries</a> + <a href="https://foosoft.net/projects/yomichan" target="_blank" rel="noopener">download free dictionaries</a> for use with this extension and to learn about importing proprietary EPWING dictionaries. </p> <input type="file" id="dict-file"> @@ -291,7 +488,7 @@ <div data-show-for-browser="firefox firefox-mobile"><div class="alert alert-danger options-advanced"> On Firefox and Firefox for Android, the storage information feature may be hidden behind a browser flag. - If you would like to enable this flag, open <a href="about:config" target="_blank">about:config</a> and search for the + If you would like to enable this flag, open <a href="about:config" target="_blank" rel="noopener">about:config</a> and search for the <strong>dom.storageManager.enabled</strong> option. If this option has a value of <strong>false</strong>, toggling it to <strong>true</strong> may allow storage information to be calculated. </div></div> @@ -313,9 +510,9 @@ </div> <p class="help-block"> - Yomichan supports automatic flashcard creation for <a href="http://ankisrs.net/" target="_blank">Anki</a>, a free application + Yomichan supports automatic flashcard creation for <a href="https://apps.ankiweb.net/" target="_blank" rel="noopener">Anki</a>, a free application designed to help you remember. This feature requires installation of the - <a href="https://foosoft.net/projects/anki-connect/" target="_blank">AnkiConnect</a> plugin. + <a href="https://foosoft.net/projects/anki-connect/" target="_blank" rel="noopener">AnkiConnect</a> plugin. </p> <div class="alert alert-danger" id="anki-error"></div> @@ -326,12 +523,12 @@ <div id="anki-general"> <div class="form-group"> - <label for="card-tags">Card tags (comma or space separated)</label> + <label for="card-tags">Card tags <span class="label-light">(comma or space separated)</span></label> <input type="text" id="card-tags" class="form-control"> </div> <div class="form-group options-advanced"> - <label for="sentence-detection-extent">Sentence detection extent (in characters)</label> + <label for="sentence-detection-extent">Sentence detection extent <span class="label-light">(in characters)</span></label> <input type="number" min="1" id="sentence-detection-extent" class="form-control"> </div> @@ -349,7 +546,7 @@ </div> <div class="form-group options-advanced"> - <label for="screenshot-quality">Screenshot quality (JPEG only)</label> + <label for="screenshot-quality">Screenshot quality <span class="label-light">(JPEG only)</span></label> <input type="number" min="0" max="100" step="1" id="screenshot-quality" class="form-control"> </div> @@ -408,12 +605,12 @@ <div class="options-advanced"> <p class="help-block"> - Fields are formatted using the <a href="http://handlebarsjs.com/">Handlebars.js</a> template rendering + Fields are formatted using the <a href="https://handlebarsjs.com/" target="_blank" rel="noopener">Handlebars.js</a> template rendering engine. Advanced users can modify these templates for ultimate control of what information gets included in their Anki cards. If you encounter problems with your changes you can always <a href="#" id="field-templates-reset">reset to default</a> template settings. </p> - <textarea class="form-control" rows="10" id="field-templates"></textarea> + <textarea autocomplete="off" spellcheck="false" wrap="soft" class="form-control" rows="10" id="field-templates"></textarea> </div> </div> </div> @@ -431,14 +628,14 @@ countless hours that I have devoted to this extension. </p> <p> - <a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=4DBTN9A3CUAFN" target="_blank"><img src="/bg/img/paypal.gif" alt></a> + <a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=4DBTN9A3CUAFN" target="_blank" rel="noopener"><img src="/bg/img/paypal.gif" alt></a> </p> </div> <pre id="debug"></pre> <div class="pull-right bottom-links"> - <small><a href="search.html">Search</a> • <a href="https://foosoft.net/projects/yomichan/" target="_blank">Homepage</a> • <a href="legal.html">Legal</a></small> + <small><a href="search.html">Search</a> • <a href="https://foosoft.net/projects/yomichan/" target="_blank" rel="noopener">Homepage</a> • <a href="legal.html">Legal</a></small> </div> </div> @@ -450,12 +647,16 @@ <script src="/bg/js/anki.js"></script> <script src="/bg/js/api.js"></script> + <script src="/bg/js/conditions.js"></script> + <script src="/bg/js/conditions-ui.js"></script> <script src="/bg/js/dictionary.js"></script> <script src="/bg/js/handlebars.js"></script> <script src="/bg/js/options.js"></script> + <script src="/bg/js/profile-conditions.js"></script> <script src="/bg/js/templates.js"></script> <script src="/bg/js/util.js"></script> + <script src="/bg/js/settings-profiles.js"></script> <script src="/bg/js/settings.js"></script> </body> </html> diff --git a/ext/fg/js/api.js b/ext/fg/js/api.js index 6bcb0dbb..d0ac649a 100644 --- a/ext/fg/js/api.js +++ b/ext/fg/js/api.js @@ -17,28 +17,24 @@ */ -function apiOptionsSet(options) { - return utilInvoke('optionsSet', {options}); +function apiOptionsGet(optionsContext) { + return utilInvoke('optionsGet', {optionsContext}); } -function apiOptionsGet() { - return utilInvoke('optionsGet'); +function apiTermsFind(text, optionsContext) { + return utilInvoke('termsFind', {text, optionsContext}); } -function apiTermsFind(text) { - return utilInvoke('termsFind', {text}); +function apiKanjiFind(text, optionsContext) { + return utilInvoke('kanjiFind', {text, optionsContext}); } -function apiKanjiFind(text) { - return utilInvoke('kanjiFind', {text}); +function apiDefinitionAdd(definition, mode, context, optionsContext) { + return utilInvoke('definitionAdd', {definition, mode, context, optionsContext}); } -function apiDefinitionAdd(definition, mode, context) { - return utilInvoke('definitionAdd', {definition, mode, context}); -} - -function apiDefinitionsAddable(definitions, modes) { - return utilInvoke('definitionsAddable', {definitions, modes}).catch(() => null); +function apiDefinitionsAddable(definitions, modes, optionsContext) { + return utilInvoke('definitionsAddable', {definitions, modes, optionsContext}).catch(() => null); } function apiNoteView(noteId) { diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js index 60b1b9bd..94a68e6c 100644 --- a/ext/fg/js/document.js +++ b/ext/fg/js/document.js @@ -89,13 +89,23 @@ function docImposterCreate(element, isTextarea) { return [imposter, container]; } -function docRangeFromPoint({x, y}, options) { - const elements = document.elementsFromPoint(x, y); +function docElementsFromPoint(x, y, all) { + if (all) { + return document.elementsFromPoint(x, y); + } + + const e = document.elementFromPoint(x, y); + return e !== null ? [e] : []; +} + +function docRangeFromPoint(x, y, options) { + const deepDomScan = options.scanning.deepDomScan; + const elements = docElementsFromPoint(x, y, deepDomScan); let imposter = null; let imposterContainer = null; if (elements.length > 0) { const element = elements[0]; - switch (element.nodeName) { + switch (element.nodeName.toUpperCase()) { case 'IMG': case 'BUTTON': return new TextSourceElement(element); @@ -108,7 +118,7 @@ function docRangeFromPoint({x, y}, options) { } } - const range = caretRangeFromPointExt(x, y, options.scanning.deepDomScan ? elements : []); + const range = caretRangeFromPointExt(x, y, deepDomScan ? elements : []); if (range !== null) { if (imposter !== null) { docSetImposterStyle(imposterContainer.style, 'z-index', '-2147483646'); @@ -257,6 +267,9 @@ const caretRangeFromPoint = (() => { // Firefox return (x, y) => { const position = document.caretPositionFromPoint(x, y); + if (position === null) { + return null; + } const node = position.offsetNode; if (node === null) { return null; diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js index 3c521714..fd7986b8 100644 --- a/ext/fg/js/float.js +++ b/ext/fg/js/float.js @@ -23,6 +23,11 @@ class DisplayFloat extends Display { this.autoPlayAudioTimer = null; this.styleNode = null; + this.optionsContext = { + depth: 0, + url: window.location.href + }; + this.dependencies = Object.assign({}, this.dependencies, {docRangeFromPoint, docSentenceExtract}); $(window).on('message', utilAsync(this.onMessage.bind(this))); @@ -74,8 +79,10 @@ class DisplayFloat extends Display { } }, - popupNestedInitialize: ({id, depth, parentFrameId}) => { - popupNestedInitialize(id, depth, parentFrameId); + popupNestedInitialize: ({id, depth, parentFrameId, url}) => { + this.optionsContext.depth = depth; + this.optionsContext.url = url; + popupNestedInitialize(id, depth, parentFrameId, url); } }; diff --git a/ext/fg/js/frontend-api-sender.js b/ext/fg/js/frontend-api-sender.js index a1cb02c4..2e037e62 100644 --- a/ext/fg/js/frontend-api-sender.js +++ b/ext/fg/js/frontend-api-sender.js @@ -26,9 +26,7 @@ class FrontendApiSender { this.disconnected = false; this.nextId = 0; - this.port = chrome.runtime.connect(null, {name: 'backend-api-forwarder'}); - this.port.onDisconnect.addListener(this.onDisconnect.bind(this)); - this.port.onMessage.addListener(this.onMessage.bind(this)); + this.port = null; } invoke(action, params, target) { @@ -36,6 +34,10 @@ class FrontendApiSender { return Promise.reject('Disconnected'); } + if (this.port === null) { + this.createPort(); + } + const id = `${this.nextId}`; ++this.nextId; @@ -48,6 +50,12 @@ class FrontendApiSender { }); } + createPort() { + this.port = chrome.runtime.connect(null, {name: 'backend-api-forwarder'}); + this.port.onDisconnect.addListener(this.onDisconnect.bind(this)); + this.port.onMessage.addListener(this.onMessage.bind(this)); + } + onMessage({type, id, data, senderId}) { if (senderId !== this.senderId) { return; } switch (type) { diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index 079a7ef2..167e82c0 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -21,13 +21,16 @@ class Frontend { constructor(popup, ignoreNodes) { this.popup = popup; this.popupTimer = null; - this.mouseDownLeft = false; - this.mouseDownMiddle = false; this.textSourceLast = null; this.pendingLookup = false; this.options = null; this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null); + this.optionsContext = { + depth: popup.depth, + url: popup.url + }; + this.primaryTouchIdentifier = null; this.contextMenuChecking = false; this.contextMenuPrevent = false; @@ -40,9 +43,9 @@ class Frontend { static create() { const initializationData = window.frontendInitializationData; const isNested = (initializationData !== null && typeof initializationData === 'object'); - const {id, parentFrameId, ignoreNodes} = isNested ? initializationData : {}; + const {id, depth, parentFrameId, ignoreNodes, url} = isNested ? initializationData : {}; - const popup = isNested ? new PopupProxy(id, parentFrameId) : PopupProxyHost.instance.createPopup(null); + const popup = isNested ? new PopupProxy(depth + 1, id, parentFrameId, url) : PopupProxyHost.instance.createPopup(null); const frontend = new Frontend(popup, ignoreNodes); frontend.prepare(); return frontend; @@ -50,14 +53,13 @@ class Frontend { async prepare() { try { - this.options = await apiOptionsGet(); + this.options = await apiOptionsGet(this.getOptionsContext()); window.addEventListener('message', this.onFrameMessage.bind(this)); window.addEventListener('mousedown', this.onMouseDown.bind(this)); window.addEventListener('mousemove', this.onMouseMove.bind(this)); window.addEventListener('mouseover', this.onMouseOver.bind(this)); window.addEventListener('mouseout', this.onMouseOut.bind(this)); - window.addEventListener('mouseup', this.onMouseUp.bind(this)); window.addEventListener('resize', this.onResize.bind(this)); if (this.options.scanning.touchInputEnabled) { @@ -76,7 +78,7 @@ class Frontend { } onMouseOver(e) { - if (e.target === this.popup.container && this.popupTimer) { + if (e.target === this.popup.container && this.popupTimer !== null) { this.popupTimerClear(); } } @@ -84,38 +86,32 @@ class Frontend { onMouseMove(e) { this.popupTimerClear(); - if (!this.options.general.enable) { - return; - } - - if (this.mouseDownLeft) { - return; - } - - if (this.pendingLookup) { + if ( + this.pendingLookup || + !this.options.general.enable || + (e.buttons & 0x1) !== 0x0 // Left mouse button + ) { return; } - const mouseScan = this.mouseDownMiddle && this.options.scanning.middleMouse; - const keyScan = - this.options.scanning.modifier === 'alt' && e.altKey || - this.options.scanning.modifier === 'ctrl' && e.ctrlKey || - this.options.scanning.modifier === 'shift' && e.shiftKey || - this.options.scanning.modifier === 'none'; - - if (!keyScan && !mouseScan) { + const scanningOptions = this.options.scanning; + const scanningModifier = scanningOptions.modifier; + if (!( + Frontend.isScanningModifierPressed(scanningModifier, e) || + (scanningOptions.middleMouse && (e.buttons & 0x4) !== 0x0) // Middle mouse button + )) { return; } const search = async () => { try { - await this.searchAt({x: e.clientX, y: e.clientY}, 'mouse'); + await this.searchAt(e.clientX, e.clientY, 'mouse'); } catch (e) { this.onError(e); } }; - if (this.options.scanning.modifier === 'none') { + if (scanningModifier === 'none') { this.popupTimerSet(search); } else { search(); @@ -131,23 +127,8 @@ class Frontend { return false; } - this.mousePosLast = {x: e.clientX, y: e.clientY}; this.popupTimerClear(); this.searchClear(); - - if (e.which === 1) { - this.mouseDownLeft = true; - } else if (e.which === 2) { - this.mouseDownMiddle = true; - } - } - - onMouseUp(e) { - if (e.which === 1) { - this.mouseDownLeft = false; - } else if (e.which === 2) { - this.mouseDownMiddle = false; - } } onMouseOut(e) { @@ -239,8 +220,8 @@ class Frontend { } } - onAfterSearch(newRange, type, searched, success) { - if (type === 'mouse') { + onAfterSearch(newRange, cause, searched, success) { + if (cause === 'mouse') { return; } @@ -250,7 +231,7 @@ class Frontend { return; } - if (type === 'touchStart' && newRange !== null) { + if (cause === 'touchStart' && newRange !== null) { this.scrollPrevent = true; } @@ -261,11 +242,8 @@ class Frontend { onBgMessage({action, params}, sender, callback) { const handlers = { - optionsSet: options => { - this.options = options; - if (!this.options.enable) { - this.searchClear(); - } + optionsUpdate: () => { + this.updateOptions(); }, popupSetVisible: ({visible}) => { @@ -284,24 +262,35 @@ class Frontend { console.log(error); } + async updateOptions() { + this.options = await apiOptionsGet(this.getOptionsContext()); + if (!this.options.enable) { + this.searchClear(); + } + } + popupTimerSet(callback) { - this.popupTimerClear(); - this.popupTimer = window.setTimeout(callback, this.options.scanning.delay); + const delay = this.options.scanning.delay; + if (delay > 0) { + this.popupTimer = window.setTimeout(callback, delay); + } else { + Promise.resolve().then(callback); + } } popupTimerClear() { - if (this.popupTimer) { + if (this.popupTimer !== null) { window.clearTimeout(this.popupTimer); this.popupTimer = null; } } - async searchAt(point, type) { - if (this.pendingLookup || await this.popup.containsPoint(point)) { + async searchAt(x, y, cause) { + if (this.pendingLookup || await this.popup.containsPoint(x, y)) { return; } - const textSource = docRangeFromPoint(point, this.options); + const textSource = docRangeFromPoint(x, y, this.options); let hideResults = textSource === null; let searched = false; let success = false; @@ -310,7 +299,7 @@ class Frontend { if (!hideResults && (!this.textSourceLast || !this.textSourceLast.equals(textSource))) { searched = true; this.pendingLookup = true; - const focus = (type === 'mouse'); + const focus = (cause === 'mouse'); hideResults = !await this.searchTerms(textSource, focus) && !await this.searchKanji(textSource, focus); success = true; } @@ -335,7 +324,7 @@ class Frontend { } this.pendingLookup = false; - this.onAfterSearch(this.textSourceLast, type, searched, success); + this.onAfterSearch(this.textSourceLast, cause, searched, success); } } @@ -347,7 +336,7 @@ class Frontend { return; } - const {definitions, length} = await apiTermsFind(searchText); + const {definitions, length} = await apiTermsFind(searchText, this.getOptionsContext()); if (definitions.length === 0) { return false; } @@ -380,7 +369,7 @@ class Frontend { return; } - const definitions = await apiKanjiFind(searchText); + const definitions = await apiKanjiFind(searchText, this.getOptionsContext()); if (definitions.length === 0) { return false; } @@ -477,7 +466,7 @@ class Frontend { this.clickPrevent = value; } - searchFromTouch(x, y, type) { + searchFromTouch(x, y, cause) { this.popupTimerClear(); if (!this.options.general.enable || this.pendingLookup) { @@ -486,7 +475,7 @@ class Frontend { const search = async () => { try { - await this.searchAt({x, y}, type); + await this.searchAt(x, y, cause); } catch (e) { this.onError(e); } @@ -523,6 +512,21 @@ class Frontend { textSource.setEndOffset(length); } } + + getOptionsContext() { + this.optionsContext.url = this.popup.url; + return this.optionsContext; + } + + static isScanningModifierPressed(scanningModifier, mouseEvent) { + switch (scanningModifier) { + case 'alt': return mouseEvent.altKey; + case 'ctrl': return mouseEvent.ctrlKey; + case 'shift': return mouseEvent.shiftKey; + case 'none': return true; + default: return false; + } + } } window.yomichan_frontend = Frontend.create(); diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/popup-nested.js index e0376bb2..b36de2ec 100644 --- a/ext/fg/js/popup-nested.js +++ b/ext/fg/js/popup-nested.js @@ -19,13 +19,14 @@ let popupNestedInitialized = false; -async function popupNestedInitialize(id, depth, parentFrameId) { +async function popupNestedInitialize(id, depth, parentFrameId, url) { if (popupNestedInitialized) { return; } popupNestedInitialized = true; - const options = await apiOptionsGet(); + const optionsContext = {depth, url}; + const options = await apiOptionsGet(optionsContext); const popupNestingMaxDepth = options.scanning.popupNestingMaxDepth; if (!(typeof popupNestingMaxDepth === 'number' && typeof depth === 'number' && depth < popupNestingMaxDepth)) { @@ -34,7 +35,7 @@ async function popupNestedInitialize(id, depth, parentFrameId) { const ignoreNodes = options.scanning.enableOnPopupExpressions ? [] : [ '.expression', '.expression *' ]; - window.frontendInitializationData = {id, depth, parentFrameId, ignoreNodes}; + window.frontendInitializationData = {id, depth, parentFrameId, ignoreNodes, url}; const scriptSrcs = [ '/fg/js/frontend-api-sender.js', diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js index fa61aeb4..396f7556 100644 --- a/ext/fg/js/popup-proxy-host.js +++ b/ext/fg/js/popup-proxy-host.js @@ -42,9 +42,9 @@ class PopupProxyHost { showOrphaned: ({id, elementRect, options}) => this.show(id, elementRect, options), hide: ({id}) => this.hide(id), setVisible: ({id, visible}) => this.setVisible(id, visible), - containsPoint: ({id, point}) => this.containsPoint(id, point), - termsShow: ({id, elementRect, definitions, options, context}) => this.termsShow(id, elementRect, definitions, options, context), - kanjiShow: ({id, elementRect, definitions, options, context}) => this.kanjiShow(id, elementRect, definitions, options, context), + containsPoint: ({id, x, y}) => this.containsPoint(id, x, y), + termsShow: ({id, elementRect, writingMode, definitions, options, context}) => this.termsShow(id, elementRect, writingMode, definitions, options, context), + kanjiShow: ({id, elementRect, writingMode, definitions, options, context}) => this.kanjiShow(id, elementRect, writingMode, definitions, options, context), clearAutoPlayTimer: ({id}) => this.clearAutoPlayTimer(id) }); } @@ -108,27 +108,33 @@ class PopupProxyHost { return popup.setVisible(visible); } - async containsPoint(id, point) { + async containsPoint(id, x, y) { const popup = this.getPopup(id); - return await popup.containsPoint(point); + return await popup.containsPoint(x, y); } - async termsShow(id, elementRect, definitions, options, context) { + async termsShow(id, elementRect, writingMode, definitions, options, context) { const popup = this.getPopup(id); elementRect = this.jsonRectToDOMRect(popup, elementRect); - return await popup.termsShow(elementRect, definitions, options, context); + if (!PopupProxyHost.popupCanShow(popup)) { return false; } + return await popup.termsShow(elementRect, writingMode, definitions, options, context); } - async kanjiShow(id, elementRect, definitions, options, context) { + async kanjiShow(id, elementRect, writingMode, definitions, options, context) { const popup = this.getPopup(id); elementRect = this.jsonRectToDOMRect(popup, elementRect); - return await popup.kanjiShow(elementRect, definitions, options, context); + if (!PopupProxyHost.popupCanShow(popup)) { return false; } + return await popup.kanjiShow(elementRect, writingMode, definitions, options, context); } async clearAutoPlayTimer(id) { const popup = this.getPopup(id); return popup.clearAutoPlayTimer(); } + + static popupCanShow(popup) { + return popup.parent === null || popup.parent.isVisible(); + } } PopupProxyHost.instance = PopupProxyHost.create(); diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js index f6295079..235e1730 100644 --- a/ext/fg/js/popup-proxy.js +++ b/ext/fg/js/popup-proxy.js @@ -18,14 +18,15 @@ class PopupProxy { - constructor(parentId, parentFrameId) { + constructor(depth, parentId, parentFrameId, url) { this.parentId = parentId; this.parentFrameId = parentFrameId; this.id = null; this.idPromise = null; this.parent = null; this.child = null; - this.depth = 0; + this.depth = depth; + this.url = url; this.container = null; @@ -69,23 +70,23 @@ class PopupProxy { return await this.invokeHostApi('setVisible', {id, visible}); } - async containsPoint(point) { + async containsPoint(x, y) { if (this.id === null) { return false; } - return await this.invokeHostApi('containsPoint', {id: this.id, point}); + return await this.invokeHostApi('containsPoint', {id: this.id, x, y}); } - async termsShow(elementRect, definitions, options, context) { + async termsShow(elementRect, writingMode, definitions, options, context) { const id = await this.getPopupId(); elementRect = PopupProxy.DOMRectToJson(elementRect); - return await this.invokeHostApi('termsShow', {id, elementRect, definitions, options, context}); + return await this.invokeHostApi('termsShow', {id, elementRect, writingMode, definitions, options, context}); } - async kanjiShow(elementRect, definitions, options, context) { + async kanjiShow(elementRect, writingMode, definitions, options, context) { const id = await this.getPopupId(); elementRect = PopupProxy.DOMRectToJson(elementRect); - return await this.invokeHostApi('kanjiShow', {id, elementRect, definitions, options, context}); + return await this.invokeHostApi('kanjiShow', {id, elementRect, writingMode, definitions, options, context}); } async clearAutoPlayTimer() { diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index 1b15977b..08965084 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -59,7 +59,8 @@ class Popup { this.invokeApi('popupNestedInitialize', { id: this.id, depth: this.depth, - parentFrameId + parentFrameId, + url: this.url }); this.invokeApi('setOptions', { general: { @@ -239,9 +240,12 @@ class Popup { } focusParent() { - if (this.parent && this.parent.container) { + if (this.parent !== null) { // Chrome doesn't like focusing iframe without contentWindow. - this.parent.container.contentWindow.focus(); + const contentWindow = this.parent.container.contentWindow; + if (contentWindow !== null) { + contentWindow.focus(); + } } else { // Firefox doesn't like focusing window without first blurring the iframe. // this.container.contentWindow.blur() doesn't work on Firefox for some reason. @@ -251,7 +255,7 @@ class Popup { } } - async containsPoint({x, y}) { + async containsPoint(x, y) { for (let popup = this; popup !== null && popup.isVisible(); popup = popup.child) { const rect = popup.container.getBoundingClientRect(); if (x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) { @@ -308,4 +312,8 @@ class Popup { parent.appendChild(this.container); } } + + get url() { + return window.location.href; + } } diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js index 18a1a976..4642de50 100644 --- a/ext/fg/js/source.js +++ b/ext/fg/js/source.js @@ -88,7 +88,7 @@ class TextSourceRange { } const skip = ['RT', 'SCRIPT', 'STYLE']; - if (skip.includes(node.nodeName)) { + if (skip.includes(node.nodeName.toUpperCase())) { return false; } @@ -285,7 +285,7 @@ class TextSourceElement { } setEndOffset(length) { - switch (this.element.nodeName) { + switch (this.element.nodeName.toUpperCase()) { case 'BUTTON': this.content = this.element.innerHTML; break; diff --git a/ext/manifest.json b/ext/manifest.json index 3676b236..6a120a97 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Yomichan (testing)", - "version": "1.7.6", + "version": "1.8.0", "description": "Japanese dictionary with Anki integration (testing)", "icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"}, diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index ebf56897..ca1738a6 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -27,6 +27,7 @@ class Display { this.sequence = 0; this.index = 0; this.audioCache = {}; + this.optionsContext = {}; this.dependencies = {}; @@ -66,7 +67,7 @@ class Display { context.source.source = this.context.source; } - const kanjiDefs = await apiKanjiFind(link.text()); + const kanjiDefs = await apiKanjiFind(link.text(), this.optionsContext); this.kanjiShow(kanjiDefs, this.options, context); } catch (e) { this.onError(e); @@ -80,7 +81,7 @@ class Display { const {docRangeFromPoint, docSentenceExtract} = this.dependencies; const clickedElement = $(e.target); - const textSource = docRangeFromPoint({x: e.clientX, y: e.clientY}, this.options); + const textSource = docRangeFromPoint(e.clientX, e.clientY, this.options); if (textSource === null) { return false; } @@ -89,7 +90,7 @@ class Display { try { textSource.setEndOffset(this.options.scanning.length); - ({definitions, length} = await apiTermsFind(textSource.text())); + ({definitions, length} = await apiTermsFind(textSource.text(), this.optionsContext)); if (definitions.length === 0) { return false; } @@ -379,7 +380,7 @@ class Display { async adderButtonUpdate(modes, sequence) { try { - const states = await apiDefinitionsAddable(this.definitions, modes); + const states = await apiDefinitionsAddable(this.definitions, modes, this.optionsContext); if (!states || sequence !== this.sequence) { return; } @@ -453,7 +454,7 @@ class Display { } } - const noteId = await apiDefinitionAdd(definition, mode, context); + const noteId = await apiDefinitionAdd(definition, mode, context, this.optionsContext); if (noteId) { const index = this.definitions.indexOf(definition); Display.adderButtonFind(index, mode).addClass('disabled'); |