summaryrefslogtreecommitdiff
path: root/ext/js/data
diff options
context:
space:
mode:
authortoasted-nutbread <toasted-nutbread@users.noreply.github.com>2021-02-14 11:19:54 -0500
committerGitHub <noreply@github.com>2021-02-14 11:19:54 -0500
commite419a418f6f03ef0a24330b67e7b76c5e3a7c22d (patch)
treea4c27bdfabc9280d9f6262d93d5152a58de8bc15 /ext/js/data
parent43d1457ebfe23196348649c245dfb942a0f00a1a (diff)
Move bg/js (#1387)
* Move bg/js/anki.js to js/comm/anki.js * Move bg/js/mecab.js to js/comm/mecab.js * Move bg/js/search-main.js to js/display/search-main.js * Move bg/js/template-patcher.js to js/templates/template-patcher.js * Move bg/js/template-renderer-frame-api.js to js/templates/template-renderer-frame-api.js * Move bg/js/template-renderer-frame-main.js to js/templates/template-renderer-frame-main.js * Move bg/js/template-renderer-proxy.js to js/templates/template-renderer-proxy.js * Move bg/js/template-renderer.js to js/templates/template-renderer.js * Move bg/js/media-utility.js to js/media/media-utility.js * Move bg/js/native-simple-dom-parser.js to js/dom/native-simple-dom-parser.js * Move bg/js/simple-dom-parser.js to js/dom/simple-dom-parser.js * Move bg/js/audio-downloader.js to js/media/audio-downloader.js * Move bg/js/deinflector.js to js/language/deinflector.js * Move bg/js/backend.js to js/background/backend.js * Move bg/js/translator.js to js/language/translator.js * Move bg/js/search-display-controller.js to js/display/search-display-controller.js * Move bg/js/request-builder.js to js/background/request-builder.js * Move bg/js/text-source-map.js to js/general/text-source-map.js * Move bg/js/clipboard-reader.js to js/comm/clipboard-reader.js * Move bg/js/clipboard-monitor.js to js/comm/clipboard-monitor.js * Move bg/js/query-parser.js to js/display/query-parser.js * Move bg/js/profile-conditions.js to js/background/profile-conditions.js * Move bg/js/dictionary-database.js to js/language/dictionary-database.js * Move bg/js/dictionary-importer.js to js/language/dictionary-importer.js * Move bg/js/anki-note-builder.js to js/data/anki-note-builder.js * Move bg/js/anki-note-data.js to js/data/anki-note-data.js * Move bg/js/database.js to js/data/database.js * Move bg/js/json-schema.js to js/data/json-schema.js * Move bg/js/options.js to js/data/options-util.js * Move bg/js/background-main.js to js/background/background-main.js * Move bg/js/permissions-util.js to js/data/permissions-util.js * Move bg/js/context-main.js to js/pages/action-popup-main.js * Move bg/js/generic-page-main.js to js/pages/generic-page-main.js * Move bg/js/info-main.js to js/pages/info-main.js * Move bg/js/permissions-main.js to js/pages/permissions-main.js * Move bg/js/welcome-main.js to js/pages/welcome-main.js
Diffstat (limited to 'ext/js/data')
-rw-r--r--ext/js/data/anki-note-builder.js148
-rw-r--r--ext/js/data/anki-note-data.js240
-rw-r--r--ext/js/data/database.js327
-rw-r--r--ext/js/data/json-schema.js757
-rw-r--r--ext/js/data/options-util.js739
-rw-r--r--ext/js/data/permissions-util.js126
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;
+ }
+}