diff options
-rw-r--r-- | ext/bg/background.html | 2 | ||||
-rw-r--r-- | ext/bg/js/conditions-ui.js | 317 | ||||
-rw-r--r-- | ext/bg/js/conditions.js | 117 | ||||
-rw-r--r-- | ext/bg/js/options.js | 22 | ||||
-rw-r--r-- | ext/bg/js/profile-conditions.js | 85 | ||||
-rw-r--r-- | ext/bg/js/settings-profiles.js | 14 | ||||
-rw-r--r-- | ext/bg/settings.html | 89 |
7 files changed, 644 insertions, 2 deletions
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/js/conditions-ui.js b/ext/bg/js/conditions-ui.js new file mode 100644 index 00000000..9b161c95 --- /dev/null +++ b/ext/bg/js/conditions-ui.js @@ -0,0 +1,317 @@ +/* + * 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 + } + + 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 = { + 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(); + } + + 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.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/options.js b/ext/bg/js/options.js index 3dce5221..e9e321df 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -329,6 +329,22 @@ function profileOptionsUpdateVersion(options) { /* * 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 = []; @@ -351,7 +367,8 @@ function optionsUpdateVersion(options, defaultProfileOptions) { if (profiles.length === 0) { profiles.push({ name: 'Default', - options: defaultProfileOptions + options: defaultProfileOptions, + conditionGroups: [] }); } @@ -369,6 +386,9 @@ function optionsUpdateVersion(options, defaultProfileOptions) { // Update profile options for (const profile of profiles) { + if (!Array.isArray(profile.conditionGroups)) { + profile.conditionGroups = []; + } profile.options = profileOptionsUpdateVersion(profile.options); } diff --git a/ext/bg/js/profile-conditions.js b/ext/bg/js/profile-conditions.js new file mode 100644 index 00000000..86bafa95 --- /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: (value, optionValue) => (value === optionValue) + }, + notEqual: { + name: '\u2260', + test: (value, optionValue) => (value !== optionValue) + }, + lessThan: { + name: '<', + test: (value, optionValue) => (value < optionValue) + }, + greaterThan: { + name: '>', + test: (value, optionValue) => (value > optionValue) + }, + lessThanOrEqual: { + name: '\u2264', + test: (value, optionValue) => (value <= optionValue) + }, + greaterThanOrEqual: { + name: '\u2265', + test: (value, optionValue) => (value >= 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: (value, transformedOptionValue) => (transformedOptionValue.indexOf(new URL(value).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: (value, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(value)) + } + } + } +}; diff --git a/ext/bg/js/settings-profiles.js b/ext/bg/js/settings-profiles.js index 624562c6..70f77d7b 100644 --- a/ext/bg/js/settings-profiles.js +++ b/ext/bg/js/settings-profiles.js @@ -17,6 +17,7 @@ */ let currentProfileIndex = 0; +let profileConditionsContainer = null; function getOptionsContext() { return { @@ -81,6 +82,19 @@ async function profileFormWrite(optionsFull) { $('#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(); } function profileOptionsPopulateSelect(select, profiles, currentValue, ignoreIndices) { diff --git a/ext/bg/settings.html b/ext/bg/settings.html index c0489894..d38aa090 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -34,6 +34,55 @@ 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; @@ -71,7 +120,7 @@ <h3>Profiles</h3> <p class="help-block"> - Profiles allow you to create multiple configurations and quickly switch between them. + Profiles allow you to create multiple configurations and quickly switch between them or use them in different contexts. </p> <div class="form-group"> @@ -100,6 +149,27 @@ <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"> @@ -136,6 +206,20 @@ </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> @@ -563,9 +647,12 @@ <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> |