Add support creating profile usage conditions

This commit is contained in:
toasted-nutbread 2019-09-09 20:19:49 -04:00
parent e3fb9603e2
commit 8c4fb28a30
7 changed files with 644 additions and 2 deletions

View File

@ -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>

317
ext/bg/js/conditions-ui.js Normal file
View File

@ -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();
}
};

117
ext/bg/js/conditions.js Normal file
View File

@ -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 = {};
}
}
}
}

View File

@ -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);
}

View File

@ -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))
}
}
}
};

View File

@ -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) {

View File

@ -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>