diff options
Diffstat (limited to 'ext/js/data')
-rw-r--r-- | ext/js/data/anki-note-builder.js | 148 | ||||
-rw-r--r-- | ext/js/data/anki-note-data.js | 240 | ||||
-rw-r--r-- | ext/js/data/database.js | 327 | ||||
-rw-r--r-- | ext/js/data/json-schema.js | 757 | ||||
-rw-r--r-- | ext/js/data/options-util.js | 739 | ||||
-rw-r--r-- | ext/js/data/permissions-util.js | 126 |
6 files changed, 2337 insertions, 0 deletions
diff --git a/ext/js/data/anki-note-builder.js b/ext/js/data/anki-note-builder.js new file mode 100644 index 00000000..e1399f66 --- /dev/null +++ b/ext/js/data/anki-note-builder.js @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2020-2021 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 + * TemplateRendererProxy + */ + +class AnkiNoteBuilder { + constructor(enabled) { + this._markerPattern = /\{([\w-]+)\}/g; + this._templateRenderer = enabled ? new TemplateRendererProxy() : null; + } + + async createNote({ + definition, + mode, + context, + templates, + deckName, + modelName, + fields, + tags=[], + injectedMedia=null, + checkForDuplicates=true, + duplicateScope='collection', + resultOutputMode='split', + glossaryLayoutMode='default', + compactTags=false, + errors=null + }) { + let duplicateScopeDeckName = null; + let duplicateScopeCheckChildren = false; + if (duplicateScope === 'deck-root') { + duplicateScope = 'deck'; + duplicateScopeDeckName = this.getRootDeckName(deckName); + duplicateScopeCheckChildren = true; + } + + const data = { + definition, + mode, + context, + resultOutputMode, + glossaryLayoutMode, + compactTags, + injectedMedia + }; + const formattedFieldValuePromises = []; + for (const [, fieldValue] of fields) { + const formattedFieldValuePromise = this._formatField(fieldValue, data, templates, errors); + formattedFieldValuePromises.push(formattedFieldValuePromise); + } + + const formattedFieldValues = await Promise.all(formattedFieldValuePromises); + const noteFields = {}; + for (let i = 0, ii = fields.length; i < ii; ++i) { + const fieldName = fields[i][0]; + const formattedFieldValue = formattedFieldValues[i]; + noteFields[fieldName] = formattedFieldValue; + } + + return { + fields: noteFields, + tags, + deckName, + modelName, + options: { + allowDuplicate: !checkForDuplicates, + duplicateScope, + duplicateScopeOptions: { + deckName: duplicateScopeDeckName, + checkChildren: duplicateScopeCheckChildren + } + } + }; + } + + containsMarker(fields, marker) { + marker = `{${marker}}`; + for (const [, fieldValue] of fields) { + if (fieldValue.includes(marker)) { + return true; + } + } + return false; + } + + containsAnyMarker(field) { + const result = this._markerPattern.test(field); + this._markerPattern.lastIndex = 0; + return result; + } + + getRootDeckName(deckName) { + const index = deckName.indexOf('::'); + return index >= 0 ? deckName.substring(0, index) : deckName; + } + + // Private + + async _formatField(field, data, templates, errors=null) { + return await this._stringReplaceAsync(field, this._markerPattern, async (g0, marker) => { + try { + return await this._renderTemplate(templates, data, marker); + } catch (e) { + if (errors) { + const error = new Error(`Template render error for {${marker}}`); + error.data = {error: e}; + errors.push(error); + } + return `{${marker}-render-error}`; + } + }); + } + + async _stringReplaceAsync(str, regex, replacer) { + let match; + let index = 0; + const parts = []; + while ((match = regex.exec(str)) !== null) { + parts.push(str.substring(index, match.index), replacer(...match, match.index, str)); + index = regex.lastIndex; + } + if (parts.length === 0) { + return str; + } + parts.push(str.substring(index)); + return (await Promise.all(parts)).join(''); + } + + async _renderTemplate(template, data, marker) { + return await this._templateRenderer.render(template, {data, marker}, 'ankiNote'); + } +} diff --git a/ext/js/data/anki-note-data.js b/ext/js/data/anki-note-data.js new file mode 100644 index 00000000..a7d0f9f6 --- /dev/null +++ b/ext/js/data/anki-note-data.js @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2021 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 + * DictionaryDataUtil + */ + +/** + * This class represents the data that is exposed to the Anki template renderer. + * The public properties and data should be backwards compatible. + */ +class AnkiNoteData { + constructor({ + definition, + resultOutputMode, + mode, + glossaryLayoutMode, + compactTags, + context, + injectedMedia=null + }, marker) { + this._definition = definition; + this._resultOutputMode = resultOutputMode; + this._mode = mode; + this._glossaryLayoutMode = glossaryLayoutMode; + this._compactTags = compactTags; + this._context = context; + this._marker = marker; + this._injectedMedia = injectedMedia; + this._pitches = null; + this._pitchCount = null; + this._uniqueExpressions = null; + this._uniqueReadings = null; + this._publicContext = null; + this._cloze = null; + + this._prepareDefinition(definition, injectedMedia, context); + } + + get marker() { + return this._marker; + } + + set marker(value) { + this._marker = value; + } + + get definition() { + return this._definition; + } + + get uniqueExpressions() { + if (this._uniqueExpressions === null) { + this._uniqueExpressions = this._getUniqueExpressions(); + } + return this._uniqueExpressions; + } + + get uniqueReadings() { + if (this._uniqueReadings === null) { + this._uniqueReadings = this._getUniqueReadings(); + } + return this._uniqueReadings; + } + + get pitches() { + if (this._pitches === null) { + this._pitches = DictionaryDataUtil.getPitchAccentInfos(this._definition); + } + return this._pitches; + } + + get pitchCount() { + if (this._pitchCount === null) { + this._pitchCount = this.pitches.reduce((i, v) => i + v.pitches.length, 0); + } + return this._pitchCount; + } + + get group() { + return this._resultOutputMode === 'group'; + } + + get merge() { + return this._resultOutputMode === 'merge'; + } + + get modeTermKanji() { + return this._mode === 'term-kanji'; + } + + get modeTermKana() { + return this._mode === 'term-kana'; + } + + get modeKanji() { + return this._mode === 'kanji'; + } + + get compactGlossaries() { + return this._glossaryLayoutMode === 'compact'; + } + + get glossaryLayoutMode() { + return this._glossaryLayoutMode; + } + + get compactTags() { + return this._compactTags; + } + + get context() { + if (this._publicContext === null) { + this._publicContext = this._getPublicContext(); + } + return this._publicContext; + } + + createPublic() { + const self = this; + return { + get marker() { return self.marker; }, + set marker(value) { self.marker = value; }, + get definition() { return self.definition; }, + get glossaryLayoutMode() { return self.glossaryLayoutMode; }, + get compactTags() { return self.compactTags; }, + get group() { return self.group; }, + get merge() { return self.merge; }, + get modeTermKanji() { return self.modeTermKanji; }, + get modeTermKana() { return self.modeTermKana; }, + get modeKanji() { return self.modeKanji; }, + get compactGlossaries() { return self.compactGlossaries; }, + get uniqueExpressions() { return self.uniqueExpressions; }, + get uniqueReadings() { return self.uniqueReadings; }, + get pitches() { return self.pitches; }, + get pitchCount() { return self.pitchCount; }, + get context() { return self.context; } + }; + } + + // Private + + _asObject(value) { + return (typeof value === 'object' && value !== null ? value : {}); + } + + _getUniqueExpressions() { + const results = new Set(); + const definition = this._definition; + if (definition.type !== 'kanji') { + for (const {expression} of definition.expressions) { + results.add(expression); + } + } + return [...results]; + } + + _getUniqueReadings() { + const results = new Set(); + const definition = this._definition; + if (definition.type !== 'kanji') { + for (const {reading} of definition.expressions) { + results.add(reading); + } + } + return [...results]; + } + + _getPublicContext() { + let {documentTitle} = this._asObject(this._context); + if (typeof documentTitle !== 'string') { documentTitle = ''; } + + return { + document: { + title: documentTitle + } + }; + } + + _getCloze() { + const {sentence} = this._asObject(this._context); + let {text, offset} = this._asObject(sentence); + if (typeof text !== 'string') { text = ''; } + if (typeof offset !== 'number') { offset = 0; } + + const definition = this._definition; + const source = definition.type === 'kanji' ? definition.character : definition.rawSource; + + return { + sentence: text, + prefix: text.substring(0, offset), + body: text.substring(offset, offset + source.length), + suffix: text.substring(offset + source.length) + }; + } + + _getClozeCached() { + if (this._cloze === null) { + this._cloze = this._getCloze(); + } + return this._cloze; + } + + _prepareDefinition(definition, injectedMedia, context) { + const { + screenshotFileName=null, + clipboardImageFileName=null, + clipboardText=null, + audioFileName=null + } = this._asObject(injectedMedia); + + let {url} = this._asObject(context); + if (typeof url !== 'string') { url = ''; } + + definition.screenshotFileName = screenshotFileName; + definition.clipboardImageFileName = clipboardImageFileName; + definition.clipboardText = clipboardText; + definition.audioFileName = audioFileName; + definition.url = url; + Object.defineProperty(definition, 'cloze', { + configurable: true, + enumerable: true, + get: this._getClozeCached.bind(this) + }); + } +} diff --git a/ext/js/data/database.js b/ext/js/data/database.js new file mode 100644 index 00000000..068f4a5f --- /dev/null +++ b/ext/js/data/database.js @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2020-2021 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/>. + */ + +class Database { + constructor() { + this._db = null; + this._isOpening = false; + } + + // Public + + async open(databaseName, version, structure) { + if (this._db !== null) { + throw new Error('Database already open'); + } + if (this._isOpening) { + throw new Error('Already opening'); + } + + try { + this._isOpening = true; + this._db = await this._open(databaseName, version, (db, transaction, oldVersion) => { + this._upgrade(db, transaction, oldVersion, structure); + }); + } finally { + this._isOpening = false; + } + } + + close() { + if (this._db === null) { + throw new Error('Database is not open'); + } + + this._db.close(); + this._db = null; + } + + isOpening() { + return this._isOpening; + } + + isOpen() { + return this._db !== null; + } + + transaction(storeNames, mode) { + if (this._db === null) { + throw new Error(this._isOpening ? 'Database not ready' : 'Database not open'); + } + return this._db.transaction(storeNames, mode); + } + + bulkAdd(objectStoreName, items, start, count) { + return new Promise((resolve, reject) => { + if (start + count > items.length) { + count = items.length - start; + } + + if (count <= 0) { + resolve(); + return; + } + + const end = start + count; + let completedCount = 0; + const onError = (e) => reject(e.target.error); + const onSuccess = () => { + if (++completedCount >= count) { + resolve(); + } + }; + + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + for (let i = start; i < end; ++i) { + const request = objectStore.add(items[i]); + request.onerror = onError; + request.onsuccess = onSuccess; + } + }); + } + + getAll(objectStoreOrIndex, query, resolve, reject) { + if (typeof objectStoreOrIndex.getAll === 'function') { + this._getAllFast(objectStoreOrIndex, query, resolve, reject); + } else { + this._getAllUsingCursor(objectStoreOrIndex, query, resolve, reject); + } + } + + getAllKeys(objectStoreOrIndex, query, resolve, reject) { + if (typeof objectStoreOrIndex.getAll === 'function') { + this._getAllKeysFast(objectStoreOrIndex, query, resolve, reject); + } else { + this._getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject); + } + } + + find(objectStoreName, indexName, query, predicate=null, defaultValue) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readonly'); + const objectStore = transaction.objectStore(objectStoreName); + const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + const request = objectStoreOrIndex.openCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + const value = cursor.value; + if (typeof predicate !== 'function' || predicate(value)) { + resolve(value); + } else { + cursor.continue(); + } + } else { + resolve(defaultValue); + } + }; + }); + } + + bulkCount(targets, resolve, reject) { + const targetCount = targets.length; + if (targetCount <= 0) { + resolve(); + return; + } + + let completedCount = 0; + const results = new Array(targetCount).fill(null); + + const onError = (e) => reject(e.target.error); + const onSuccess = (e, index) => { + const count = e.target.result; + results[index] = count; + if (++completedCount >= targetCount) { + resolve(results); + } + }; + + for (let i = 0; i < targetCount; ++i) { + const index = i; + const [objectStoreOrIndex, query] = targets[i]; + const request = objectStoreOrIndex.count(query); + request.onerror = onError; + request.onsuccess = (e) => onSuccess(e, index); + } + } + + delete(objectStoreName, key) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + const request = objectStore.delete(key); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(); + }); + } + + bulkDelete(objectStoreName, indexName, query, filterKeys=null, onProgress=null) { + return new Promise((resolve, reject) => { + const transaction = this.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + + const onGetKeys = (keys) => { + try { + if (typeof filterKeys === 'function') { + keys = filterKeys(keys); + } + this._bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject); + } catch (e) { + reject(e); + } + }; + + this.getAllKeys(objectStoreOrIndex, query, onGetKeys, reject); + }); + } + + static deleteDatabase(databaseName) { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(databaseName); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(); + request.onblocked = () => reject(new Error('Database deletion blocked')); + }); + } + + // Private + + _open(name, version, onUpgradeNeeded) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, version); + + request.onupgradeneeded = (event) => { + try { + request.transaction.onerror = (e) => reject(e.target.error); + onUpgradeNeeded(request.result, request.transaction, event.oldVersion, event.newVersion); + } catch (e) { + reject(e); + } + }; + + request.onerror = (e) => reject(e.target.error); + request.onsuccess = () => resolve(request.result); + }); + } + + _upgrade(db, transaction, oldVersion, upgrades) { + for (const {version, stores} of upgrades) { + if (oldVersion >= version) { continue; } + + for (const [objectStoreName, {primaryKey, indices}] of Object.entries(stores)) { + const existingObjectStoreNames = transaction.objectStoreNames || db.objectStoreNames; + const objectStore = ( + this._listContains(existingObjectStoreNames, objectStoreName) ? + transaction.objectStore(objectStoreName) : + db.createObjectStore(objectStoreName, primaryKey) + ); + const existingIndexNames = objectStore.indexNames; + + for (const indexName of indices) { + if (this._listContains(existingIndexNames, indexName)) { continue; } + + objectStore.createIndex(indexName, indexName, {}); + } + } + } + } + + _listContains(list, value) { + for (let i = 0, ii = list.length; i < ii; ++i) { + if (list[i] === value) { return true; } + } + return false; + } + + _getAllFast(objectStoreOrIndex, query, resolve, reject) { + const request = objectStoreOrIndex.getAll(query); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => resolve(e.target.result); + } + + _getAllUsingCursor(objectStoreOrIndex, query, resolve, reject) { + const results = []; + const request = objectStoreOrIndex.openCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + results.push(cursor.value); + cursor.continue(); + } else { + resolve(results); + } + }; + } + + _getAllKeysFast(objectStoreOrIndex, query, resolve, reject) { + const request = objectStoreOrIndex.getAllKeys(query); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => resolve(e.target.result); + } + + _getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject) { + const results = []; + const request = objectStoreOrIndex.openKeyCursor(query, 'next'); + request.onerror = (e) => reject(e.target.error); + request.onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + results.push(cursor.primaryKey); + cursor.continue(); + } else { + resolve(results); + } + }; + } + + _bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject) { + const count = keys.length; + if (count === 0) { + resolve(); + return; + } + + let completedCount = 0; + const hasProgress = (typeof onProgress === 'function'); + + const onError = (e) => reject(e.target.error); + const onSuccess = () => { + ++completedCount; + if (hasProgress) { + try { + onProgress(completedCount, count); + } catch (e) { + // NOP + } + } + if (completedCount >= count) { + resolve(); + } + }; + + for (const key of keys) { + const request = objectStore.delete(key); + request.onerror = onError; + request.onsuccess = onSuccess; + } + } +} diff --git a/ext/js/data/json-schema.js b/ext/js/data/json-schema.js new file mode 100644 index 00000000..7b6b9c53 --- /dev/null +++ b/ext/js/data/json-schema.js @@ -0,0 +1,757 @@ +/* + * Copyright (C) 2019-2021 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 + * CacheMap + */ + +class JsonSchemaProxyHandler { + constructor(schema, jsonSchemaValidator) { + this._schema = schema; + this._jsonSchemaValidator = jsonSchemaValidator; + } + + getPrototypeOf(target) { + return Object.getPrototypeOf(target); + } + + setPrototypeOf() { + throw new Error('setPrototypeOf not supported'); + } + + isExtensible(target) { + return Object.isExtensible(target); + } + + preventExtensions(target) { + Object.preventExtensions(target); + return true; + } + + getOwnPropertyDescriptor(target, property) { + return Object.getOwnPropertyDescriptor(target, property); + } + + defineProperty() { + throw new Error('defineProperty not supported'); + } + + has(target, property) { + return property in target; + } + + get(target, property) { + if (typeof property === 'symbol') { + return target[property]; + } + + if (Array.isArray(target)) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + property = parseInt(property, 10); + } else if (typeof property === 'string') { + return target[property]; + } + } + + const propertySchema = this._jsonSchemaValidator.getPropertySchema(this._schema, property, target); + if (propertySchema === null) { + return; + } + + const value = target[property]; + return value !== null && typeof value === 'object' ? this._jsonSchemaValidator.createProxy(value, propertySchema) : value; + } + + set(target, property, value) { + if (Array.isArray(target)) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + property = parseInt(property, 10); + if (property > target.length) { + throw new Error('Array index out of range'); + } + } else if (typeof property === 'string') { + target[property] = value; + return true; + } + } + + const propertySchema = this._jsonSchemaValidator.getPropertySchema(this._schema, property, target); + if (propertySchema === null) { + throw new Error(`Property ${property} not supported`); + } + + value = clone(value); + + this._jsonSchemaValidator.validate(value, propertySchema); + + target[property] = value; + return true; + } + + deleteProperty(target, property) { + const required = this._schema.required; + if (Array.isArray(required) && required.includes(property)) { + throw new Error(`${property} cannot be deleted`); + } + return Reflect.deleteProperty(target, property); + } + + ownKeys(target) { + return Reflect.ownKeys(target); + } + + apply() { + throw new Error('apply not supported'); + } + + construct() { + throw new Error('construct not supported'); + } +} + +class JsonSchemaValidator { + constructor() { + this._regexCache = new CacheMap(100); + } + + createProxy(target, schema) { + return new Proxy(target, new JsonSchemaProxyHandler(schema, this)); + } + + isValid(value, schema) { + try { + this.validate(value, schema); + return true; + } catch (e) { + return false; + } + } + + validate(value, schema) { + const info = new JsonSchemaTraversalInfo(value, schema); + this._validate(value, schema, info); + } + + getValidValueOrDefault(schema, value) { + const info = new JsonSchemaTraversalInfo(value, schema); + return this._getValidValueOrDefault(schema, value, info); + } + + getPropertySchema(schema, property, value) { + return this._getPropertySchema(schema, property, value, null); + } + + clearCache() { + this._regexCache.clear(); + } + + // Private + + _getPropertySchema(schema, property, value, path) { + const type = this._getSchemaOrValueType(schema, value); + switch (type) { + case 'object': + { + const properties = schema.properties; + if (this._isObject(properties)) { + const propertySchema = properties[property]; + if (this._isObject(propertySchema)) { + if (path !== null) { path.push(['properties', properties], [property, propertySchema]); } + return propertySchema; + } + } + + const additionalProperties = schema.additionalProperties; + if (additionalProperties === false) { + return null; + } else if (this._isObject(additionalProperties)) { + if (path !== null) { path.push(['additionalProperties', additionalProperties]); } + return additionalProperties; + } else { + const result = JsonSchemaValidator.unconstrainedSchema; + if (path !== null) { path.push([null, result]); } + return result; + } + } + case 'array': + { + const items = schema.items; + if (this._isObject(items)) { + return items; + } + if (Array.isArray(items)) { + if (property >= 0 && property < items.length) { + const propertySchema = items[property]; + if (this._isObject(propertySchema)) { + if (path !== null) { path.push(['items', items], [property, propertySchema]); } + return propertySchema; + } + } + } + + const additionalItems = schema.additionalItems; + if (additionalItems === false) { + return null; + } else if (this._isObject(additionalItems)) { + if (path !== null) { path.push(['additionalItems', additionalItems]); } + return additionalItems; + } else { + const result = JsonSchemaValidator.unconstrainedSchema; + if (path !== null) { path.push([null, result]); } + return result; + } + } + default: + return null; + } + } + + _getSchemaOrValueType(schema, value) { + const type = schema.type; + + if (Array.isArray(type)) { + if (typeof value !== 'undefined') { + const valueType = this._getValueType(value); + if (type.indexOf(valueType) >= 0) { + return valueType; + } + } + return null; + } + + if (typeof type === 'undefined') { + if (typeof value !== 'undefined') { + return this._getValueType(value); + } + return null; + } + + return type; + } + + _validate(value, schema, info) { + this._validateSingleSchema(value, schema, info); + this._validateConditional(value, schema, info); + this._validateAllOf(value, schema, info); + this._validateAnyOf(value, schema, info); + this._validateOneOf(value, schema, info); + this._validateNoneOf(value, schema, info); + } + + _validateConditional(value, schema, info) { + const ifSchema = schema.if; + if (!this._isObject(ifSchema)) { return; } + + let okay = true; + info.schemaPush('if', ifSchema); + try { + this._validate(value, ifSchema, info); + } catch (e) { + okay = false; + } + info.schemaPop(); + + const nextSchema = okay ? schema.then : schema.else; + if (this._isObject(nextSchema)) { + info.schemaPush(okay ? 'then' : 'else', nextSchema); + this._validate(value, nextSchema, info); + info.schemaPop(); + } + } + + _validateAllOf(value, schema, info) { + const subSchemas = schema.allOf; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('allOf', subSchemas); + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + this._validate(value, subSchema, info); + info.schemaPop(); + } + info.schemaPop(); + } + + _validateAnyOf(value, schema, info) { + const subSchemas = schema.anyOf; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('anyOf', subSchemas); + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + try { + this._validate(value, subSchema, info); + return; + } catch (e) { + // NOP + } + info.schemaPop(); + } + + throw new JsonSchemaValidationError('0 anyOf schemas matched', value, schema, info); + // info.schemaPop(); // Unreachable + } + + _validateOneOf(value, schema, info) { + const subSchemas = schema.oneOf; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('oneOf', subSchemas); + let count = 0; + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + try { + this._validate(value, subSchema, info); + ++count; + } catch (e) { + // NOP + } + info.schemaPop(); + } + + if (count !== 1) { + throw new JsonSchemaValidationError(`${count} oneOf schemas matched`, value, schema, info); + } + + info.schemaPop(); + } + + _validateNoneOf(value, schema, info) { + const subSchemas = schema.not; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('not', subSchemas); + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + try { + this._validate(value, subSchema, info); + } catch (e) { + info.schemaPop(); + continue; + } + throw new JsonSchemaValidationError(`not[${i}] schema matched`, value, schema, info); + } + info.schemaPop(); + } + + _validateSingleSchema(value, schema, info) { + const type = this._getValueType(value); + const schemaType = schema.type; + if (!this._isValueTypeAny(value, type, schemaType)) { + throw new JsonSchemaValidationError(`Value type ${type} does not match schema type ${schemaType}`, value, schema, info); + } + + const schemaConst = schema.const; + if (typeof schemaConst !== 'undefined' && !this._valuesAreEqual(value, schemaConst)) { + throw new JsonSchemaValidationError('Invalid constant value', value, schema, info); + } + + const schemaEnum = schema.enum; + if (Array.isArray(schemaEnum) && !this._valuesAreEqualAny(value, schemaEnum)) { + throw new JsonSchemaValidationError('Invalid enum value', value, schema, info); + } + + switch (type) { + case 'number': + this._validateNumber(value, schema, info); + break; + case 'string': + this._validateString(value, schema, info); + break; + case 'array': + this._validateArray(value, schema, info); + break; + case 'object': + this._validateObject(value, schema, info); + break; + } + } + + _validateNumber(value, schema, info) { + const multipleOf = schema.multipleOf; + if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { + throw new JsonSchemaValidationError(`Number is not a multiple of ${multipleOf}`, value, schema, info); + } + + const minimum = schema.minimum; + if (typeof minimum === 'number' && value < minimum) { + throw new JsonSchemaValidationError(`Number is less than ${minimum}`, value, schema, info); + } + + const exclusiveMinimum = schema.exclusiveMinimum; + if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) { + throw new JsonSchemaValidationError(`Number is less than or equal to ${exclusiveMinimum}`, value, schema, info); + } + + const maximum = schema.maximum; + if (typeof maximum === 'number' && value > maximum) { + throw new JsonSchemaValidationError(`Number is greater than ${maximum}`, value, schema, info); + } + + const exclusiveMaximum = schema.exclusiveMaximum; + if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) { + throw new JsonSchemaValidationError(`Number is greater than or equal to ${exclusiveMaximum}`, value, schema, info); + } + } + + _validateString(value, schema, info) { + const minLength = schema.minLength; + if (typeof minLength === 'number' && value.length < minLength) { + throw new JsonSchemaValidationError('String length too short', value, schema, info); + } + + const maxLength = schema.maxLength; + if (typeof maxLength === 'number' && value.length > maxLength) { + throw new JsonSchemaValidationError('String length too long', value, schema, info); + } + + const pattern = schema.pattern; + if (typeof pattern === 'string') { + let patternFlags = schema.patternFlags; + if (typeof patternFlags !== 'string') { patternFlags = ''; } + + let regex; + try { + regex = this._getRegex(pattern, patternFlags); + } catch (e) { + throw new JsonSchemaValidationError(`Pattern is invalid (${e.message})`, value, schema, info); + } + + if (!regex.test(value)) { + throw new JsonSchemaValidationError('Pattern match failed', value, schema, info); + } + } + } + + _validateArray(value, schema, info) { + const minItems = schema.minItems; + if (typeof minItems === 'number' && value.length < minItems) { + throw new JsonSchemaValidationError('Array length too short', value, schema, info); + } + + const maxItems = schema.maxItems; + if (typeof maxItems === 'number' && value.length > maxItems) { + throw new JsonSchemaValidationError('Array length too long', value, schema, info); + } + + this._validateArrayContains(value, schema, info); + + for (let i = 0, ii = value.length; i < ii; ++i) { + const schemaPath = []; + const propertySchema = this._getPropertySchema(schema, i, value, schemaPath); + if (propertySchema === null) { + throw new JsonSchemaValidationError(`No schema found for array[${i}]`, value, schema, info); + } + + const propertyValue = value[i]; + + for (const [p, s] of schemaPath) { info.schemaPush(p, s); } + info.valuePush(i, propertyValue); + this._validate(propertyValue, propertySchema, info); + info.valuePop(); + for (let j = 0, jj = schemaPath.length; j < jj; ++j) { info.schemaPop(); } + } + } + + _validateArrayContains(value, schema, info) { + const containsSchema = schema.contains; + if (!this._isObject(containsSchema)) { return; } + + info.schemaPush('contains', containsSchema); + for (let i = 0, ii = value.length; i < ii; ++i) { + const propertyValue = value[i]; + info.valuePush(i, propertyValue); + try { + this._validate(propertyValue, containsSchema, info); + info.schemaPop(); + return; + } catch (e) { + // NOP + } + info.valuePop(); + } + throw new JsonSchemaValidationError('contains schema didn\'t match', value, schema, info); + } + + _validateObject(value, schema, info) { + const properties = new Set(Object.getOwnPropertyNames(value)); + + const required = schema.required; + if (Array.isArray(required)) { + for (const property of required) { + if (!properties.has(property)) { + throw new JsonSchemaValidationError(`Missing property ${property}`, value, schema, info); + } + } + } + + const minProperties = schema.minProperties; + if (typeof minProperties === 'number' && properties.length < minProperties) { + throw new JsonSchemaValidationError('Not enough object properties', value, schema, info); + } + + const maxProperties = schema.maxProperties; + if (typeof maxProperties === 'number' && properties.length > maxProperties) { + throw new JsonSchemaValidationError('Too many object properties', value, schema, info); + } + + for (const property of properties) { + const schemaPath = []; + const propertySchema = this._getPropertySchema(schema, property, value, schemaPath); + if (propertySchema === null) { + throw new JsonSchemaValidationError(`No schema found for ${property}`, value, schema, info); + } + + const propertyValue = value[property]; + + for (const [p, s] of schemaPath) { info.schemaPush(p, s); } + info.valuePush(property, propertyValue); + this._validate(propertyValue, propertySchema, info); + info.valuePop(); + for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); } + } + } + + _isValueTypeAny(value, type, schemaTypes) { + if (typeof schemaTypes === 'string') { + return this._isValueType(value, type, schemaTypes); + } else if (Array.isArray(schemaTypes)) { + for (const schemaType of schemaTypes) { + if (this._isValueType(value, type, schemaType)) { + return true; + } + } + return false; + } + return true; + } + + _isValueType(value, type, schemaType) { + return ( + type === schemaType || + (schemaType === 'integer' && Math.floor(value) === value) + ); + } + + _getValueType(value) { + const type = typeof value; + if (type === 'object') { + if (value === null) { return 'null'; } + if (Array.isArray(value)) { return 'array'; } + } + return type; + } + + _valuesAreEqualAny(value1, valueList) { + for (const value2 of valueList) { + if (this._valuesAreEqual(value1, value2)) { + return true; + } + } + return false; + } + + _valuesAreEqual(value1, value2) { + return value1 === value2; + } + + _getDefaultTypeValue(type) { + if (typeof type === 'string') { + switch (type) { + case 'null': + return null; + case 'boolean': + return false; + case 'number': + case 'integer': + return 0; + case 'string': + return ''; + case 'array': + return []; + case 'object': + return {}; + } + } + return null; + } + + _getDefaultSchemaValue(schema) { + const schemaType = schema.type; + const schemaDefault = schema.default; + return ( + typeof schemaDefault !== 'undefined' && + this._isValueTypeAny(schemaDefault, this._getValueType(schemaDefault), schemaType) ? + clone(schemaDefault) : + this._getDefaultTypeValue(schemaType) + ); + } + + _getValidValueOrDefault(schema, value, info) { + let type = this._getValueType(value); + if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, schema.type)) { + value = this._getDefaultSchemaValue(schema); + type = this._getValueType(value); + } + + switch (type) { + case 'object': + value = this._populateObjectDefaults(value, schema, info); + break; + case 'array': + value = this._populateArrayDefaults(value, schema, info); + break; + default: + if (!this.isValid(value, schema)) { + const schemaDefault = this._getDefaultSchemaValue(schema); + if (this.isValid(schemaDefault, schema)) { + value = schemaDefault; + } + } + break; + } + + return value; + } + + _populateObjectDefaults(value, schema, info) { + const properties = new Set(Object.getOwnPropertyNames(value)); + + const required = schema.required; + if (Array.isArray(required)) { + for (const property of required) { + properties.delete(property); + + const propertySchema = this._getPropertySchema(schema, property, value, null); + if (propertySchema === null) { continue; } + info.valuePush(property, value); + info.schemaPush(property, propertySchema); + const hasValue = Object.prototype.hasOwnProperty.call(value, property); + value[property] = this._getValidValueOrDefault(propertySchema, hasValue ? value[property] : void 0, info); + info.schemaPop(); + info.valuePop(); + } + } + + for (const property of properties) { + const propertySchema = this._getPropertySchema(schema, property, value, null); + if (propertySchema === null) { + Reflect.deleteProperty(value, property); + } else { + info.valuePush(property, value); + info.schemaPush(property, propertySchema); + value[property] = this._getValidValueOrDefault(propertySchema, value[property], info); + info.schemaPop(); + info.valuePop(); + } + } + + return value; + } + + _populateArrayDefaults(value, schema, info) { + for (let i = 0, ii = value.length; i < ii; ++i) { + const propertySchema = this._getPropertySchema(schema, i, value, null); + if (propertySchema === null) { continue; } + info.valuePush(i, value); + info.schemaPush(i, propertySchema); + value[i] = this._getValidValueOrDefault(propertySchema, value[i], info); + info.schemaPop(); + info.valuePop(); + } + + const minItems = schema.minItems; + if (typeof minItems === 'number' && value.length < minItems) { + for (let i = value.length; i < minItems; ++i) { + const propertySchema = this._getPropertySchema(schema, i, value, null); + if (propertySchema === null) { break; } + info.valuePush(i, value); + info.schemaPush(i, propertySchema); + const item = this._getValidValueOrDefault(propertySchema, void 0, info); + info.schemaPop(); + info.valuePop(); + value.push(item); + } + } + + const maxItems = schema.maxItems; + if (typeof maxItems === 'number' && value.length > maxItems) { + value.splice(maxItems, value.length - maxItems); + } + + return value; + } + + _isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } + + _getRegex(pattern, flags) { + const key = `${flags}:${pattern}`; + let regex = this._regexCache.get(key); + if (typeof regex === 'undefined') { + regex = new RegExp(pattern, flags); + this._regexCache.set(key, regex); + } + return regex; + } +} + +Object.defineProperty(JsonSchemaValidator, 'unconstrainedSchema', { + value: Object.freeze({}), + configurable: false, + enumerable: true, + writable: false +}); + +class JsonSchemaTraversalInfo { + constructor(value, schema) { + this.valuePath = []; + this.schemaPath = []; + this.valuePush(null, value); + this.schemaPush(null, schema); + } + + valuePush(path, value) { + this.valuePath.push([path, value]); + } + + valuePop() { + this.valuePath.pop(); + } + + schemaPush(path, schema) { + this.schemaPath.push([path, schema]); + } + + schemaPop() { + this.schemaPath.pop(); + } +} + +class JsonSchemaValidationError extends Error { + constructor(message, value, schema, info) { + super(message); + this.value = value; + this.schema = schema; + this.info = info; + } +} diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js new file mode 100644 index 00000000..1105dfed --- /dev/null +++ b/ext/js/data/options-util.js @@ -0,0 +1,739 @@ +/* + * Copyright (C) 2016-2021 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 + * JsonSchemaValidator + * TemplatePatcher + */ + +class OptionsUtil { + constructor() { + this._schemaValidator = new JsonSchemaValidator(); + this._templatePatcher = null; + this._optionsSchema = null; + } + + async prepare() { + this._optionsSchema = await this._fetchAsset('/data/schemas/options-schema.json', true); + } + + async update(options) { + // Invalid options + if (!isObject(options)) { + options = {}; + } + + // Check for legacy options + let defaultProfileOptions = {}; + if (!Array.isArray(options.profiles)) { + defaultProfileOptions = options; + options = {}; + } + + // Ensure profiles is an array + if (!Array.isArray(options.profiles)) { + options.profiles = []; + } + + // Remove invalid profiles + const profiles = options.profiles; + for (let i = profiles.length - 1; i >= 0; --i) { + if (!isObject(profiles[i])) { + profiles.splice(i, 1); + } + } + + // Require at least one profile + if (profiles.length === 0) { + profiles.push({ + name: 'Default', + options: defaultProfileOptions, + conditionGroups: [] + }); + } + + // Ensure profileCurrent is valid + const profileCurrent = options.profileCurrent; + if (!( + typeof profileCurrent === 'number' && + Number.isFinite(profileCurrent) && + Math.floor(profileCurrent) === profileCurrent && + profileCurrent >= 0 && + profileCurrent < profiles.length + )) { + options.profileCurrent = 0; + } + + // Version + if (typeof options.version !== 'number') { + options.version = 0; + } + + // Generic updates + options = await this._applyUpdates(options, this._getVersionUpdates()); + + // Validation + options = this._schemaValidator.getValidValueOrDefault(this._optionsSchema, options); + + // Result + return options; + } + + async load() { + let options; + try { + const optionsStr = await new Promise((resolve, reject) => { + chrome.storage.local.get(['options'], (store) => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + } else { + resolve(store.options); + } + }); + }); + options = JSON.parse(optionsStr); + } catch (e) { + // NOP + } + + if (typeof options !== 'undefined') { + options = await this.update(options); + } else { + options = this.getDefault(); + } + + return options; + } + + save(options) { + return new Promise((resolve, reject) => { + chrome.storage.local.set({options: JSON.stringify(options)}, () => { + const error = chrome.runtime.lastError; + if (error) { + reject(new Error(error.message)); + } else { + resolve(); + } + }); + }); + } + + getDefault() { + const optionsVersion = this._getVersionUpdates().length; + const options = this._schemaValidator.getValidValueOrDefault(this._optionsSchema); + options.version = optionsVersion; + return options; + } + + createValidatingProxy(options) { + return this._schemaValidator.createProxy(options, this._optionsSchema); + } + + validate(options) { + return this._schemaValidator.validate(options, this._optionsSchema); + } + + // Legacy profile updating + + _legacyProfileUpdateGetUpdates() { + return [ + null, + null, + null, + null, + (options) => { + options.general.audioSource = options.general.audioPlayback ? 'jpod101' : 'disabled'; + }, + (options) => { + options.general.showGuide = false; + }, + (options) => { + options.scanning.modifier = options.scanning.requireShift ? 'shift' : 'none'; + }, + (options) => { + options.general.resultOutputMode = options.general.groupResults ? 'group' : 'split'; + options.anki.fieldTemplates = null; + }, + (options) => { + if (this._getStringHashCode(options.anki.fieldTemplates) === 1285806040) { + options.anki.fieldTemplates = null; + } + }, + (options) => { + if (this._getStringHashCode(options.anki.fieldTemplates) === -250091611) { + options.anki.fieldTemplates = null; + } + }, + (options) => { + const oldAudioSource = options.general.audioSource; + const disabled = oldAudioSource === 'disabled'; + options.audio.enabled = !disabled; + options.audio.volume = options.general.audioVolume; + options.audio.autoPlay = options.general.autoPlayAudio; + options.audio.sources = [disabled ? 'jpod101' : oldAudioSource]; + + delete options.general.audioSource; + delete options.general.audioVolume; + delete options.general.autoPlayAudio; + }, + (options) => { + // Version 12 changes: + // The preferred default value of options.anki.fieldTemplates has been changed to null. + if (this._getStringHashCode(options.anki.fieldTemplates) === 1444379824) { + options.anki.fieldTemplates = null; + } + }, + (options) => { + // Version 13 changes: + // Default anki field tempaltes updated to include {document-title}. + let fieldTemplates = options.anki.fieldTemplates; + if (typeof fieldTemplates === 'string') { + fieldTemplates += '\n\n{{#*inline "document-title"}}\n {{~context.document.title~}}\n{{/inline}}'; + options.anki.fieldTemplates = fieldTemplates; + } + }, + (options) => { + // Version 14 changes: + // Changed template for Anki audio and tags. + let fieldTemplates = options.anki.fieldTemplates; + if (typeof fieldTemplates !== 'string') { return; } + + const replacements = [ + [ + '{{#*inline "audio"}}{{/inline}}', + '{{#*inline "audio"}}\n {{~#if definition.audioFileName~}}\n [sound:{{definition.audioFileName}}]\n {{~/if~}}\n{{/inline}}' + ], + [ + '{{#*inline "tags"}}\n {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}}\n{{/inline}}', + '{{#*inline "tags"}}\n {{~#mergeTags definition group merge}}{{this}}{{/mergeTags~}}\n{{/inline}}' + ] + ]; + + for (const [pattern, replacement] of replacements) { + let replaced = false; + fieldTemplates = fieldTemplates.replace(new RegExp(escapeRegExp(pattern), 'g'), () => { + replaced = true; + return replacement; + }); + + if (!replaced) { + fieldTemplates += '\n\n' + replacement; + } + } + + options.anki.fieldTemplates = fieldTemplates; + } + ]; + } + + _legacyProfileUpdateGetDefaults() { + return { + general: { + enable: true, + enableClipboardPopups: false, + resultOutputMode: 'group', + debugInfo: false, + maxResults: 32, + showAdvanced: false, + popupDisplayMode: 'default', + popupWidth: 400, + popupHeight: 250, + popupHorizontalOffset: 0, + popupVerticalOffset: 10, + popupHorizontalOffset2: 10, + popupVerticalOffset2: 0, + popupHorizontalTextPosition: 'below', + popupVerticalTextPosition: 'before', + popupScalingFactor: 1, + popupScaleRelativeToPageZoom: false, + popupScaleRelativeToVisualViewport: true, + showGuide: true, + compactTags: false, + compactGlossaries: false, + mainDictionary: '', + popupTheme: 'default', + popupOuterTheme: 'default', + customPopupCss: '', + customPopupOuterCss: '', + enableWanakana: true, + enableClipboardMonitor: false, + showPitchAccentDownstepNotation: true, + showPitchAccentPositionNotation: true, + showPitchAccentGraph: false, + showIframePopupsInRootFrame: false, + useSecurePopupFrameUrl: true, + usePopupShadowDom: true + }, + + audio: { + enabled: true, + sources: ['jpod101'], + volume: 100, + autoPlay: false, + customSourceUrl: '', + textToSpeechVoice: '' + }, + + scanning: { + middleMouse: true, + touchInputEnabled: true, + selectText: true, + alphanumeric: true, + autoHideResults: false, + delay: 20, + length: 10, + modifier: 'shift', + deepDomScan: false, + popupNestingMaxDepth: 0, + enablePopupSearch: false, + enableOnPopupExpressions: false, + enableOnSearchPage: true, + enableSearchTags: false, + layoutAwareScan: false + }, + + translation: { + convertHalfWidthCharacters: 'false', + convertNumericCharacters: 'false', + convertAlphabeticCharacters: 'false', + convertHiraganaToKatakana: 'false', + convertKatakanaToHiragana: 'variant', + collapseEmphaticSequences: 'false' + }, + + dictionaries: {}, + + parsing: { + enableScanningParser: true, + enableMecabParser: false, + selectedParser: null, + termSpacing: true, + readingMode: 'hiragana' + }, + + anki: { + enable: false, + server: 'http://127.0.0.1:8765', + tags: ['yomichan'], + sentenceExt: 200, + screenshot: {format: 'png', quality: 92}, + terms: {deck: '', model: '', fields: {}}, + kanji: {deck: '', model: '', fields: {}}, + duplicateScope: 'collection', + fieldTemplates: null + } + }; + } + + _legacyProfileUpdateAssignDefaults(options) { + const defaults = this._legacyProfileUpdateGetDefaults(); + + const combine = (target, source) => { + for (const key in source) { + if (!Object.prototype.hasOwnProperty.call(target, key)) { + target[key] = source[key]; + } + } + }; + + combine(options, defaults); + combine(options.general, defaults.general); + combine(options.scanning, defaults.scanning); + combine(options.anki, defaults.anki); + combine(options.anki.terms, defaults.anki.terms); + combine(options.anki.kanji, defaults.anki.kanji); + + return options; + } + + _legacyProfileUpdateUpdateVersion(options) { + const updates = this._legacyProfileUpdateGetUpdates(); + this._legacyProfileUpdateAssignDefaults(options); + + const targetVersion = updates.length; + const currentVersion = options.version; + + if (typeof currentVersion === 'number' && Number.isFinite(currentVersion)) { + for (let i = Math.max(0, Math.floor(currentVersion)); i < targetVersion; ++i) { + const update = updates[i]; + if (update !== null) { + update(options); + } + } + } + + options.version = targetVersion; + return options; + } + + // Private + + async _applyAnkiFieldTemplatesPatch(options, modificationsUrl) { + let patch = null; + for (const {options: profileOptions} of options.profiles) { + const fieldTemplates = profileOptions.anki.fieldTemplates; + if (fieldTemplates === null) { continue; } + + if (patch === null) { + const content = await this._fetchAsset(modificationsUrl); + if (this._templatePatcher === null) { + this._templatePatcher = new TemplatePatcher(); + } + patch = this._templatePatcher.parsePatch(content); + } + + profileOptions.anki.fieldTemplates = this._templatePatcher.applyPatch(fieldTemplates, patch); + } + } + + async _fetchAsset(url, json=false) { + url = chrome.runtime.getURL(url); + const response = await fetch(url, { + method: 'GET', + mode: 'no-cors', + cache: 'default', + credentials: 'omit', + redirect: 'follow', + referrerPolicy: 'no-referrer' + }); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status}`); + } + return await (json ? response.json() : response.text()); + } + + _getStringHashCode(string) { + let hashCode = 0; + + if (typeof string !== 'string') { return hashCode; } + + for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) { + hashCode = ((hashCode << 5) - hashCode) + charCode; + hashCode |= 0; + } + + return hashCode; + } + + async _applyUpdates(options, updates) { + const targetVersion = updates.length; + let currentVersion = options.version; + + if (typeof currentVersion !== 'number' || !Number.isFinite(currentVersion)) { + currentVersion = 0; + } + + for (let i = Math.max(0, Math.floor(currentVersion)); i < targetVersion; ++i) { + const {update, async} = updates[i]; + const result = update(options); + options = (async ? await result : result); + } + + options.version = targetVersion; + return options; + } + + _getVersionUpdates() { + return [ + {async: false, update: this._updateVersion1.bind(this)}, + {async: false, update: this._updateVersion2.bind(this)}, + {async: true, update: this._updateVersion3.bind(this)}, + {async: true, update: this._updateVersion4.bind(this)}, + {async: false, update: this._updateVersion5.bind(this)}, + {async: true, update: this._updateVersion6.bind(this)}, + {async: false, update: this._updateVersion7.bind(this)}, + {async: true, update: this._updateVersion8.bind(this)} + ]; + } + + _updateVersion1(options) { + // Version 1 changes: + // Added options.global.database.prefixWildcardsSupported = false. + options.global = { + database: { + prefixWildcardsSupported: false + } + }; + return options; + } + + _updateVersion2(options) { + // Version 2 changes: + // Legacy profile update process moved into this upgrade function. + for (const profile of options.profiles) { + if (!Array.isArray(profile.conditionGroups)) { + profile.conditionGroups = []; + } + profile.options = this._legacyProfileUpdateUpdateVersion(profile.options); + } + return options; + } + + async _updateVersion3(options) { + // Version 3 changes: + // Pitch accent Anki field templates added. + await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v2.handlebars'); + return options; + } + + async _updateVersion4(options) { + // Version 4 changes: + // Options conditions converted to string representations. + // Added usePopupWindow. + // Updated handlebars templates to include "clipboard-image" definition. + // Updated handlebars templates to include "clipboard-text" definition. + // Added hideDelay. + // Added inputs to profileOptions.scanning. + // Added pointerEventsEnabled to profileOptions.scanning. + // Added preventMiddleMouse to profileOptions.scanning. + 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}` + ); + } + } + } + const createInputDefaultOptions = () => ({ + showAdvanced: false, + searchTerms: true, + searchKanji: true, + scanOnTouchMove: true, + scanOnPenHover: true, + scanOnPenPress: true, + scanOnPenRelease: false, + preventTouchScrolling: true + }); + for (const {options: profileOptions} of options.profiles) { + profileOptions.general.usePopupWindow = false; + profileOptions.scanning.hideDelay = 0; + profileOptions.scanning.pointerEventsEnabled = false; + profileOptions.scanning.preventMiddleMouse = { + onWebPages: false, + onPopupPages: false, + onSearchPages: false, + onSearchQuery: false + }; + + const {modifier, middleMouse} = profileOptions.scanning; + delete profileOptions.scanning.modifier; + delete profileOptions.scanning.middleMouse; + const scanningInputs = []; + let modifierInput = ''; + switch (modifier) { + case 'alt': + case 'ctrl': + case 'shift': + case 'meta': + modifierInput = modifier; + break; + case 'none': + modifierInput = ''; + break; + } + scanningInputs.push({ + include: modifierInput, + exclude: 'mouse0', + types: {mouse: true, touch: false, pen: false}, + options: createInputDefaultOptions() + }); + if (middleMouse) { + scanningInputs.push({ + include: 'mouse2', + exclude: '', + types: {mouse: true, touch: false, pen: false}, + options: createInputDefaultOptions() + }); + } + scanningInputs.push({ + include: '', + exclude: '', + types: {mouse: false, touch: true, pen: true}, + options: createInputDefaultOptions() + }); + profileOptions.scanning.inputs = scanningInputs; + } + await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v4.handlebars'); + return options; + } + + _updateVersion5(options) { + // Version 5 changes: + // Removed legacy version number from profile options. + for (const profile of options.profiles) { + delete profile.options.version; + } + return options; + } + + async _updateVersion6(options) { + // Version 6 changes: + // Updated handlebars templates to include "conjugation" definition. + // Added global option showPopupPreview. + // Added global option useSettingsV2. + // Added anki.checkForDuplicates. + // Added general.glossaryLayoutMode; removed general.compactGlossaries. + await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v6.handlebars'); + options.global.showPopupPreview = false; + options.global.useSettingsV2 = false; + for (const profile of options.profiles) { + profile.options.anki.checkForDuplicates = true; + profile.options.general.glossaryLayoutMode = (profile.options.general.compactGlossaries ? 'compact' : 'default'); + delete profile.options.general.compactGlossaries; + const fieldTemplates = profile.options.anki.fieldTemplates; + if (typeof fieldTemplates === 'string') { + profile.options.anki.fieldTemplates = this._updateVersion6AnkiTemplatesCompactTags(fieldTemplates); + } + } + return options; + } + + _updateVersion6AnkiTemplatesCompactTags(templates) { + const rawPattern1 = '{{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}}'; + const pattern1 = new RegExp(`((\r?\n)?[ \t]*)${escapeRegExp(rawPattern1)}`, 'g'); + const replacement1 = ( + // eslint-disable-next-line indent +`{{~#scope~}} + {{~#set "any" false}}{{/set~}} + {{~#if definitionTags~}}{{#each definitionTags~}} + {{~#if (op "||" (op "!" ../data.compactTags) (op "!" redundant))~}} + {{~#if (get "any")}}, {{else}}<i>({{/if~}} + {{name}} + {{~#set "any" true}}{{/set~}} + {{~/if~}} + {{~/each~}} + {{~#if (get "any")}})</i> {{/if~}} + {{~/if~}} +{{~/scope~}}` + ); + const simpleNewline = /\n/g; + templates = templates.replace(pattern1, (g0, space) => (space + replacement1.replace(simpleNewline, space))); + templates = templates.replace(/\bcompactGlossaries=((?:\.*\/)*)compactGlossaries\b/g, (g0, g1) => `${g0} data=${g1}.`); + return templates; + } + + _updateVersion7(options) { + // Version 7 changes: + // Added general.maximumClipboardSearchLength. + // Added general.popupCurrentIndicatorMode. + // Added general.popupActionBarVisibility. + // Added general.popupActionBarLocation. + // Removed global option showPopupPreview. + delete options.global.showPopupPreview; + for (const profile of options.profiles) { + profile.options.general.maximumClipboardSearchLength = 1000; + profile.options.general.popupCurrentIndicatorMode = 'triangle'; + profile.options.general.popupActionBarVisibility = 'auto'; + profile.options.general.popupActionBarLocation = 'right'; + } + return options; + } + + async _updateVersion8(options) { + // Version 8 changes: + // Added translation.textReplacements. + // Moved anki.sentenceExt to sentenceParsing.scanExtent. + // Added sentenceParsing.enableTerminationCharacters. + // Added sentenceParsing.terminationCharacters. + // Changed general.popupActionBarLocation. + // Added inputs.hotkeys. + // Added anki.suspendNewCards. + // Added popupWindow. + // Updated handlebars templates to include "stroke-count" definition. + // Updated global.useSettingsV2 to be true (opt-out). + // Added audio.customSourceType. + // Moved general.enableClipboardPopups => clipboard.enableBackgroundMonitor. + // Moved general.enableClipboardMonitor => clipboard.enableSearchPageMonitor. Forced value to false due to a bug which caused its value to not be read. + // Moved general.maximumClipboardSearchLength => clipboard.maximumSearchLength. + // Added clipboard.autoSearchContent. + await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v8.handlebars'); + options.global.useSettingsV2 = true; + for (const profile of options.profiles) { + profile.options.translation.textReplacements = { + searchOriginal: true, + groups: [] + }; + profile.options.sentenceParsing = { + scanExtent: profile.options.anki.sentenceExt, + enableTerminationCharacters: true, + terminationCharacters: [ + {enabled: true, character1: '「', character2: '」', includeCharacterAtStart: false, includeCharacterAtEnd: false}, + {enabled: true, character1: '『', character2: '』', includeCharacterAtStart: false, includeCharacterAtEnd: false}, + {enabled: true, character1: '"', character2: '"', includeCharacterAtStart: false, includeCharacterAtEnd: false}, + {enabled: true, character1: '\'', character2: '\'', includeCharacterAtStart: false, includeCharacterAtEnd: false}, + {enabled: true, character1: '.', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '!', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '?', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '.', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '。', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '!', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '?', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true}, + {enabled: true, character1: '…', character2: null, includeCharacterAtStart: false, includeCharacterAtEnd: true} + ] + }; + delete profile.options.anki.sentenceExt; + profile.options.general.popupActionBarLocation = 'top'; + profile.options.inputs = { + hotkeys: [ + {action: 'close', key: 'Escape', modifiers: [], scopes: ['popup'], enabled: true}, + {action: 'focusSearchBox', key: 'Escape', modifiers: [], scopes: ['search'], enabled: true}, + {action: 'previousEntry3', key: 'PageUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'nextEntry3', key: 'PageDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'lastEntry', key: 'End', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'firstEntry', key: 'Home', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'previousEntry', key: 'ArrowUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'nextEntry', key: 'ArrowDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'historyBackward', key: 'KeyB', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'historyForward', key: 'KeyF', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'addNoteKanji', key: 'KeyK', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'addNoteTermKanji', key: 'KeyE', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'addNoteTermKana', key: 'KeyR', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'playAudio', key: 'KeyP', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'viewNote', key: 'KeyV', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'copyHostSelection', key: 'KeyC', modifiers: ['ctrl'], scopes: ['popup'], enabled: true} + ] + }; + profile.options.anki.suspendNewCards = false; + profile.options.popupWindow = { + width: profile.options.general.popupWidth, + height: profile.options.general.popupHeight, + left: 0, + top: 0, + useLeft: false, + useTop: false, + windowType: 'popup', + windowState: 'normal' + }; + profile.options.audio.customSourceType = 'audio'; + profile.options.clipboard = { + enableBackgroundMonitor: profile.options.general.enableClipboardPopups, + enableSearchPageMonitor: false, + autoSearchContent: true, + maximumSearchLength: profile.options.general.maximumClipboardSearchLength + }; + delete profile.options.general.enableClipboardPopups; + delete profile.options.general.enableClipboardMonitor; + delete profile.options.general.maximumClipboardSearchLength; + } + return options; + } +} diff --git a/ext/js/data/permissions-util.js b/ext/js/data/permissions-util.js new file mode 100644 index 00000000..bd3a18ce --- /dev/null +++ b/ext/js/data/permissions-util.js @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2021 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/>. + */ + +class PermissionsUtil { + constructor() { + this._ankiFieldMarkersRequiringClipboardPermission = new Set([ + 'clipboard-image', + 'clipboard-text' + ]); + this._ankiMarkerPattern = /\{([\w-]+)\}/g; + } + + hasPermissions(permissions) { + return new Promise((resolve, reject) => chrome.permissions.contains(permissions, (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + })); + } + + setPermissionsGranted(permissions, shouldHave) { + return ( + shouldHave ? + new Promise((resolve, reject) => chrome.permissions.request(permissions, (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + })) : + new Promise((resolve, reject) => chrome.permissions.remove(permissions, (result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(!result); + } + })) + ); + } + + getAllPermissions() { + return new Promise((resolve, reject) => chrome.permissions.getAll((result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + })); + } + + getRequiredPermissionsForAnkiFieldValue(fieldValue) { + const markers = this._getAnkiFieldMarkers(fieldValue); + const markerPermissions = this._ankiFieldMarkersRequiringClipboardPermission; + for (const marker of markers) { + if (markerPermissions.has(marker)) { + return ['clipboardRead']; + } + } + return []; + } + + hasRequiredPermissionsForOptions(permissions, options) { + const permissionsSet = new Set(permissions.permissions); + + if (!permissionsSet.has('nativeMessaging')) { + if (options.parsing.enableMecabParser) { + return false; + } + } + + if (!permissionsSet.has('clipboardRead')) { + if (options.clipboard.enableBackgroundMonitor || options.clipboard.enableSearchPageMonitor) { + return false; + } + const fieldMarkersRequiringClipboardPermission = this._ankiFieldMarkersRequiringClipboardPermission; + const fieldsList = [ + options.anki.terms.fields, + options.anki.kanji.fields + ]; + for (const fields of fieldsList) { + for (const fieldValue of Object.values(fields)) { + const markers = this._getAnkiFieldMarkers(fieldValue); + for (const marker of markers) { + if (fieldMarkersRequiringClipboardPermission.has(marker)) { + return false; + } + } + } + } + } + + return true; + } + + // Private + + _getAnkiFieldMarkers(fieldValue) { + const pattern = this._ankiMarkerPattern; + const markers = []; + let match; + while ((match = pattern.exec(fieldValue)) !== null) { + markers.push(match[1]); + } + return markers; + } +} |