Json schema profile conditions (#758)

* Add clearCache function

* Add upgrade

* Use schema-based profile condition validation

* Update profile conditions settings controller

* Remove unnecessary async

* Remove old

* Remove unused templates
This commit is contained in:
toasted-nutbread 2020-09-04 17:44:00 -04:00 committed by GitHub
parent 74edf462ab
commit f3dd2270a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 773 additions and 700 deletions

View File

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

View File

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

View File

@ -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)) {
let schema;
if (index < this._profileConditionsSchemaCache.length) {
schema = this._profileConditionsSchemaCache[index];
} else {
schema = this._profileConditionsUtil.createSchema(conditionGroups);
this._profileConditionsSchemaCache.push(schema);
}
if (conditionGroups.length > 0 && this._optionsSchemaValidator.isValid(optionsContext, schema)) {
return profile;
}
++index;
}
return null;
}
_testConditionGroups(conditionGroups, data) {
if (conditionGroups.length === 0) { return false; }
for (const conditionGroup of conditionGroups) {
const conditions = conditionGroup.conditions;
if (conditions.length > 0 && this._testConditions(conditions, data)) {
return true;
}
}
return false;
}
_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() {

View File

@ -181,6 +181,10 @@ class JsonSchemaValidator {
return this._getPropertySchema(schema, property, value, null);
}
clearCache() {
this._regexCache.clear();
}
// Private
_getPropertySchema(schema, property, value, path) {

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
}
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;
_refreshProfileConditions(optionsFull) {
this._profileConditionsUI.cleanup();
const profileIndex = this._settingsController.profileIndex;
if (profileIndex < 0 || profileIndex >= optionsFull.profiles.length) { return; }
const {conditionGroups} = optionsFull.profiles[profileIndex];
this._profileConditionsUI.prepare(conditionGroups);
}
_populateSelect(select, profiles, currentValue, ignoreIndices) {

View File

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