diff options
Diffstat (limited to 'ext')
-rw-r--r-- | ext/bg/background.html | 2 | ||||
-rw-r--r-- | ext/bg/css/settings.css | 22 | ||||
-rw-r--r-- | ext/bg/js/backend.js | 46 | ||||
-rw-r--r-- | ext/bg/js/json-schema.js | 4 | ||||
-rw-r--r-- | ext/bg/js/options.js | 22 | ||||
-rw-r--r-- | ext/bg/js/profile-conditions.js | 178 | ||||
-rw-r--r-- | ext/bg/js/settings/conditions-ui.js | 449 | ||||
-rw-r--r-- | ext/bg/js/settings/profile-conditions-ui.js | 686 | ||||
-rw-r--r-- | ext/bg/js/settings/profiles.js | 41 | ||||
-rw-r--r-- | ext/bg/settings.html | 21 |
10 files changed, 772 insertions, 699 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html index 218e9925..b3067b1e 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -38,7 +38,7 @@ <script src="/bg/js/json-schema.js"></script> <script src="/bg/js/media-utility.js"></script> <script src="/bg/js/options.js"></script> - <script src="/bg/js/profile-conditions.js"></script> + <script src="/bg/js/profile-conditions2.js"></script> <script src="/bg/js/request-builder.js"></script> <script src="/bg/js/template-renderer.js"></script> <script src="/bg/js/text-source-map.js"></script> diff --git a/ext/bg/css/settings.css b/ext/bg/css/settings.css index 1b945310..2abcc1b1 100644 --- a/ext/bg/css/settings.css +++ b/ext/bg/css/settings.css @@ -68,39 +68,39 @@ html:root:not([data-options-general-result-output-mode=merge]) #dict-main-group content: "AND"; } -.input-group .condition-prefix { +.condition-prefix { flex: 0 0 auto; } -.input-group .condition-prefix, -.input-group .condition-group-separator-label { +.condition-prefix, +.condition-group-separator-label { width: 60px; text-align: center; } -.input-group .condition-group-separator-label { +.condition-group-separator-label { padding: 6px 12px; font-weight: bold; display: inline-block; } -.input-group .condition-type, -.input-group .condition-operator { +.condition-type, +.condition-operator { width: auto; text-align: center; text-align-last: center; } -.condition-group>.condition>*:first-child, +.condition-list>.condition>*:first-child, .audio-source-list>.audio-source>*:first-child { border-bottom-left-radius: 0; } -.condition-group>.condition:nth-child(n+2)>*:first-child, +.condition-list>.condition:nth-child(n+2)>*:first-child, .audio-source-list>.audio-source:nth-child(n+2)>*:first-child { border-top-left-radius: 0; } -.condition-group>.condition:nth-child(n+2)>div:last-child>button, +.condition-list>.condition:nth-child(n+2)>div:last-child>button, .audio-source-list>.audio-source:nth-child(n+2)>*:last-child>button { border-top-right-radius: 0; } -.condition-group>.condition:nth-last-child(n+2)>div:last-child>button, +.condition-list>.condition:nth-last-child(n+2)>div:last-child>button, .audio-source-list>.audio-source:nth-last-child(n+2)>*:last-child>button { border-bottom-right-radius: 0; } @@ -110,7 +110,7 @@ html:root:not([data-options-general-result-output-mode=merge]) #dict-main-group border-top-right-radius: 0; } -.condition-groups>*:last-of-type { +.condition-groups>.condition-group:last-child>.condition-group-separator-label { display: none; } diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 810370c4..7f85d9a5 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -28,13 +28,11 @@ * Mecab * ObjectPropertyAccessor * OptionsUtil + * ProfileConditions * RequestBuilder * TemplateRenderer * Translator - * conditionsTestValue * jp - * profileConditionsDescriptor - * profileConditionsDescriptorPromise */ class Backend { @@ -49,6 +47,8 @@ class Backend { this._options = null; this._optionsSchema = null; this._optionsSchemaValidator = new JsonSchemaValidator(); + this._profileConditionsSchemaCache = []; + this._profileConditionsUtil = new ProfileConditions(); this._defaultAnkiFieldTemplates = null; this._requestBuilder = new RequestBuilder(); this._audioUriBuilder = new AudioUriBuilder({ @@ -200,8 +200,6 @@ class Backend { } await this._translator.prepare(); - await profileConditionsDescriptorPromise; - this._optionsSchema = await this._fetchAsset('/bg/data/options-schema.json', true); this._defaultAnkiFieldTemplates = (await this._fetchAsset('/bg/data/default-anki-field-templates.handlebars')).trim(); this._options = await OptionsUtil.load(); @@ -397,6 +395,7 @@ class Backend { } async _onApiOptionsSave({source}) { + this._clearProfileConditionsSchemaCache(); const options = this.getFullOptions(); await OptionsUtil.save(options); this._applyOptions(source); @@ -1006,35 +1005,32 @@ class Backend { } _getProfileFromContext(options, optionsContext) { + optionsContext = this._profileConditionsUtil.normalizeContext(optionsContext); + + let index = 0; for (const profile of options.profiles) { const conditionGroups = profile.conditionGroups; - if (conditionGroups.length > 0 && this._testConditionGroups(conditionGroups, optionsContext)) { - return profile; - } - } - return null; - } - _testConditionGroups(conditionGroups, data) { - if (conditionGroups.length === 0) { return false; } + let schema; + if (index < this._profileConditionsSchemaCache.length) { + schema = this._profileConditionsSchemaCache[index]; + } else { + schema = this._profileConditionsUtil.createSchema(conditionGroups); + this._profileConditionsSchemaCache.push(schema); + } - for (const conditionGroup of conditionGroups) { - const conditions = conditionGroup.conditions; - if (conditions.length > 0 && this._testConditions(conditions, data)) { - return true; + if (conditionGroups.length > 0 && this._optionsSchemaValidator.isValid(optionsContext, schema)) { + return profile; } + ++index; } - return false; + return null; } - _testConditions(conditions, data) { - for (const condition of conditions) { - if (!conditionsTestValue(profileConditionsDescriptor, condition.type, condition.operator, condition.value, data)) { - return false; - } - } - return true; + _clearProfileConditionsSchemaCache() { + this._profileConditionsSchemaCache = []; + this._optionsSchemaValidator.clearCache(); } _checkLastError() { diff --git a/ext/bg/js/json-schema.js b/ext/bg/js/json-schema.js index 30446559..1be78fd2 100644 --- a/ext/bg/js/json-schema.js +++ b/ext/bg/js/json-schema.js @@ -181,6 +181,10 @@ class JsonSchemaValidator { return this._getPropertySchema(schema, property, value, null); } + clearCache() { + this._regexCache.clear(); + } + // Private _getPropertySchema(schema, property, value, path) { diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index 0d83f428..c513f572 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -389,6 +389,10 @@ class OptionsUtil { { async: true, update: this._updateVersion3.bind(this) + }, + { + async: false, + update: this._updateVersion4.bind(this) } ]; } @@ -459,4 +463,22 @@ class OptionsUtil { } return fieldTemplates; } + + static _updateVersion4(options) { + // Version 4 changes: + // Options conditions converted to string representations. + for (const {conditionGroups} of options.profiles) { + for (const {conditions} of conditionGroups) { + for (const condition of conditions) { + const value = condition.value; + condition.value = ( + Array.isArray(value) ? + value.join(', ') : + `${value}` + ); + } + } + } + return options; + } } diff --git a/ext/bg/js/profile-conditions.js b/ext/bg/js/profile-conditions.js deleted file mode 100644 index f3a85cb1..00000000 --- a/ext/bg/js/profile-conditions.js +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright (C) 2019-2020 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -/* global - * Environment - */ - -let profileConditionsDescriptor = null; - -const profileConditionsDescriptorPromise = (async () => { - function profileConditionTestDomain(urlDomain, domain) { - return ( - urlDomain.endsWith(domain) && - ( - domain.length === urlDomain.length || - urlDomain[urlDomain.length - domain.length - 1] === '.' - ) - ); - } - - function profileConditionTestDomainList(url, domainList) { - const urlDomain = new URL(url).hostname.toLowerCase(); - for (const domain of domainList) { - if (profileConditionTestDomain(urlDomain, domain)) { - return true; - } - } - return false; - } - - const environment = new Environment(); - await environment.prepare(); - - const modifiers = environment.getInfo().modifiers; - const modifierSeparator = modifiers.separator; - const modifierKeyValues = modifiers.keys.map( - ({value, name}) => ({optionValue: value, name}) - ); - - const modifierValueToName = new Map( - modifierKeyValues.map(({optionValue, name}) => [optionValue, name]) - ); - - const modifierNameToValue = new Map( - modifierKeyValues.map(({optionValue, name}) => [name, optionValue]) - ); - - 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) => profileConditionTestDomainList(url, transformedOptionValue) - }, - 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)) - } - } - }, - modifierKeys: { - name: 'Modifier Keys', - description: 'Use profile depending on the active modifier keys.', - values: modifierKeyValues, - defaultOperator: 'are', - operators: { - are: { - name: 'are', - placeholder: 'Press one or more modifier keys here', - defaultValue: [], - type: 'keyMulti', - keySeparator: modifierSeparator, - transformInput: (optionValue) => optionValue - .split(modifierSeparator) - .filter((v) => v.length > 0) - .map((v) => modifierNameToValue.get(v)), - transformReverse: (transformedOptionValue) => transformedOptionValue - .map((v) => modifierValueToName.get(v)) - .join(modifierSeparator), - test: ({modifierKeys}, optionValue) => areSetsEqual(new Set(modifierKeys), new Set(optionValue)) - }, - areNot: { - name: 'are not', - placeholder: 'Press one or more modifier keys here', - defaultValue: [], - type: 'keyMulti', - keySeparator: modifierSeparator, - transformInput: (optionValue) => optionValue - .split(modifierSeparator) - .filter((v) => v.length > 0) - .map((v) => modifierNameToValue.get(v)), - transformReverse: (transformedOptionValue) => transformedOptionValue - .map((v) => modifierValueToName.get(v)) - .join(modifierSeparator), - test: ({modifierKeys}, optionValue) => !areSetsEqual(new Set(modifierKeys), new Set(optionValue)) - }, - include: { - name: 'include', - type: 'select', - defaultValue: 'alt', - test: ({modifierKeys}, optionValue) => modifierKeys.includes(optionValue) - }, - notInclude: { - name: 'don\'t include', - type: 'select', - defaultValue: 'alt', - test: ({modifierKeys}, optionValue) => !modifierKeys.includes(optionValue) - } - } - } - }; -})(); diff --git a/ext/bg/js/settings/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js deleted file mode 100644 index 98b3d432..00000000 --- a/ext/bg/js/settings/conditions-ui.js +++ /dev/null @@ -1,449 +0,0 @@ -/* - * Copyright (C) 2019-2020 Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see <https://www.gnu.org/licenses/>. - */ - -/* global - * DocumentUtil - * conditionsNormalizeOptionValue - */ - -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 toIterable(conditionGroups)) { - this.children.push(new ConditionsUI.ConditionGroup(this, conditionGroup)); - } - - this.addButton.on('click', this.onAddConditionGroup.bind(this)); - } - - 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 (hasOwn(this.conditionDescriptors, 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 (hasOwn(this.conditionDescriptors, type)) { - const conditionDescriptor = this.conditionDescriptors[type]; - if (hasOwn(conditionDescriptor.operators, operator)) { - const operatorDescriptor = conditionDescriptor.operators[operator]; - if (hasOwn(operatorDescriptor, 'defaultValue')) { - return {value: this.isolate(operatorDescriptor.defaultValue), fromOperator: true}; - } - } - if (hasOwn(conditionDescriptor, 'defaultValue')) { - return {value: this.isolate(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 toIterable(conditionGroup.conditions)) { - this.children.push(new ConditionsUI.Condition(this, condition)); - } - - this.addButton.on('click', this.onAddCondition.bind(this)); - } - - 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('.condition-input'); - this.inputInner = null; - 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.typeSelect.on('change', this.onConditionTypeChanged.bind(this)); - this.operatorSelect.on('change', this.onConditionOperatorChanged.bind(this)); - this.removeButton.on('click', this.onRemoveClicked.bind(this)); - } - - cleanup() { - this.inputInner.off('change'); - this.typeSelect.off('change'); - this.operatorSelect.off('change'); - this.removeButton.off('click'); - this.container.remove(); - } - - save() { - this.parent.save(); - } - - isolate(object) { - return this.parent.isolate(object); - } - - 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 (hasOwn(conditionDescriptors, 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 objects = []; - let inputType = null; - if (hasOwn(conditionDescriptors, type)) { - const conditionDescriptor = conditionDescriptors[type]; - objects.push(conditionDescriptor); - if (hasOwn(conditionDescriptor, 'type')) { - inputType = conditionDescriptor.type; - } - if (hasOwn(conditionDescriptor.operators, operator)) { - const operatorDescriptor = conditionDescriptor.operators[operator]; - objects.push(operatorDescriptor); - if (hasOwn(operatorDescriptor, 'type')) { - inputType = operatorDescriptor.type; - } - } - } - - this.input.empty(); - if (inputType === 'select') { - this.inputInner = this.createSelectElement(objects); - } else if (inputType === 'keyMulti') { - this.inputInner = this.createInputKeyMultiElement(objects); - } else { - this.inputInner = this.createInputElement(objects); - } - this.inputInner.appendTo(this.input); - this.inputInner.on('change', this.onInputChanged.bind(this)); - - const {valid, value} = this.validateValue(this.condition.value); - this.inputInner.toggleClass('is-invalid', !valid); - this.inputInner.val(value); - } - - createInputElement(objects) { - const inputInner = ConditionsUI.instantiateTemplate('#condition-input-text-template'); - - const props = new Map([ - ['placeholder', ''], - ['type', 'text'] - ]); - - for (const object of objects) { - if (hasOwn(object, 'placeholder')) { - props.set('placeholder', object.placeholder); - } - if (object.type === 'number') { - props.set('type', 'number'); - for (const prop of ['step', 'min', 'max']) { - if (hasOwn(object, prop)) { - props.set(prop, object[prop]); - } - } - } - } - - for (const [prop, value] of props.entries()) { - inputInner.prop(prop, value); - } - - return inputInner; - } - - createInputKeyMultiElement(objects) { - const inputInner = this.createInputElement(objects); - - inputInner.prop('readonly', true); - - let values = []; - let keySeparator = ' + '; - for (const object of objects) { - if (hasOwn(object, 'values')) { - values = object.values; - } - if (hasOwn(object, 'keySeparator')) { - keySeparator = object.keySeparator; - } - } - - const pressedKeyIndices = new Set(); - - const onKeyDown = ({originalEvent}) => { - const pressedKeyEventName = DocumentUtil.getKeyFromEvent(originalEvent); - if (pressedKeyEventName === 'Escape' || pressedKeyEventName === 'Backspace') { - pressedKeyIndices.clear(); - inputInner.val(''); - inputInner.change(); - return; - } - - const pressedModifiers = DocumentUtil.getActiveModifiers(originalEvent); - // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey - // https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta - // It works with mouse events on some platforms, so try to determine if metaKey is pressed - // hack; only works when Shift and Alt are not pressed - const isMetaKeyChrome = ( - pressedKeyEventName === 'Meta' && - getSetDifference(new Set(['shift', 'alt']), pressedModifiers).size !== 0 - ); - if (isMetaKeyChrome) { - pressedModifiers.add('meta'); - } - - for (const modifier of pressedModifiers) { - const foundIndex = values.findIndex(({optionValue}) => optionValue === modifier); - if (foundIndex !== -1) { - pressedKeyIndices.add(foundIndex); - } - } - - const inputValue = [...pressedKeyIndices].map((i) => values[i].name).join(keySeparator); - inputInner.val(inputValue); - inputInner.change(); - }; - - inputInner.on('keydown', onKeyDown); - - return inputInner; - } - - createSelectElement(objects) { - const inputInner = ConditionsUI.instantiateTemplate('#condition-input-select-template'); - - const data = new Map([ - ['values', []], - ['defaultValue', null] - ]); - - for (const object of objects) { - if (hasOwn(object, 'values')) { - data.set('values', object.values); - } - if (hasOwn(object, 'defaultValue')) { - data.set('defaultValue', this.isolate(object.defaultValue)); - } - } - - for (const {optionValue, name} of data.get('values')) { - const option = ConditionsUI.instantiateTemplate('#condition-input-option-template'); - option.attr('value', optionValue); - option.text(name); - option.appendTo(inputInner); - } - - const defaultValue = data.get('defaultValue'); - if (defaultValue !== null) { - inputInner.val(this.isolate(defaultValue)); - } - - return inputInner; - } - - validateValue(value, isInput=false) { - const conditionDescriptors = this.parent.parent.conditionDescriptors; - let valid = true; - let inputTransformedValue = null; - try { - [value, inputTransformedValue] = conditionsNormalizeOptionValue( - conditionDescriptors, - this.condition.type, - this.condition.operator, - value, - isInput - ); - } catch (e) { - valid = false; - } - return {valid, value, inputTransformedValue}; - } - - onInputChanged() { - const {valid, value, inputTransformedValue} = this.validateValue(this.inputInner.val(), true); - this.inputInner.toggleClass('is-invalid', !valid); - this.inputInner.val(value); - this.condition.value = inputTransformedValue !== null ? inputTransformedValue : 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/settings/profile-conditions-ui.js b/ext/bg/js/settings/profile-conditions-ui.js new file mode 100644 index 00000000..4f206cc1 --- /dev/null +++ b/ext/bg/js/settings/profile-conditions-ui.js @@ -0,0 +1,686 @@ +/* + * Copyright (C) 2020 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * DocumentUtil + */ + +class ProfileConditionsUI { + constructor(settingsController) { + this._settingsController = settingsController; + this._keySeparator = ''; + this._keyNames = new Map(); + this._conditionGroupsContainer = null; + this._addConditionGroupButton = null; + this._children = []; + this._eventListeners = new EventListenerCollection(); + this._defaultType = 'popupLevel'; + this._descriptors = new Map([ + [ + 'popupLevel', + { + displayName: 'Popup Level', + defaultOperator: 'equal', + operators: new Map([ + ['equal', {displayName: '=', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}], + ['notEqual', {displayName: '\u2260', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}], + ['lessThan', {displayName: '<', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}], + ['greaterThan', {displayName: '>', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}], + ['lessThanOrEqual', {displayName: '\u2264', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}], + ['greaterThanOrEqual', {displayName: '\u2265', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}] + ]) + } + ], + [ + 'url', + { + displayName: 'URL', + defaultOperator: 'matchDomain', + operators: new Map([ + ['matchDomain', {displayName: 'Matches Domain', type: 'string', defaultValue: 'example.com', resetDefaultOnChange: true, validate: this._validateDomains.bind(this), normalize: this._normalizeDomains.bind(this)}], + ['matchRegExp', {displayName: 'Matches RegExp', type: 'string', defaultValue: 'example\\.com', resetDefaultOnChange: true, validate: this._validateRegExp.bind(this)}] + ]) + } + ], + [ + 'modifierKeys', + { + displayName: 'Modifier Keys', + defaultOperator: 'are', + operators: new Map([ + ['are', {displayName: 'Are', type: 'modifierKeys', defaultValue: ''}], + ['areNot', {displayName: 'Are Not', type: 'modifierKeys', defaultValue: ''}], + ['include', {displayName: 'Include', type: 'modifierKeys', defaultValue: ''}], + ['notInclude', {displayName: 'Don\'t Include', type: 'modifierKeys', defaultValue: ''}] + ]) + } + ] + ]); + } + + get settingsController() { + return this._settingsController; + } + + get index() { + return this._settingsController.profileIndex; + } + + setKeyInfo(separator, keyNames) { + this._keySeparator = separator; + this._keyNames.clear(); + for (const {value, name} of keyNames) { + this._keyNames.set(value, name); + } + } + + prepare(conditionGroups) { + this._conditionGroupsContainer = document.querySelector('#profile-condition-groups'); + this._addConditionGroupButton = document.querySelector('#profile-add-condition-group'); + + for (let i = 0, ii = conditionGroups.length; i < ii; ++i) { + this._addConditionGroup(conditionGroups[i], i); + } + + this._eventListeners.addEventListener(this._addConditionGroupButton, 'click', this._onAddConditionGroupButtonClick.bind(this), false); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + + for (const child of this._children) { + child.cleanup(); + } + this._children = []; + + this._conditionGroupsContainer = null; + this._addConditionGroupButton = null; + } + + instantiateTemplate(templateSelector) { + const template = document.querySelector(templateSelector); + const content = document.importNode(template.content, true); + return content.firstChild; + } + + getDescriptorTypes() { + const results = []; + for (const [name, {displayName}] of this._descriptors.entries()) { + results.push({name, displayName}); + } + return results; + } + + getDescriptorOperators(type) { + const info = this._descriptors.get(type); + const results = []; + if (typeof info !== 'undefined') { + for (const [name, {displayName}] of info.operators.entries()) { + results.push({name, displayName}); + } + } + return results; + } + + getDefaultType() { + return this._defaultType; + } + + getDefaultOperator(type) { + const info = this._descriptors.get(type); + return (typeof info !== 'undefined' ? info.defaultOperator : ''); + } + + getOperatorDetails(type, operator) { + const info = this._getOperatorDetails(type, operator); + + const { + displayName=operator, + type: type2='string', + defaultValue='', + resetDefaultOnChange=false, + validate=null, + normalize=null + } = (typeof info === 'undefined' ? {} : info); + + return { + displayName, + type: type2, + defaultValue, + resetDefaultOnChange, + validate, + normalize + }; + } + + getDefaultCondition() { + const type = this.getDefaultType(); + const operator = this.getDefaultOperator(type); + const {defaultValue: value} = this.getOperatorDetails(type, operator); + return {type, operator, value}; + } + + removeConditionGroup(child) { + const index = child.index; + if (index < 0 || index >= this._children.length) { return false; } + + const child2 = this._children[index]; + if (child !== child2) { return false; } + + this._children.splice(index, 1); + child.cleanup(); + + for (let i = index, ii = this._children.length; i < ii; ++i) { + this._children[i].index = i; + } + + this.settingsController.modifyGlobalSettings([{ + action: 'splice', + path: this.getPath('conditionGroups'), + start: index, + deleteCount: 1, + items: [] + }]); + + return true; + } + + splitValue(value) { + return value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0); + } + + getModifierKeyStrings(modifiers) { + let value = ''; + let displayValue = ''; + let first = true; + for (const modifier of modifiers) { + let keyName = this._keyNames.get(modifier); + if (typeof keyName === 'undefined') { keyName = modifier; } + + if (first) { + first = false; + } else { + value += ', '; + displayValue += this._keySeparator; + } + value += modifier; + displayValue += keyName; + } + return {value, displayValue}; + } + + sortModifiers(modifiers) { + return modifiers.sort(); + } + + getPath(property) { + property = (typeof property === 'string' ? `.${property}` : ''); + return `profiles[${this.index}]${property}`; + } + + // Private + + _onAddConditionGroupButtonClick() { + const conditionGroup = { + conditions: [this.getDefaultCondition()] + }; + const index = this._children.length; + + this._addConditionGroup(conditionGroup, index); + + this.settingsController.modifyGlobalSettings([{ + action: 'splice', + path: this.getPath('conditionGroups'), + start: index, + deleteCount: 0, + items: [conditionGroup] + }]); + } + + _addConditionGroup(conditionGroup, index) { + const child = new ProfileConditionGroupUI(this, index); + child.prepare(conditionGroup); + this._children.push(child); + this._conditionGroupsContainer.appendChild(child.node); + return child; + } + + _getOperatorDetails(type, operator) { + const info = this._descriptors.get(type); + return (typeof info !== 'undefined' ? info.operators.get(operator) : void 0); + } + + _validateInteger(value) { + const number = Number.parseFloat(value); + return Number.isFinite(number) && Math.floor(number) === number; + } + + _validateDomains(value) { + return this.splitValue(value).length > 0; + } + + _validateRegExp(value) { + try { + new RegExp(value, 'i'); + return true; + } catch (e) { + return false; + } + } + + _normalizeInteger(value) { + const number = Number.parseFloat(value); + return `${number}`; + } + + _normalizeDomains(value) { + return this.splitValue(value).join(', '); + } +} + +class ProfileConditionGroupUI { + constructor(parent, index) { + this._parent = parent; + this._index = index; + this._node = null; + this._conditionContainer = null; + this._addConditionButton = null; + this._children = []; + this._eventListeners = new EventListenerCollection(); + } + + get settingsController() { + return this._parent.settingsController; + } + + get parent() { + return this._parent; + } + + get index() { + return this._index; + } + + set index(value) { + this._index = value; + } + + get node() { + return this._node; + } + + prepare(conditionGroup) { + this._node = this._parent.instantiateTemplate('#condition-group-template'); + this._conditionContainer = this._node.querySelector('.condition-list'); + this._addConditionButton = this._node.querySelector('.condition-add'); + + const conditions = conditionGroup.conditions; + for (let i = 0, ii = conditions.length; i < ii; ++i) { + this._addCondition(conditions[i], i); + } + + this._eventListeners.addEventListener(this._addConditionButton, 'click', this._onAddConditionButtonClick.bind(this), false); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + + for (const child of this._children) { + child.cleanup(); + } + this._children = []; + + if (this._node === null) { return; } + + const node = this._node; + this._node = null; + this._conditionContainer = null; + this._addConditionButton = null; + + if (node.parentNode !== null) { + node.parentNode.removeChild(node); + } + } + + removeCondition(child) { + const index = child.index; + if (index < 0 || index >= this._children.length) { return false; } + + const child2 = this._children[index]; + if (child !== child2) { return false; } + + this._children.splice(index, 1); + child.cleanup(); + + for (let i = index, ii = this._children.length; i < ii; ++i) { + this._children[i].index = i; + } + + this.settingsController.modifyGlobalSettings([{ + action: 'splice', + path: this.getPath('conditions'), + start: index, + deleteCount: 1, + items: [] + }]); + + if (this._children.length === 0) { + this._parent.removeConditionGroup(this); + } + + return true; + } + + getPath(property) { + property = (typeof property === 'string' ? `.${property}` : ''); + return this._parent.getPath(`conditionGroups[${this._index}]${property}`); + } + + // Private + + _onAddConditionButtonClick() { + const condition = this._parent.getDefaultCondition(); + const index = this._children.length; + + this._addCondition(condition, index); + + this.settingsController.modifyGlobalSettings([{ + action: 'splice', + path: this.getPath('conditions'), + start: index, + deleteCount: 0, + items: [condition] + }]); + } + + _addCondition(condition, index) { + const child = new ProfileConditionUI(this, index); + child.prepare(condition); + this._children.push(child); + this._conditionContainer.appendChild(child.node); + return child; + } +} + +class ProfileConditionUI { + constructor(parent, index) { + this._parent = parent; + this._index = index; + this._node = null; + this._typeInput = null; + this._operatorInput = null; + this._valueInputContainer = null; + this._removeButton = null; + this._value = ''; + this._eventListeners = new EventListenerCollection(); + this._inputEventListeners = new EventListenerCollection(); + } + + get settingsController() { + return this._parent.parent.settingsController; + } + + get parent() { + return this._parent; + } + + get index() { + return this._index; + } + + set index(value) { + this._index = value; + } + + get node() { + return this._node; + } + + prepare(condition) { + const {type, operator, value} = condition; + + this._node = this._parent.parent.instantiateTemplate('#condition-template'); + this._typeInput = this._node.querySelector('.condition-type'); + this._typeOptionContainer = this._typeInput.querySelector('optgroup'); + this._operatorInput = this._node.querySelector('.condition-operator'); + this._operatorOptionContainer = this._operatorInput.querySelector('optgroup'); + this._valueInput = this._node.querySelector('.condition-input-inner'); + this._removeButton = this._node.querySelector('.condition-remove'); + + const operatorDetails = this._getOperatorDetails(type, operator); + this._updateTypes(type); + this._updateOperators(type, operator); + this._updateValueInput(value, operatorDetails); + + this._eventListeners.addEventListener(this._typeInput, 'change', this._onTypeChange.bind(this), false); + this._eventListeners.addEventListener(this._operatorInput, 'change', this._onOperatorChange.bind(this), false); + this._eventListeners.addEventListener(this._removeButton, 'click', this._onRemoveButtonClick.bind(this), false); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + this._value = ''; + + if (this._node === null) { return; } + + const node = this._node; + this._node = null; + this._typeInput = null; + this._operatorInput = null; + this._valueInputContainer = null; + this._removeButton = null; + + if (node.parentNode !== null) { + node.parentNode.removeChild(node); + } + } + + getPath(property) { + property = (typeof property === 'string' ? `.${property}` : ''); + return this._parent.getPath(`conditions[${this._index}]${property}`); + } + + // Private + + _onTypeChange(e) { + const type = e.currentTarget.value; + const operators = this._getDescriptorOperators(type); + const operator = operators.length > 0 ? operators[0].name : ''; + const operatorDetails = this._getOperatorDetails(type, operator); + this._updateSelect(this._operatorInput, this._operatorOptionContainer, operators, operator); + this._updateValueInput(operatorDetails.defaultValue, operatorDetails); + this.settingsController.setGlobalSetting(this.getPath('type'), type); + } + + _onOperatorChange(e) { + const type = this._typeInput.value; + const operator = e.currentTarget.value; + const operatorDetails = this._getOperatorDetails(type, operator); + if (operatorDetails.resetDefaultOnChange) { + const okay = this._updateValueInput(operatorDetails.defaultValue, operatorDetails); + if (okay) { + this.settingsController.setGlobalSetting(this.getPath('operator'), operator); + } + } + } + + _onValueInputChange({validate, normalize}, e) { + const node = e.currentTarget; + const value = node.value; + const okay = this._validateValue(value, validate); + this._value = value; + if (okay) { + const normalizedValue = this._normalizeValue(value, normalize); + node.value = normalizedValue; + this.settingsController.setGlobalSetting(this.getPath('value'), normalizedValue); + } + } + + _onModifierKeyDown({validate, normalize}, e) { + e.preventDefault(); + const node = e.currentTarget; + + let modifiers; + const key = DocumentUtil.getKeyFromEvent(e); + switch (key) { + case 'Escape': + case 'Backspace': + modifiers = []; + break; + default: + { + modifiers = this._getModifiers(e); + const currentModifier = this._splitValue(this._value); + for (const modifier of currentModifier) { + modifiers.add(modifier); + } + modifiers = [...modifiers]; + modifiers = this._sortModifiers(modifiers); + } + break; + } + + const {value, displayValue} = this._getModifierKeyStrings(modifiers); + node.value = displayValue; + const okay = this._validateValue(value, validate); + this._value = value; + if (okay) { + const normalizedValue = this._normalizeValue(value, normalize); + node.value = normalizedValue; + this.settingsController.setGlobalSetting(this.getPath('value'), normalizedValue); + } + } + + _onRemoveButtonClick() { + this._parent.removeCondition(this); + } + + _getDescriptorTypes() { + return this._parent.parent.getDescriptorTypes(); + } + + _getDescriptorOperators(type) { + return this._parent.parent.getDescriptorOperators(type); + } + + _getOperatorDetails(type, operator) { + return this._parent.parent.getOperatorDetails(type, operator); + } + + _getModifierKeyStrings(modifiers) { + return this._parent.parent.getModifierKeyStrings(modifiers); + } + + _sortModifiers(modifiers) { + return this._parent.parent.sortModifiers(modifiers); + } + + _splitValue(value) { + return this._parent.parent.splitValue(value); + } + + _updateTypes(type) { + const types = this._getDescriptorTypes(); + this._updateSelect(this._typeInput, this._typeOptionContainer, types, type); + } + + _updateOperators(type, operator) { + const operators = this._getDescriptorOperators(type); + this._updateSelect(this._operatorInput, this._operatorOptionContainer, operators, operator); + } + + _updateSelect(select, optionContainer, values, value) { + optionContainer.textContent = ''; + for (const {name, displayName} of values) { + const option = document.createElement('option'); + option.value = name; + option.textContent = displayName; + optionContainer.appendChild(option); + } + select.value = value; + } + + _updateValueInput(value, {type, validate, normalize}) { + this._inputEventListeners.removeAllEventListeners(); + + const inputData = {validate, normalize}; + const node = this._valueInput; + node.classList.remove('is-invalid'); + this._value = value; + + switch (type) { + case 'integer': + { + node.type = 'number'; + node.step = '1'; + node.value = value; + this._inputEventListeners.addEventListener(node, 'change', this._onValueInputChange.bind(this, inputData), false); + } + break; + case 'modifierKeys': + { + const modifiers = this._splitValue(value); + const {displayValue} = this._getModifierKeyStrings(modifiers); + node.type = 'text'; + node.removeAttribute('step'); + node.value = displayValue; + this._inputEventListeners.addEventListener(node, 'keydown', this._onModifierKeyDown.bind(this, inputData), false); + } + break; + default: // 'string' + { + node.type = 'text'; + node.removeAttribute('step'); + node.value = value; + this._inputEventListeners.addEventListener(node, 'change', this._onValueInputChange.bind(this, inputData), false); + } + break; + } + + this._validateValue(value, validate); + } + + _validateValue(value, validate) { + const okay = (validate === null || validate(value)); + this._valueInput.classList.toggle('is-invalid', !okay); + return okay; + } + + _normalizeValue(value, normalize) { + return (normalize !== null ? normalize(value) : value); + } + + _getModifiers(e) { + const modifiers = DocumentUtil.getActiveModifiers(e); + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey + // https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta + // It works with mouse events on some platforms, so try to determine if metaKey is pressed. + // This is a hack and only works when both Shift and Alt are not pressed. + if ( + !modifiers.has('meta') && + DocumentUtil.getKeyFromEvent(e) === 'Meta' && + !( + modifiers.size === 2 && + modifiers.has('shift') && + modifiers.has('alt') + ) + ) { + modifiers.add('meta'); + } + return modifiers; + } +} diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js index 2449ab44..c1961e20 100644 --- a/ext/bg/js/settings/profiles.js +++ b/ext/bg/js/settings/profiles.js @@ -16,17 +16,15 @@ */ /* global - * ConditionsUI - * conditionsClearCaches - * profileConditionsDescriptor - * profileConditionsDescriptorPromise + * ProfileConditionsUI + * api * utilBackgroundIsolate */ class ProfileController { constructor(settingsController) { this._settingsController = settingsController; - this._conditionsContainer = null; + this._profileConditionsUI = new ProfileConditionsUI(settingsController); } async prepare() { @@ -49,8 +47,11 @@ class ProfileController { // Private async _onOptionsChanged() { + const {modifiers} = await api.getEnvironmentInfo(); + this._profileConditionsUI.setKeyInfo(modifiers.separator, modifiers.keys); + const optionsFull = await this._settingsController.getOptionsFullMutable(); - await this._formWrite(optionsFull); + this._formWrite(optionsFull); } _tryGetIntegerValue(selector, min, max) { @@ -78,7 +79,7 @@ class ProfileController { profile.name = $('#profile-name').val(); } - async _formWrite(optionsFull) { + _formWrite(optionsFull) { const currentProfileIndex = this._settingsController.profileIndex; const profile = optionsFull.profiles[currentProfileIndex]; @@ -91,23 +92,17 @@ class ProfileController { $('#profile-name').val(profile.name); - if (this._conditionsContainer !== null) { - this._conditionsContainer.cleanup(); - } + this._refreshProfileConditions(optionsFull); + } + + _refreshProfileConditions(optionsFull) { + this._profileConditionsUI.cleanup(); + + const profileIndex = this._settingsController.profileIndex; + if (profileIndex < 0 || profileIndex >= optionsFull.profiles.length) { return; } - await profileConditionsDescriptorPromise; - this._conditionsContainer = new ConditionsUI.Container( - profileConditionsDescriptor, - 'popupLevel', - profile.conditionGroups, - $('#profile-condition-groups'), - $('#profile-add-condition-group') - ); - this._conditionsContainer.save = () => { - this._settingsController.save(); - conditionsClearCaches(profileConditionsDescriptor); - }; - this._conditionsContainer.isolate = utilBackgroundIsolate; + const {conditionGroups} = optionsFull.profiles[profileIndex]; + this._profileConditionsUI.prepare(conditionGroups); } _populateSelect(select, profiles, currentValue, ignoreIndices) { diff --git a/ext/bg/settings.html b/ext/bg/settings.html index abe3b6aa..f7877dd2 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -112,23 +112,21 @@ </div> </div> + <template id="condition-group-template"><div class="condition-group"> + <div class="condition-list"></div> + <div class="condition-group-options"> + <button class="btn btn-default condition-add"><span class="glyphicon glyphicon-plus"></span></button> + </div> + <div class="condition-group-separator-label">OR</div> + </div></template> <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> <div class="condition-line-break"></div> - <div class="condition-input"></div> + <div class="condition-input"><input type="text" class="form-control condition-input-inner"></div> <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> - <template id="condition-input-text-template"><input type="text" class="form-control condition-input-inner" /></template> - <template id="condition-input-select-template"><select class="form-control condition-input-inner"></select></template> - <template id="condition-input-option-template"><option></option></template> </div> <div> @@ -1143,7 +1141,6 @@ <script src="/bg/js/anki-note-builder.js"></script> <script src="/bg/js/conditions.js"></script> <script src="/bg/js/options.js"></script> - <script src="/bg/js/profile-conditions.js"></script> <script src="/bg/js/util.js"></script> <script src="/mixed/js/audio-system.js"></script> <script src="/mixed/js/document-util.js"></script> @@ -1153,11 +1150,11 @@ <script src="/bg/js/settings/audio.js"></script> <script src="/bg/js/settings/backup.js"></script> <script src="/bg/js/settings/clipboard-popups-controller.js"></script> - <script src="/bg/js/settings/conditions-ui.js"></script> <script src="/bg/js/settings/dictionaries.js"></script> <script src="/bg/js/settings/generic-setting-controller.js"></script> <script src="/bg/js/settings/popup-preview.js"></script> <script src="/bg/js/settings/profiles.js"></script> + <script src="/bg/js/settings/profile-conditions-ui.js"></script> <script src="/bg/js/settings/settings-controller.js"></script> <script src="/bg/js/settings/storage.js"></script> <script src="/mixed/js/dictionary-data-util.js"></script> |