diff options
Diffstat (limited to 'ext/bg/js')
-rw-r--r-- | ext/bg/js/api.js | 5 | ||||
-rw-r--r-- | ext/bg/js/backend.js | 38 | ||||
-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 | 5 | ||||
-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/search-frontend.js | 5 | ||||
-rw-r--r-- | ext/bg/js/search.js | 3 | ||||
-rw-r--r-- | ext/bg/js/settings-profiles.js | 18 |
10 files changed, 617 insertions, 7 deletions
diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js index f32b984f..474fe604 100644 --- a/ext/bg/js/api.js +++ b/ext/bg/js/api.js @@ -140,7 +140,10 @@ async function apiCommandExec(command) { }, toggle: async () => { - const optionsContext = {depth: 0}; + const optionsContext = { + depth: 0, + url: window.location.href + }; const options = await apiOptionsGet(optionsContext); options.general.enable = !options.general.enable; await apiOptionsSave('popup'); diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 3839da39..4068b760 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -23,7 +23,8 @@ class Backend { this.anki = new AnkiNull(); this.options = null; this.optionsContext = { - depth: 0 + depth: 0, + url: window.location.href }; this.isPreparedResolve = null; @@ -173,7 +174,40 @@ class Backend { if (typeof optionsContext.index === 'number') { return profiles[optionsContext.index]; } - return this.options.profiles[this.options.profileCurrent]; + 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) { 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 dfa224a7..0f88e9c0 100644 --- a/ext/bg/js/context.js +++ b/ext/bg/js/context.js @@ -22,7 +22,10 @@ $(document).ready(utilAsync(() => { $('#open-options').click(() => apiCommandExec('options')); $('#open-help').click(() => apiCommandExec('help')); - const optionsContext = {depth: 0}; + const optionsContext = { + depth: 0, + url: window.location.href + }; apiOptionsGet(optionsContext).then(options => { const toggle = $('#enable-search'); toggle.prop('checked', options.general.enable).change(); 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..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 df5ccf81..faec29ef 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-frontend.js @@ -18,7 +18,10 @@ async function searchFrontendSetup() { - const optionsContext = {depth: 0}; + const optionsContext = { + depth: 0, + url: window.location.href + }; const options = await apiOptionsGet(optionsContext); if (!options.scanning.enableOnSearchPage) { return; } diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index 6bdc47d8..6ff710f0 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -22,7 +22,8 @@ class DisplaySearch extends Display { super($('#spinner'), $('#content')); this.optionsContext = { - depth: 0 + depth: 0, + url: window.location.href }; this.search = $('#search').click(this.onSearch.bind(this)); diff --git a/ext/bg/js/settings-profiles.js b/ext/bg/js/settings-profiles.js index 624562c6..ededc998 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,23 @@ 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(); + conditionsClearCaches(profileConditionsDescriptor); + }; + profileConditionsContainer.isolate = utilBackgroundIsolate; } function profileOptionsPopulateSelect(select, profiles, currentValue, ignoreIndices) { |