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