aboutsummaryrefslogtreecommitdiff
path: root/ext/bg/js
diff options
context:
space:
mode:
authorAlex Yatskov <alex@foosoft.net>2020-06-27 19:04:19 -0700
committerAlex Yatskov <alex@foosoft.net>2020-06-27 19:04:19 -0700
commit88af95d20bfdbeb59d44bf0f0d46e772a329f839 (patch)
treed1dfa7268f274fed32061221c0f030e3647f9ae2 /ext/bg/js
parent19197a9a5d6a1f54a179d894577dfac513b97401 (diff)
parent0a6c08d0f53090a4ad48663bc5846ddae5723d52 (diff)
Merge branch 'master' into testing
Diffstat (limited to 'ext/bg/js')
-rw-r--r--ext/bg/js/anki-note-builder.js18
-rw-r--r--ext/bg/js/audio-uri-builder.js40
-rw-r--r--ext/bg/js/backend-api-forwarder.js44
-rw-r--r--ext/bg/js/backend.js375
-rw-r--r--ext/bg/js/background-main.js12
-rw-r--r--ext/bg/js/context-main.js28
-rw-r--r--ext/bg/js/database.js807
-rw-r--r--ext/bg/js/generic-database.js327
-rw-r--r--ext/bg/js/handlebars.js172
-rw-r--r--ext/bg/js/options.js7
-rw-r--r--ext/bg/js/request.js42
-rw-r--r--ext/bg/js/search-main.js48
-rw-r--r--ext/bg/js/search-query-parser-generator.js4
-rw-r--r--ext/bg/js/search-query-parser.js21
-rw-r--r--ext/bg/js/search.js143
-rw-r--r--ext/bg/js/settings/anki-templates.js226
-rw-r--r--ext/bg/js/settings/anki.js454
-rw-r--r--ext/bg/js/settings/audio-ui.js139
-rw-r--r--ext/bg/js/settings/audio.js273
-rw-r--r--ext/bg/js/settings/backup.js571
-rw-r--r--ext/bg/js/settings/clipboard-popups-controller.js51
-rw-r--r--ext/bg/js/settings/dictionaries.js560
-rw-r--r--ext/bg/js/settings/generic-setting-controller.js132
-rw-r--r--ext/bg/js/settings/main.js331
-rw-r--r--ext/bg/js/settings/popup-preview-frame-main.js22
-rw-r--r--ext/bg/js/settings/popup-preview-frame.js161
-rw-r--r--ext/bg/js/settings/popup-preview.js141
-rw-r--r--ext/bg/js/settings/profiles.js419
-rw-r--r--ext/bg/js/settings/settings-controller.js150
-rw-r--r--ext/bg/js/settings/storage.js199
-rw-r--r--ext/bg/js/template-renderer.js202
-rw-r--r--ext/bg/js/util.js9
32 files changed, 3294 insertions, 2834 deletions
diff --git a/ext/bg/js/anki-note-builder.js b/ext/bg/js/anki-note-builder.js
index 76199db7..7fe8962a 100644
--- a/ext/bg/js/anki-note-builder.js
+++ b/ext/bg/js/anki-note-builder.js
@@ -28,8 +28,9 @@ class AnkiNoteBuilder {
const modeOptions = isKanji ? options.anki.kanji : options.anki.terms;
const modeOptionsFieldEntries = Object.entries(modeOptions.fields);
+ const fields = {};
const note = {
- fields: {},
+ fields,
tags,
deckName: modeOptions.deck,
modelName: modeOptions.model,
@@ -38,8 +39,17 @@ class AnkiNoteBuilder {
}
};
- for (const [fieldName, fieldValue] of modeOptionsFieldEntries) {
- note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, context, options, templates, null);
+ const formattedFieldValuePromises = [];
+ for (const [, fieldValue] of modeOptionsFieldEntries) {
+ const formattedFieldValuePromise = this.formatField(fieldValue, definition, mode, context, options, templates, null);
+ formattedFieldValuePromises.push(formattedFieldValuePromise);
+ }
+
+ const formattedFieldValues = await Promise.all(formattedFieldValuePromises);
+ for (let i = 0, ii = modeOptionsFieldEntries.length; i < ii; ++i) {
+ const fieldName = modeOptionsFieldEntries[i][0];
+ const formattedFieldValue = formattedFieldValues[i];
+ fields[fieldName] = formattedFieldValue;
}
return note;
@@ -155,7 +165,7 @@ class AnkiNoteBuilder {
}
static arrayBufferToBase64(arrayBuffer) {
- return window.btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
+ return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
}
static stringReplaceAsync(str, regex, replacer) {
diff --git a/ext/bg/js/audio-uri-builder.js b/ext/bg/js/audio-uri-builder.js
index 27e97680..390e1e4d 100644
--- a/ext/bg/js/audio-uri-builder.js
+++ b/ext/bg/js/audio-uri-builder.js
@@ -82,16 +82,23 @@ class AudioUriBuilder {
}
async _getUriJpod101Alternate(definition) {
- const response = await new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
- xhr.open('POST', 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post');
- xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
- xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data')));
- xhr.addEventListener('load', () => resolve(xhr.responseText));
- xhr.send(`post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(definition.expression)}`);
+ const fetchUrl = 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post';
+ const data = `post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(definition.expression)}`;
+ const response = await fetch(fetchUrl, {
+ method: 'POST',
+ mode: 'no-cors',
+ cache: 'default',
+ credentials: 'omit',
+ redirect: 'follow',
+ referrerPolicy: 'no-referrer',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: data
});
+ const responseText = await response.text();
- const dom = new DOMParser().parseFromString(response, 'text/html');
+ const dom = new DOMParser().parseFromString(responseText, 'text/html');
for (const row of dom.getElementsByClassName('dc-result-row')) {
try {
const url = row.querySelector('audio>source[src]').getAttribute('src');
@@ -108,15 +115,18 @@ class AudioUriBuilder {
}
async _getUriJisho(definition) {
- const response = await new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
- xhr.open('GET', `https://jisho.org/search/${definition.expression}`);
- xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data')));
- xhr.addEventListener('load', () => resolve(xhr.responseText));
- xhr.send();
+ const fetchUrl = `https://jisho.org/search/${definition.expression}`;
+ const response = await fetch(fetchUrl, {
+ method: 'GET',
+ mode: 'no-cors',
+ cache: 'default',
+ credentials: 'omit',
+ redirect: 'follow',
+ referrerPolicy: 'no-referrer'
});
+ const responseText = await response.text();
- const dom = new DOMParser().parseFromString(response, 'text/html');
+ const dom = new DOMParser().parseFromString(responseText, 'text/html');
try {
const audio = dom.getElementById(`audio_${definition.expression}:${definition.reading}`);
if (audio !== null) {
diff --git a/ext/bg/js/backend-api-forwarder.js b/ext/bg/js/backend-api-forwarder.js
deleted file mode 100644
index 4ac12730..00000000
--- a/ext/bg/js/backend-api-forwarder.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Copyright (C) 2019-2020 Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-
-class BackendApiForwarder {
- prepare() {
- chrome.runtime.onConnect.addListener(this._onConnect.bind(this));
- }
-
- _onConnect(port) {
- if (port.name !== 'backend-api-forwarder') { return; }
-
- let tabId;
- if (!(
- port.sender &&
- port.sender.tab &&
- (typeof (tabId = port.sender.tab.id)) === 'number'
- )) {
- port.disconnect();
- return;
- }
-
- const forwardPort = chrome.tabs.connect(tabId, {name: 'frontend-api-receiver'});
-
- port.onMessage.addListener((message) => forwardPort.postMessage(message));
- forwardPort.onMessage.addListener((message) => port.postMessage(message));
- port.onDisconnect.addListener(() => forwardPort.disconnect());
- forwardPort.onDisconnect.addListener(() => port.disconnect());
- }
-}
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js
index 20d31efc..344706d1 100644
--- a/ext/bg/js/backend.js
+++ b/ext/bg/js/backend.js
@@ -20,7 +20,6 @@
* AnkiNoteBuilder
* AudioSystem
* AudioUriBuilder
- * BackendApiForwarder
* ClipboardMonitor
* Database
* DictionaryImporter
@@ -28,10 +27,10 @@
* JsonSchema
* Mecab
* ObjectPropertyAccessor
+ * TemplateRenderer
* Translator
* conditionsTestValue
* dictTermsSort
- * handlebarsRenderDynamic
* jp
* optionsLoad
* optionsSave
@@ -64,22 +63,28 @@ class Backend {
audioSystem: this.audioSystem,
renderTemplate: this._renderTemplate.bind(this)
});
+ this._templateRenderer = new TemplateRenderer();
- this.optionsContext = {
- depth: 0,
- url: window.location.href
- };
+ const url = (typeof window === 'object' && window !== null ? window.location.href : '');
+ this.optionsContext = {depth: 0, url};
- this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target');
+ this.clipboardPasteTarget = (
+ typeof document === 'object' && document !== null ?
+ document.querySelector('#clipboard-paste-target') :
+ null
+ );
this.popupWindow = null;
- const apiForwarder = new BackendApiForwarder();
- apiForwarder.prepare();
-
- this._defaultBrowserActionTitle = null;
this._isPrepared = false;
this._prepareError = false;
+ this._preparePromise = null;
+ this._prepareCompletePromise = new Promise((resolve, reject) => {
+ this._prepareCompleteResolve = resolve;
+ this._prepareCompleteReject = reject;
+ });
+
+ this._defaultBrowserActionTitle = null;
this._badgePrepareDelayTimer = null;
this._logErrorLevel = null;
@@ -103,6 +108,7 @@ class Backend {
['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}],
['frameInformationGet', {async: true, contentScript: true, handler: this._onApiFrameInformationGet.bind(this)}],
['injectStylesheet', {async: true, contentScript: true, handler: this._onApiInjectStylesheet.bind(this)}],
+ ['getStylesheetContent', {async: true, contentScript: true, handler: this._onApiGetStylesheetContent.bind(this)}],
['getEnvironmentInfo', {async: false, contentScript: true, handler: this._onApiGetEnvironmentInfo.bind(this)}],
['clipboardGet', {async: true, contentScript: true, handler: this._onApiClipboardGet.bind(this)}],
['getDisplayTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetDisplayTemplatesHtml.bind(this)}],
@@ -119,7 +125,9 @@ class Backend {
['log', {async: false, contentScript: true, handler: this._onApiLog.bind(this)}],
['logIndicatorClear', {async: false, contentScript: true, handler: this._onApiLogIndicatorClear.bind(this)}],
['createActionPort', {async: false, contentScript: true, handler: this._onApiCreateActionPort.bind(this)}],
- ['modifySettings', {async: true, contentScript: true, handler: this._onApiModifySettings.bind(this)}]
+ ['modifySettings', {async: true, contentScript: true, handler: this._onApiModifySettings.bind(this)}],
+ ['getSettings', {async: false, contentScript: true, handler: this._onApiGetSettings.bind(this)}],
+ ['setAllSettings', {async: true, contentScript: false, handler: this._onApiSetAllSettings.bind(this)}]
]);
this._messageHandlersWithProgress = new Map([
['importDictionaryArchive', {async: true, contentScript: false, handler: this._onApiImportDictionaryArchive.bind(this)}],
@@ -134,7 +142,26 @@ class Backend {
]);
}
- async prepare() {
+ prepare() {
+ if (this._preparePromise === null) {
+ const promise = this._prepareInternal();
+ promise.then(
+ (value) => {
+ this._isPrepared = true;
+ this._prepareCompleteResolve(value);
+ },
+ (error) => {
+ this._prepareError = true;
+ this._prepareCompleteReject(error);
+ }
+ );
+ promise.finally(() => this._updateBadge());
+ this._preparePromise = promise;
+ }
+ return this._prepareCompletePromise;
+ }
+
+ async _prepareInternal() {
try {
this._defaultBrowserActionTitle = await this._getBrowserIconTitle();
this._badgePrepareDelayTimer = setTimeout(() => {
@@ -143,8 +170,14 @@ class Backend {
}, 1000);
this._updateBadge();
+ yomichan.on('log', this._onLog.bind(this));
+
await this.environment.prepare();
- await this.database.prepare();
+ try {
+ await this.database.prepare();
+ } catch (e) {
+ yomichan.logError(e);
+ }
await this.translator.prepare();
await profileConditionsDescriptorPromise;
@@ -156,14 +189,6 @@ class Backend {
this.onOptionsUpdated('background');
- if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) {
- chrome.commands.onCommand.addListener(this._runCommand.bind(this));
- }
- if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) {
- chrome.tabs.onZoomChange.addListener(this._onZoomChange.bind(this));
- }
- chrome.runtime.onMessage.addListener(this.onMessage.bind(this));
-
const options = this.getOptions(this.optionsContext);
if (options.general.showGuide) {
chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')});
@@ -174,10 +199,7 @@ class Backend {
this._sendMessageAllTabs('backendPrepared');
const callback = () => this.checkLastError(chrome.runtime.lastError);
chrome.runtime.sendMessage({action: 'backendPrepared'}, callback);
-
- this._isPrepared = true;
} catch (e) {
- this._prepareError = true;
yomichan.logError(e);
throw e;
} finally {
@@ -185,15 +207,33 @@ class Backend {
clearTimeout(this._badgePrepareDelayTimer);
this._badgePrepareDelayTimer = null;
}
-
- this._updateBadge();
}
}
+ prepareComplete() {
+ return this._prepareCompletePromise;
+ }
+
isPrepared() {
return this._isPrepared;
}
+ handleCommand(...args) {
+ return this._runCommand(...args);
+ }
+
+ handleZoomChange(...args) {
+ return this._onZoomChange(...args);
+ }
+
+ handleConnect(...args) {
+ return this._onConnect(...args);
+ }
+
+ handleMessage(...args) {
+ return this.onMessage(...args);
+ }
+
_sendMessageAllTabs(action, params={}) {
const callback = () => this.checkLastError(chrome.runtime.lastError);
chrome.tabs.query({}, (tabs) => {
@@ -236,6 +276,45 @@ class Backend {
}
}
+ _onConnect(port) {
+ try {
+ const match = /^background-cross-frame-communication-port-(\d+)$/.exec(`${port.name}`);
+ if (match === null) { return; }
+
+ const tabId = (port.sender && port.sender.tab ? port.sender.tab.id : null);
+ if (typeof tabId !== 'number') {
+ throw new Error('Port does not have an associated tab ID');
+ }
+ const senderFrameId = port.sender.frameId;
+ if (typeof tabId !== 'number') {
+ throw new Error('Port does not have an associated frame ID');
+ }
+ const targetFrameId = parseInt(match[1], 10);
+
+ let forwardPort = chrome.tabs.connect(tabId, {frameId: targetFrameId, name: `cross-frame-communication-port-${senderFrameId}`});
+
+ const cleanup = () => {
+ this.checkLastError(chrome.runtime.lastError);
+ if (forwardPort !== null) {
+ forwardPort.disconnect();
+ forwardPort = null;
+ }
+ if (port !== null) {
+ port.disconnect();
+ port = null;
+ }
+ };
+
+ port.onMessage.addListener((message) => { forwardPort.postMessage(message); });
+ forwardPort.onMessage.addListener((message) => { port.postMessage(message); });
+ port.onDisconnect.addListener(cleanup);
+ forwardPort.onDisconnect.addListener(cleanup);
+ } catch (e) {
+ port.disconnect();
+ yomichan.logError(e);
+ }
+ }
+
_onClipboardText({text}) {
this._onCommandSearch({mode: 'popup', query: text});
}
@@ -245,6 +324,14 @@ class Backend {
chrome.tabs.sendMessage(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}, callback);
}
+ _onLog({level}) {
+ const levelValue = this._getErrorLevelValue(level);
+ if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; }
+
+ this._logErrorLevel = level;
+ this._updateBadge();
+ }
+
applyOptions() {
const options = this.getOptions(this.optionsContext);
this._updateBadge();
@@ -274,15 +361,6 @@ class Backend {
return useSchema ? JsonSchema.createProxy(options, this.optionsSchema) : options;
}
- setFullOptions(options) {
- try {
- this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options));
- } catch (e) {
- // This shouldn't happen, but catch errors just in case of bugs
- yomichan.logError(e);
- }
- }
-
getOptions(optionsContext, useSchema=false) {
return this.getProfile(optionsContext, useSchema).options;
}
@@ -506,13 +584,14 @@ class Backend {
const states = [];
try {
- const notes = [];
+ const notePromises = [];
for (const definition of definitions) {
for (const mode of modes) {
- const note = await this.ankiNoteBuilder.createNote(definition, mode, context, options, templates);
- notes.push(note);
+ const notePromise = this.ankiNoteBuilder.createNote(definition, mode, context, options, templates);
+ notePromises.push(notePromise);
}
}
+ const notes = await Promise.all(notePromises);
const cannotAdd = [];
const results = await this.anki.canAddNotes(notes);
@@ -641,6 +720,13 @@ class Backend {
});
}
+ async _onApiGetStylesheetContent({url}) {
+ if (!url.startsWith('/') || url.startsWith('//') || !url.endsWith('.css')) {
+ throw new Error('Invalid URL');
+ }
+ return await requestText(url, 'GET');
+ }
+
_onApiGetEnvironmentInfo() {
return this.environment.getInfo();
}
@@ -663,6 +749,9 @@ class Backend {
return await navigator.clipboard.readText();
} else {
const clipboardPasteTarget = this.clipboardPasteTarget;
+ if (clipboardPasteTarget === null) {
+ throw new Error('Reading the clipboard is not supported in this context');
+ }
clipboardPasteTarget.value = '';
clipboardPasteTarget.focus();
document.execCommand('paste');
@@ -744,12 +833,6 @@ class Backend {
_onApiLog({error, level, context}) {
yomichan.log(jsonToError(error), level, context);
-
- const levelValue = this._getErrorLevelValue(level);
- if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; }
-
- this._logErrorLevel = level;
- this._updateBadge();
}
_onApiLogIndicatorClear() {
@@ -791,8 +874,8 @@ class Backend {
const results = [];
for (const target of targets) {
try {
- this._modifySetting(target);
- results.push({result: true});
+ const result = this._modifySetting(target);
+ results.push({result: utilIsolate(result)});
} catch (e) {
results.push({error: errorToJson(e)});
}
@@ -801,10 +884,29 @@ class Backend {
return results;
}
+ _onApiGetSettings({targets}) {
+ const results = [];
+ for (const target of targets) {
+ try {
+ const result = this._getSetting(target);
+ results.push({result: utilIsolate(result)});
+ } catch (e) {
+ results.push({error: errorToJson(e)});
+ }
+ }
+ return results;
+ }
+
+ async _onApiSetAllSettings({value, source}) {
+ this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, value);
+ await this._onApiOptionsSave({source});
+ }
+
// Command handlers
_createActionListenerPort(port, sender, handlers) {
let hasStarted = false;
+ let messageString = '';
const onProgress = (...data) => {
try {
@@ -815,12 +917,34 @@ class Backend {
}
};
- const onMessage = async ({action, params}) => {
+ const onMessage = (message) => {
if (hasStarted) { return; }
- hasStarted = true;
- port.onMessage.removeListener(onMessage);
try {
+ const {action, data} = message;
+ switch (action) {
+ case 'fragment':
+ messageString += data;
+ break;
+ case 'invoke':
+ {
+ hasStarted = true;
+ port.onMessage.removeListener(onMessage);
+
+ const messageData = JSON.parse(messageString);
+ messageString = null;
+ onMessageComplete(messageData);
+ }
+ break;
+ }
+ } catch (e) {
+ cleanup(e);
+ }
+ };
+
+ const onMessageComplete = async (message) => {
+ try {
+ const {action, params} = message;
port.postMessage({type: 'ack'});
const messageHandler = handlers.get(action);
@@ -837,25 +961,29 @@ class Backend {
const result = async ? await promiseOrResult : promiseOrResult;
port.postMessage({type: 'complete', data: result});
} catch (e) {
- if (port !== null) {
- port.postMessage({type: 'error', data: errorToJson(e)});
- }
- cleanup();
+ cleanup(e);
}
};
- const cleanup = () => {
+ const onDisconnect = () => {
+ cleanup(null);
+ };
+
+ const cleanup = (error) => {
if (port === null) { return; }
+ if (error !== null) {
+ port.postMessage({type: 'error', data: errorToJson(error)});
+ }
if (!hasStarted) {
port.onMessage.removeListener(onMessage);
}
- port.onDisconnect.removeListener(cleanup);
+ port.onDisconnect.removeListener(onDisconnect);
port = null;
handlers = null;
};
port.onMessage.addListener(onMessage);
- port.onDisconnect.addListener(cleanup);
+ port.onDisconnect.addListener(onDisconnect);
}
_getErrorLevelValue(errorLevel) {
@@ -951,13 +1079,8 @@ class Backend {
}
async _onCommandToggle() {
- const optionsContext = {
- depth: 0,
- url: window.location.href
- };
const source = 'popup';
-
- const options = this.getOptions(optionsContext);
+ const options = this.getOptions(this.optionsContext);
options.general.enable = !options.general.enable;
await this._onApiOptionsSave({source});
}
@@ -977,45 +1100,53 @@ class Backend {
}
}
- async _modifySetting(target) {
+ _getSetting(target) {
+ const options = this._getModifySettingObject(target);
+ const accessor = new ObjectPropertyAccessor(options);
+ const {path} = target;
+ if (typeof path !== 'string') { throw new Error('Invalid path'); }
+ return accessor.get(ObjectPropertyAccessor.getPathArray(path));
+ }
+
+ _modifySetting(target) {
const options = this._getModifySettingObject(target);
const accessor = new ObjectPropertyAccessor(options);
const action = target.action;
switch (action) {
case 'set':
- {
- const {path, value} = target;
- if (typeof path !== 'string') { throw new Error('Invalid path'); }
- accessor.set(ObjectPropertyAccessor.getPathArray(path), value);
- }
- break;
+ {
+ const {path, value} = target;
+ if (typeof path !== 'string') { throw new Error('Invalid path'); }
+ const pathArray = ObjectPropertyAccessor.getPathArray(path);
+ accessor.set(pathArray, value);
+ return accessor.get(pathArray);
+ }
case 'delete':
- {
- const {path} = target;
- if (typeof path !== 'string') { throw new Error('Invalid path'); }
- accessor.delete(ObjectPropertyAccessor.getPathArray(path));
- }
- break;
+ {
+ const {path} = target;
+ if (typeof path !== 'string') { throw new Error('Invalid path'); }
+ accessor.delete(ObjectPropertyAccessor.getPathArray(path));
+ return true;
+ }
case 'swap':
- {
- const {path1, path2} = target;
- if (typeof path1 !== 'string') { throw new Error('Invalid path1'); }
- if (typeof path2 !== 'string') { throw new Error('Invalid path2'); }
- accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2));
- }
- break;
+ {
+ const {path1, path2} = target;
+ if (typeof path1 !== 'string') { throw new Error('Invalid path1'); }
+ if (typeof path2 !== 'string') { throw new Error('Invalid path2'); }
+ accessor.swap(ObjectPropertyAccessor.getPathArray(path1), ObjectPropertyAccessor.getPathArray(path2));
+ return true;
+ }
case 'splice':
- {
- const {path, start, deleteCount, items} = target;
- if (typeof path !== 'string') { throw new Error('Invalid path'); }
- if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); }
- if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); }
- if (!Array.isArray(items)) { throw new Error('Invalid items'); }
- const array = accessor.get(ObjectPropertyAccessor.getPathArray(path));
- if (!Array.isArray(array)) { throw new Error('Invalid target type'); }
- array.splice(start, deleteCount, ...items);
- }
- break;
+ {
+ const {path, start, deleteCount, items} = target;
+ if (typeof path !== 'string') { throw new Error('Invalid path'); }
+ if (typeof start !== 'number' || Math.floor(start) !== start) { throw new Error('Invalid start'); }
+ if (typeof deleteCount !== 'number' || Math.floor(deleteCount) !== deleteCount) { throw new Error('Invalid deleteCount'); }
+ if (!Array.isArray(items)) { throw new Error('Invalid items'); }
+ const array = accessor.get(ObjectPropertyAccessor.getPathArray(path));
+ if (!Array.isArray(array)) { throw new Error('Invalid target type'); }
+ return array.splice(start, deleteCount, ...items);
+ }
default:
throw new Error(`Unknown action: ${action}`);
}
@@ -1113,7 +1244,7 @@ class Backend {
}
async _renderTemplate(template, data) {
- return handlebarsRenderDynamic(template, data);
+ return await this._templateRenderer.render(template, data);
}
_getTemplates(options) {
@@ -1211,3 +1342,57 @@ class Backend {
}
}
}
+
+class BackendEventHandler {
+ constructor(backend) {
+ this._backend = backend;
+ }
+
+ prepare() {
+ if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) {
+ const onCommand = this._createGenericEventHandler((...args) => this._backend.handleCommand(...args));
+ chrome.commands.onCommand.addListener(onCommand);
+ }
+
+ if (isObject(chrome.tabs) && isObject(chrome.tabs.onZoomChange)) {
+ const onZoomChange = this._createGenericEventHandler((...args) => this._backend.handleZoomChange(...args));
+ chrome.tabs.onZoomChange.addListener(onZoomChange);
+ }
+
+ const onConnect = this._createGenericEventHandler((...args) => this._backend.handleConnect(...args));
+ chrome.runtime.onConnect.addListener(onConnect);
+
+ const onMessage = this._onMessage.bind(this);
+ chrome.runtime.onMessage.addListener(onMessage);
+ }
+
+ // Event handlers
+
+ _createGenericEventHandler(handler) {
+ return this._onGenericEvent.bind(this, handler);
+ }
+
+ _onGenericEvent(handler, ...args) {
+ if (this._backend.isPrepared()) {
+ handler(...args);
+ return;
+ }
+
+ this._backend.prepareComplete().then(
+ () => { handler(...args); },
+ () => {} // NOP
+ );
+ }
+
+ _onMessage(message, sender, sendResponse) {
+ if (this._backend.isPrepared()) {
+ return this._backend.handleMessage(message, sender, sendResponse);
+ }
+
+ this._backend.prepareComplete().then(
+ () => { this._backend.handleMessage(message, sender, sendResponse); },
+ () => { sendResponse(); } // NOP
+ );
+ return true;
+ }
+}
diff --git a/ext/bg/js/background-main.js b/ext/bg/js/background-main.js
index 24117f4e..9dd447c4 100644
--- a/ext/bg/js/background-main.js
+++ b/ext/bg/js/background-main.js
@@ -17,9 +17,15 @@
/* global
* Backend
+ * BackendEventHandler
*/
-(async () => {
- window.yomichanBackend = new Backend();
- await window.yomichanBackend.prepare();
+(() => {
+ const backend = new Backend();
+ const backendEventHandler = new BackendEventHandler(backend);
+ backendEventHandler.prepare();
+ if (typeof window === 'object' && window !== null) {
+ window.yomichanBackend = backend;
+ }
+ backend.prepare();
})();
diff --git a/ext/bg/js/context-main.js b/ext/bg/js/context-main.js
index dbba0272..4a2ea168 100644
--- a/ext/bg/js/context-main.js
+++ b/ext/bg/js/context-main.js
@@ -16,11 +16,7 @@
*/
/* global
- * apiCommandExec
- * apiForwardLogsToBackend
- * apiGetEnvironmentInfo
- * apiLogIndicatorClear
- * apiOptionsGet
+ * api
*/
function showExtensionInfo() {
@@ -36,12 +32,12 @@ function setupButtonEvents(selector, command, url) {
for (const node of nodes) {
node.addEventListener('click', (e) => {
if (e.button !== 0) { return; }
- apiCommandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'});
+ api.commandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'});
e.preventDefault();
}, false);
node.addEventListener('auxclick', (e) => {
if (e.button !== 1) { return; }
- apiCommandExec(command, {mode: 'newTab'});
+ api.commandExec(command, {mode: 'newTab'});
e.preventDefault();
}, false);
@@ -54,14 +50,14 @@ function setupButtonEvents(selector, command, url) {
}
async function mainInner() {
- apiForwardLogsToBackend();
+ api.forwardLogsToBackend();
await yomichan.prepare();
- await apiLogIndicatorClear();
+ await api.logIndicatorClear();
showExtensionInfo();
- apiGetEnvironmentInfo().then(({browser}) => {
+ api.getEnvironmentInfo().then(({browser}) => {
// Firefox mobile opens this page as a full webpage.
document.documentElement.dataset.mode = (browser === 'firefox-mobile' ? 'full' : 'mini');
});
@@ -70,25 +66,23 @@ async function mainInner() {
setupButtonEvents('.action-open-search', 'search', chrome.runtime.getURL('/bg/search.html'));
setupButtonEvents('.action-open-options', 'options', chrome.runtime.getURL(manifest.options_ui.page));
- setupButtonEvents('.action-open-help', 'help');
+ setupButtonEvents('.action-open-help', 'help', 'https://foosoft.net/projects/yomichan/');
const optionsContext = {
depth: 0,
url: window.location.href
};
- apiOptionsGet(optionsContext).then((options) => {
+ api.optionsGet(optionsContext).then((options) => {
const toggle = document.querySelector('#enable-search');
toggle.checked = options.general.enable;
- toggle.addEventListener('change', () => apiCommandExec('toggle'), false);
+ toggle.addEventListener('change', () => api.commandExec('toggle'), false);
const toggle2 = document.querySelector('#enable-search2');
toggle2.checked = options.general.enable;
- toggle2.addEventListener('change', () => apiCommandExec('toggle'), false);
+ toggle2.addEventListener('change', () => api.commandExec('toggle'), false);
setTimeout(() => {
- for (const n of document.querySelectorAll('.toggle-group')) {
- n.classList.add('toggle-group-animated');
- }
+ document.body.dataset.loaded = 'true';
}, 10);
});
}
diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js
index 930cd0d0..47f1ebdd 100644
--- a/ext/bg/js/database.js
+++ b/ext/bg/js/database.js
@@ -16,423 +16,422 @@
*/
/* global
+ * GenericDatabase
* dictFieldSplit
*/
class Database {
constructor() {
- this.db = null;
+ this._db = new GenericDatabase();
+ this._dbName = 'dict';
this._schemas = new Map();
}
// Public
async prepare() {
- if (this.db !== null) {
- throw new Error('Database already initialized');
- }
-
- try {
- this.db = await Database._open('dict', 6, (db, transaction, oldVersion) => {
- Database._upgrade(db, transaction, oldVersion, [
- {
- version: 2,
- stores: {
- terms: {
- primaryKey: {keyPath: 'id', autoIncrement: true},
- indices: ['dictionary', 'expression', 'reading']
- },
- kanji: {
- primaryKey: {autoIncrement: true},
- indices: ['dictionary', 'character']
- },
- tagMeta: {
- primaryKey: {autoIncrement: true},
- indices: ['dictionary']
- },
- dictionaries: {
- primaryKey: {autoIncrement: true},
- indices: ['title', 'version']
- }
+ await this._db.open(
+ this._dbName,
+ 60,
+ [
+ {
+ version: 20,
+ stores: {
+ terms: {
+ primaryKey: {keyPath: 'id', autoIncrement: true},
+ indices: ['dictionary', 'expression', 'reading']
+ },
+ kanji: {
+ primaryKey: {autoIncrement: true},
+ indices: ['dictionary', 'character']
+ },
+ tagMeta: {
+ primaryKey: {autoIncrement: true},
+ indices: ['dictionary']
+ },
+ dictionaries: {
+ primaryKey: {autoIncrement: true},
+ indices: ['title', 'version']
}
- },
- {
- version: 3,
- stores: {
- termMeta: {
- primaryKey: {autoIncrement: true},
- indices: ['dictionary', 'expression']
- },
- kanjiMeta: {
- primaryKey: {autoIncrement: true},
- indices: ['dictionary', 'character']
- },
- tagMeta: {
- primaryKey: {autoIncrement: true},
- indices: ['dictionary', 'name']
- }
+ }
+ },
+ {
+ version: 30,
+ stores: {
+ termMeta: {
+ primaryKey: {autoIncrement: true},
+ indices: ['dictionary', 'expression']
+ },
+ kanjiMeta: {
+ primaryKey: {autoIncrement: true},
+ indices: ['dictionary', 'character']
+ },
+ tagMeta: {
+ primaryKey: {autoIncrement: true},
+ indices: ['dictionary', 'name']
}
- },
- {
- version: 4,
- stores: {
- terms: {
- primaryKey: {keyPath: 'id', autoIncrement: true},
- indices: ['dictionary', 'expression', 'reading', 'sequence']
- }
+ }
+ },
+ {
+ version: 40,
+ stores: {
+ terms: {
+ primaryKey: {keyPath: 'id', autoIncrement: true},
+ indices: ['dictionary', 'expression', 'reading', 'sequence']
}
- },
- {
- version: 5,
- stores: {
- terms: {
- primaryKey: {keyPath: 'id', autoIncrement: true},
- indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse']
- }
+ }
+ },
+ {
+ version: 50,
+ stores: {
+ terms: {
+ primaryKey: {keyPath: 'id', autoIncrement: true},
+ indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse']
}
- },
- {
- version: 6,
- stores: {
- media: {
- primaryKey: {keyPath: 'id', autoIncrement: true},
- indices: ['dictionary', 'path']
- }
+ }
+ },
+ {
+ version: 60,
+ stores: {
+ media: {
+ primaryKey: {keyPath: 'id', autoIncrement: true},
+ indices: ['dictionary', 'path']
}
}
- ]);
- });
- return true;
- } catch (e) {
- yomichan.logError(e);
- return false;
- }
+ }
+ ]
+ );
}
async close() {
- this._validate();
- this.db.close();
- this.db = null;
+ this._db.close();
}
isPrepared() {
- return this.db !== null;
+ return this._db.isOpen();
}
async purge() {
- this._validate();
-
- this.db.close();
- await Database._deleteDatabase(this.db.name);
- this.db = null;
-
+ if (this._db.isOpening()) {
+ throw new Error('Cannot purge database while opening');
+ }
+ if (this._db.isOpen()) {
+ this._db.close();
+ }
+ await GenericDatabase.deleteDatabase(this._dbName);
await this.prepare();
}
async deleteDictionary(dictionaryName, progressSettings, onProgress) {
- this._validate();
-
const targets = [
['dictionaries', 'title'],
['kanji', 'dictionary'],
['kanjiMeta', 'dictionary'],
['terms', 'dictionary'],
['termMeta', 'dictionary'],
- ['tagMeta', 'dictionary']
+ ['tagMeta', 'dictionary'],
+ ['media', 'dictionary']
];
- const promises = [];
+
+ const {rate} = progressSettings;
const progressData = {
count: 0,
processed: 0,
storeCount: targets.length,
storesProcesed: 0
};
- let progressRate = (typeof progressSettings === 'object' && progressSettings !== null ? progressSettings.rate : 0);
- if (typeof progressRate !== 'number' || progressRate <= 0) {
- progressRate = 1000;
- }
-
- for (const [objectStoreName, index] of targets) {
- const dbTransaction = this.db.transaction([objectStoreName], 'readwrite');
- const dbObjectStore = dbTransaction.objectStore(objectStoreName);
- const dbIndex = dbObjectStore.index(index);
- const only = IDBKeyRange.only(dictionaryName);
- promises.push(Database._deleteValues(dbObjectStore, dbIndex, only, onProgress, progressData, progressRate));
- }
-
- await Promise.all(promises);
- }
-
- async findTermsBulk(termList, dictionaries, wildcard) {
- this._validate();
- const promises = [];
- const visited = new Set();
- const results = [];
- const processRow = (row, index) => {
- if (dictionaries.has(row.dictionary) && !visited.has(row.id)) {
- visited.add(row.id);
- results.push(Database._createTerm(row, index));
+ const filterKeys = (keys) => {
+ ++progressData.storesProcesed;
+ progressData.count += keys.length;
+ onProgress(progressData);
+ return keys;
+ };
+ const onProgress2 = () => {
+ const processed = progressData.processed + 1;
+ progressData.processed = processed;
+ if ((processed % rate) === 0 || processed === progressData.count) {
+ onProgress(progressData);
}
};
- const useWildcard = !!wildcard;
- const prefixWildcard = wildcard === 'prefix';
-
- const dbTransaction = this.db.transaction(['terms'], 'readonly');
- const dbTerms = dbTransaction.objectStore('terms');
- const dbIndex1 = dbTerms.index(prefixWildcard ? 'expressionReverse' : 'expression');
- const dbIndex2 = dbTerms.index(prefixWildcard ? 'readingReverse' : 'reading');
-
- for (let i = 0; i < termList.length; ++i) {
- const term = prefixWildcard ? stringReverse(termList[i]) : termList[i];
- const query = useWildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term);
- promises.push(
- Database._getAll(dbIndex1, query, i, processRow),
- Database._getAll(dbIndex2, query, i, processRow)
- );
+ const promises = [];
+ for (const [objectStoreName, indexName] of targets) {
+ const query = IDBKeyRange.only(dictionaryName);
+ const promise = this._db.bulkDelete(objectStoreName, indexName, query, filterKeys, onProgress2);
+ promises.push(promise);
}
-
await Promise.all(promises);
-
- return results;
}
- async findTermsExactBulk(termList, readingList, dictionaries) {
- this._validate();
-
- const promises = [];
- const results = [];
- const processRow = (row, index) => {
- if (row.reading === readingList[index] && dictionaries.has(row.dictionary)) {
- results.push(Database._createTerm(row, index));
+ findTermsBulk(termList, dictionaries, wildcard) {
+ return new Promise((resolve, reject) => {
+ const results = [];
+ const count = termList.length;
+ if (count === 0) {
+ resolve(results);
+ return;
}
- };
-
- const dbTransaction = this.db.transaction(['terms'], 'readonly');
- const dbTerms = dbTransaction.objectStore('terms');
- const dbIndex = dbTerms.index('expression');
- for (let i = 0; i < termList.length; ++i) {
- const only = IDBKeyRange.only(termList[i]);
- promises.push(Database._getAll(dbIndex, only, i, processRow));
- }
-
- await Promise.all(promises);
+ const visited = new Set();
+ const useWildcard = !!wildcard;
+ const prefixWildcard = wildcard === 'prefix';
+
+ const transaction = this._db.transaction(['terms'], 'readonly');
+ const terms = transaction.objectStore('terms');
+ const index1 = terms.index(prefixWildcard ? 'expressionReverse' : 'expression');
+ const index2 = terms.index(prefixWildcard ? 'readingReverse' : 'reading');
+
+ const count2 = count * 2;
+ let completeCount = 0;
+ for (let i = 0; i < count; ++i) {
+ const inputIndex = i;
+ const term = prefixWildcard ? stringReverse(termList[i]) : termList[i];
+ const query = useWildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term);
+
+ const onGetAll = (rows) => {
+ for (const row of rows) {
+ if (dictionaries.has(row.dictionary) && !visited.has(row.id)) {
+ visited.add(row.id);
+ results.push(this._createTerm(row, inputIndex));
+ }
+ }
+ if (++completeCount >= count2) {
+ resolve(results);
+ }
+ };
- return results;
+ this._db.getAll(index1, query, onGetAll, reject);
+ this._db.getAll(index2, query, onGetAll, reject);
+ }
+ });
}
- async findTermsBySequenceBulk(sequenceList, mainDictionary) {
- this._validate();
-
- const promises = [];
- const results = [];
- const processRow = (row, index) => {
- if (row.dictionary === mainDictionary) {
- results.push(Database._createTerm(row, index));
+ findTermsExactBulk(termList, readingList, dictionaries) {
+ return new Promise((resolve, reject) => {
+ const results = [];
+ const count = termList.length;
+ if (count === 0) {
+ resolve(results);
+ return;
}
- };
-
- const dbTransaction = this.db.transaction(['terms'], 'readonly');
- const dbTerms = dbTransaction.objectStore('terms');
- const dbIndex = dbTerms.index('sequence');
-
- for (let i = 0; i < sequenceList.length; ++i) {
- const only = IDBKeyRange.only(sequenceList[i]);
- promises.push(Database._getAll(dbIndex, only, i, processRow));
- }
-
- await Promise.all(promises);
- return results;
- }
-
- async findTermMetaBulk(termList, dictionaries) {
- return this._findGenericBulk('termMeta', 'expression', termList, dictionaries, Database._createTermMeta);
- }
+ const transaction = this._db.transaction(['terms'], 'readonly');
+ const terms = transaction.objectStore('terms');
+ const index = terms.index('expression');
- async findKanjiBulk(kanjiList, dictionaries) {
- return this._findGenericBulk('kanji', 'character', kanjiList, dictionaries, Database._createKanji);
- }
+ let completeCount = 0;
+ for (let i = 0; i < count; ++i) {
+ const inputIndex = i;
+ const reading = readingList[i];
+ const query = IDBKeyRange.only(termList[i]);
- async findKanjiMetaBulk(kanjiList, dictionaries) {
- return this._findGenericBulk('kanjiMeta', 'character', kanjiList, dictionaries, Database._createKanjiMeta);
- }
+ const onGetAll = (rows) => {
+ for (const row of rows) {
+ if (row.reading === reading && dictionaries.has(row.dictionary)) {
+ results.push(this._createTerm(row, inputIndex));
+ }
+ }
+ if (++completeCount >= count) {
+ resolve(results);
+ }
+ };
- async findTagForTitle(name, title) {
- this._validate();
-
- let result = null;
- const dbTransaction = this.db.transaction(['tagMeta'], 'readonly');
- const dbTerms = dbTransaction.objectStore('tagMeta');
- const dbIndex = dbTerms.index('name');
- const only = IDBKeyRange.only(name);
- await Database._getAll(dbIndex, only, null, (row) => {
- if (title === row.dictionary) {
- result = row;
+ this._db.getAll(index, query, onGetAll, reject);
}
});
-
- return result;
}
- async getMedia(targets) {
- this._validate();
-
- const count = targets.length;
- const promises = [];
- const results = new Array(count).fill(null);
- const createResult = Database._createMedia;
- const processRow = (row, [index, dictionaryName]) => {
- if (row.dictionary === dictionaryName) {
- results[index] = createResult(row, index);
+ findTermsBySequenceBulk(sequenceList, mainDictionary) {
+ return new Promise((resolve, reject) => {
+ const results = [];
+ const count = sequenceList.length;
+ if (count === 0) {
+ resolve(results);
+ return;
}
- };
- const transaction = this.db.transaction(['media'], 'readonly');
- const objectStore = transaction.objectStore('media');
- const index = objectStore.index('path');
+ const transaction = this._db.transaction(['terms'], 'readonly');
+ const terms = transaction.objectStore('terms');
+ const index = terms.index('sequence');
- for (let i = 0; i < count; ++i) {
- const {path, dictionaryName} = targets[i];
- const only = IDBKeyRange.only(path);
- promises.push(Database._getAll(index, only, [i, dictionaryName], processRow));
- }
+ let completeCount = 0;
+ for (let i = 0; i < count; ++i) {
+ const inputIndex = i;
+ const query = IDBKeyRange.only(sequenceList[i]);
- await Promise.all(promises);
+ const onGetAll = (rows) => {
+ for (const row of rows) {
+ if (row.dictionary === mainDictionary) {
+ results.push(this._createTerm(row, inputIndex));
+ }
+ }
+ if (++completeCount >= count) {
+ resolve(results);
+ }
+ };
- return results;
+ this._db.getAll(index, query, onGetAll, reject);
+ }
+ });
}
- async getDictionaryInfo() {
- this._validate();
+ findTermMetaBulk(termList, dictionaries) {
+ return this._findGenericBulk('termMeta', 'expression', termList, dictionaries, this._createTermMeta.bind(this));
+ }
- const results = [];
- const dbTransaction = this.db.transaction(['dictionaries'], 'readonly');
- const dbDictionaries = dbTransaction.objectStore('dictionaries');
+ findKanjiBulk(kanjiList, dictionaries) {
+ return this._findGenericBulk('kanji', 'character', kanjiList, dictionaries, this._createKanji.bind(this));
+ }
- await Database._getAll(dbDictionaries, null, null, (info) => results.push(info));
+ findKanjiMetaBulk(kanjiList, dictionaries) {
+ return this._findGenericBulk('kanjiMeta', 'character', kanjiList, dictionaries, this._createKanjiMeta.bind(this));
+ }
- return results;
+ findTagForTitle(name, title) {
+ const query = IDBKeyRange.only(name);
+ return this._db.find('tagMeta', 'name', query, (row) => (row.dictionary === title), null);
}
- async getDictionaryCounts(dictionaryNames, getTotal) {
- this._validate();
+ getMedia(targets) {
+ return new Promise((resolve, reject) => {
+ const count = targets.length;
+ const results = new Array(count).fill(null);
+ if (count === 0) {
+ resolve(results);
+ return;
+ }
- const objectStoreNames = [
- 'kanji',
- 'kanjiMeta',
- 'terms',
- 'termMeta',
- 'tagMeta'
- ];
- const dbCountTransaction = this.db.transaction(objectStoreNames, 'readonly');
-
- const targets = [];
- for (const objectStoreName of objectStoreNames) {
- targets.push([
- objectStoreName,
- dbCountTransaction.objectStore(objectStoreName).index('dictionary')
- ]);
- }
+ let completeCount = 0;
+ const transaction = this._db.transaction(['media'], 'readonly');
+ const objectStore = transaction.objectStore('media');
+ const index = objectStore.index('path');
- // Query is required for Edge, otherwise index.count throws an exception.
- const query1 = IDBKeyRange.lowerBound('', false);
- const totalPromise = getTotal ? Database._getCounts(targets, query1) : null;
-
- const counts = [];
- const countPromises = [];
- for (let i = 0; i < dictionaryNames.length; ++i) {
- counts.push(null);
- const index = i;
- const query2 = IDBKeyRange.only(dictionaryNames[i]);
- const countPromise = Database._getCounts(targets, query2).then((v) => counts[index] = v);
- countPromises.push(countPromise);
- }
- await Promise.all(countPromises);
+ for (let i = 0; i < count; ++i) {
+ const inputIndex = i;
+ const {path, dictionaryName} = targets[i];
+ const query = IDBKeyRange.only(path);
- const result = {counts};
- if (totalPromise !== null) {
- result.total = await totalPromise;
- }
- return result;
+ const onGetAll = (rows) => {
+ for (const row of rows) {
+ if (row.dictionary !== dictionaryName) { continue; }
+ results[inputIndex] = this._createMedia(row, inputIndex);
+ }
+ if (++completeCount >= count) {
+ resolve(results);
+ }
+ };
+
+ this._db.getAll(index, query, onGetAll, reject);
+ }
+ });
}
- async dictionaryExists(title) {
- this._validate();
- const transaction = this.db.transaction(['dictionaries'], 'readonly');
- const index = transaction.objectStore('dictionaries').index('title');
- const query = IDBKeyRange.only(title);
- const count = await Database._getCount(index, query);
- return count > 0;
+ getDictionaryInfo() {
+ return new Promise((resolve, reject) => {
+ const transaction = this._db.transaction(['dictionaries'], 'readonly');
+ const objectStore = transaction.objectStore('dictionaries');
+ this._db.getAll(objectStore, null, resolve, reject);
+ });
}
- bulkAdd(objectStoreName, items, start, count) {
+ getDictionaryCounts(dictionaryNames, getTotal) {
return new Promise((resolve, reject) => {
- const transaction = this.db.transaction([objectStoreName], 'readwrite');
- const objectStore = transaction.objectStore(objectStoreName);
+ const targets = [
+ ['kanji', 'dictionary'],
+ ['kanjiMeta', 'dictionary'],
+ ['terms', 'dictionary'],
+ ['termMeta', 'dictionary'],
+ ['tagMeta', 'dictionary'],
+ ['media', 'dictionary']
+ ];
+ const objectStoreNames = targets.map(([objectStoreName]) => objectStoreName);
+ const transaction = this._db.transaction(objectStoreNames, 'readonly');
+ const databaseTargets = targets.map(([objectStoreName, indexName]) => {
+ const objectStore = transaction.objectStore(objectStoreName);
+ const index = objectStore.index(indexName);
+ return {objectStore, index};
+ });
- if (start + count > items.length) {
- count = items.length - start;
+ const countTargets = [];
+ if (getTotal) {
+ for (const {objectStore} of databaseTargets) {
+ countTargets.push([objectStore, null]);
+ }
}
-
- if (count <= 0) {
- resolve();
- return;
+ for (const dictionaryName of dictionaryNames) {
+ const query = IDBKeyRange.only(dictionaryName);
+ for (const {index} of databaseTargets) {
+ countTargets.push([index, query]);
+ }
}
- const end = start + count;
- let completedCount = 0;
- const onError = (e) => reject(e);
- const onSuccess = () => {
- if (++completedCount >= count) {
- resolve();
+ const onCountComplete = (results) => {
+ const resultCount = results.length;
+ const targetCount = targets.length;
+ const counts = [];
+ for (let i = 0; i < resultCount; i += targetCount) {
+ const countGroup = {};
+ for (let j = 0; j < targetCount; ++j) {
+ countGroup[targets[j][0]] = results[i + j];
+ }
+ counts.push(countGroup);
}
+ const total = getTotal ? counts.shift() : null;
+ resolve({total, counts});
};
- for (let i = start; i < end; ++i) {
- const request = objectStore.add(items[i]);
- request.onerror = onError;
- request.onsuccess = onSuccess;
- }
+ this._db.bulkCount(countTargets, onCountComplete, reject);
});
}
- // Private
+ async dictionaryExists(title) {
+ const query = IDBKeyRange.only(title);
+ const result = await this._db.find('dictionaries', 'title', query);
+ return typeof result !== 'undefined';
+ }
- _validate() {
- if (this.db === null) {
- throw new Error('Database not initialized');
- }
+ bulkAdd(objectStoreName, items, start, count) {
+ return this._db.bulkAdd(objectStoreName, items, start, count);
}
- async _findGenericBulk(tableName, indexName, indexValueList, dictionaries, createResult) {
- this._validate();
+ // Private
- const promises = [];
- const results = [];
- const processRow = (row, index) => {
- if (dictionaries.has(row.dictionary)) {
- results.push(createResult(row, index));
+ async _findGenericBulk(objectStoreName, indexName, indexValueList, dictionaries, createResult) {
+ return new Promise((resolve, reject) => {
+ const results = [];
+ const count = indexValueList.length;
+ if (count === 0) {
+ resolve(results);
+ return;
}
- };
- const dbTransaction = this.db.transaction([tableName], 'readonly');
- const dbTerms = dbTransaction.objectStore(tableName);
- const dbIndex = dbTerms.index(indexName);
+ const transaction = this._db.transaction([objectStoreName], 'readonly');
+ const terms = transaction.objectStore(objectStoreName);
+ const index = terms.index(indexName);
- for (let i = 0; i < indexValueList.length; ++i) {
- const only = IDBKeyRange.only(indexValueList[i]);
- promises.push(Database._getAll(dbIndex, only, i, processRow));
- }
+ let completeCount = 0;
+ for (let i = 0; i < count; ++i) {
+ const inputIndex = i;
+ const query = IDBKeyRange.only(indexValueList[i]);
- await Promise.all(promises);
+ const onGetAll = (rows) => {
+ for (const row of rows) {
+ if (dictionaries.has(row.dictionary)) {
+ results.push(createResult(row, inputIndex));
+ }
+ }
+ if (++completeCount >= count) {
+ resolve(results);
+ }
+ };
- return results;
+ this._db.getAll(index, query, onGetAll, reject);
+ }
+ });
}
- static _createTerm(row, index) {
+ _createTerm(row, index) {
return {
index,
expression: row.expression,
@@ -448,7 +447,7 @@ class Database {
};
}
- static _createKanji(row, index) {
+ _createKanji(row, index) {
return {
index,
character: row.character,
@@ -461,193 +460,15 @@ class Database {
};
}
- static _createTermMeta({expression, mode, data, dictionary}, index) {
+ _createTermMeta({expression, mode, data, dictionary}, index) {
return {expression, mode, data, dictionary, index};
}
- static _createKanjiMeta({character, mode, data, dictionary}, index) {
+ _createKanjiMeta({character, mode, data, dictionary}, index) {
return {character, mode, data, dictionary, index};
}
- static _createMedia(row, index) {
+ _createMedia(row, index) {
return Object.assign({}, row, {index});
}
-
- static _getAll(dbIndex, query, context, processRow) {
- const fn = typeof dbIndex.getAll === 'function' ? Database._getAllFast : Database._getAllUsingCursor;
- return fn(dbIndex, query, context, processRow);
- }
-
- static _getAllFast(dbIndex, query, context, processRow) {
- return new Promise((resolve, reject) => {
- const request = dbIndex.getAll(query);
- request.onerror = (e) => reject(e);
- request.onsuccess = (e) => {
- for (const row of e.target.result) {
- processRow(row, context);
- }
- resolve();
- };
- });
- }
-
- static _getAllUsingCursor(dbIndex, query, context, processRow) {
- return new Promise((resolve, reject) => {
- const request = dbIndex.openCursor(query, 'next');
- request.onerror = (e) => reject(e);
- request.onsuccess = (e) => {
- const cursor = e.target.result;
- if (cursor) {
- processRow(cursor.value, context);
- cursor.continue();
- } else {
- resolve();
- }
- };
- });
- }
-
- static _getCounts(targets, query) {
- const countPromises = [];
- const counts = {};
- for (const [objectStoreName, index] of targets) {
- const n = objectStoreName;
- const countPromise = Database._getCount(index, query).then((count) => counts[n] = count);
- countPromises.push(countPromise);
- }
- return Promise.all(countPromises).then(() => counts);
- }
-
- static _getCount(dbIndex, query) {
- return new Promise((resolve, reject) => {
- const request = dbIndex.count(query);
- request.onerror = (e) => reject(e);
- request.onsuccess = (e) => resolve(e.target.result);
- });
- }
-
- static _getAllKeys(dbIndex, query) {
- const fn = typeof dbIndex.getAllKeys === 'function' ? Database._getAllKeysFast : Database._getAllKeysUsingCursor;
- return fn(dbIndex, query);
- }
-
- static _getAllKeysFast(dbIndex, query) {
- return new Promise((resolve, reject) => {
- const request = dbIndex.getAllKeys(query);
- request.onerror = (e) => reject(e);
- request.onsuccess = (e) => resolve(e.target.result);
- });
- }
-
- static _getAllKeysUsingCursor(dbIndex, query) {
- return new Promise((resolve, reject) => {
- const primaryKeys = [];
- const request = dbIndex.openKeyCursor(query, 'next');
- request.onerror = (e) => reject(e);
- request.onsuccess = (e) => {
- const cursor = e.target.result;
- if (cursor) {
- primaryKeys.push(cursor.primaryKey);
- cursor.continue();
- } else {
- resolve(primaryKeys);
- }
- };
- });
- }
-
- static async _deleteValues(dbObjectStore, dbIndex, query, onProgress, progressData, progressRate) {
- const hasProgress = (typeof onProgress === 'function');
- const count = await Database._getCount(dbIndex, query);
- ++progressData.storesProcesed;
- progressData.count += count;
- if (hasProgress) {
- onProgress(progressData);
- }
-
- const onValueDeleted = (
- hasProgress ?
- () => {
- const p = ++progressData.processed;
- if ((p % progressRate) === 0 || p === progressData.count) {
- onProgress(progressData);
- }
- } :
- () => {}
- );
-
- const promises = [];
- const primaryKeys = await Database._getAllKeys(dbIndex, query);
- for (const key of primaryKeys) {
- const promise = Database._deleteValue(dbObjectStore, key).then(onValueDeleted);
- promises.push(promise);
- }
-
- await Promise.all(promises);
- }
-
- static _deleteValue(dbObjectStore, key) {
- return new Promise((resolve, reject) => {
- const request = dbObjectStore.delete(key);
- request.onerror = (e) => reject(e);
- request.onsuccess = () => resolve();
- });
- }
-
- static _open(name, version, onUpgradeNeeded) {
- return new Promise((resolve, reject) => {
- const request = window.indexedDB.open(name, version * 10);
-
- request.onupgradeneeded = (event) => {
- try {
- request.transaction.onerror = (e) => reject(e);
- onUpgradeNeeded(request.result, request.transaction, event.oldVersion / 10, event.newVersion / 10);
- } catch (e) {
- reject(e);
- }
- };
-
- request.onerror = (e) => reject(e);
- request.onsuccess = () => resolve(request.result);
- });
- }
-
- static _upgrade(db, transaction, oldVersion, upgrades) {
- for (const {version, stores} of upgrades) {
- if (oldVersion >= version) { continue; }
-
- const objectStoreNames = Object.keys(stores);
- for (const objectStoreName of objectStoreNames) {
- const {primaryKey, indices} = stores[objectStoreName];
-
- const objectStoreNames2 = transaction.objectStoreNames || db.objectStoreNames;
- const objectStore = (
- Database._listContains(objectStoreNames2, objectStoreName) ?
- transaction.objectStore(objectStoreName) :
- db.createObjectStore(objectStoreName, primaryKey)
- );
-
- for (const indexName of indices) {
- if (Database._listContains(objectStore.indexNames, indexName)) { continue; }
-
- objectStore.createIndex(indexName, indexName, {});
- }
- }
- }
- }
-
- static _deleteDatabase(dbName) {
- return new Promise((resolve, reject) => {
- const request = indexedDB.deleteDatabase(dbName);
- request.onerror = (e) => reject(e);
- request.onsuccess = () => resolve();
- });
- }
-
- static _listContains(list, value) {
- for (let i = 0, ii = list.length; i < ii; ++i) {
- if (list[i] === value) { return true; }
- }
- return false;
- }
}
diff --git a/ext/bg/js/generic-database.js b/ext/bg/js/generic-database.js
new file mode 100644
index 00000000..a82ad650
--- /dev/null
+++ b/ext/bg/js/generic-database.js
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class GenericDatabase {
+ 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/bg/js/handlebars.js b/ext/bg/js/handlebars.js
deleted file mode 100644
index 822174e2..00000000
--- a/ext/bg/js/handlebars.js
+++ /dev/null
@@ -1,172 +0,0 @@
-/*
- * Copyright (C) 2016-2020 Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-/* global
- * Handlebars
- * jp
- */
-
-function handlebarsEscape(text) {
- return Handlebars.Utils.escapeExpression(text);
-}
-
-function handlebarsDumpObject(options) {
- const dump = JSON.stringify(options.fn(this), null, 4);
- return handlebarsEscape(dump);
-}
-
-function handlebarsFurigana(options) {
- const definition = options.fn(this);
- const segs = jp.distributeFurigana(definition.expression, definition.reading);
-
- let result = '';
- for (const seg of segs) {
- if (seg.furigana) {
- result += `<ruby>${seg.text}<rt>${seg.furigana}</rt></ruby>`;
- } else {
- result += seg.text;
- }
- }
-
- return result;
-}
-
-function handlebarsFuriganaPlain(options) {
- const definition = options.fn(this);
- const segs = jp.distributeFurigana(definition.expression, definition.reading);
-
- let result = '';
- for (const seg of segs) {
- if (seg.furigana) {
- result += ` ${seg.text}[${seg.furigana}]`;
- } else {
- result += seg.text;
- }
- }
-
- return result.trimLeft();
-}
-
-function handlebarsKanjiLinks(options) {
- let result = '';
- for (const c of options.fn(this)) {
- if (jp.isCodePointKanji(c.codePointAt(0))) {
- result += `<a href="#" class="kanji-link">${c}</a>`;
- } else {
- result += c;
- }
- }
-
- return result;
-}
-
-function handlebarsMultiLine(options) {
- return options.fn(this).split('\n').join('<br>');
-}
-
-function handlebarsSanitizeCssClass(options) {
- return options.fn(this).replace(/[^_a-z0-9\u00a0-\uffff]/ig, '_');
-}
-
-function handlebarsRegexReplace(...args) {
- // Usage:
- // {{#regexReplace regex string [flags]}}content{{/regexReplace}}
- // regex: regular expression string
- // string: string to replace
- // flags: optional flags for regular expression
- // e.g. "i" for case-insensitive, "g" for replace all
- let value = args[args.length - 1].fn(this);
- if (args.length >= 3) {
- try {
- const flags = args.length > 3 ? args[2] : 'g';
- const regex = new RegExp(args[0], flags);
- value = value.replace(regex, args[1]);
- } catch (e) {
- return `${e}`;
- }
- }
- return value;
-}
-
-function handlebarsRegexMatch(...args) {
- // Usage:
- // {{#regexMatch regex [flags]}}content{{/regexMatch}}
- // regex: regular expression string
- // flags: optional flags for regular expression
- // e.g. "i" for case-insensitive, "g" for match all
- let value = args[args.length - 1].fn(this);
- if (args.length >= 2) {
- try {
- const flags = args.length > 2 ? args[1] : '';
- const regex = new RegExp(args[0], flags);
- const parts = [];
- value.replace(regex, (g0) => parts.push(g0));
- value = parts.join('');
- } catch (e) {
- return `${e}`;
- }
- }
- return value;
-}
-
-function handlebarsMergeTags(object, isGroupMode, isMergeMode) {
- const tagSources = [];
- if (isGroupMode || isMergeMode) {
- for (const definition of object.definitions) {
- tagSources.push(definition.definitionTags);
- }
- } else {
- tagSources.push(object.definitionTags);
- }
-
- const tags = new Set();
- for (const tagSource of tagSources) {
- for (const tag of tagSource) {
- tags.add(tag.name);
- }
- }
-
- return [...tags].join(', ');
-}
-
-function handlebarsRegisterHelpers() {
- if (Handlebars.partials !== Handlebars.templates) {
- Handlebars.partials = Handlebars.templates;
- Handlebars.registerHelper('dumpObject', handlebarsDumpObject);
- Handlebars.registerHelper('furigana', handlebarsFurigana);
- Handlebars.registerHelper('furiganaPlain', handlebarsFuriganaPlain);
- Handlebars.registerHelper('kanjiLinks', handlebarsKanjiLinks);
- Handlebars.registerHelper('multiLine', handlebarsMultiLine);
- Handlebars.registerHelper('sanitizeCssClass', handlebarsSanitizeCssClass);
- Handlebars.registerHelper('regexReplace', handlebarsRegexReplace);
- Handlebars.registerHelper('regexMatch', handlebarsRegexMatch);
- Handlebars.registerHelper('mergeTags', handlebarsMergeTags);
- }
-}
-
-function handlebarsRenderDynamic(template, data) {
- handlebarsRegisterHelpers();
- const cache = handlebarsRenderDynamic._cache;
- let instance = cache.get(template);
- if (typeof instance === 'undefined') {
- instance = Handlebars.compile(template);
- cache.set(template, instance);
- }
-
- return instance(data).trim();
-}
-handlebarsRenderDynamic._cache = new Map();
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index 35fdde82..ccc56848 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -176,7 +176,9 @@ function profileOptionsCreateDefaults() {
showPitchAccentDownstepNotation: true,
showPitchAccentPositionNotation: true,
showPitchAccentGraph: false,
- showIframePopupsInRootFrame: false
+ showIframePopupsInRootFrame: false,
+ useSecurePopupFrameUrl: true,
+ usePopupShadowDom: true
},
audio: {
@@ -202,7 +204,8 @@ function profileOptionsCreateDefaults() {
enablePopupSearch: false,
enableOnPopupExpressions: false,
enableOnSearchPage: true,
- enableSearchTags: false
+ enableSearchTags: false,
+ layoutAwareScan: false
},
translation: {
diff --git a/ext/bg/js/request.js b/ext/bg/js/request.js
index 957ac0f5..d1c6ed4e 100644
--- a/ext/bg/js/request.js
+++ b/ext/bg/js/request.js
@@ -16,28 +16,28 @@
*/
-function requestText(url, action, params) {
- return new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
- xhr.overrideMimeType('text/plain');
- xhr.addEventListener('load', () => resolve(xhr.responseText));
- xhr.addEventListener('error', () => reject(new Error('Failed to connect')));
- xhr.open(action, url);
- if (params) {
- xhr.send(JSON.stringify(params));
- } else {
- xhr.send();
- }
+async function requestText(url, method, data) {
+ const response = await fetch(url, {
+ method,
+ mode: 'no-cors',
+ cache: 'default',
+ credentials: 'omit',
+ redirect: 'follow',
+ referrerPolicy: 'no-referrer',
+ body: (data ? JSON.stringify(data) : void 0)
});
+ return await response.text();
}
-async function requestJson(url, action, params) {
- const responseText = await requestText(url, action, params);
- try {
- return JSON.parse(responseText);
- } catch (e) {
- const error = new Error(`Invalid response (${e.message || e})`);
- error.data = {url, action, params, responseText};
- throw error;
- }
+async function requestJson(url, method, data) {
+ const response = await fetch(url, {
+ method,
+ mode: 'no-cors',
+ cache: 'default',
+ credentials: 'omit',
+ redirect: 'follow',
+ referrerPolicy: 'no-referrer',
+ body: (data ? JSON.stringify(data) : void 0)
+ });
+ return await response.json();
}
diff --git a/ext/bg/js/search-main.js b/ext/bg/js/search-main.js
index 54fa549d..13bd8767 100644
--- a/ext/bg/js/search-main.js
+++ b/ext/bg/js/search-main.js
@@ -17,45 +17,17 @@
/* global
* DisplaySearch
- * apiForwardLogsToBackend
- * apiOptionsGet
- * dynamicLoader
+ * api
*/
-async function injectSearchFrontend() {
- await dynamicLoader.loadScripts([
- '/mixed/js/text-scanner.js',
- '/fg/js/frontend-api-receiver.js',
- '/fg/js/frame-offset-forwarder.js',
- '/fg/js/popup.js',
- '/fg/js/popup-factory.js',
- '/fg/js/frontend.js',
- '/fg/js/content-script-main.js'
- ]);
-}
-
(async () => {
- apiForwardLogsToBackend();
- await yomichan.prepare();
-
- const displaySearch = new DisplaySearch();
- await displaySearch.prepare();
-
- let optionsApplied = false;
-
- const applyOptions = async () => {
- const optionsContext = {depth: 0, url: window.location.href};
- const options = await apiOptionsGet(optionsContext);
- if (!options.scanning.enableOnSearchPage || optionsApplied) { return; }
-
- optionsApplied = true;
- yomichan.off('optionsUpdated', applyOptions);
-
- window.frontendInitializationData = {depth: 1, proxy: false, isSearchPage: true};
- await injectSearchFrontend();
- };
-
- yomichan.on('optionsUpdated', applyOptions);
-
- await applyOptions();
+ try {
+ api.forwardLogsToBackend();
+ await yomichan.prepare();
+
+ const displaySearch = new DisplaySearch();
+ await displaySearch.prepare();
+ } catch (e) {
+ yomichan.logError(e);
+ }
})();
diff --git a/ext/bg/js/search-query-parser-generator.js b/ext/bg/js/search-query-parser-generator.js
index 9e7ff8aa..6989e157 100644
--- a/ext/bg/js/search-query-parser-generator.js
+++ b/ext/bg/js/search-query-parser-generator.js
@@ -17,7 +17,7 @@
/* global
* TemplateHandler
- * apiGetQueryParserTemplatesHtml
+ * api
*/
class QueryParserGenerator {
@@ -26,7 +26,7 @@ class QueryParserGenerator {
}
async prepare() {
- const html = await apiGetQueryParserTemplatesHtml();
+ const html = await api.getQueryParserTemplatesHtml();
this._templateHandler = new TemplateHandler(html);
}
diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js
index e1e37d1c..86524b66 100644
--- a/ext/bg/js/search-query-parser.js
+++ b/ext/bg/js/search-query-parser.js
@@ -18,9 +18,7 @@
/* global
* QueryParserGenerator
* TextScanner
- * apiModifySettings
- * apiTermsFind
- * apiTextParse
+ * api
* docSentenceExtract
*/
@@ -44,6 +42,7 @@ class QueryParser {
async prepare() {
await this._queryParserGenerator.prepare();
+ this._textScanner.prepare();
this._queryParser.addEventListener('click', this._onClick.bind(this));
}
@@ -59,7 +58,7 @@ class QueryParser {
this._setPreview(text);
- this._parseResults = await apiTextParse(text, this._getOptionsContext());
+ this._parseResults = await api.textParse(text, this._getOptionsContext());
this._refreshSelectedParser();
this._renderParserSelect();
@@ -77,15 +76,17 @@ class QueryParser {
async _search(textSource, cause) {
if (textSource === null) { return null; }
- const searchText = this._textScanner.getTextSourceContent(textSource, this._options.scanning.length);
+ const {length: scanLength, layoutAwareScan} = this._options.scanning;
+ const searchText = this._textScanner.getTextSourceContent(textSource, scanLength, layoutAwareScan);
if (searchText.length === 0) { return null; }
- const {definitions, length} = await apiTermsFind(searchText, {}, this._getOptionsContext());
+ const {definitions, length} = await api.termsFind(searchText, {}, this._getOptionsContext());
if (definitions.length === 0) { return null; }
- const sentence = docSentenceExtract(textSource, this._options.anki.sentenceExt);
+ const sentenceExtent = this._options.anki.sentenceExt;
+ const sentence = docSentenceExtract(textSource, sentenceExtent, layoutAwareScan);
- textSource.setEndOffset(length);
+ textSource.setEndOffset(length, layoutAwareScan);
this._setContent('terms', {definitions, context: {
focus: false,
@@ -99,7 +100,7 @@ class QueryParser {
_onParserChange(e) {
const value = e.target.value;
- apiModifySettings([{
+ api.modifySettings([{
action: 'set',
path: 'parsing.selectedParser',
value,
@@ -112,7 +113,7 @@ class QueryParser {
if (this._parseResults.length > 0) {
if (!this._getParseResult()) {
const value = this._parseResults[0].id;
- apiModifySettings([{
+ api.modifySettings([{
action: 'set',
path: 'parsing.selectedParser',
value,
diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js
index 96e8a70b..e968e8cf 100644
--- a/ext/bg/js/search.js
+++ b/ext/bg/js/search.js
@@ -19,10 +19,11 @@
* ClipboardMonitor
* DOM
* Display
+ * Frontend
+ * PopupFactory
* QueryParser
- * apiClipboardGet
- * apiModifySettings
- * apiTermsFind
+ * api
+ * dynamicLoader
* wanakana
*/
@@ -52,7 +53,7 @@ class DisplaySearch extends Display {
this.introVisible = true;
this.introAnimationTimer = null;
- this.clipboardMonitor = new ClipboardMonitor({getClipboard: apiClipboardGet});
+ this.clipboardMonitor = new ClipboardMonitor({getClipboard: api.clipboardGet.bind(api)});
this._onKeyDownIgnoreKeys = new Map([
['ANY_MOD', new Set([
@@ -75,51 +76,49 @@ class DisplaySearch extends Display {
}
async prepare() {
- try {
- await super.prepare();
- await this.updateOptions();
- yomichan.on('optionsUpdated', () => this.updateOptions());
- await this.queryParser.prepare();
+ await super.prepare();
+ await this.updateOptions();
+ yomichan.on('optionsUpdated', () => this.updateOptions());
+ await this.queryParser.prepare();
+
+ const {queryParams: {query='', mode=''}} = parseUrl(window.location.href);
+
+ document.documentElement.dataset.searchMode = mode;
- const {queryParams: {query='', mode=''}} = parseUrl(window.location.href);
+ if (this.options.general.enableWanakana === true) {
+ this.wanakanaEnable.checked = true;
+ wanakana.bind(this.query);
+ } else {
+ this.wanakanaEnable.checked = false;
+ }
- document.documentElement.dataset.searchMode = mode;
+ this.setQuery(query);
+ this.onSearchQueryUpdated(this.query.value, false);
- if (this.options.general.enableWanakana === true) {
- this.wanakanaEnable.checked = true;
- wanakana.bind(this.query);
+ if (mode !== 'popup') {
+ if (this.options.general.enableClipboardMonitor === true) {
+ this.clipboardMonitorEnable.checked = true;
+ this.clipboardMonitor.start();
} else {
- this.wanakanaEnable.checked = false;
+ this.clipboardMonitorEnable.checked = false;
}
+ this.clipboardMonitorEnable.addEventListener('change', this.onClipboardMonitorEnableChange.bind(this));
+ }
- this.setQuery(query);
- this.onSearchQueryUpdated(this.query.value, false);
-
- if (mode !== 'popup') {
- if (this.options.general.enableClipboardMonitor === true) {
- this.clipboardMonitorEnable.checked = true;
- this.clipboardMonitor.start();
- } else {
- this.clipboardMonitorEnable.checked = false;
- }
- this.clipboardMonitorEnable.addEventListener('change', this.onClipboardMonitorEnableChange.bind(this));
- }
+ chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));
- chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));
+ this.search.addEventListener('click', this.onSearch.bind(this), false);
+ this.query.addEventListener('input', this.onSearchInput.bind(this), false);
+ this.wanakanaEnable.addEventListener('change', this.onWanakanaEnableChange.bind(this));
+ window.addEventListener('popstate', this.onPopState.bind(this));
+ window.addEventListener('copy', this.onCopy.bind(this));
+ this.clipboardMonitor.on('change', this.onExternalSearchUpdate.bind(this));
- this.search.addEventListener('click', this.onSearch.bind(this), false);
- this.query.addEventListener('input', this.onSearchInput.bind(this), false);
- this.wanakanaEnable.addEventListener('change', this.onWanakanaEnableChange.bind(this));
- window.addEventListener('popstate', this.onPopState.bind(this));
- window.addEventListener('copy', this.onCopy.bind(this));
- this.clipboardMonitor.on('change', this.onExternalSearchUpdate.bind(this));
+ this.updateSearchButton();
- this.updateSearchButton();
+ await this._prepareNestedPopups();
- this._isPrepared = true;
- } catch (e) {
- this.onError(e);
- }
+ this._isPrepared = true;
}
onError(error) {
@@ -234,7 +233,7 @@ class DisplaySearch extends Display {
this.setIntroVisible(!valid, animate);
this.updateSearchButton();
if (valid) {
- const {definitions} = await apiTermsFind(query, details, this.getOptionsContext());
+ const {definitions} = await api.termsFind(query, details, this.getOptionsContext());
this.setContent('terms', {definitions, context: {
focus: false,
disableHistory: true,
@@ -258,7 +257,7 @@ class DisplaySearch extends Display {
} else {
wanakana.unbind(this.query);
}
- apiModifySettings([{
+ api.modifySettings([{
action: 'set',
path: 'general.enableWanakana',
value,
@@ -274,7 +273,7 @@ class DisplaySearch extends Display {
(granted) => {
if (granted) {
this.clipboardMonitor.start();
- apiModifySettings([{
+ api.modifySettings([{
action: 'set',
path: 'general.enableClipboardMonitor',
value: true,
@@ -288,7 +287,7 @@ class DisplaySearch extends Display {
);
} else {
this.clipboardMonitor.stop();
- apiModifySettings([{
+ api.modifySettings([{
action: 'set',
path: 'general.enableClipboardMonitor',
value: false,
@@ -314,7 +313,14 @@ class DisplaySearch extends Display {
}
setQuery(query) {
- const interpretedQuery = this.isWanakanaEnabled() ? wanakana.toKana(query) : query;
+ let interpretedQuery = query;
+ if (this.isWanakanaEnabled()) {
+ try {
+ interpretedQuery = wanakana.toKana(query);
+ } catch (e) {
+ // NOP
+ }
+ }
this.query.value = interpretedQuery;
this.queryParser.setText(interpretedQuery);
}
@@ -396,4 +402,53 @@ class DisplaySearch extends Display {
document.title = `${text} - Yomichan Search`;
}
}
+
+ async _prepareNestedPopups() {
+ let complete = false;
+
+ const onOptionsUpdated = async () => {
+ const optionsContext = this.getOptionsContext();
+ const options = await api.optionsGet(optionsContext);
+ if (!options.scanning.enableOnSearchPage || complete) { return; }
+
+ complete = true;
+ yomichan.off('optionsUpdated', onOptionsUpdated);
+
+ try {
+ await this._setupNestedPopups();
+ } catch (e) {
+ yomichan.logError(e);
+ }
+ };
+
+ yomichan.on('optionsUpdated', onOptionsUpdated);
+
+ await onOptionsUpdated();
+ }
+
+ async _setupNestedPopups() {
+ await dynamicLoader.loadScripts([
+ '/mixed/js/text-scanner.js',
+ '/fg/js/frame-offset-forwarder.js',
+ '/fg/js/popup.js',
+ '/fg/js/popup-factory.js',
+ '/fg/js/frontend.js'
+ ]);
+
+ const {frameId} = await api.frameInformationGet();
+
+ const popupFactory = new PopupFactory(frameId);
+ popupFactory.prepare();
+
+ const frontend = new Frontend(
+ frameId,
+ popupFactory,
+ {
+ depth: 1,
+ proxy: false,
+ isSearchPage: true
+ }
+ );
+ await frontend.prepare();
+ }
}
diff --git a/ext/bg/js/settings/anki-templates.js b/ext/bg/js/settings/anki-templates.js
index d5b6e677..88d4fe04 100644
--- a/ext/bg/js/settings/anki-templates.js
+++ b/ext/bg/js/settings/anki-templates.js
@@ -17,144 +17,146 @@
/* global
* AnkiNoteBuilder
- * ankiGetFieldMarkers
- * ankiGetFieldMarkersHtml
- * apiGetDefaultAnkiFieldTemplates
- * apiOptionsGet
- * apiTemplateRender
- * apiTermsFind
- * getOptionsContext
- * getOptionsMutable
- * settingsSaveOptions
+ * api
*/
-function onAnkiFieldTemplatesReset(e) {
- e.preventDefault();
- $('#field-template-reset-modal').modal('show');
-}
+class AnkiTemplatesController {
+ constructor(settingsController, ankiController) {
+ this._settingsController = settingsController;
+ this._ankiController = ankiController;
+ this._cachedDefinitionValue = null;
+ this._cachedDefinitionText = null;
+ this._defaultFieldTemplates = null;
+ }
-async function onAnkiFieldTemplatesResetConfirm(e) {
- e.preventDefault();
+ async prepare() {
+ this._defaultFieldTemplates = await api.getDefaultAnkiFieldTemplates();
- $('#field-template-reset-modal').modal('hide');
+ const markers = new Set([
+ ...this._ankiController.getFieldMarkers('terms'),
+ ...this._ankiController.getFieldMarkers('kanji')
+ ]);
+ const fragment = this._ankiController.getFieldMarkersHtml(markers);
- const value = await apiGetDefaultAnkiFieldTemplates();
+ const list = document.querySelector('#field-templates-list');
+ list.appendChild(fragment);
+ for (const node of list.querySelectorAll('.marker-link')) {
+ node.addEventListener('click', this._onMarkerClicked.bind(this), false);
+ }
- const element = document.querySelector('#field-templates');
- element.value = value;
- element.dispatchEvent(new Event('change'));
-}
+ document.querySelector('#field-templates').addEventListener('change', this._onChanged.bind(this), false);
+ document.querySelector('#field-template-render').addEventListener('click', this._onRender.bind(this), false);
+ document.querySelector('#field-templates-reset').addEventListener('click', this._onReset.bind(this), false);
+ document.querySelector('#field-templates-reset-confirm').addEventListener('click', this._onResetConfirm.bind(this), false);
-function ankiTemplatesInitialize() {
- const markers = new Set(ankiGetFieldMarkers('terms').concat(ankiGetFieldMarkers('kanji')));
- const fragment = ankiGetFieldMarkersHtml(markers);
+ this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
- const list = document.querySelector('#field-templates-list');
- list.appendChild(fragment);
- for (const node of list.querySelectorAll('.marker-link')) {
- node.addEventListener('click', onAnkiTemplateMarkerClicked, false);
+ const options = await this._settingsController.getOptions();
+ this._onOptionsChanged({options});
}
- $('#field-templates').on('change', onAnkiFieldTemplatesChanged);
- $('#field-template-render').on('click', onAnkiTemplateRender);
- $('#field-templates-reset').on('click', onAnkiFieldTemplatesReset);
- $('#field-templates-reset-confirm').on('click', onAnkiFieldTemplatesResetConfirm);
+ // Private
- ankiTemplatesUpdateValue();
-}
+ _onOptionsChanged({options}) {
+ let templates = options.anki.fieldTemplates;
+ if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; }
+ document.querySelector('#field-templates').value = templates;
-async function ankiTemplatesUpdateValue() {
- const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
- let templates = options.anki.fieldTemplates;
- if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); }
- $('#field-templates').val(templates);
+ this._onValidateCompile();
+ }
- onAnkiTemplatesValidateCompile();
-}
+ _onReset(e) {
+ e.preventDefault();
+ $('#field-template-reset-modal').modal('show');
+ }
-const ankiTemplatesValidateGetDefinition = (() => {
- let cachedValue = null;
- let cachedText = null;
+ _onResetConfirm(e) {
+ e.preventDefault();
- return async (text, optionsContext) => {
- if (cachedText !== text) {
- const {definitions} = await apiTermsFind(text, {}, optionsContext);
- if (definitions.length === 0) { return null; }
+ $('#field-template-reset-modal').modal('hide');
- cachedValue = definitions[0];
- cachedText = text;
- }
- return cachedValue;
- };
-})();
-
-async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, invalidateInput) {
- const text = document.querySelector('#field-templates-preview-text').value || '';
- const exceptions = [];
- let result = `No definition found for ${text}`;
- try {
- const optionsContext = getOptionsContext();
- const definition = await ankiTemplatesValidateGetDefinition(text, optionsContext);
- if (definition !== null) {
- const options = await apiOptionsGet(optionsContext);
- const context = {
- document: {
- title: document.title
- }
- };
- let templates = options.anki.fieldTemplates;
- if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); }
- const ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: apiTemplateRender});
- result = await ankiNoteBuilder.formatField(field, definition, mode, context, options, templates, exceptions);
+ const value = this._defaultFieldTemplates;
+
+ const element = document.querySelector('#field-templates');
+ element.value = value;
+ element.dispatchEvent(new Event('change'));
+ }
+
+ async _onChanged(e) {
+ // Get value
+ let templates = e.currentTarget.value;
+ if (templates === this._defaultFieldTemplates) {
+ // Default
+ templates = null;
}
- } catch (e) {
- exceptions.push(e);
+
+ // Overwrite
+ await this._settingsController.setProfileSetting('anki.fieldTemplates', templates);
+
+ // Compile
+ this._onValidateCompile();
}
- const hasException = exceptions.length > 0;
- infoNode.hidden = !(showSuccessResult || hasException);
- infoNode.textContent = hasException ? exceptions.map((e) => `${e}`).join('\n') : (showSuccessResult ? result : '');
- infoNode.classList.toggle('text-danger', hasException);
- if (invalidateInput) {
- const input = document.querySelector('#field-templates');
- input.classList.toggle('is-invalid', hasException);
+ _onValidateCompile() {
+ const infoNode = document.querySelector('#field-template-compile-result');
+ this._validate(infoNode, '{expression}', 'term-kanji', false, true);
}
-}
-async function onAnkiFieldTemplatesChanged(e) {
- // Get value
- let templates = e.currentTarget.value;
- if (templates === await apiGetDefaultAnkiFieldTemplates()) {
- // Default
- templates = null;
+ _onMarkerClicked(e) {
+ e.preventDefault();
+ document.querySelector('#field-template-render-text').value = `{${e.target.textContent}}`;
}
- // Overwrite
- const optionsContext = getOptionsContext();
- const options = await getOptionsMutable(optionsContext);
- options.anki.fieldTemplates = templates;
- await settingsSaveOptions();
+ _onRender(e) {
+ e.preventDefault();
- // Compile
- onAnkiTemplatesValidateCompile();
-}
+ const field = document.querySelector('#field-template-render-text').value;
+ const infoNode = document.querySelector('#field-template-render-result');
+ infoNode.hidden = true;
+ this._validate(infoNode, field, 'term-kanji', true, false);
+ }
-function onAnkiTemplatesValidateCompile() {
- const infoNode = document.querySelector('#field-template-compile-result');
- ankiTemplatesValidate(infoNode, '{expression}', 'term-kanji', false, true);
-}
+ async _getDefinition(text, optionsContext) {
+ if (this._cachedDefinitionText !== text) {
+ const {definitions} = await api.termsFind(text, {}, optionsContext);
+ if (definitions.length === 0) { return null; }
-function onAnkiTemplateMarkerClicked(e) {
- e.preventDefault();
- document.querySelector('#field-template-render-text').value = `{${e.target.textContent}}`;
-}
+ this._cachedDefinitionValue = definitions[0];
+ this._cachedDefinitionText = text;
+ }
+ return this._cachedDefinitionValue;
+ }
-function onAnkiTemplateRender(e) {
- e.preventDefault();
+ async _validate(infoNode, field, mode, showSuccessResult, invalidateInput) {
+ const text = document.querySelector('#field-templates-preview-text').value || '';
+ const exceptions = [];
+ let result = `No definition found for ${text}`;
+ try {
+ const optionsContext = this._settingsController.getOptionsContext();
+ const definition = await this._getDefinition(text, optionsContext);
+ if (definition !== null) {
+ const options = await this._settingsController.getOptions();
+ const context = {
+ document: {
+ title: document.title
+ }
+ };
+ let templates = options.anki.fieldTemplates;
+ if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; }
+ const ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: api.templateRender.bind(api)});
+ result = await ankiNoteBuilder.formatField(field, definition, mode, context, options, templates, exceptions);
+ }
+ } catch (e) {
+ exceptions.push(e);
+ }
- const field = document.querySelector('#field-template-render-text').value;
- const infoNode = document.querySelector('#field-template-render-result');
- infoNode.hidden = true;
- ankiTemplatesValidate(infoNode, field, 'term-kanji', true, false);
+ const hasException = exceptions.length > 0;
+ infoNode.hidden = !(showSuccessResult || hasException);
+ infoNode.textContent = hasException ? exceptions.map((e) => `${e}`).join('\n') : (showSuccessResult ? result : '');
+ infoNode.classList.toggle('text-danger', hasException);
+ if (invalidateInput) {
+ const input = document.querySelector('#field-templates');
+ input.classList.toggle('is-invalid', hasException);
+ }
+ }
}
diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js
index ff1277ed..51dabba4 100644
--- a/ext/bg/js/settings/anki.js
+++ b/ext/bg/js/settings/anki.js
@@ -16,278 +16,282 @@
*/
/* global
- * apiGetAnkiDeckNames
- * apiGetAnkiModelFieldNames
- * apiGetAnkiModelNames
- * getOptionsContext
- * getOptionsMutable
- * onFormOptionsChanged
- * settingsSaveOptions
- * utilBackgroundIsolate
+ * api
*/
-// Private
-
-let _ankiDataPopulated = false;
-
-
-function _ankiSpinnerShow(show) {
- const spinner = $('#anki-spinner');
- if (show) {
- spinner.show();
- } else {
- spinner.hide();
+class AnkiController {
+ constructor(settingsController) {
+ this._settingsController = settingsController;
}
-}
-function _ankiSetError(error) {
- const node = document.querySelector('#anki-error');
- const node2 = document.querySelector('#anki-invalid-response-error');
- if (error) {
- const errorString = `${error}`;
- if (node !== null) {
- node.hidden = false;
- node.textContent = errorString;
- _ankiSetErrorData(node, error);
+ async prepare() {
+ for (const element of document.querySelectorAll('#anki-fields-container input,#anki-fields-container select')) {
+ element.addEventListener('change', this._onFieldsChanged.bind(this), false);
}
- if (node2 !== null) {
- node2.hidden = (errorString.indexOf('Invalid response') < 0);
- }
- } else {
- if (node !== null) {
- node.hidden = true;
- node.textContent = '';
+ for (const element of document.querySelectorAll('#anki-terms-model,#anki-kanji-model')) {
+ element.addEventListener('change', this._onModelChanged.bind(this), false);
}
- if (node2 !== null) {
- node2.hidden = true;
- }
- }
-}
+ this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
-function _ankiSetErrorData(node, error) {
- const data = error.data;
- let message = '';
- if (typeof data !== 'undefined') {
- message += `${JSON.stringify(data, null, 4)}\n\n`;
+ const options = await this._settingsController.getOptions();
+ this._onOptionsChanged({options});
}
- message += `${error.stack}`.trimRight();
- const button = document.createElement('a');
- button.className = 'error-data-show-button';
+ getFieldMarkers(type) {
+ switch (type) {
+ case 'terms':
+ return [
+ 'audio',
+ 'cloze-body',
+ 'cloze-prefix',
+ 'cloze-suffix',
+ 'dictionary',
+ 'document-title',
+ 'expression',
+ 'furigana',
+ 'furigana-plain',
+ 'glossary',
+ 'glossary-brief',
+ 'reading',
+ 'screenshot',
+ 'sentence',
+ 'tags',
+ 'url'
+ ];
+ case 'kanji':
+ return [
+ 'character',
+ 'dictionary',
+ 'document-title',
+ 'glossary',
+ 'kunyomi',
+ 'onyomi',
+ 'screenshot',
+ 'sentence',
+ 'tags',
+ 'url'
+ ];
+ default:
+ return [];
+ }
+ }
- const content = document.createElement('div');
- content.className = 'error-data-container';
- content.textContent = message;
- content.hidden = true;
+ getFieldMarkersHtml(markers) {
+ const template = document.querySelector('#anki-field-marker-template').content;
+ const fragment = document.createDocumentFragment();
+ for (const marker of markers) {
+ const markerNode = document.importNode(template, true).firstChild;
+ markerNode.querySelector('.marker-link').textContent = marker;
+ fragment.appendChild(markerNode);
+ }
+ return fragment;
+ }
- button.addEventListener('click', () => content.hidden = !content.hidden, false);
+ // Private
- node.appendChild(button);
- node.appendChild(content);
-}
+ async _onOptionsChanged({options}) {
+ if (!options.anki.enable) {
+ return;
+ }
-function _ankiSetDropdownOptions(dropdown, optionValues) {
- const fragment = document.createDocumentFragment();
- for (const optionValue of optionValues) {
- const option = document.createElement('option');
- option.value = optionValue;
- option.textContent = optionValue;
- fragment.appendChild(option);
+ await this._deckAndModelPopulate(options);
+ await Promise.all([
+ this._populateFields('terms', options.anki.terms.fields),
+ this._populateFields('kanji', options.anki.kanji.fields)
+ ]);
}
- dropdown.textContent = '';
- dropdown.appendChild(fragment);
-}
-async function _ankiDeckAndModelPopulate(options) {
- const termsDeck = {value: options.anki.terms.deck, selector: '#anki-terms-deck'};
- const kanjiDeck = {value: options.anki.kanji.deck, selector: '#anki-kanji-deck'};
- const termsModel = {value: options.anki.terms.model, selector: '#anki-terms-model'};
- const kanjiModel = {value: options.anki.kanji.model, selector: '#anki-kanji-model'};
- try {
- _ankiSpinnerShow(true);
- const [deckNames, modelNames] = await Promise.all([apiGetAnkiDeckNames(), apiGetAnkiModelNames()]);
- deckNames.sort();
- modelNames.sort();
- termsDeck.values = deckNames;
- kanjiDeck.values = deckNames;
- termsModel.values = modelNames;
- kanjiModel.values = modelNames;
- _ankiSetError(null);
- } catch (error) {
- _ankiSetError(error);
- } finally {
- _ankiSpinnerShow(false);
+ _fieldsToDict(elements) {
+ const result = {};
+ for (const element of elements) {
+ result[element.dataset.field] = element.value;
+ }
+ return result;
}
- for (const {value, values, selector} of [termsDeck, kanjiDeck, termsModel, kanjiModel]) {
- const node = document.querySelector(selector);
- _ankiSetDropdownOptions(node, Array.isArray(values) ? values : [value]);
- node.value = value;
+ _spinnerShow(show) {
+ const spinner = document.querySelector('#anki-spinner');
+ spinner.hidden = !show;
}
-}
-function _ankiCreateFieldTemplate(name, value, markers) {
- const template = document.querySelector('#anki-field-template').content;
- const content = document.importNode(template, true).firstChild;
+ _setError(error) {
+ const node = document.querySelector('#anki-error');
+ const node2 = document.querySelector('#anki-invalid-response-error');
+ if (error) {
+ const errorString = `${error}`;
+ if (node !== null) {
+ node.hidden = false;
+ node.textContent = errorString;
+ this._setErrorData(node, error);
+ }
+
+ if (node2 !== null) {
+ node2.hidden = (errorString.indexOf('Invalid response') < 0);
+ }
+ } else {
+ if (node !== null) {
+ node.hidden = true;
+ node.textContent = '';
+ }
+
+ if (node2 !== null) {
+ node2.hidden = true;
+ }
+ }
+ }
- content.querySelector('.anki-field-name').textContent = name;
+ _setErrorData(node, error) {
+ const data = error.data;
+ let message = '';
+ if (typeof data !== 'undefined') {
+ message += `${JSON.stringify(data, null, 4)}\n\n`;
+ }
+ message += `${error.stack}`.trimRight();
- const field = content.querySelector('.anki-field-value');
- field.dataset.field = name;
- field.value = value;
+ const button = document.createElement('a');
+ button.className = 'error-data-show-button';
- content.querySelector('.anki-field-marker-list').appendChild(ankiGetFieldMarkersHtml(markers));
+ const content = document.createElement('div');
+ content.className = 'error-data-container';
+ content.textContent = message;
+ content.hidden = true;
- return content;
-}
+ button.addEventListener('click', () => content.hidden = !content.hidden, false);
-async function _ankiFieldsPopulate(tabId, options) {
- const tab = document.querySelector(`.tab-pane[data-anki-card-type=${tabId}]`);
- const container = tab.querySelector('tbody');
- const markers = ankiGetFieldMarkers(tabId);
-
- const fragment = document.createDocumentFragment();
- const fields = options.anki[tabId].fields;
- for (const name of Object.keys(fields)) {
- const value = fields[name];
- const html = _ankiCreateFieldTemplate(name, value, markers);
- fragment.appendChild(html);
+ node.appendChild(button);
+ node.appendChild(content);
}
- container.textContent = '';
- container.appendChild(fragment);
-
- for (const node of container.querySelectorAll('.anki-field-value')) {
- node.addEventListener('change', onFormOptionsChanged, false);
- }
- for (const node of container.querySelectorAll('.marker-link')) {
- node.addEventListener('click', _onAnkiMarkerClicked, false);
+ _setDropdownOptions(dropdown, optionValues) {
+ const fragment = document.createDocumentFragment();
+ for (const optionValue of optionValues) {
+ const option = document.createElement('option');
+ option.value = optionValue;
+ option.textContent = optionValue;
+ fragment.appendChild(option);
+ }
+ dropdown.textContent = '';
+ dropdown.appendChild(fragment);
}
-}
-function _onAnkiMarkerClicked(e) {
- e.preventDefault();
- const link = e.currentTarget;
- const input = $(link).closest('.input-group').find('.anki-field-value')[0];
- input.value = `{${link.textContent}}`;
- input.dispatchEvent(new Event('change'));
-}
+ async _deckAndModelPopulate(options) {
+ const termsDeck = {value: options.anki.terms.deck, selector: '#anki-terms-deck'};
+ const kanjiDeck = {value: options.anki.kanji.deck, selector: '#anki-kanji-deck'};
+ const termsModel = {value: options.anki.terms.model, selector: '#anki-terms-model'};
+ const kanjiModel = {value: options.anki.kanji.model, selector: '#anki-kanji-model'};
+ try {
+ this._spinnerShow(true);
+ const [deckNames, modelNames] = await Promise.all([api.getAnkiDeckNames(), api.getAnkiModelNames()]);
+ deckNames.sort();
+ modelNames.sort();
+ termsDeck.values = deckNames;
+ kanjiDeck.values = deckNames;
+ termsModel.values = modelNames;
+ kanjiModel.values = modelNames;
+ this._setError(null);
+ } catch (error) {
+ this._setError(error);
+ } finally {
+ this._spinnerShow(false);
+ }
-async function _onAnkiModelChanged(e) {
- const node = e.currentTarget;
- let fieldNames;
- try {
- const modelName = node.value;
- fieldNames = await apiGetAnkiModelFieldNames(modelName);
- _ankiSetError(null);
- } catch (error) {
- _ankiSetError(error);
- return;
- } finally {
- _ankiSpinnerShow(false);
+ for (const {value, values, selector} of [termsDeck, kanjiDeck, termsModel, kanjiModel]) {
+ const node = document.querySelector(selector);
+ this._setDropdownOptions(node, Array.isArray(values) ? values : [value]);
+ node.value = value;
+ }
}
- const tabId = node.dataset.ankiCardType;
- if (tabId !== 'terms' && tabId !== 'kanji') { return; }
-
- const fields = {};
- for (const name of fieldNames) {
- fields[name] = '';
- }
+ _createFieldTemplate(name, value, markers) {
+ const template = document.querySelector('#anki-field-template').content;
+ const content = document.importNode(template, true).firstChild;
- const optionsContext = getOptionsContext();
- const options = await getOptionsMutable(optionsContext);
- options.anki[tabId].fields = utilBackgroundIsolate(fields);
- await settingsSaveOptions();
+ content.querySelector('.anki-field-name').textContent = name;
- await _ankiFieldsPopulate(tabId, options);
-}
+ const field = content.querySelector('.anki-field-value');
+ field.dataset.field = name;
+ field.value = value;
+ content.querySelector('.anki-field-marker-list').appendChild(this.getFieldMarkersHtml(markers));
-// Public
+ return content;
+ }
-function ankiErrorShown() {
- const node = document.querySelector('#anki-error');
- return node && !node.hidden;
-}
+ async _populateFields(tabId, fields) {
+ const tab = document.querySelector(`.tab-pane[data-anki-card-type=${tabId}]`);
+ const container = tab.querySelector('tbody');
+ const markers = this.getFieldMarkers(tabId);
-function ankiFieldsToDict(elements) {
- const result = {};
- for (const element of elements) {
- result[element.dataset.field] = element.value;
- }
- return result;
-}
+ const fragment = document.createDocumentFragment();
+ for (const [name, value] of Object.entries(fields)) {
+ const html = this._createFieldTemplate(name, value, markers);
+ fragment.appendChild(html);
+ }
+ container.textContent = '';
+ container.appendChild(fragment);
-function ankiGetFieldMarkersHtml(markers) {
- const template = document.querySelector('#anki-field-marker-template').content;
- const fragment = document.createDocumentFragment();
- for (const marker of markers) {
- const markerNode = document.importNode(template, true).firstChild;
- markerNode.querySelector('.marker-link').textContent = marker;
- fragment.appendChild(markerNode);
+ for (const node of container.querySelectorAll('.anki-field-value')) {
+ node.addEventListener('change', this._onFieldsChanged.bind(this), false);
+ }
+ for (const node of container.querySelectorAll('.marker-link')) {
+ node.addEventListener('click', this._onMarkerClicked.bind(this), false);
+ }
}
- return fragment;
-}
-function ankiGetFieldMarkers(type) {
- switch (type) {
- case 'terms':
- return [
- 'audio',
- 'cloze-body',
- 'cloze-prefix',
- 'cloze-suffix',
- 'dictionary',
- 'document-title',
- 'expression',
- 'furigana',
- 'furigana-plain',
- 'glossary',
- 'glossary-brief',
- 'reading',
- 'screenshot',
- 'sentence',
- 'tags',
- 'url'
- ];
- case 'kanji':
- return [
- 'character',
- 'dictionary',
- 'document-title',
- 'glossary',
- 'kunyomi',
- 'onyomi',
- 'screenshot',
- 'sentence',
- 'tags',
- 'url'
- ];
- default:
- return [];
+ _onMarkerClicked(e) {
+ e.preventDefault();
+ const link = e.currentTarget;
+ const input = link.closest('.input-group').querySelector('.anki-field-value');
+ input.value = `{${link.textContent}}`;
+ input.dispatchEvent(new Event('change'));
}
-}
+ async _onModelChanged(e) {
+ const node = e.currentTarget;
+ let fieldNames;
+ try {
+ const modelName = node.value;
+ fieldNames = await api.getAnkiModelFieldNames(modelName);
+ this._setError(null);
+ } catch (error) {
+ this._setError(error);
+ return;
+ } finally {
+ this._spinnerShow(false);
+ }
-function ankiInitialize() {
- for (const node of document.querySelectorAll('#anki-terms-model,#anki-kanji-model')) {
- node.addEventListener('change', _onAnkiModelChanged, false);
- }
-}
+ const tabId = node.dataset.ankiCardType;
+ if (tabId !== 'terms' && tabId !== 'kanji') { return; }
-async function onAnkiOptionsChanged(options) {
- if (!options.anki.enable) {
- _ankiDataPopulated = false;
- return;
- }
+ const fields = {};
+ for (const name of fieldNames) {
+ fields[name] = '';
+ }
- if (_ankiDataPopulated) { return; }
+ await this._settingsController.setProfileSetting(`anki["${tabId}"].fields`, fields);
+ await this._populateFields(tabId, fields);
+ }
- await _ankiDeckAndModelPopulate(options);
- _ankiDataPopulated = true;
- await Promise.all([_ankiFieldsPopulate('terms', options), _ankiFieldsPopulate('kanji', options)]);
+ async _onFieldsChanged() {
+ const termsDeck = document.querySelector('#anki-terms-deck').value;
+ const termsModel = document.querySelector('#anki-terms-model').value;
+ const termsFields = this._fieldsToDict(document.querySelectorAll('#terms .anki-field-value'));
+ const kanjiDeck = document.querySelector('#anki-kanji-deck').value;
+ const kanjiModel = document.querySelector('#anki-kanji-model').value;
+ const kanjiFields = this._fieldsToDict(document.querySelectorAll('#kanji .anki-field-value'));
+
+ const targets = [
+ {action: 'set', path: 'anki.terms.deck', value: termsDeck},
+ {action: 'set', path: 'anki.terms.model', value: termsModel},
+ {action: 'set', path: 'anki.terms.fields', value: termsFields},
+ {action: 'set', path: 'anki.kanji.deck', value: kanjiDeck},
+ {action: 'set', path: 'anki.kanji.model', value: kanjiModel},
+ {action: 'set', path: 'anki.kanji.fields', value: kanjiFields}
+ ];
+
+ await this._settingsController.modifyProfileSettings(targets);
+ }
}
diff --git a/ext/bg/js/settings/audio-ui.js b/ext/bg/js/settings/audio-ui.js
deleted file mode 100644
index 73c64227..00000000
--- a/ext/bg/js/settings/audio-ui.js
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * Copyright (C) 2019-2020 Yomichan Authors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-
-class AudioSourceUI {
- static instantiateTemplate(templateSelector) {
- const template = document.querySelector(templateSelector);
- const content = document.importNode(template.content, true);
- return content.firstChild;
- }
-}
-
-AudioSourceUI.Container = class Container {
- constructor(audioSources, container, addButton) {
- this.audioSources = audioSources;
- this.container = container;
- this.addButton = addButton;
- this.children = [];
-
- this.container.textContent = '';
-
- for (const audioSource of toIterable(audioSources)) {
- this.children.push(new AudioSourceUI.AudioSource(this, audioSource, this.children.length));
- }
-
- this._clickListener = this.onAddAudioSource.bind(this);
- this.addButton.addEventListener('click', this._clickListener, false);
- }
-
- cleanup() {
- for (const child of this.children) {
- child.cleanup();
- }
-
- this.addButton.removeEventListener('click', this._clickListener, false);
- this.container.textContent = '';
- this._clickListener = null;
- }
-
- save() {
- // Override
- }
-
- remove(child) {
- const index = this.children.indexOf(child);
- if (index < 0) {
- return;
- }
-
- child.cleanup();
- this.children.splice(index, 1);
- this.audioSources.splice(index, 1);
-
- for (let i = index; i < this.children.length; ++i) {
- this.children[i].index = i;
- }
- }
-
- onAddAudioSource() {
- const audioSource = this.getUnusedAudioSource();
- this.audioSources.push(audioSource);
- this.save();
- this.children.push(new AudioSourceUI.AudioSource(this, audioSource, this.children.length));
- }
-
- getUnusedAudioSource() {
- const audioSourcesAvailable = [
- 'jpod101',
- 'jpod101-alternate',
- 'jisho',
- 'custom'
- ];
- for (const source of audioSourcesAvailable) {
- if (this.audioSources.indexOf(source) < 0) {
- return source;
- }
- }
- return audioSourcesAvailable[0];
- }
-};
-
-AudioSourceUI.AudioSource = class AudioSource {
- constructor(parent, audioSource, index) {
- this.parent = parent;
- this.audioSource = audioSource;
- this.index = index;
-
- this.container = AudioSourceUI.instantiateTemplate('#audio-source-template');
- this.select = this.container.querySelector('.audio-source-select');
- this.removeButton = this.container.querySelector('.audio-source-remove');
-
- this.select.value = audioSource;
-
- this._selectChangeListener = this.onSelectChanged.bind(this);
- this._removeClickListener = this.onRemoveClicked.bind(this);
-
- this.select.addEventListener('change', this._selectChangeListener, false);
- this.removeButton.addEventListener('click', this._removeClickListener, false);
-
- parent.container.appendChild(this.container);
- }
-
- cleanup() {
- this.select.removeEventListener('change', this._selectChangeListener, false);
- this.removeButton.removeEventListener('click', this._removeClickListener, false);
-
- if (this.container.parentNode !== null) {
- this.container.parentNode.removeChild(this.container);
- }
- }
-
- save() {
- this.parent.save();
- }
-
- onSelectChanged() {
- this.audioSource = this.select.value;
- this.parent.audioSources[this.index] = this.audioSource;
- this.save();
- }
-
- onRemoveClicked() {
- this.parent.remove(this);
- this.save();
- }
-};
diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js
index ac2d82f3..d389acb5 100644
--- a/ext/bg/js/settings/audio.js
+++ b/ext/bg/js/settings/audio.js
@@ -16,110 +16,219 @@
*/
/* global
- * AudioSourceUI
* AudioSystem
- * getOptionsContext
- * getOptionsMutable
- * settingsSaveOptions
*/
-let audioSourceUI = null;
-let audioSystem = null;
-
-async function audioSettingsInitialize() {
- audioSystem = new AudioSystem({
- audioUriBuilder: null,
- useCache: true
- });
-
- const optionsContext = getOptionsContext();
- const options = await getOptionsMutable(optionsContext);
- audioSourceUI = new AudioSourceUI.Container(
- options.audio.sources,
- document.querySelector('.audio-source-list'),
- document.querySelector('.audio-source-add')
- );
- audioSourceUI.save = settingsSaveOptions;
-
- textToSpeechInitialize();
-}
+class AudioController {
+ constructor(settingsController) {
+ this._settingsController = settingsController;
+ this._audioSystem = null;
+ this._audioSourceContainer = null;
+ this._audioSourceAddButton = null;
+ this._audioSourceEntries = [];
+ }
-function textToSpeechInitialize() {
- if (typeof speechSynthesis === 'undefined') { return; }
+ async prepare() {
+ this._audioSystem = new AudioSystem({
+ audioUriBuilder: null,
+ useCache: true
+ });
- speechSynthesis.addEventListener('voiceschanged', updateTextToSpeechVoices, false);
- updateTextToSpeechVoices();
+ this._audioSourceContainer = document.querySelector('.audio-source-list');
+ this._audioSourceAddButton = document.querySelector('.audio-source-add');
+ this._audioSourceContainer.textContent = '';
- document.querySelector('#text-to-speech-voice').addEventListener('change', onTextToSpeechVoiceChange, false);
- document.querySelector('#text-to-speech-voice-test').addEventListener('click', textToSpeechTest, false);
-}
+ this._audioSourceAddButton.addEventListener('click', this._onAddAudioSource.bind(this), false);
+
+ this._prepareTextToSpeech();
+
+ this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
-function updateTextToSpeechVoices() {
- const voices = Array.prototype.map.call(speechSynthesis.getVoices(), (voice, index) => ({voice, index}));
- voices.sort(textToSpeechVoiceCompare);
+ const options = await this._settingsController.getOptions();
+ this._onOptionsChanged({options});
+ }
+
+ // Private
- document.querySelector('#text-to-speech-voice-container').hidden = (voices.length === 0);
+ _onOptionsChanged({options}) {
+ for (let i = this._audioSourceEntries.length - 1; i >= 0; --i) {
+ this._cleanupAudioSourceEntry(i);
+ }
- const fragment = document.createDocumentFragment();
+ for (const audioSource of options.audio.sources) {
+ this._createAudioSourceEntry(audioSource);
+ }
+ }
- let option = document.createElement('option');
- option.value = '';
- option.textContent = 'None';
- fragment.appendChild(option);
+ _prepareTextToSpeech() {
+ if (typeof speechSynthesis === 'undefined') { return; }
- for (const {voice} of voices) {
- option = document.createElement('option');
- option.value = voice.voiceURI;
- option.textContent = `${voice.name} (${voice.lang})`;
+ speechSynthesis.addEventListener('voiceschanged', this._updateTextToSpeechVoices.bind(this), false);
+ this._updateTextToSpeechVoices();
+
+ document.querySelector('#text-to-speech-voice').addEventListener('change', this._onTextToSpeechVoiceChange.bind(this), false);
+ document.querySelector('#text-to-speech-voice-test').addEventListener('click', this._testTextToSpeech.bind(this), false);
+ }
+
+ _updateTextToSpeechVoices() {
+ const voices = Array.prototype.map.call(speechSynthesis.getVoices(), (voice, index) => ({voice, index}));
+ voices.sort(this._textToSpeechVoiceCompare.bind(this));
+
+ document.querySelector('#text-to-speech-voice-container').hidden = (voices.length === 0);
+
+ const fragment = document.createDocumentFragment();
+
+ let option = document.createElement('option');
+ option.value = '';
+ option.textContent = 'None';
fragment.appendChild(option);
+
+ for (const {voice} of voices) {
+ option = document.createElement('option');
+ option.value = voice.voiceURI;
+ option.textContent = `${voice.name} (${voice.lang})`;
+ fragment.appendChild(option);
+ }
+
+ const select = document.querySelector('#text-to-speech-voice');
+ select.textContent = '';
+ select.appendChild(fragment);
+ select.value = select.dataset.value;
}
- const select = document.querySelector('#text-to-speech-voice');
- select.textContent = '';
- select.appendChild(fragment);
- select.value = select.dataset.value;
-}
+ _textToSpeechVoiceCompare(a, b) {
+ const aIsJapanese = this._languageTagIsJapanese(a.voice.lang);
+ const bIsJapanese = this._languageTagIsJapanese(b.voice.lang);
+ if (aIsJapanese) {
+ if (!bIsJapanese) { return -1; }
+ } else {
+ if (bIsJapanese) { return 1; }
+ }
+
+ const aIsDefault = a.voice.default;
+ const bIsDefault = b.voice.default;
+ if (aIsDefault) {
+ if (!bIsDefault) { return -1; }
+ } else {
+ if (bIsDefault) { return 1; }
+ }
+
+ return a.index - b.index;
+ }
-function languageTagIsJapanese(languageTag) {
- return (
- languageTag.startsWith('ja-') ||
- languageTag.startsWith('jpn-')
- );
-}
+ _languageTagIsJapanese(languageTag) {
+ return (
+ languageTag.startsWith('ja_') ||
+ languageTag.startsWith('ja-') ||
+ languageTag.startsWith('jpn-')
+ );
+ }
-function textToSpeechVoiceCompare(a, b) {
- const aIsJapanese = languageTagIsJapanese(a.voice.lang);
- const bIsJapanese = languageTagIsJapanese(b.voice.lang);
- if (aIsJapanese) {
- if (!bIsJapanese) { return -1; }
- } else {
- if (bIsJapanese) { return 1; }
+ _testTextToSpeech() {
+ try {
+ const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || '';
+ const voiceUri = document.querySelector('#text-to-speech-voice').value;
+
+ const audio = this._audioSystem.createTextToSpeechAudio(text, voiceUri);
+ audio.volume = 1.0;
+ audio.play();
+ } catch (e) {
+ // NOP
+ }
}
- const aIsDefault = a.voice.default;
- const bIsDefault = b.voice.default;
- if (aIsDefault) {
- if (!bIsDefault) { return -1; }
- } else {
- if (bIsDefault) { return 1; }
+ _instantiateTemplate(templateSelector) {
+ const template = document.querySelector(templateSelector);
+ const content = document.importNode(template.content, true);
+ return content.firstChild;
}
- return a.index - b.index;
-}
+ _getUnusedAudioSource() {
+ const audioSourcesAvailable = [
+ 'jpod101',
+ 'jpod101-alternate',
+ 'jisho',
+ 'custom'
+ ];
+ for (const source of audioSourcesAvailable) {
+ if (!this._audioSourceEntries.some((metadata) => metadata.value === source)) {
+ return source;
+ }
+ }
+ return audioSourcesAvailable[0];
+ }
+
+ _createAudioSourceEntry(value) {
+ const eventListeners = new EventListenerCollection();
+ const container = this._instantiateTemplate('#audio-source-template');
+ const select = container.querySelector('.audio-source-select');
+ const removeButton = container.querySelector('.audio-source-remove');
+
+ select.value = value;
+
+ const entry = {
+ container,
+ eventListeners,
+ value
+ };
-function textToSpeechTest() {
- try {
- const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || '';
- const voiceUri = document.querySelector('#text-to-speech-voice').value;
+ eventListeners.addEventListener(select, 'change', this._onAudioSourceSelectChange.bind(this, entry), false);
+ eventListeners.addEventListener(removeButton, 'click', this._onAudioSourceRemoveClicked.bind(this, entry), false);
- const audio = audioSystem.createTextToSpeechAudio(text, voiceUri);
- audio.volume = 1.0;
- audio.play();
- } catch (e) {
- // NOP
+ this._audioSourceContainer.appendChild(container);
+ this._audioSourceEntries.push(entry);
+ }
+
+ async _removeAudioSourceEntry(entry) {
+ const index = this._audioSourceEntries.indexOf(entry);
+ if (index < 0) { return; }
+
+ this._cleanupAudioSourceEntry(index);
+ await this._settingsController.modifyProfileSettings([{
+ action: 'splice',
+ path: 'audio.sources',
+ start: index,
+ deleteCount: 1,
+ items: []
+ }]);
+ }
+
+ _cleanupAudioSourceEntry(index) {
+ const {container, eventListeners} = this._audioSourceEntries[index];
+ if (container.parentNode !== null) {
+ container.parentNode.removeChild(container);
+ }
+ eventListeners.removeAllEventListeners();
+ this._audioSourceEntries.splice(index, 1);
+ }
+
+ _onTextToSpeechVoiceChange(e) {
+ e.currentTarget.dataset.value = e.currentTarget.value;
+ }
+
+ async _onAddAudioSource() {
+ const audioSource = this._getUnusedAudioSource();
+ const index = this._audioSourceEntries.length;
+ this._createAudioSourceEntry(audioSource);
+ await this._settingsController.modifyProfileSettings([{
+ action: 'splice',
+ path: 'audio.sources',
+ start: index,
+ deleteCount: 0,
+ items: [audioSource]
+ }]);
}
-}
-function onTextToSpeechVoiceChange(e) {
- e.currentTarget.dataset.value = e.currentTarget.value;
+ async _onAudioSourceSelectChange(entry, event) {
+ const index = this._audioSourceEntries.indexOf(entry);
+ if (index < 0) { return; }
+
+ const value = event.currentTarget.value;
+ entry.value = value;
+ await this._settingsController.setProfileSetting(`audio.sources[${index}]`, value);
+ }
+
+ async _onAudioSourceRemoveClicked(entry) {
+ await this._removeAudioSourceEntry(entry);
+ }
}
diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js
index faf4e592..13f90886 100644
--- a/ext/bg/js/settings/backup.js
+++ b/ext/bg/js/settings/backup.js
@@ -16,363 +16,362 @@
*/
/* global
- * apiGetDefaultAnkiFieldTemplates
- * apiGetEnvironmentInfo
- * apiOptionsGetFull
+ * api
* optionsGetDefault
* optionsUpdateVersion
- * utilBackend
- * utilBackgroundIsolate
- * utilIsolate
- * utilReadFileArrayBuffer
*/
-// Exporting
-
-let _settingsExportToken = null;
-let _settingsExportRevoke = null;
-const SETTINGS_EXPORT_CURRENT_VERSION = 0;
-
-function _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) {
- const values = [
- date.getUTCFullYear().toString(),
- dateSeparator,
- (date.getUTCMonth() + 1).toString().padStart(2, '0'),
- dateSeparator,
- date.getUTCDate().toString().padStart(2, '0'),
- dateTimeSeparator,
- date.getUTCHours().toString().padStart(2, '0'),
- timeSeparator,
- date.getUTCMinutes().toString().padStart(2, '0'),
- timeSeparator,
- date.getUTCSeconds().toString().padStart(2, '0')
- ];
- return values.slice(0, resolution * 2 - 1).join('');
-}
+class SettingsBackup {
+ constructor(settingsController) {
+ this._settingsController = settingsController;
+ this._settingsExportToken = null;
+ this._settingsExportRevoke = null;
+ this._currentVersion = 0;
+ }
-async function _getSettingsExportData(date) {
- const optionsFull = await apiOptionsGetFull();
- const environment = await apiGetEnvironmentInfo();
- const fieldTemplatesDefault = await apiGetDefaultAnkiFieldTemplates();
+ prepare() {
+ document.querySelector('#settings-export').addEventListener('click', this._onSettingsExportClick.bind(this), false);
+ document.querySelector('#settings-import').addEventListener('click', this._onSettingsImportClick.bind(this), false);
+ document.querySelector('#settings-import-file').addEventListener('change', this._onSettingsImportFileChange.bind(this), false);
+ document.querySelector('#settings-reset').addEventListener('click', this._onSettingsResetClick.bind(this), false);
+ document.querySelector('#settings-reset-modal-confirm').addEventListener('click', this._onSettingsResetConfirmClick.bind(this), false);
+ }
- // Format options
- for (const {options} of optionsFull.profiles) {
- if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) {
- delete options.anki.fieldTemplates; // Default
- }
+ // Private
+
+ _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) {
+ const values = [
+ date.getUTCFullYear().toString(),
+ dateSeparator,
+ (date.getUTCMonth() + 1).toString().padStart(2, '0'),
+ dateSeparator,
+ date.getUTCDate().toString().padStart(2, '0'),
+ dateTimeSeparator,
+ date.getUTCHours().toString().padStart(2, '0'),
+ timeSeparator,
+ date.getUTCMinutes().toString().padStart(2, '0'),
+ timeSeparator,
+ date.getUTCSeconds().toString().padStart(2, '0')
+ ];
+ return values.slice(0, resolution * 2 - 1).join('');
}
- const data = {
- version: SETTINGS_EXPORT_CURRENT_VERSION,
- date: _getSettingsExportDateString(date, '-', ' ', ':', 6),
- url: chrome.runtime.getURL('/'),
- manifest: chrome.runtime.getManifest(),
- environment,
- userAgent: navigator.userAgent,
- options: optionsFull
- };
-
- return data;
-}
+ async _getSettingsExportData(date) {
+ const optionsFull = await this._settingsController.getOptionsFull();
+ const environment = await api.getEnvironmentInfo();
+ const fieldTemplatesDefault = await api.getDefaultAnkiFieldTemplates();
-function _saveBlob(blob, fileName) {
- if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') {
- if (navigator.msSaveBlob(blob)) {
- return;
+ // Format options
+ for (const {options} of optionsFull.profiles) {
+ if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) {
+ delete options.anki.fieldTemplates; // Default
+ }
}
- }
- const blobUrl = URL.createObjectURL(blob);
+ const data = {
+ version: this._currentVersion,
+ date: this._getSettingsExportDateString(date, '-', ' ', ':', 6),
+ url: chrome.runtime.getURL('/'),
+ manifest: chrome.runtime.getManifest(),
+ environment,
+ userAgent: navigator.userAgent,
+ options: optionsFull
+ };
- const a = document.createElement('a');
- a.href = blobUrl;
- a.download = fileName;
- a.rel = 'noopener';
- a.target = '_blank';
+ return data;
+ }
- const revoke = () => {
- URL.revokeObjectURL(blobUrl);
- a.href = '';
- _settingsExportRevoke = null;
- };
- _settingsExportRevoke = revoke;
+ _saveBlob(blob, fileName) {
+ if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') {
+ if (navigator.msSaveBlob(blob)) {
+ return;
+ }
+ }
- a.dispatchEvent(new MouseEvent('click'));
- setTimeout(revoke, 60000);
-}
+ const blobUrl = URL.createObjectURL(blob);
-async function _onSettingsExportClick() {
- if (_settingsExportRevoke !== null) {
- _settingsExportRevoke();
- _settingsExportRevoke = null;
- }
+ const a = document.createElement('a');
+ a.href = blobUrl;
+ a.download = fileName;
+ a.rel = 'noopener';
+ a.target = '_blank';
- const date = new Date(Date.now());
+ const revoke = () => {
+ URL.revokeObjectURL(blobUrl);
+ a.href = '';
+ this._settingsExportRevoke = null;
+ };
+ this._settingsExportRevoke = revoke;
- const token = {};
- _settingsExportToken = token;
- const data = await _getSettingsExportData(date);
- if (_settingsExportToken !== token) {
- // A new export has been started
- return;
+ a.dispatchEvent(new MouseEvent('click'));
+ setTimeout(revoke, 60000);
}
- _settingsExportToken = null;
- const fileName = `yomichan-settings-${_getSettingsExportDateString(date, '-', '-', '-', 6)}.json`;
- const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'});
- _saveBlob(blob, fileName);
-}
-
-
-// Importing
+ async _onSettingsExportClick() {
+ if (this._settingsExportRevoke !== null) {
+ this._settingsExportRevoke();
+ this._settingsExportRevoke = null;
+ }
-async function _settingsImportSetOptionsFull(optionsFull) {
- return utilIsolate(utilBackend().setFullOptions(
- utilBackgroundIsolate(optionsFull)
- ));
-}
+ const date = new Date(Date.now());
-function _showSettingsImportError(error) {
- yomichan.logError(error);
- document.querySelector('#settings-import-error-modal-message').textContent = `${error}`;
- $('#settings-import-error-modal').modal('show');
-}
+ const token = {};
+ this._settingsExportToken = token;
+ const data = await this._getSettingsExportData(date);
+ if (this._settingsExportToken !== token) {
+ // A new export has been started
+ return;
+ }
+ this._settingsExportToken = null;
-async function _showSettingsImportWarnings(warnings) {
- const modalNode = $('#settings-import-warning-modal');
- const buttons = document.querySelectorAll('.settings-import-warning-modal-import-button');
- const messageContainer = document.querySelector('#settings-import-warning-modal-message');
- if (modalNode.length === 0 || buttons.length === 0 || messageContainer === null) {
- return {result: false};
+ const fileName = `yomichan-settings-${this._getSettingsExportDateString(date, '-', '-', '-', 6)}.json`;
+ const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'});
+ this._saveBlob(blob, fileName);
}
- // Set message
- const fragment = document.createDocumentFragment();
- for (const warning of warnings) {
- const node = document.createElement('li');
- node.textContent = `${warning}`;
- fragment.appendChild(node);
+ _readFileArrayBuffer(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsArrayBuffer(file);
+ });
}
- messageContainer.textContent = '';
- messageContainer.appendChild(fragment);
-
- // Show modal
- modalNode.modal('show');
-
- // Wait for modal to close
- return new Promise((resolve) => {
- const onButtonClick = (e) => {
- e.preventDefault();
- complete({
- result: true,
- sanitize: e.currentTarget.dataset.importSanitize === 'true'
- });
- modalNode.modal('hide');
- };
- const onModalHide = () => {
- complete({result: false});
- };
- let completed = false;
- const complete = (result) => {
- if (completed) { return; }
- completed = true;
+ // Importing
- modalNode.off('hide.bs.modal', onModalHide);
- for (const button of buttons) {
- button.removeEventListener('click', onButtonClick, false);
- }
+ async _settingsImportSetOptionsFull(optionsFull) {
+ await this._settingsController.setAllSettings(optionsFull);
+ }
- resolve(result);
- };
+ _showSettingsImportError(error) {
+ yomichan.logError(error);
+ document.querySelector('#settings-import-error-modal-message').textContent = `${error}`;
+ $('#settings-import-error-modal').modal('show');
+ }
- // Hook events
- modalNode.on('hide.bs.modal', onModalHide);
- for (const button of buttons) {
- button.addEventListener('click', onButtonClick, false);
+ async _showSettingsImportWarnings(warnings) {
+ const modalNode = $('#settings-import-warning-modal');
+ const buttons = document.querySelectorAll('.settings-import-warning-modal-import-button');
+ const messageContainer = document.querySelector('#settings-import-warning-modal-message');
+ if (modalNode.length === 0 || buttons.length === 0 || messageContainer === null) {
+ return {result: false};
}
- });
-}
-function _isLocalhostUrl(urlString) {
- try {
- const url = new URL(urlString);
- switch (url.hostname.toLowerCase()) {
- case 'localhost':
- case '127.0.0.1':
- case '[::1]':
- switch (url.protocol.toLowerCase()) {
- case 'http:':
- case 'https:':
- return true;
- }
- break;
+ // Set message
+ const fragment = document.createDocumentFragment();
+ for (const warning of warnings) {
+ const node = document.createElement('li');
+ node.textContent = `${warning}`;
+ fragment.appendChild(node);
}
- } catch (e) {
- // NOP
- }
- return false;
-}
+ messageContainer.textContent = '';
+ messageContainer.appendChild(fragment);
+
+ // Show modal
+ modalNode.modal('show');
+
+ // Wait for modal to close
+ return new Promise((resolve) => {
+ const onButtonClick = (e) => {
+ e.preventDefault();
+ complete({
+ result: true,
+ sanitize: e.currentTarget.dataset.importSanitize === 'true'
+ });
+ modalNode.modal('hide');
+ };
+ const onModalHide = () => {
+ complete({result: false});
+ };
+
+ let completed = false;
+ const complete = (result) => {
+ if (completed) { return; }
+ completed = true;
+
+ modalNode.off('hide.bs.modal', onModalHide);
+ for (const button of buttons) {
+ button.removeEventListener('click', onButtonClick, false);
+ }
-function _settingsImportSanitizeProfileOptions(options, dryRun) {
- const warnings = [];
+ resolve(result);
+ };
- const anki = options.anki;
- if (isObject(anki)) {
- const fieldTemplates = anki.fieldTemplates;
- if (typeof fieldTemplates === 'string') {
- warnings.push('anki.fieldTemplates contains a non-default value');
- if (!dryRun) {
- delete anki.fieldTemplates;
- }
- }
- const server = anki.server;
- if (typeof server === 'string' && server.length > 0 && !_isLocalhostUrl(server)) {
- warnings.push('anki.server uses a non-localhost URL');
- if (!dryRun) {
- delete anki.server;
+ // Hook events
+ modalNode.on('hide.bs.modal', onModalHide);
+ for (const button of buttons) {
+ button.addEventListener('click', onButtonClick, false);
}
- }
+ });
}
- const audio = options.audio;
- if (isObject(audio)) {
- const customSourceUrl = audio.customSourceUrl;
- if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !_isLocalhostUrl(customSourceUrl)) {
- warnings.push('audio.customSourceUrl uses a non-localhost URL');
- if (!dryRun) {
- delete audio.customSourceUrl;
+ _isLocalhostUrl(urlString) {
+ try {
+ const url = new URL(urlString);
+ switch (url.hostname.toLowerCase()) {
+ case 'localhost':
+ case '127.0.0.1':
+ case '[::1]':
+ switch (url.protocol.toLowerCase()) {
+ case 'http:':
+ case 'https:':
+ return true;
+ }
+ break;
}
+ } catch (e) {
+ // NOP
}
+ return false;
}
- return warnings;
-}
-
-function _settingsImportSanitizeOptions(optionsFull, dryRun) {
- const warnings = new Set();
+ _settingsImportSanitizeProfileOptions(options, dryRun) {
+ const warnings = [];
- const profiles = optionsFull.profiles;
- if (Array.isArray(profiles)) {
- for (const profile of profiles) {
- if (!isObject(profile)) { continue; }
- const options = profile.options;
- if (!isObject(options)) { continue; }
-
- const warnings2 = _settingsImportSanitizeProfileOptions(options, dryRun);
- for (const warning of warnings2) {
- warnings.add(warning);
+ const anki = options.anki;
+ if (isObject(anki)) {
+ const fieldTemplates = anki.fieldTemplates;
+ if (typeof fieldTemplates === 'string') {
+ warnings.push('anki.fieldTemplates contains a non-default value');
+ if (!dryRun) {
+ delete anki.fieldTemplates;
+ }
+ }
+ const server = anki.server;
+ if (typeof server === 'string' && server.length > 0 && !this._isLocalhostUrl(server)) {
+ warnings.push('anki.server uses a non-localhost URL');
+ if (!dryRun) {
+ delete anki.server;
+ }
}
}
- }
- return warnings;
-}
+ const audio = options.audio;
+ if (isObject(audio)) {
+ const customSourceUrl = audio.customSourceUrl;
+ if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !this._isLocalhostUrl(customSourceUrl)) {
+ warnings.push('audio.customSourceUrl uses a non-localhost URL');
+ if (!dryRun) {
+ delete audio.customSourceUrl;
+ }
+ }
+ }
-function _utf8Decode(arrayBuffer) {
- try {
- return new TextDecoder('utf-8').decode(arrayBuffer);
- } catch (e) {
- const binaryString = String.fromCharCode.apply(null, new Uint8Array(arrayBuffer));
- return decodeURIComponent(escape(binaryString));
+ return warnings;
}
-}
-async function _importSettingsFile(file) {
- const dataString = _utf8Decode(await utilReadFileArrayBuffer(file));
- const data = JSON.parse(dataString);
+ _settingsImportSanitizeOptions(optionsFull, dryRun) {
+ const warnings = new Set();
- // Type check
- if (!isObject(data)) {
- throw new Error(`Invalid data type: ${typeof data}`);
- }
+ const profiles = optionsFull.profiles;
+ if (Array.isArray(profiles)) {
+ for (const profile of profiles) {
+ if (!isObject(profile)) { continue; }
+ const options = profile.options;
+ if (!isObject(options)) { continue; }
- // Version check
- const version = data.version;
- if (!(
- typeof version === 'number' &&
- Number.isFinite(version) &&
- version === Math.floor(version)
- )) {
- throw new Error(`Invalid version: ${version}`);
- }
+ const warnings2 = this._settingsImportSanitizeProfileOptions(options, dryRun);
+ for (const warning of warnings2) {
+ warnings.add(warning);
+ }
+ }
+ }
- if (!(
- version >= 0 &&
- version <= SETTINGS_EXPORT_CURRENT_VERSION
- )) {
- throw new Error(`Unsupported version: ${version}`);
+ return warnings;
}
- // Verify options exists
- let optionsFull = data.options;
- if (!isObject(optionsFull)) {
- throw new Error(`Invalid options type: ${typeof optionsFull}`);
+ _utf8Decode(arrayBuffer) {
+ try {
+ return new TextDecoder('utf-8').decode(arrayBuffer);
+ } catch (e) {
+ const binaryString = String.fromCharCode.apply(null, new Uint8Array(arrayBuffer));
+ return decodeURIComponent(escape(binaryString));
+ }
}
- // Upgrade options
- optionsFull = optionsUpdateVersion(optionsFull, {});
+ async _importSettingsFile(file) {
+ const dataString = this._utf8Decode(await this._readFileArrayBuffer(file));
+ const data = JSON.parse(dataString);
- // Check for warnings
- const sanitizationWarnings = _settingsImportSanitizeOptions(optionsFull, true);
-
- // Show sanitization warnings
- if (sanitizationWarnings.size > 0) {
- const {result, sanitize} = await _showSettingsImportWarnings(sanitizationWarnings);
- if (!result) { return; }
+ // Type check
+ if (!isObject(data)) {
+ throw new Error(`Invalid data type: ${typeof data}`);
+ }
- if (sanitize !== false) {
- _settingsImportSanitizeOptions(optionsFull, false);
+ // Version check
+ const version = data.version;
+ if (!(
+ typeof version === 'number' &&
+ Number.isFinite(version) &&
+ version === Math.floor(version)
+ )) {
+ throw new Error(`Invalid version: ${version}`);
}
- }
- // Assign options
- await _settingsImportSetOptionsFull(optionsFull);
+ if (!(
+ version >= 0 &&
+ version <= this._currentVersion
+ )) {
+ throw new Error(`Unsupported version: ${version}`);
+ }
- // Reload settings page
- window.location.reload();
-}
+ // Verify options exists
+ let optionsFull = data.options;
+ if (!isObject(optionsFull)) {
+ throw new Error(`Invalid options type: ${typeof optionsFull}`);
+ }
-function _onSettingsImportClick() {
- document.querySelector('#settings-import-file').click();
-}
+ // Upgrade options
+ optionsFull = optionsUpdateVersion(optionsFull, {});
-function _onSettingsImportFileChange(e) {
- const files = e.target.files;
- if (files.length === 0) { return; }
+ // Check for warnings
+ const sanitizationWarnings = this._settingsImportSanitizeOptions(optionsFull, true);
- const file = files[0];
- e.target.value = null;
- _importSettingsFile(file).catch(_showSettingsImportError);
-}
+ // Show sanitization warnings
+ if (sanitizationWarnings.size > 0) {
+ const {result, sanitize} = await this._showSettingsImportWarnings(sanitizationWarnings);
+ if (!result) { return; }
+ if (sanitize !== false) {
+ this._settingsImportSanitizeOptions(optionsFull, false);
+ }
+ }
-// Resetting
+ // Assign options
+ await this._settingsImportSetOptionsFull(optionsFull);
+ }
-function _onSettingsResetClick() {
- $('#settings-reset-modal').modal('show');
-}
+ _onSettingsImportClick() {
+ document.querySelector('#settings-import-file').click();
+ }
-async function _onSettingsResetConfirmClick() {
- $('#settings-reset-modal').modal('hide');
+ async _onSettingsImportFileChange(e) {
+ const files = e.target.files;
+ if (files.length === 0) { return; }
- // Get default options
- const optionsFull = optionsGetDefault();
+ const file = files[0];
+ e.target.value = null;
+ try {
+ await this._importSettingsFile(file);
+ } catch (error) {
+ this._showSettingsImportError(error);
+ }
+ }
- // Assign options
- await _settingsImportSetOptionsFull(optionsFull);
+ // Resetting
- // Reload settings page
- window.location.reload();
-}
+ _onSettingsResetClick() {
+ $('#settings-reset-modal').modal('show');
+ }
+ async _onSettingsResetConfirmClick() {
+ $('#settings-reset-modal').modal('hide');
-// Setup
+ // Get default options
+ const optionsFull = optionsGetDefault();
-function backupInitialize() {
- document.querySelector('#settings-export').addEventListener('click', _onSettingsExportClick, false);
- document.querySelector('#settings-import').addEventListener('click', _onSettingsImportClick, false);
- document.querySelector('#settings-import-file').addEventListener('change', _onSettingsImportFileChange, false);
- document.querySelector('#settings-reset').addEventListener('click', _onSettingsResetClick, false);
- document.querySelector('#settings-reset-modal-confirm').addEventListener('click', _onSettingsResetConfirmClick, false);
+ // Assign options
+ await this._settingsImportSetOptionsFull(optionsFull);
+ }
}
diff --git a/ext/bg/js/settings/clipboard-popups-controller.js b/ext/bg/js/settings/clipboard-popups-controller.js
new file mode 100644
index 00000000..294663f9
--- /dev/null
+++ b/ext/bg/js/settings/clipboard-popups-controller.js
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+class ClipboardPopupsController {
+ constructor(settingsController) {
+ this._settingsController = settingsController;
+ this._checkbox = document.querySelector('#enable-clipboard-popups');
+ }
+
+ async prepare() {
+ this._checkbox.addEventListener('change', this._onEnableClipboardPopupsChanged.bind(this), false);
+ this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+
+ const options = await this._settingsController.getOptions();
+ this._onOptionsChanged({options});
+ }
+
+ // Private
+
+ _onOptionsChanged({options}) {
+ this._checkbox.checked = options.general.enableClipboardPopups;
+ }
+
+ async _onEnableClipboardPopupsChanged(e) {
+ const checkbox = e.currentTarget;
+ let value = checkbox.checked;
+
+ if (value) {
+ value = await new Promise((resolve) => {
+ chrome.permissions.request({permissions: ['clipboardRead']}, resolve);
+ });
+ checkbox.checked = value;
+ }
+
+ await this._settingsController.setProfileSetting('general.enableClipboardPopups', value);
+ }
+}
diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js
index 632c01ea..94a71233 100644
--- a/ext/bg/js/settings/dictionaries.js
+++ b/ext/bg/js/settings/dictionaries.js
@@ -17,27 +17,13 @@
/* global
* PageExitPrevention
- * apiDeleteDictionary
- * apiGetDictionaryCounts
- * apiGetDictionaryInfo
- * apiImportDictionaryArchive
- * apiOptionsGet
- * apiOptionsGetFull
- * apiPurgeDatabase
- * getOptionsContext
- * getOptionsFullMutable
- * getOptionsMutable
- * settingsSaveOptions
- * storageEstimate
- * storageUpdateStats
+ * api
* utilBackgroundIsolate
*/
-let dictionaryUI = null;
-
-
-class SettingsDictionaryListUI {
+class SettingsDictionaryListUI extends EventDispatcher {
constructor(container, template, extraContainer, extraTemplate) {
+ super();
this.container = container;
this.template = template;
this.extraContainer = extraContainer;
@@ -312,15 +298,15 @@ class SettingsDictionaryEntryUI {
progressBar.style.width = `${percent}%`;
};
- await apiDeleteDictionary(this.dictionaryInfo.title, onProgress);
+ await api.deleteDictionary(this.dictionaryInfo.title, onProgress);
} catch (e) {
- dictionaryErrorsShow([e]);
+ this.dictionaryErrorsShow([e]);
} finally {
prevention.end();
this.isDeleting = false;
progress.hidden = true;
- onDatabaseUpdated();
+ this.parent.trigger('databaseUpdated');
}
}
@@ -394,340 +380,342 @@ class SettingsDictionaryExtraUI {
}
}
+class DictionaryController {
+ constructor(settingsController, storageController) {
+ this._settingsController = settingsController;
+ this._storageController = storageController;
+ this._dictionaryUI = null;
+ this._dictionaryErrorToStringOverrides = [
+ [
+ 'A mutation operation was attempted on a database that did not allow mutations.',
+ 'Access to IndexedDB appears to be restricted. Firefox seems to require that the history preference is set to "Remember history" before IndexedDB use of any kind is allowed.'
+ ],
+ [
+ 'The operation failed for reasons unrelated to the database itself and not covered by any other error code.',
+ 'Unable to access IndexedDB due to a possibly corrupt user profile. Try using the "Refresh Firefox" feature to reset your user profile.'
+ ],
+ [
+ 'BulkError',
+ 'Unable to finish importing dictionary data into IndexedDB. This may indicate that you do not have sufficient disk space available to complete this operation.'
+ ]
+ ];
+ }
-async function dictSettingsInitialize() {
- dictionaryUI = new SettingsDictionaryListUI(
- document.querySelector('#dict-groups'),
- document.querySelector('#dict-template'),
- document.querySelector('#dict-groups-extra'),
- document.querySelector('#dict-extra-template')
- );
- dictionaryUI.save = settingsSaveOptions;
-
- document.querySelector('#dict-purge-button').addEventListener('click', onDictionaryPurgeButtonClick, false);
- document.querySelector('#dict-purge-confirm').addEventListener('click', onDictionaryPurge, false);
- document.querySelector('#dict-file-button').addEventListener('click', onDictionaryImportButtonClick, false);
- document.querySelector('#dict-file').addEventListener('change', onDictionaryImport, false);
- document.querySelector('#dict-main').addEventListener('change', onDictionaryMainChanged, false);
- document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', onDatabaseEnablePrefixWildcardSearchesChanged, false);
-
- await onDictionaryOptionsChanged();
- await onDatabaseUpdated();
-}
-
-async function onDictionaryOptionsChanged() {
- if (dictionaryUI === null) { return; }
+ async prepare() {
+ this._dictionaryUI = new SettingsDictionaryListUI(
+ document.querySelector('#dict-groups'),
+ document.querySelector('#dict-template'),
+ document.querySelector('#dict-groups-extra'),
+ document.querySelector('#dict-extra-template')
+ );
+ this._dictionaryUI.save = () => this._settingsController.save();
+ this._dictionaryUI.on('databaseUpdated', this._onDatabaseUpdated.bind(this));
- const optionsContext = getOptionsContext();
- const options = await getOptionsMutable(optionsContext);
+ document.querySelector('#dict-purge-button').addEventListener('click', this._onPurgeButtonClick.bind(this), false);
+ document.querySelector('#dict-purge-confirm').addEventListener('click', this._onPurgeConfirmButtonClick.bind(this), false);
+ document.querySelector('#dict-file-button').addEventListener('click', this._onImportButtonClick.bind(this), false);
+ document.querySelector('#dict-file').addEventListener('change', this._onImportFileChange.bind(this), false);
+ document.querySelector('#dict-main').addEventListener('change', this._onDictionaryMainChanged.bind(this), false);
+ document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', this._onDatabaseEnablePrefixWildcardSearchesChanged.bind(this), false);
- dictionaryUI.setOptionsDictionaries(options.dictionaries);
+ this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
- const optionsFull = await apiOptionsGetFull();
- document.querySelector('#database-enable-prefix-wildcard-searches').checked = optionsFull.global.database.prefixWildcardsSupported;
+ await this._onOptionsChanged();
+ await this._onDatabaseUpdated();
+ }
- await updateMainDictionarySelectValue();
-}
+ // Private
-async function onDatabaseUpdated() {
- try {
- const dictionaries = await apiGetDictionaryInfo();
- dictionaryUI.setDictionaries(dictionaries);
+ async _onOptionsChanged() {
+ const options = await this._settingsController.getOptionsMutable();
- document.querySelector('#dict-warning').hidden = (dictionaries.length > 0);
+ this._dictionaryUI.setOptionsDictionaries(options.dictionaries);
- updateMainDictionarySelectOptions(dictionaries);
- await updateMainDictionarySelectValue();
+ const optionsFull = await this._settingsController.getOptionsFull();
+ document.querySelector('#database-enable-prefix-wildcard-searches').checked = optionsFull.global.database.prefixWildcardsSupported;
- const {counts, total} = await apiGetDictionaryCounts(dictionaries.map((v) => v.title), true);
- dictionaryUI.setCounts(counts, total);
- } catch (e) {
- dictionaryErrorsShow([e]);
+ await this._updateMainDictionarySelectValue();
}
-}
-function updateMainDictionarySelectOptions(dictionaries) {
- const select = document.querySelector('#dict-main');
- select.textContent = ''; // Empty
+ _updateMainDictionarySelectOptions(dictionaries) {
+ const select = document.querySelector('#dict-main');
+ select.textContent = ''; // Empty
- let option = document.createElement('option');
- option.className = 'text-muted';
- option.value = '';
- option.textContent = 'Not selected';
- select.appendChild(option);
+ let option = document.createElement('option');
+ option.className = 'text-muted';
+ option.value = '';
+ option.textContent = 'Not selected';
+ select.appendChild(option);
- for (const {title, sequenced} of toIterable(dictionaries)) {
- if (!sequenced) { continue; }
+ for (const {title, sequenced} of toIterable(dictionaries)) {
+ if (!sequenced) { continue; }
- option = document.createElement('option');
- option.value = title;
- option.textContent = title;
- select.appendChild(option);
+ option = document.createElement('option');
+ option.value = title;
+ option.textContent = title;
+ select.appendChild(option);
+ }
}
-}
-async function updateMainDictionarySelectValue() {
- const optionsContext = getOptionsContext();
- const options = await apiOptionsGet(optionsContext);
+ async _updateMainDictionarySelectValue() {
+ const options = await this._settingsController.getOptions();
- const value = options.general.mainDictionary;
+ const value = options.general.mainDictionary;
- const select = document.querySelector('#dict-main');
- let selectValue = null;
- for (const child of select.children) {
- if (child.nodeName.toUpperCase() === 'OPTION' && child.value === value) {
- selectValue = value;
- break;
+ const select = document.querySelector('#dict-main');
+ let selectValue = null;
+ for (const child of select.children) {
+ if (child.nodeName.toUpperCase() === 'OPTION' && child.value === value) {
+ selectValue = value;
+ break;
+ }
}
- }
- let missingNodeOption = select.querySelector('option[data-not-installed=true]');
- if (selectValue === null) {
- if (missingNodeOption === null) {
- missingNodeOption = document.createElement('option');
- missingNodeOption.className = 'text-muted';
- missingNodeOption.value = value;
- missingNodeOption.textContent = `${value} (Not installed)`;
- missingNodeOption.dataset.notInstalled = 'true';
- select.appendChild(missingNodeOption);
- }
- } else {
- if (missingNodeOption !== null) {
- missingNodeOption.parentNode.removeChild(missingNodeOption);
+ let missingNodeOption = select.querySelector('option[data-not-installed=true]');
+ if (selectValue === null) {
+ if (missingNodeOption === null) {
+ missingNodeOption = document.createElement('option');
+ missingNodeOption.className = 'text-muted';
+ missingNodeOption.value = value;
+ missingNodeOption.textContent = `${value} (Not installed)`;
+ missingNodeOption.dataset.notInstalled = 'true';
+ select.appendChild(missingNodeOption);
+ }
+ } else {
+ if (missingNodeOption !== null) {
+ missingNodeOption.parentNode.removeChild(missingNodeOption);
+ }
}
+
+ select.value = value;
}
- select.value = value;
-}
+ _dictionaryErrorToString(error) {
+ if (error.toString) {
+ error = error.toString();
+ } else {
+ error = `${error}`;
+ }
-async function onDictionaryMainChanged(e) {
- const select = e.target;
- const value = select.value;
+ for (const [match, subst] of this._dictionaryErrorToStringOverrides) {
+ if (error.includes(match)) {
+ error = subst;
+ break;
+ }
+ }
- const missingNodeOption = select.querySelector('option[data-not-installed=true]');
- if (missingNodeOption !== null && missingNodeOption.value !== value) {
- missingNodeOption.parentNode.removeChild(missingNodeOption);
+ return error;
}
- const optionsContext = getOptionsContext();
- const options = await getOptionsMutable(optionsContext);
- options.general.mainDictionary = value;
- await settingsSaveOptions();
-}
+ _dictionaryErrorsShow(errors) {
+ const dialog = document.querySelector('#dict-error');
+ dialog.textContent = '';
+ if (errors !== null && errors.length > 0) {
+ const uniqueErrors = new Map();
+ for (let e of errors) {
+ yomichan.logError(e);
+ e = this._dictionaryErrorToString(e);
+ let count = uniqueErrors.get(e);
+ if (typeof count === 'undefined') {
+ count = 0;
+ }
+ uniqueErrors.set(e, count + 1);
+ }
-function dictionaryErrorToString(error) {
- if (error.toString) {
- error = error.toString();
- } else {
- error = `${error}`;
- }
+ for (const [e, count] of uniqueErrors.entries()) {
+ const div = document.createElement('p');
+ if (count > 1) {
+ div.textContent = `${e} `;
+ const em = document.createElement('em');
+ em.textContent = `(${count})`;
+ div.appendChild(em);
+ } else {
+ div.textContent = `${e}`;
+ }
+ dialog.appendChild(div);
+ }
- for (const [match, subst] of dictionaryErrorToString.overrides) {
- if (error.includes(match)) {
- error = subst;
- break;
+ dialog.hidden = false;
+ } else {
+ dialog.hidden = true;
}
}
- return error;
-}
-dictionaryErrorToString.overrides = [
- [
- 'A mutation operation was attempted on a database that did not allow mutations.',
- 'Access to IndexedDB appears to be restricted. Firefox seems to require that the history preference is set to "Remember history" before IndexedDB use of any kind is allowed.'
- ],
- [
- 'The operation failed for reasons unrelated to the database itself and not covered by any other error code.',
- 'Unable to access IndexedDB due to a possibly corrupt user profile. Try using the "Refresh Firefox" feature to reset your user profile.'
- ],
- [
- 'BulkError',
- 'Unable to finish importing dictionary data into IndexedDB. This may indicate that you do not have sufficient disk space available to complete this operation.'
- ]
-];
-
-function dictionaryErrorsShow(errors) {
- const dialog = document.querySelector('#dict-error');
- dialog.textContent = '';
-
- if (errors !== null && errors.length > 0) {
- const uniqueErrors = new Map();
- for (let e of errors) {
- yomichan.logError(e);
- e = dictionaryErrorToString(e);
- let count = uniqueErrors.get(e);
- if (typeof count === 'undefined') {
- count = 0;
- }
- uniqueErrors.set(e, count + 1);
- }
-
- for (const [e, count] of uniqueErrors.entries()) {
- const div = document.createElement('p');
- if (count > 1) {
- div.textContent = `${e} `;
- const em = document.createElement('em');
- em.textContent = `(${count})`;
- div.appendChild(em);
- } else {
- div.textContent = `${e}`;
- }
- dialog.appendChild(div);
+ _dictionarySpinnerShow(show) {
+ const spinner = $('#dict-spinner');
+ if (show) {
+ spinner.show();
+ } else {
+ spinner.hide();
}
+ }
- dialog.hidden = false;
- } else {
- dialog.hidden = true;
+ _dictReadFile(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsBinaryString(file);
+ });
}
-}
+ async _onDatabaseUpdated() {
+ try {
+ const dictionaries = await api.getDictionaryInfo();
+ this._dictionaryUI.setDictionaries(dictionaries);
+
+ document.querySelector('#dict-warning').hidden = (dictionaries.length > 0);
-function dictionarySpinnerShow(show) {
- const spinner = $('#dict-spinner');
- if (show) {
- spinner.show();
- } else {
- spinner.hide();
+ this._updateMainDictionarySelectOptions(dictionaries);
+ await this._updateMainDictionarySelectValue();
+
+ const {counts, total} = await api.getDictionaryCounts(dictionaries.map((v) => v.title), true);
+ this._dictionaryUI.setCounts(counts, total);
+ } catch (e) {
+ this._dictionaryErrorsShow([e]);
+ }
}
-}
-function onDictionaryImportButtonClick() {
- const dictFile = document.querySelector('#dict-file');
- dictFile.click();
-}
+ async _onDictionaryMainChanged(e) {
+ const select = e.target;
+ const value = select.value;
-function onDictionaryPurgeButtonClick(e) {
- e.preventDefault();
- $('#dict-purge-modal').modal('show');
-}
+ const missingNodeOption = select.querySelector('option[data-not-installed=true]');
+ if (missingNodeOption !== null && missingNodeOption.value !== value) {
+ missingNodeOption.parentNode.removeChild(missingNodeOption);
+ }
-async function onDictionaryPurge(e) {
- e.preventDefault();
+ const options = await this._settingsController.getOptionsMutable();
+ options.general.mainDictionary = value;
+ await this._settingsController.save();
+ }
- $('#dict-purge-modal').modal('hide');
+ _onImportButtonClick() {
+ const dictFile = document.querySelector('#dict-file');
+ dictFile.click();
+ }
- const dictControls = $('#dict-importer, #dict-groups, #dict-groups-extra, #dict-main-group').hide();
- const dictProgress = document.querySelector('#dict-purge');
- dictProgress.hidden = false;
+ _onPurgeButtonClick(e) {
+ e.preventDefault();
+ $('#dict-purge-modal').modal('show');
+ }
- const prevention = new PageExitPrevention();
+ async _onPurgeConfirmButtonClick(e) {
+ e.preventDefault();
- try {
- prevention.start();
- dictionaryErrorsShow(null);
- dictionarySpinnerShow(true);
+ $('#dict-purge-modal').modal('hide');
- await apiPurgeDatabase();
- for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) {
- options.dictionaries = utilBackgroundIsolate({});
- options.general.mainDictionary = '';
- }
- await settingsSaveOptions();
+ const dictControls = $('#dict-importer, #dict-groups, #dict-groups-extra, #dict-main-group').hide();
+ const dictProgress = document.querySelector('#dict-purge');
+ dictProgress.hidden = false;
+
+ const prevention = new PageExitPrevention();
- onDatabaseUpdated();
- } catch (err) {
- dictionaryErrorsShow([err]);
- } finally {
- prevention.end();
+ try {
+ prevention.start();
+ this._dictionaryErrorsShow(null);
+ this._dictionarySpinnerShow(true);
+
+ await api.purgeDatabase();
+ const optionsFull = await this._settingsController.getOptionsFullMutable();
+ for (const {options} of toIterable(optionsFull.profiles)) {
+ options.dictionaries = utilBackgroundIsolate({});
+ options.general.mainDictionary = '';
+ }
+ await this._settingsController.save();
+
+ this._onDatabaseUpdated();
+ } catch (err) {
+ this._dictionaryErrorsShow([err]);
+ } finally {
+ prevention.end();
- dictionarySpinnerShow(false);
+ this._dictionarySpinnerShow(false);
- dictControls.show();
- dictProgress.hidden = true;
+ dictControls.show();
+ dictProgress.hidden = true;
- if (storageEstimate.mostRecent !== null) {
- storageUpdateStats();
+ this._storageController.updateStats();
}
}
-}
-async function onDictionaryImport(e) {
- const files = [...e.target.files];
- e.target.value = null;
+ async _onImportFileChange(e) {
+ const files = [...e.target.files];
+ e.target.value = null;
- const dictFile = $('#dict-file');
- const dictControls = $('#dict-importer').hide();
- const dictProgress = $('#dict-import-progress').show();
- const dictImportInfo = document.querySelector('#dict-import-info');
+ const dictFile = $('#dict-file');
+ const dictControls = $('#dict-importer').hide();
+ const dictProgress = $('#dict-import-progress').show();
+ const dictImportInfo = document.querySelector('#dict-import-info');
- const prevention = new PageExitPrevention();
+ const prevention = new PageExitPrevention();
- try {
- prevention.start();
- dictionaryErrorsShow(null);
- dictionarySpinnerShow(true);
+ try {
+ prevention.start();
+ this._dictionaryErrorsShow(null);
+ this._dictionarySpinnerShow(true);
- const setProgress = (percent) => dictProgress.find('.progress-bar').css('width', `${percent}%`);
- const updateProgress = (total, current) => {
- setProgress(current / total * 100.0);
- if (storageEstimate.mostRecent !== null && !storageUpdateStats.isUpdating) {
- storageUpdateStats();
- }
- };
+ const setProgress = (percent) => dictProgress.find('.progress-bar').css('width', `${percent}%`);
+ const updateProgress = (total, current) => {
+ setProgress(current / total * 100.0);
+ this._storageController.updateStats();
+ };
- const optionsFull = await apiOptionsGetFull();
+ const optionsFull = await this._settingsController.getOptionsFull();
- const importDetails = {
- prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported
- };
+ const importDetails = {
+ prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported
+ };
- for (let i = 0, ii = files.length; i < ii; ++i) {
- setProgress(0.0);
- if (ii > 1) {
- dictImportInfo.hidden = false;
- dictImportInfo.textContent = `(${i + 1} of ${ii})`;
- }
+ for (let i = 0, ii = files.length; i < ii; ++i) {
+ setProgress(0.0);
+ if (ii > 1) {
+ dictImportInfo.hidden = false;
+ dictImportInfo.textContent = `(${i + 1} of ${ii})`;
+ }
- const archiveContent = await dictReadFile(files[i]);
- const {result, errors} = await apiImportDictionaryArchive(archiveContent, importDetails, updateProgress);
- for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) {
- const dictionaryOptions = SettingsDictionaryListUI.createDictionaryOptions();
- dictionaryOptions.enabled = true;
- options.dictionaries[result.title] = dictionaryOptions;
- if (result.sequenced && options.general.mainDictionary === '') {
- options.general.mainDictionary = result.title;
+ const archiveContent = await this._dictReadFile(files[i]);
+ const {result, errors} = await api.importDictionaryArchive(archiveContent, importDetails, updateProgress);
+ const optionsFull2 = await this._settingsController.getOptionsFullMutable();
+ for (const {options} of toIterable(optionsFull2.profiles)) {
+ const dictionaryOptions = SettingsDictionaryListUI.createDictionaryOptions();
+ dictionaryOptions.enabled = true;
+ options.dictionaries[result.title] = dictionaryOptions;
+ if (result.sequenced && options.general.mainDictionary === '') {
+ options.general.mainDictionary = result.title;
+ }
}
- }
- await settingsSaveOptions();
+ await this._settingsController.save();
- if (errors.length > 0) {
- const errors2 = errors.map((error) => jsonToError(error));
- errors2.push(`Dictionary may not have been imported properly: ${errors2.length} error${errors2.length === 1 ? '' : 's'} reported.`);
- dictionaryErrorsShow(errors2);
+ if (errors.length > 0) {
+ const errors2 = errors.map((error) => jsonToError(error));
+ errors2.push(`Dictionary may not have been imported properly: ${errors2.length} error${errors2.length === 1 ? '' : 's'} reported.`);
+ this._dictionaryErrorsShow(errors2);
+ }
+
+ this._onDatabaseUpdated();
}
+ } catch (err) {
+ this._dictionaryErrorsShow([err]);
+ } finally {
+ prevention.end();
+ this._dictionarySpinnerShow(false);
- onDatabaseUpdated();
+ dictImportInfo.hidden = false;
+ dictImportInfo.textContent = '';
+ dictFile.val('');
+ dictControls.show();
+ dictProgress.hide();
}
- } catch (err) {
- dictionaryErrorsShow([err]);
- } finally {
- prevention.end();
- dictionarySpinnerShow(false);
-
- dictImportInfo.hidden = false;
- dictImportInfo.textContent = '';
- dictFile.val('');
- dictControls.show();
- dictProgress.hide();
}
-}
-
-function dictReadFile(file) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result);
- reader.onerror = () => reject(reader.error);
- reader.readAsBinaryString(file);
- });
-}
-
-async function onDatabaseEnablePrefixWildcardSearchesChanged(e) {
- const optionsFull = await getOptionsFullMutable();
- const v = !!e.target.checked;
- if (optionsFull.global.database.prefixWildcardsSupported === v) { return; }
- optionsFull.global.database.prefixWildcardsSupported = !!e.target.checked;
- await settingsSaveOptions();
+ async _onDatabaseEnablePrefixWildcardSearchesChanged(e) {
+ const optionsFull = await this._settingsController.getOptionsFullMutable();
+ const v = !!e.target.checked;
+ if (optionsFull.global.database.prefixWildcardsSupported === v) { return; }
+ optionsFull.global.database.prefixWildcardsSupported = !!e.target.checked;
+ await this._settingsController.save();
+ }
}
diff --git a/ext/bg/js/settings/generic-setting-controller.js b/ext/bg/js/settings/generic-setting-controller.js
new file mode 100644
index 00000000..bdea8e3d
--- /dev/null
+++ b/ext/bg/js/settings/generic-setting-controller.js
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* globals
+ * DOMDataBinder
+ */
+
+class GenericSettingController {
+ constructor(settingsController) {
+ this._settingsController = settingsController;
+ this._defaultScope = 'profile';
+ this._dataBinder = new DOMDataBinder({
+ selector: '[data-setting]',
+ createElementMetadata: this._createElementMetadata.bind(this),
+ compareElementMetadata: this._compareElementMetadata.bind(this),
+ getValues: this._getValues.bind(this),
+ setValues: this._setValues.bind(this)
+ });
+ this._transforms = new Map([
+ ['setDocumentAttribute', this._setDocumentAttribute.bind(this)],
+ ['splitTags', this._splitTags.bind(this)],
+ ['joinTags', this._joinTags.bind(this)]
+ ]);
+ }
+
+ async prepare() {
+ this._dataBinder.observe(document.body);
+ this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+ }
+
+ // Private
+
+ _onOptionsChanged() {
+ this._dataBinder.refresh();
+ }
+
+ _createElementMetadata(element) {
+ return {
+ path: element.dataset.setting,
+ scope: element.dataset.scope,
+ transformPre: element.dataset.transformPre,
+ transformPost: element.dataset.transformPost
+ };
+ }
+
+ _compareElementMetadata(metadata1, metadata2) {
+ return (
+ metadata1.path === metadata2.path &&
+ metadata1.scope === metadata2.scope &&
+ metadata1.transformPre === metadata2.transformPre &&
+ metadata1.transformPost === metadata2.transformPost
+ );
+ }
+
+ async _getValues(targets) {
+ const defaultScope = this._defaultScope;
+ const settingsTargets = [];
+ for (const {metadata: {path, scope}} of targets) {
+ const target = {
+ path,
+ scope: scope || defaultScope
+ };
+ settingsTargets.push(target);
+ }
+ return this._transformResults(await this._settingsController.getSettings(settingsTargets), targets);
+ }
+
+ async _setValues(targets) {
+ const defaultScope = this._defaultScope;
+ const settingsTargets = [];
+ for (const {metadata, value, element} of targets) {
+ const {path, scope, transformPre} = metadata;
+ const target = {
+ path,
+ scope: scope || defaultScope,
+ action: 'set',
+ value: this._transform(value, transformPre, metadata, element)
+ };
+ settingsTargets.push(target);
+ }
+ return this._transformResults(await this._settingsController.modifySettings(settingsTargets), targets);
+ }
+
+ _transformResults(values, targets) {
+ return values.map((value, i) => {
+ const error = value.error;
+ if (error) { return jsonToError(error); }
+ const {metadata, element} = targets[i];
+ const result = this._transform(value.result, metadata.transformPost, metadata, element);
+ return {result};
+ });
+ }
+
+ _transform(value, transform, metadata, element) {
+ if (typeof transform === 'string') {
+ const transformFunction = this._transforms.get(transform);
+ if (typeof transformFunction !== 'undefined') {
+ value = transformFunction(value, metadata, element);
+ }
+ }
+ return value;
+ }
+
+ // Transforms
+
+ _setDocumentAttribute(value, metadata, element) {
+ document.documentElement.setAttribute(element.dataset.documentAttribute, `${value}`);
+ return value;
+ }
+
+ _splitTags(value) {
+ return `${value}`.split(/[,; ]+/).filter((v) => !!v);
+ }
+
+ _joinTags(value) {
+ return value.join(' ');
+ }
+}
diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js
index 61395b1c..e22c5e53 100644
--- a/ext/bg/js/settings/main.js
+++ b/ext/bg/js/settings/main.js
@@ -16,268 +16,20 @@
*/
/* global
- * ankiErrorShown
- * ankiFieldsToDict
- * ankiInitialize
- * ankiTemplatesInitialize
- * ankiTemplatesUpdateValue
- * apiForwardLogsToBackend
- * apiGetEnvironmentInfo
- * apiOptionsSave
- * appearanceInitialize
- * audioSettingsInitialize
- * backupInitialize
- * dictSettingsInitialize
- * getOptionsContext
- * onAnkiOptionsChanged
- * onDictionaryOptionsChanged
- * profileOptionsSetup
- * storageInfoInitialize
- * utilBackend
- * utilBackgroundIsolate
- * utilIsolate
+ * AnkiController
+ * AnkiTemplatesController
+ * AudioController
+ * ClipboardPopupsController
+ * DictionaryController
+ * GenericSettingController
+ * PopupPreviewController
+ * ProfileController
+ * SettingsBackup
+ * SettingsController
+ * StorageController
+ * api
*/
-function getOptionsMutable(optionsContext) {
- return utilBackend().getOptions(
- utilBackgroundIsolate(optionsContext)
- );
-}
-
-function getOptionsFullMutable() {
- return utilBackend().getFullOptions();
-}
-
-async function formRead(options) {
- options.general.enable = $('#enable').prop('checked');
- const enableClipboardPopups = $('#enable-clipboard-popups').prop('checked');
- if (enableClipboardPopups) {
- options.general.enableClipboardPopups = await new Promise((resolve, _reject) => {
- chrome.permissions.request(
- {permissions: ['clipboardRead']},
- (granted) => {
- if (!granted) {
- $('#enable-clipboard-popups').prop('checked', false);
- }
- resolve(granted);
- }
- );
- });
- } else {
- options.general.enableClipboardPopups = false;
- }
- options.general.showGuide = $('#show-usage-guide').prop('checked');
- options.general.compactTags = $('#compact-tags').prop('checked');
- options.general.compactGlossaries = $('#compact-glossaries').prop('checked');
- options.general.resultOutputMode = $('#result-output-mode').val();
- options.general.debugInfo = $('#show-debug-info').prop('checked');
- options.general.showAdvanced = $('#show-advanced-options').prop('checked');
- options.general.maxResults = parseInt($('#max-displayed-results').val(), 10);
- options.general.popupDisplayMode = $('#popup-display-mode').val();
- options.general.popupHorizontalTextPosition = $('#popup-horizontal-text-position').val();
- options.general.popupVerticalTextPosition = $('#popup-vertical-text-position').val();
- options.general.popupWidth = parseInt($('#popup-width').val(), 10);
- options.general.popupHeight = parseInt($('#popup-height').val(), 10);
- options.general.popupHorizontalOffset = parseInt($('#popup-horizontal-offset').val(), 0);
- options.general.popupVerticalOffset = parseInt($('#popup-vertical-offset').val(), 10);
- options.general.popupHorizontalOffset2 = parseInt($('#popup-horizontal-offset2').val(), 0);
- options.general.popupVerticalOffset2 = parseInt($('#popup-vertical-offset2').val(), 10);
- options.general.popupScalingFactor = parseFloat($('#popup-scaling-factor').val());
- options.general.popupScaleRelativeToPageZoom = $('#popup-scale-relative-to-page-zoom').prop('checked');
- options.general.popupScaleRelativeToVisualViewport = $('#popup-scale-relative-to-visual-viewport').prop('checked');
- options.general.showPitchAccentDownstepNotation = $('#show-pitch-accent-downstep-notation').prop('checked');
- options.general.showPitchAccentPositionNotation = $('#show-pitch-accent-position-notation').prop('checked');
- options.general.showPitchAccentGraph = $('#show-pitch-accent-graph').prop('checked');
- options.general.showIframePopupsInRootFrame = $('#show-iframe-popups-in-root-frame').prop('checked');
- options.general.popupTheme = $('#popup-theme').val();
- options.general.popupOuterTheme = $('#popup-outer-theme').val();
- options.general.customPopupCss = $('#custom-popup-css').val();
- options.general.customPopupOuterCss = $('#custom-popup-outer-css').val();
-
- options.audio.enabled = $('#audio-playback-enabled').prop('checked');
- options.audio.autoPlay = $('#auto-play-audio').prop('checked');
- options.audio.volume = parseFloat($('#audio-playback-volume').val());
- options.audio.customSourceUrl = $('#audio-custom-source').val();
- options.audio.textToSpeechVoice = $('#text-to-speech-voice').val();
-
- options.scanning.middleMouse = $('#middle-mouse-button-scan').prop('checked');
- options.scanning.touchInputEnabled = $('#touch-input-enabled').prop('checked');
- options.scanning.selectText = $('#select-matched-text').prop('checked');
- options.scanning.alphanumeric = $('#search-alphanumeric').prop('checked');
- options.scanning.autoHideResults = $('#auto-hide-results').prop('checked');
- options.scanning.deepDomScan = $('#deep-dom-scan').prop('checked');
- options.scanning.enablePopupSearch = $('#enable-search-within-first-popup').prop('checked');
- options.scanning.enableOnPopupExpressions = $('#enable-scanning-of-popup-expressions').prop('checked');
- options.scanning.enableOnSearchPage = $('#enable-scanning-on-search-page').prop('checked');
- options.scanning.enableSearchTags = $('#enable-search-tags').prop('checked');
- options.scanning.delay = parseInt($('#scan-delay').val(), 10);
- options.scanning.length = parseInt($('#scan-length').val(), 10);
- options.scanning.modifier = $('#scan-modifier-key').val();
- options.scanning.popupNestingMaxDepth = parseInt($('#popup-nesting-max-depth').val(), 10);
-
- options.translation.convertHalfWidthCharacters = $('#translation-convert-half-width-characters').val();
- options.translation.convertNumericCharacters = $('#translation-convert-numeric-characters').val();
- options.translation.convertAlphabeticCharacters = $('#translation-convert-alphabetic-characters').val();
- options.translation.convertHiraganaToKatakana = $('#translation-convert-hiragana-to-katakana').val();
- options.translation.convertKatakanaToHiragana = $('#translation-convert-katakana-to-hiragana').val();
- options.translation.collapseEmphaticSequences = $('#translation-collapse-emphatic-sequences').val();
-
- options.parsing.enableScanningParser = $('#parsing-scan-enable').prop('checked');
- options.parsing.enableMecabParser = $('#parsing-mecab-enable').prop('checked');
- options.parsing.termSpacing = $('#parsing-term-spacing').prop('checked');
- options.parsing.readingMode = $('#parsing-reading-mode').val();
-
- const optionsAnkiEnableOld = options.anki.enable;
- options.anki.enable = $('#anki-enable').prop('checked');
- options.anki.tags = utilBackgroundIsolate($('#card-tags').val().split(/[,; ]+/));
- options.anki.sentenceExt = parseInt($('#sentence-detection-extent').val(), 10);
- options.anki.server = $('#interface-server').val();
- options.anki.duplicateScope = $('#duplicate-scope').val();
- options.anki.screenshot.format = $('#screenshot-format').val();
- options.anki.screenshot.quality = parseInt($('#screenshot-quality').val(), 10);
-
- if (optionsAnkiEnableOld && !ankiErrorShown()) {
- options.anki.terms.deck = $('#anki-terms-deck').val();
- options.anki.terms.model = $('#anki-terms-model').val();
- options.anki.terms.fields = utilBackgroundIsolate(ankiFieldsToDict(document.querySelectorAll('#terms .anki-field-value')));
- options.anki.kanji.deck = $('#anki-kanji-deck').val();
- options.anki.kanji.model = $('#anki-kanji-model').val();
- options.anki.kanji.fields = utilBackgroundIsolate(ankiFieldsToDict(document.querySelectorAll('#kanji .anki-field-value')));
- }
-}
-
-async function formWrite(options) {
- $('#enable').prop('checked', options.general.enable);
- $('#enable-clipboard-popups').prop('checked', options.general.enableClipboardPopups);
- $('#show-usage-guide').prop('checked', options.general.showGuide);
- $('#compact-tags').prop('checked', options.general.compactTags);
- $('#compact-glossaries').prop('checked', options.general.compactGlossaries);
- $('#result-output-mode').val(options.general.resultOutputMode);
- $('#show-debug-info').prop('checked', options.general.debugInfo);
- $('#show-advanced-options').prop('checked', options.general.showAdvanced);
- $('#max-displayed-results').val(options.general.maxResults);
- $('#popup-display-mode').val(options.general.popupDisplayMode);
- $('#popup-horizontal-text-position').val(options.general.popupHorizontalTextPosition);
- $('#popup-vertical-text-position').val(options.general.popupVerticalTextPosition);
- $('#popup-width').val(options.general.popupWidth);
- $('#popup-height').val(options.general.popupHeight);
- $('#popup-horizontal-offset').val(options.general.popupHorizontalOffset);
- $('#popup-vertical-offset').val(options.general.popupVerticalOffset);
- $('#popup-horizontal-offset2').val(options.general.popupHorizontalOffset2);
- $('#popup-vertical-offset2').val(options.general.popupVerticalOffset2);
- $('#popup-scaling-factor').val(options.general.popupScalingFactor);
- $('#popup-scale-relative-to-page-zoom').prop('checked', options.general.popupScaleRelativeToPageZoom);
- $('#popup-scale-relative-to-visual-viewport').prop('checked', options.general.popupScaleRelativeToVisualViewport);
- $('#show-pitch-accent-downstep-notation').prop('checked', options.general.showPitchAccentDownstepNotation);
- $('#show-pitch-accent-position-notation').prop('checked', options.general.showPitchAccentPositionNotation);
- $('#show-pitch-accent-graph').prop('checked', options.general.showPitchAccentGraph);
- $('#show-iframe-popups-in-root-frame').prop('checked', options.general.showIframePopupsInRootFrame);
- $('#popup-theme').val(options.general.popupTheme);
- $('#popup-outer-theme').val(options.general.popupOuterTheme);
- $('#custom-popup-css').val(options.general.customPopupCss);
- $('#custom-popup-outer-css').val(options.general.customPopupOuterCss);
-
- $('#audio-playback-enabled').prop('checked', options.audio.enabled);
- $('#auto-play-audio').prop('checked', options.audio.autoPlay);
- $('#audio-playback-volume').val(options.audio.volume);
- $('#audio-custom-source').val(options.audio.customSourceUrl);
- $('#text-to-speech-voice').val(options.audio.textToSpeechVoice).attr('data-value', options.audio.textToSpeechVoice);
-
- $('#middle-mouse-button-scan').prop('checked', options.scanning.middleMouse);
- $('#touch-input-enabled').prop('checked', options.scanning.touchInputEnabled);
- $('#select-matched-text').prop('checked', options.scanning.selectText);
- $('#search-alphanumeric').prop('checked', options.scanning.alphanumeric);
- $('#auto-hide-results').prop('checked', options.scanning.autoHideResults);
- $('#deep-dom-scan').prop('checked', options.scanning.deepDomScan);
- $('#enable-search-within-first-popup').prop('checked', options.scanning.enablePopupSearch);
- $('#enable-scanning-of-popup-expressions').prop('checked', options.scanning.enableOnPopupExpressions);
- $('#enable-scanning-on-search-page').prop('checked', options.scanning.enableOnSearchPage);
- $('#enable-search-tags').prop('checked', options.scanning.enableSearchTags);
- $('#scan-delay').val(options.scanning.delay);
- $('#scan-length').val(options.scanning.length);
- $('#scan-modifier-key').val(options.scanning.modifier);
- $('#popup-nesting-max-depth').val(options.scanning.popupNestingMaxDepth);
-
- $('#translation-convert-half-width-characters').val(options.translation.convertHalfWidthCharacters);
- $('#translation-convert-numeric-characters').val(options.translation.convertNumericCharacters);
- $('#translation-convert-alphabetic-characters').val(options.translation.convertAlphabeticCharacters);
- $('#translation-convert-hiragana-to-katakana').val(options.translation.convertHiraganaToKatakana);
- $('#translation-convert-katakana-to-hiragana').val(options.translation.convertKatakanaToHiragana);
- $('#translation-collapse-emphatic-sequences').val(options.translation.collapseEmphaticSequences);
-
- $('#parsing-scan-enable').prop('checked', options.parsing.enableScanningParser);
- $('#parsing-mecab-enable').prop('checked', options.parsing.enableMecabParser);
- $('#parsing-term-spacing').prop('checked', options.parsing.termSpacing);
- $('#parsing-reading-mode').val(options.parsing.readingMode);
-
- $('#anki-enable').prop('checked', options.anki.enable);
- $('#card-tags').val(options.anki.tags.join(' '));
- $('#sentence-detection-extent').val(options.anki.sentenceExt);
- $('#interface-server').val(options.anki.server);
- $('#duplicate-scope').val(options.anki.duplicateScope);
- $('#screenshot-format').val(options.anki.screenshot.format);
- $('#screenshot-quality').val(options.anki.screenshot.quality);
-
- await ankiTemplatesUpdateValue();
- await onAnkiOptionsChanged(options);
- await onDictionaryOptionsChanged();
-
- formUpdateVisibility(options);
-}
-
-function formSetupEventListeners() {
- $('input, select, textarea').not('.anki-model').not('.ignore-form-changes *').change(onFormOptionsChanged);
-}
-
-function formUpdateVisibility(options) {
- document.documentElement.dataset.optionsAnkiEnable = `${!!options.anki.enable}`;
- document.documentElement.dataset.optionsGeneralDebugInfo = `${!!options.general.debugInfo}`;
- document.documentElement.dataset.optionsGeneralShowAdvanced = `${!!options.general.showAdvanced}`;
- document.documentElement.dataset.optionsGeneralResultOutputMode = `${options.general.resultOutputMode}`;
-
- if (options.general.debugInfo) {
- const temp = utilIsolate(options);
- if (typeof temp.anki.fieldTemplates === 'string') {
- temp.anki.fieldTemplates = '...';
- }
- const text = JSON.stringify(temp, null, 4);
- $('#debug').text(text);
- }
-}
-
-async function onFormOptionsChanged() {
- const optionsContext = getOptionsContext();
- const options = await getOptionsMutable(optionsContext);
-
- await formRead(options);
- await settingsSaveOptions();
- formUpdateVisibility(options);
-
- await onAnkiOptionsChanged(options);
-}
-
-
-function settingsGetSource() {
- return new Promise((resolve) => {
- chrome.tabs.getCurrent((tab) => resolve(`settings${tab ? tab.id : ''}`));
- });
-}
-
-async function settingsSaveOptions() {
- const source = await settingsGetSource();
- await apiOptionsSave(source);
-}
-
-async function onOptionsUpdated({source}) {
- const thisSource = await settingsGetSource();
- if (source === thisSource) { return; }
-
- const optionsContext = getOptionsContext();
- const options = await getOptionsMutable(optionsContext);
- await formWrite(options);
-}
-
-
function showExtensionInformation() {
const node = document.getElementById('extension-info');
if (node === null) { return; }
@@ -290,7 +42,7 @@ async function settingsPopulateModifierKeys() {
const scanModifierKeySelect = document.querySelector('#scan-modifier-key');
scanModifierKeySelect.textContent = '';
- const environment = await apiGetEnvironmentInfo();
+ const environment = await api.getEnvironmentInfo();
const modifierKeys = [
{value: 'none', name: 'None'},
...environment.modifiers.keys
@@ -303,26 +55,53 @@ async function settingsPopulateModifierKeys() {
}
}
+async function setupEnvironmentInfo() {
+ const {browser, platform} = await api.getEnvironmentInfo();
+ document.documentElement.dataset.browser = browser;
+ document.documentElement.dataset.operatingSystem = platform.os;
+}
+
-async function onReady() {
- apiForwardLogsToBackend();
+(async () => {
+ api.forwardLogsToBackend();
await yomichan.prepare();
+ setupEnvironmentInfo();
showExtensionInformation();
+ settingsPopulateModifierKeys();
- await settingsPopulateModifierKeys();
- formSetupEventListeners();
- appearanceInitialize();
- await audioSettingsInitialize();
- await profileOptionsSetup();
- await dictSettingsInitialize();
- ankiInitialize();
- ankiTemplatesInitialize();
- backupInitialize();
+ const optionsFull = await api.optionsGetFull();
- storageInfoInitialize();
+ const settingsController = new SettingsController(optionsFull.profileCurrent);
+ settingsController.prepare();
- yomichan.on('optionsUpdated', onOptionsUpdated);
-}
+ const storageController = new StorageController();
+ storageController.prepare();
+
+ const genericSettingController = new GenericSettingController(settingsController);
+ genericSettingController.prepare();
+
+ const clipboardPopupsController = new ClipboardPopupsController(settingsController);
+ clipboardPopupsController.prepare();
+
+ const popupPreviewController = new PopupPreviewController(settingsController);
+ popupPreviewController.prepare();
+
+ const audioController = new AudioController(settingsController);
+ audioController.prepare();
+
+ const profileController = new ProfileController(settingsController);
+ profileController.prepare();
+
+ const dictionaryController = new DictionaryController(settingsController, storageController);
+ dictionaryController.prepare();
+
+ const ankiController = new AnkiController(settingsController);
+ ankiController.prepare();
+
+ const ankiTemplatesController = new AnkiTemplatesController(settingsController, ankiController);
+ ankiTemplatesController.prepare();
-$(document).ready(() => onReady());
+ const settingsBackup = new SettingsBackup(settingsController);
+ settingsBackup.prepare();
+})();
diff --git a/ext/bg/js/settings/popup-preview-frame-main.js b/ext/bg/js/settings/popup-preview-frame-main.js
index 8228125f..866b9f57 100644
--- a/ext/bg/js/settings/popup-preview-frame-main.js
+++ b/ext/bg/js/settings/popup-preview-frame-main.js
@@ -16,11 +16,23 @@
*/
/* global
- * SettingsPopupPreview
- * apiForwardLogsToBackend
+ * PopupFactory
+ * PopupPreviewFrame
+ * api
*/
-(() => {
- apiForwardLogsToBackend();
- new SettingsPopupPreview();
+(async () => {
+ try {
+ api.forwardLogsToBackend();
+
+ const {frameId} = await api.frameInformationGet();
+
+ const popupFactory = new PopupFactory(frameId);
+ popupFactory.prepare();
+
+ const preview = new PopupPreviewFrame(frameId, popupFactory);
+ await preview.prepare();
+ } catch (e) {
+ yomichan.logError(e);
+ }
})();
diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js
index 8901a0c4..98630503 100644
--- a/ext/bg/js/settings/popup-preview-frame.js
+++ b/ext/bg/js/settings/popup-preview-frame.js
@@ -18,68 +18,78 @@
/* global
* Frontend
* Popup
- * PopupFactory
* TextSourceRange
- * apiFrameInformationGet
- * apiOptionsGet
+ * api
*/
-class SettingsPopupPreview {
- constructor() {
- this.frontend = null;
- this.apiOptionsGetOld = apiOptionsGet;
- this.popup = null;
- this.popupSetCustomOuterCssOld = null;
- this.popupShown = false;
- this.themeChangeTimeout = null;
- this.textSource = null;
- this.optionsContext = null;
+class PopupPreviewFrame {
+ constructor(frameId, popupFactory) {
+ this._frameId = frameId;
+ this._popupFactory = popupFactory;
+ this._frontend = null;
+ this._frontendGetOptionsContextOld = null;
+ this._apiOptionsGetOld = null;
+ this._popupSetCustomOuterCssOld = null;
+ this._popupShown = false;
+ this._themeChangeTimeout = null;
+ this._textSource = null;
+ this._optionsContext = null;
this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
this._windowMessageHandlers = new Map([
- ['prepare', ({optionsContext}) => this.prepare(optionsContext)],
- ['setText', ({text}) => this.setText(text)],
- ['setCustomCss', ({css}) => this.setCustomCss(css)],
- ['setCustomOuterCss', ({css}) => this.setCustomOuterCss(css)],
- ['updateOptionsContext', ({optionsContext}) => this.updateOptionsContext(optionsContext)]
+ ['setText', this._setText.bind(this)],
+ ['setCustomCss', this._setCustomCss.bind(this)],
+ ['setCustomOuterCss', this._setCustomOuterCss.bind(this)],
+ ['updateOptionsContext', this._updateOptionsContext.bind(this)]
]);
-
- window.addEventListener('message', this.onMessage.bind(this), false);
}
- async prepare(optionsContext) {
- this.optionsContext = optionsContext;
+ async prepare() {
+ window.addEventListener('message', this._onMessage.bind(this), false);
// Setup events
- document.querySelector('#theme-dark-checkbox').addEventListener('change', this.onThemeDarkCheckboxChanged.bind(this), false);
+ document.querySelector('#theme-dark-checkbox').addEventListener('change', this._onThemeDarkCheckboxChanged.bind(this), false);
// Overwrite API functions
- window.apiOptionsGet = this.apiOptionsGet.bind(this);
+ this._apiOptionsGetOld = api.optionsGet.bind(api);
+ api.optionsGet = this._apiOptionsGet.bind(this);
// Overwrite frontend
- const {frameId} = await apiFrameInformationGet();
-
- const popupFactory = new PopupFactory(frameId);
- await popupFactory.prepare();
+ this._frontend = new Frontend(
+ this._frameId,
+ this._popupFactory,
+ {
+ allowRootFramePopupProxy: false
+ }
+ );
+ this._frontendGetOptionsContextOld = this._frontend.getOptionsContext.bind(this._frontend);
+ this._frontend.getOptionsContext = this._getOptionsContext.bind(this);
+ await this._frontend.prepare();
+ this._frontend.setDisabledOverride(true);
+ this._frontend.canClearSelection = false;
+
+ const popup = this._frontend.popup;
+ popup.setChildrenSupported(false);
+
+ this._popupSetCustomOuterCssOld = popup.setCustomOuterCss.bind(popup);
+ popup.setCustomOuterCss = this._popupSetCustomOuterCss.bind(this);
- this.popup = popupFactory.getOrCreatePopup();
- this.popup.setChildrenSupported(false);
-
- this.popupSetCustomOuterCssOld = this.popup.setCustomOuterCss;
- this.popup.setCustomOuterCss = this.popupSetCustomOuterCss.bind(this);
+ // Update search
+ this._updateSearch();
+ }
- this.frontend = new Frontend(this.popup);
- this.frontend.getOptionsContext = async () => this.optionsContext;
- await this.frontend.prepare();
- this.frontend.setDisabledOverride(true);
- this.frontend.canClearSelection = false;
+ // Private
- // Update search
- this.updateSearch();
+ async _getOptionsContext() {
+ let optionsContext = this._optionsContext;
+ if (optionsContext === null) {
+ optionsContext = this._frontendGetOptionsContextOld();
+ }
+ return optionsContext;
}
- async apiOptionsGet(...args) {
- const options = await this.apiOptionsGetOld(...args);
+ async _apiOptionsGet(...args) {
+ const options = await this._apiOptionsGetOld(...args);
options.general.enable = true;
options.general.debugInfo = false;
options.general.popupWidth = 400;
@@ -94,9 +104,9 @@ class SettingsPopupPreview {
return options;
}
- async popupSetCustomOuterCss(...args) {
+ async _popupSetCustomOuterCss(...args) {
// This simulates the stylesheet priorities when injecting using the web extension API.
- const result = await this.popupSetCustomOuterCssOld.call(this.popup, ...args);
+ const result = await this._popupSetCustomOuterCssOld(...args);
const node = document.querySelector('#client-css');
if (node !== null && result !== null) {
@@ -106,7 +116,7 @@ class SettingsPopupPreview {
return result;
}
- onMessage(e) {
+ _onMessage(e) {
if (e.origin !== this._targetOrigin) { return; }
const {action, params} = e.data;
@@ -116,49 +126,57 @@ class SettingsPopupPreview {
handler(params);
}
- onThemeDarkCheckboxChanged(e) {
+ _onThemeDarkCheckboxChanged(e) {
document.documentElement.classList.toggle('dark', e.target.checked);
- if (this.themeChangeTimeout !== null) {
- clearTimeout(this.themeChangeTimeout);
+ if (this._themeChangeTimeout !== null) {
+ clearTimeout(this._themeChangeTimeout);
}
- this.themeChangeTimeout = setTimeout(() => {
- this.themeChangeTimeout = null;
- this.popup.updateTheme();
+ this._themeChangeTimeout = setTimeout(() => {
+ this._themeChangeTimeout = null;
+ const popup = this._frontend.popup;
+ if (popup === null) { return; }
+ popup.updateTheme();
}, 300);
}
- setText(text) {
+ _setText({text}) {
const exampleText = document.querySelector('#example-text');
if (exampleText === null) { return; }
exampleText.textContent = text;
- this.updateSearch();
+ if (this._frontend === null) { return; }
+ this._updateSearch();
}
- setInfoVisible(visible) {
+ _setInfoVisible(visible) {
const node = document.querySelector('.placeholder-info');
if (node === null) { return; }
node.classList.toggle('placeholder-info-visible', visible);
}
- setCustomCss(css) {
- if (this.frontend === null) { return; }
- this.popup.setCustomCss(css);
+ _setCustomCss({css}) {
+ if (this._frontend === null) { return; }
+ const popup = this._frontend.popup;
+ if (popup === null) { return; }
+ popup.setCustomCss(css);
}
- setCustomOuterCss(css) {
- if (this.frontend === null) { return; }
- this.popup.setCustomOuterCss(css, false);
+ _setCustomOuterCss({css}) {
+ if (this._frontend === null) { return; }
+ const popup = this._frontend.popup;
+ if (popup === null) { return; }
+ popup.setCustomOuterCss(css, false);
}
- async updateOptionsContext(optionsContext) {
- this.optionsContext = optionsContext;
- await this.frontend.updateOptions();
- await this.updateSearch();
+ async _updateOptionsContext({optionsContext}) {
+ this._optionsContext = optionsContext;
+ if (this._frontend === null) { return; }
+ await this._frontend.updateOptions();
+ await this._updateSearch();
}
- async updateSearch() {
+ async _updateSearch() {
const exampleText = document.querySelector('#example-text');
if (exampleText === null) { return; }
@@ -170,17 +188,18 @@ class SettingsPopupPreview {
const source = new TextSourceRange(range, range.toString(), null, null);
try {
- await this.frontend.setTextSource(source);
+ await this._frontend.setTextSource(source);
} finally {
source.cleanup();
}
- this.textSource = source;
- await this.frontend.showContentCompleted();
+ this._textSource = source;
+ await this._frontend.showContentCompleted();
- if (this.popup.isVisibleSync()) {
- this.popupShown = true;
+ const popup = this._frontend.popup;
+ if (popup !== null && popup.isVisibleSync()) {
+ this._popupShown = true;
}
- this.setInfoVisible(!this.popupShown);
+ this._setInfoVisible(!this._popupShown);
}
}
diff --git a/ext/bg/js/settings/popup-preview.js b/ext/bg/js/settings/popup-preview.js
index fdc3dd94..d4145b76 100644
--- a/ext/bg/js/settings/popup-preview.js
+++ b/ext/bg/js/settings/popup-preview.js
@@ -16,69 +16,88 @@
*/
/* global
- * getOptionsContext
* wanakana
*/
-function appearanceInitialize() {
- let previewVisible = false;
- $('#settings-popup-preview-button').on('click', () => {
- if (previewVisible) { return; }
- showAppearancePreview();
- previewVisible = true;
- });
-}
+class PopupPreviewController {
+ constructor(settingsController) {
+ this._settingsController = settingsController;
+ this._previewVisible = false;
+ this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
+ this._frame = null;
+ this._previewTextInput = null;
+ }
+
+ prepare() {
+ document.querySelector('#settings-popup-preview-button').addEventListener('click', this._onShowPopupPreviewButtonClick.bind(this), false);
+ }
+
+ // Private
+
+ _onShowPopupPreviewButtonClick() {
+ if (this._previewVisible) { return; }
+ this._showAppearancePreview();
+ this._previewVisible = true;
+ }
+
+ _showAppearancePreview() {
+ const container = document.querySelector('#settings-popup-preview-container');
+ const buttonContainer = document.querySelector('#settings-popup-preview-button-container');
+ const settings = document.querySelector('#settings-popup-preview-settings');
+ const text = document.querySelector('#settings-popup-preview-text');
+ const customCss = document.querySelector('#custom-popup-css');
+ const customOuterCss = document.querySelector('#custom-popup-outer-css');
+ const frame = document.createElement('iframe');
+
+ this._previewTextInput = text;
+ this._frame = frame;
+
+ wanakana.bind(text);
+
+ frame.addEventListener('load', this._onFrameLoad.bind(this), false);
+ text.addEventListener('input', this._onTextChange.bind(this), false);
+ customCss.addEventListener('input', this._onCustomCssChange.bind(this), false);
+ customOuterCss.addEventListener('input', this._onCustomOuterCssChange.bind(this), false);
+ this._settingsController.on('optionsContextChanged', this._onOptionsContextChange.bind(this));
+
+ frame.src = '/bg/settings-popup-preview.html';
+ frame.id = 'settings-popup-preview-frame';
+
+ container.appendChild(frame);
+ if (buttonContainer.parentNode !== null) {
+ buttonContainer.parentNode.removeChild(buttonContainer);
+ }
+ settings.style.display = '';
+ }
+
+ _onFrameLoad() {
+ this._onOptionsContextChange();
+ this._setText(this._previewTextInput.value);
+ }
+
+ _onTextChange(e) {
+ this._setText(e.currentTarget.value);
+ }
+
+ _onCustomCssChange(e) {
+ this._invoke('setCustomCss', {css: e.currentTarget.value});
+ }
+
+ _onCustomOuterCssChange(e) {
+ this._invoke('setCustomOuterCss', {css: e.currentTarget.value});
+ }
+
+ _onOptionsContextChange() {
+ const optionsContext = this._settingsController.getOptionsContext();
+ this._invoke('updateOptionsContext', {optionsContext});
+ }
+
+ _setText(text) {
+ this._invoke('setText', {text});
+ }
-function showAppearancePreview() {
- const container = $('#settings-popup-preview-container');
- const buttonContainer = $('#settings-popup-preview-button-container');
- const settings = $('#settings-popup-preview-settings');
- const text = $('#settings-popup-preview-text');
- const customCss = $('#custom-popup-css');
- const customOuterCss = $('#custom-popup-outer-css');
-
- const frame = document.createElement('iframe');
- frame.src = '/bg/settings-popup-preview.html';
- frame.id = 'settings-popup-preview-frame';
-
- wanakana.bind(text[0]);
-
- const targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
-
- text.on('input', () => {
- const action = 'setText';
- const params = {text: text.val()};
- frame.contentWindow.postMessage({action, params}, targetOrigin);
- });
- customCss.on('input', () => {
- const action = 'setCustomCss';
- const params = {css: customCss.val()};
- frame.contentWindow.postMessage({action, params}, targetOrigin);
- });
- customOuterCss.on('input', () => {
- const action = 'setCustomOuterCss';
- const params = {css: customOuterCss.val()};
- frame.contentWindow.postMessage({action, params}, targetOrigin);
- });
-
- const updateOptionsContext = () => {
- const action = 'updateOptionsContext';
- const params = {
- optionsContext: getOptionsContext()
- };
- frame.contentWindow.postMessage({action, params}, targetOrigin);
- };
- yomichan.on('modifyingProfileChange', updateOptionsContext);
-
- frame.addEventListener('load', () => {
- const action = 'prepare';
- const params = {
- optionsContext: getOptionsContext()
- };
- frame.contentWindow.postMessage({action, params}, targetOrigin);
- });
-
- container.append(frame);
- buttonContainer.remove();
- settings.css('display', '');
+ _invoke(action, params) {
+ if (this._frame === null || this._frame.contentWindow === null) { return; }
+ this._frame.contentWindow.postMessage({action, params}, this._targetOrigin);
+ }
}
diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js
index bdf5a13d..2449ab44 100644
--- a/ext/bg/js/settings/profiles.js
+++ b/ext/bg/js/settings/profiles.js
@@ -17,288 +17,271 @@
/* global
* ConditionsUI
- * apiOptionsGetFull
* conditionsClearCaches
- * formWrite
- * getOptionsFullMutable
- * getOptionsMutable
* profileConditionsDescriptor
* profileConditionsDescriptorPromise
- * settingsSaveOptions
* utilBackgroundIsolate
*/
-let currentProfileIndex = 0;
-let profileConditionsContainer = null;
-
-function getOptionsContext() {
- return {
- index: currentProfileIndex
- };
-}
-
-
-async function profileOptionsSetup() {
- const optionsFull = await getOptionsFullMutable();
- currentProfileIndex = optionsFull.profileCurrent;
-
- profileOptionsSetupEventListeners();
- await profileOptionsUpdateTarget(optionsFull);
-}
-
-function profileOptionsSetupEventListeners() {
- $('#profile-target').change(onTargetProfileChanged);
- $('#profile-name').change(onProfileNameChanged);
- $('#profile-add').click(onProfileAdd);
- $('#profile-remove').click(onProfileRemove);
- $('#profile-remove-confirm').click(onProfileRemoveConfirm);
- $('#profile-copy').click(onProfileCopy);
- $('#profile-copy-confirm').click(onProfileCopyConfirm);
- $('#profile-move-up').click(() => onProfileMove(-1));
- $('#profile-move-down').click(() => onProfileMove(1));
- $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change(onProfileOptionsChanged);
-}
+class ProfileController {
+ constructor(settingsController) {
+ this._settingsController = settingsController;
+ this._conditionsContainer = null;
+ }
-function tryGetIntegerValue(selector, min, max) {
- const value = parseInt($(selector).val(), 10);
- return (
- typeof value === 'number' &&
- Number.isFinite(value) &&
- Math.floor(value) === value &&
- value >= min &&
- value < max
- ) ? value : null;
-}
+ async prepare() {
+ $('#profile-target').change(this._onTargetProfileChanged.bind(this));
+ $('#profile-name').change(this._onNameChanged.bind(this));
+ $('#profile-add').click(this._onAdd.bind(this));
+ $('#profile-remove').click(this._onRemove.bind(this));
+ $('#profile-remove-confirm').click(this._onRemoveConfirm.bind(this));
+ $('#profile-copy').click(this._onCopy.bind(this));
+ $('#profile-copy-confirm').click(this._onCopyConfirm.bind(this));
+ $('#profile-move-up').click(() => this._onMove(-1));
+ $('#profile-move-down').click(() => this._onMove(1));
+ $('.profile-form').find('input, select, textarea').not('.profile-form-manual').change(this._onInputChanged.bind(this));
+
+ this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
+
+ this._onOptionsChanged();
+ }
-async function profileFormRead(optionsFull) {
- const profile = optionsFull.profiles[currentProfileIndex];
+ // Private
- // Current profile
- const index = tryGetIntegerValue('#profile-active', 0, optionsFull.profiles.length);
- if (index !== null) {
- optionsFull.profileCurrent = index;
+ async _onOptionsChanged() {
+ const optionsFull = await this._settingsController.getOptionsFullMutable();
+ await this._formWrite(optionsFull);
}
- // Profile name
- profile.name = $('#profile-name').val();
-}
-
-async function profileFormWrite(optionsFull) {
- const profile = optionsFull.profiles[currentProfileIndex];
+ _tryGetIntegerValue(selector, min, max) {
+ const value = parseInt($(selector).val(), 10);
+ return (
+ typeof value === 'number' &&
+ Number.isFinite(value) &&
+ Math.floor(value) === value &&
+ value >= min &&
+ value < max
+ ) ? value : null;
+ }
- profileOptionsPopulateSelect($('#profile-active'), optionsFull.profiles, optionsFull.profileCurrent, null);
- profileOptionsPopulateSelect($('#profile-target'), optionsFull.profiles, currentProfileIndex, null);
- $('#profile-remove').prop('disabled', optionsFull.profiles.length <= 1);
- $('#profile-copy').prop('disabled', optionsFull.profiles.length <= 1);
- $('#profile-move-up').prop('disabled', currentProfileIndex <= 0);
- $('#profile-move-down').prop('disabled', currentProfileIndex >= optionsFull.profiles.length - 1);
+ async _formRead(optionsFull) {
+ const currentProfileIndex = this._settingsController.profileIndex;
+ const profile = optionsFull.profiles[currentProfileIndex];
- $('#profile-name').val(profile.name);
+ // Current profile
+ const index = this._tryGetIntegerValue('#profile-active', 0, optionsFull.profiles.length);
+ if (index !== null) {
+ optionsFull.profileCurrent = index;
+ }
- if (profileConditionsContainer !== null) {
- profileConditionsContainer.cleanup();
+ // Profile name
+ profile.name = $('#profile-name').val();
}
- await profileConditionsDescriptorPromise;
- profileConditionsContainer = new ConditionsUI.Container(
- profileConditionsDescriptor,
- 'popupLevel',
- profile.conditionGroups,
- $('#profile-condition-groups'),
- $('#profile-add-condition-group')
- );
- profileConditionsContainer.save = () => {
- settingsSaveOptions();
- conditionsClearCaches(profileConditionsDescriptor);
- };
- profileConditionsContainer.isolate = utilBackgroundIsolate;
-}
+ async _formWrite(optionsFull) {
+ const currentProfileIndex = this._settingsController.profileIndex;
+ const profile = optionsFull.profiles[currentProfileIndex];
-function profileOptionsPopulateSelect(select, profiles, currentValue, ignoreIndices) {
- select.empty();
+ this._populateSelect($('#profile-active'), optionsFull.profiles, optionsFull.profileCurrent, null);
+ this._populateSelect($('#profile-target'), optionsFull.profiles, currentProfileIndex, null);
+ $('#profile-remove').prop('disabled', optionsFull.profiles.length <= 1);
+ $('#profile-copy').prop('disabled', optionsFull.profiles.length <= 1);
+ $('#profile-move-up').prop('disabled', currentProfileIndex <= 0);
+ $('#profile-move-down').prop('disabled', currentProfileIndex >= optionsFull.profiles.length - 1);
+ $('#profile-name').val(profile.name);
- for (let i = 0; i < profiles.length; ++i) {
- if (ignoreIndices !== null && ignoreIndices.indexOf(i) >= 0) {
- continue;
+ if (this._conditionsContainer !== null) {
+ this._conditionsContainer.cleanup();
}
- const profile = profiles[i];
- select.append($(`<option value="${i}">${profile.name}</option>`));
- }
- select.val(`${currentValue}`);
-}
+ await profileConditionsDescriptorPromise;
+ this._conditionsContainer = new ConditionsUI.Container(
+ profileConditionsDescriptor,
+ 'popupLevel',
+ profile.conditionGroups,
+ $('#profile-condition-groups'),
+ $('#profile-add-condition-group')
+ );
+ this._conditionsContainer.save = () => {
+ this._settingsController.save();
+ conditionsClearCaches(profileConditionsDescriptor);
+ };
+ this._conditionsContainer.isolate = utilBackgroundIsolate;
+ }
-async function profileOptionsUpdateTarget(optionsFull) {
- await profileFormWrite(optionsFull);
+ _populateSelect(select, profiles, currentValue, ignoreIndices) {
+ select.empty();
- const optionsContext = getOptionsContext();
- const options = await getOptionsMutable(optionsContext);
- await formWrite(options);
-}
-function profileOptionsCreateCopyName(name, profiles, maxUniqueAttempts) {
- let space, index, prefix, suffix;
- const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name);
- if (match === null) {
- prefix = `${name} (Copy`;
- space = '';
- index = '';
- suffix = ')';
- } else {
- prefix = match[1];
- suffix = match[5];
- if (typeof match[2] === 'string') {
- space = match[3];
- index = parseInt(match[4], 10) + 1;
- } else {
- space = ' ';
- index = 2;
+ for (let i = 0; i < profiles.length; ++i) {
+ if (ignoreIndices !== null && ignoreIndices.indexOf(i) >= 0) {
+ continue;
+ }
+ const profile = profiles[i];
+ select.append($(`<option value="${i}">${profile.name}</option>`));
}
+
+ select.val(`${currentValue}`);
}
- let i = 0;
- while (true) {
- const newName = `${prefix}${space}${index}${suffix}`;
- if (i++ >= maxUniqueAttempts || profiles.findIndex((profile) => profile.name === newName) < 0) {
- return newName;
- }
- if (typeof index !== 'number') {
- index = 2;
- space = ' ';
+ _createCopyName(name, profiles, maxUniqueAttempts) {
+ let space, index, prefix, suffix;
+ const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name);
+ if (match === null) {
+ prefix = `${name} (Copy`;
+ space = '';
+ index = '';
+ suffix = ')';
} else {
- ++index;
+ prefix = match[1];
+ suffix = match[5];
+ if (typeof match[2] === 'string') {
+ space = match[3];
+ index = parseInt(match[4], 10) + 1;
+ } else {
+ space = ' ';
+ index = 2;
+ }
}
- }
-}
-async function onProfileOptionsChanged(e) {
- if (!e.originalEvent && !e.isTrigger) {
- return;
+ let i = 0;
+ while (true) {
+ const newName = `${prefix}${space}${index}${suffix}`;
+ if (i++ >= maxUniqueAttempts || profiles.findIndex((profile) => profile.name === newName) < 0) {
+ return newName;
+ }
+ if (typeof index !== 'number') {
+ index = 2;
+ space = ' ';
+ } else {
+ ++index;
+ }
+ }
}
- const optionsFull = await getOptionsFullMutable();
- await profileFormRead(optionsFull);
- await settingsSaveOptions();
-}
+ async _onInputChanged(e) {
+ if (!e.originalEvent && !e.isTrigger) {
+ return;
+ }
-async function onTargetProfileChanged() {
- const optionsFull = await getOptionsFullMutable();
- const index = tryGetIntegerValue('#profile-target', 0, optionsFull.profiles.length);
- if (index === null || currentProfileIndex === index) {
- return;
+ const optionsFull = await this._settingsController.getOptionsFullMutable();
+ await this._formRead(optionsFull);
+ await this._settingsController.save();
}
- currentProfileIndex = index;
+ async _onTargetProfileChanged() {
+ const optionsFull = await this._settingsController.getOptionsFullMutable();
+ const currentProfileIndex = this._settingsController.profileIndex;
+ const index = this._tryGetIntegerValue('#profile-target', 0, optionsFull.profiles.length);
+ if (index === null || currentProfileIndex === index) {
+ return;
+ }
- await profileOptionsUpdateTarget(optionsFull);
+ this._settingsController.profileIndex = index;
+ }
- yomichan.trigger('modifyingProfileChange');
-}
+ async _onAdd() {
+ const optionsFull = await this._settingsController.getOptionsFullMutable();
+ const currentProfileIndex = this._settingsController.profileIndex;
+ const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]);
+ profile.name = this._createCopyName(profile.name, optionsFull.profiles, 100);
+ optionsFull.profiles.push(profile);
-async function onProfileAdd() {
- const optionsFull = await getOptionsFullMutable();
- const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]);
- profile.name = profileOptionsCreateCopyName(profile.name, optionsFull.profiles, 100);
- optionsFull.profiles.push(profile);
+ this._settingsController.profileIndex = optionsFull.profiles.length - 1;
- currentProfileIndex = optionsFull.profiles.length - 1;
+ await this._settingsController.save();
+ }
- await profileOptionsUpdateTarget(optionsFull);
- await settingsSaveOptions();
+ async _onRemove(e) {
+ if (e.shiftKey) {
+ return await this._onRemoveConfirm();
+ }
- yomichan.trigger('modifyingProfileChange');
-}
+ const optionsFull = await this._settingsController.getOptionsFull();
+ if (optionsFull.profiles.length <= 1) {
+ return;
+ }
-async function onProfileRemove(e) {
- if (e.shiftKey) {
- return await onProfileRemoveConfirm();
- }
+ const currentProfileIndex = this._settingsController.profileIndex;
+ const profile = optionsFull.profiles[currentProfileIndex];
- const optionsFull = await apiOptionsGetFull();
- if (optionsFull.profiles.length <= 1) {
- return;
+ $('#profile-remove-modal-profile-name').text(profile.name);
+ $('#profile-remove-modal').modal('show');
}
- const profile = optionsFull.profiles[currentProfileIndex];
+ async _onRemoveConfirm() {
+ $('#profile-remove-modal').modal('hide');
- $('#profile-remove-modal-profile-name').text(profile.name);
- $('#profile-remove-modal').modal('show');
-}
+ const optionsFull = await this._settingsController.getOptionsFullMutable();
+ if (optionsFull.profiles.length <= 1) {
+ return;
+ }
-async function onProfileRemoveConfirm() {
- $('#profile-remove-modal').modal('hide');
+ const currentProfileIndex = this._settingsController.profileIndex;
+ optionsFull.profiles.splice(currentProfileIndex, 1);
- const optionsFull = await getOptionsFullMutable();
- if (optionsFull.profiles.length <= 1) {
- return;
- }
+ if (currentProfileIndex >= optionsFull.profiles.length) {
+ this._settingsController.profileIndex = optionsFull.profiles.length - 1;
+ }
- optionsFull.profiles.splice(currentProfileIndex, 1);
+ if (optionsFull.profileCurrent >= optionsFull.profiles.length) {
+ optionsFull.profileCurrent = optionsFull.profiles.length - 1;
+ }
- if (currentProfileIndex >= optionsFull.profiles.length) {
- --currentProfileIndex;
+ await this._settingsController.save();
}
- if (optionsFull.profileCurrent >= optionsFull.profiles.length) {
- optionsFull.profileCurrent = optionsFull.profiles.length - 1;
+ _onNameChanged() {
+ const currentProfileIndex = this._settingsController.profileIndex;
+ $('#profile-active, #profile-target').find(`[value="${currentProfileIndex}"]`).text(this.value);
}
- await profileOptionsUpdateTarget(optionsFull);
- await settingsSaveOptions();
-
- yomichan.trigger('modifyingProfileChange');
-}
+ async _onMove(offset) {
+ const optionsFull = await this._settingsController.getOptionsFullMutable();
+ const currentProfileIndex = this._settingsController.profileIndex;
+ const index = currentProfileIndex + offset;
+ if (index < 0 || index >= optionsFull.profiles.length) {
+ return;
+ }
-function onProfileNameChanged() {
- $('#profile-active, #profile-target').find(`[value="${currentProfileIndex}"]`).text(this.value);
-}
+ const profile = optionsFull.profiles[currentProfileIndex];
+ optionsFull.profiles.splice(currentProfileIndex, 1);
+ optionsFull.profiles.splice(index, 0, profile);
-async function onProfileMove(offset) {
- const optionsFull = await getOptionsFullMutable();
- const index = currentProfileIndex + offset;
- if (index < 0 || index >= optionsFull.profiles.length) {
- return;
- }
+ if (optionsFull.profileCurrent === currentProfileIndex) {
+ optionsFull.profileCurrent = index;
+ }
- const profile = optionsFull.profiles[currentProfileIndex];
- optionsFull.profiles.splice(currentProfileIndex, 1);
- optionsFull.profiles.splice(index, 0, profile);
+ this._settingsController.profileIndex = index;
- if (optionsFull.profileCurrent === currentProfileIndex) {
- optionsFull.profileCurrent = index;
+ await this._settingsController.save();
}
- currentProfileIndex = index;
-
- await profileOptionsUpdateTarget(optionsFull);
- await settingsSaveOptions();
-
- yomichan.trigger('modifyingProfileChange');
-}
+ async _onCopy() {
+ const optionsFull = await this._settingsController.getOptionsFullMutable();
+ if (optionsFull.profiles.length <= 1) {
+ return;
+ }
-async function onProfileCopy() {
- const optionsFull = await apiOptionsGetFull();
- if (optionsFull.profiles.length <= 1) {
- return;
+ const currentProfileIndex = this._settingsController.profileIndex;
+ this._populateSelect($('#profile-copy-source'), optionsFull.profiles, currentProfileIndex === 0 ? 1 : 0, [currentProfileIndex]);
+ $('#profile-copy-modal').modal('show');
}
- profileOptionsPopulateSelect($('#profile-copy-source'), optionsFull.profiles, currentProfileIndex === 0 ? 1 : 0, [currentProfileIndex]);
- $('#profile-copy-modal').modal('show');
-}
-
-async function onProfileCopyConfirm() {
- $('#profile-copy-modal').modal('hide');
+ async _onCopyConfirm() {
+ $('#profile-copy-modal').modal('hide');
- const optionsFull = await getOptionsFullMutable();
- const index = tryGetIntegerValue('#profile-copy-source', 0, optionsFull.profiles.length);
- if (index === null || index === currentProfileIndex) {
- return;
- }
+ const optionsFull = await this._settingsController.getOptionsFullMutable();
+ const index = this._tryGetIntegerValue('#profile-copy-source', 0, optionsFull.profiles.length);
+ const currentProfileIndex = this._settingsController.profileIndex;
+ if (index === null || index === currentProfileIndex) {
+ return;
+ }
- const profileOptions = utilBackgroundIsolate(optionsFull.profiles[index].options);
- optionsFull.profiles[currentProfileIndex].options = profileOptions;
+ const profileOptions = utilBackgroundIsolate(optionsFull.profiles[index].options);
+ optionsFull.profiles[currentProfileIndex].options = profileOptions;
- await profileOptionsUpdateTarget(optionsFull);
- await settingsSaveOptions();
+ await this._settingsController.save();
+ }
}
diff --git a/ext/bg/js/settings/settings-controller.js b/ext/bg/js/settings/settings-controller.js
new file mode 100644
index 00000000..87dea408
--- /dev/null
+++ b/ext/bg/js/settings/settings-controller.js
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * api
+ * utilBackend
+ * utilBackgroundIsolate
+ */
+
+class SettingsController extends EventDispatcher {
+ constructor(profileIndex=0) {
+ super();
+ this._profileIndex = profileIndex;
+ this._source = yomichan.generateId(16);
+ }
+
+ get source() {
+ return this._source;
+ }
+
+ get profileIndex() {
+ return this._profileIndex;
+ }
+
+ set profileIndex(value) {
+ if (this._profileIndex === value) { return; }
+ this._setProfileIndex(value);
+ }
+
+ prepare() {
+ yomichan.on('optionsUpdated', this._onOptionsUpdated.bind(this));
+ }
+
+ async save() {
+ await api.optionsSave(this._source);
+ }
+
+ async getOptions() {
+ const optionsContext = this.getOptionsContext();
+ return await api.optionsGet(optionsContext);
+ }
+
+ async getOptionsFull() {
+ return await api.optionsGetFull();
+ }
+
+ async getOptionsMutable() {
+ const optionsContext = this.getOptionsContext();
+ return utilBackend().getOptions(utilBackgroundIsolate(optionsContext));
+ }
+
+ async getOptionsFullMutable() {
+ return utilBackend().getFullOptions();
+ }
+
+ async setAllSettings(value) {
+ const profileIndex = value.profileCurrent;
+ await api.setAllSettings(value, this._source);
+ this._setProfileIndex(profileIndex);
+ }
+
+ async getSettings(targets) {
+ return await this._getSettings(targets, {});
+ }
+
+ async getGlobalSettings(targets) {
+ return await this._getSettings(targets, {scope: 'global'});
+ }
+
+ async getProfileSettings(targets) {
+ return await this._getSettings(targets, {scope: 'profile'});
+ }
+
+ async modifySettings(targets) {
+ return await this._modifySettings(targets, {});
+ }
+
+ async modifyGlobalSettings(targets) {
+ return await this._modifySettings(targets, {scope: 'global'});
+ }
+
+ async modifyProfileSettings(targets) {
+ return await this._modifySettings(targets, {scope: 'profile'});
+ }
+
+ async setGlobalSetting(path, value) {
+ return await this.modifyGlobalSettings([{action: 'set', path, value}]);
+ }
+
+ async setProfileSetting(path, value) {
+ return await this.modifyProfileSettings([{action: 'set', path, value}]);
+ }
+
+ getOptionsContext() {
+ return {index: this._profileIndex};
+ }
+
+ // Private
+
+ _setProfileIndex(value) {
+ this._profileIndex = value;
+ this.trigger('optionsContextChanged');
+ this._onOptionsUpdatedInternal();
+ }
+
+ _onOptionsUpdated({source}) {
+ if (source === this._source) { return; }
+ this._onOptionsUpdatedInternal();
+ }
+
+ async _onOptionsUpdatedInternal() {
+ const optionsContext = this.getOptionsContext();
+ const options = await this.getOptions();
+ this.trigger('optionsChanged', {options, optionsContext});
+ }
+
+ _setupTargets(targets, extraFields) {
+ return targets.map((target) => {
+ target = Object.assign({}, extraFields, target);
+ if (target.scope === 'profile') {
+ target.optionsContext = this.getOptionsContext();
+ }
+ return target;
+ });
+ }
+
+ async _getSettings(targets, extraFields) {
+ targets = this._setupTargets(targets, extraFields);
+ return await api.getSettings(targets);
+ }
+
+ async _modifySettings(targets, extraFields) {
+ targets = this._setupTargets(targets, extraFields);
+ return await api.modifySettings(targets, this._source);
+ }
+}
diff --git a/ext/bg/js/settings/storage.js b/ext/bg/js/settings/storage.js
index d754a109..24c6d7ef 100644
--- a/ext/bg/js/settings/storage.js
+++ b/ext/bg/js/settings/storage.js
@@ -15,126 +15,117 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-/* global
- * apiGetEnvironmentInfo
- */
-
-function storageBytesToLabeledString(size) {
- const base = 1000;
- const labels = [' bytes', 'KB', 'MB', 'GB'];
- let labelIndex = 0;
- while (size >= base) {
- size /= base;
- ++labelIndex;
+class StorageController {
+ constructor() {
+ this._mostRecentStorageEstimate = null;
+ this._storageEstimateFailed = false;
+ this._isUpdating = false;
}
- const label = labelIndex === 0 ? `${size}` : size.toFixed(1);
- return `${label}${labels[labelIndex]}`;
-}
-async function storageEstimate() {
- try {
- return (storageEstimate.mostRecent = await navigator.storage.estimate());
- } catch (e) {
- // NOP
+ prepare() {
+ this._preparePersistentStorage();
+ this.updateStats();
+ document.querySelector('#storage-refresh').addEventListener('click', this.updateStats.bind(this), false);
}
- return null;
-}
-storageEstimate.mostRecent = null;
-
-async function isStoragePeristent() {
- try {
- return await navigator.storage.persisted();
- } catch (e) {
- // NOP
- }
- return false;
-}
-
-async function storageInfoInitialize() {
- storagePersistInitialize();
- const {browser, platform} = await apiGetEnvironmentInfo();
- document.documentElement.dataset.browser = browser;
- document.documentElement.dataset.operatingSystem = platform.os;
-
- await storageShowInfo();
- document.querySelector('#storage-refresh').addEventListener('click', storageShowInfo, false);
-}
-
-async function storageUpdateStats() {
- storageUpdateStats.isUpdating = true;
-
- const estimate = await storageEstimate();
- const valid = (estimate !== null);
-
- if (valid) {
- // Firefox reports usage as 0 when persistent storage is enabled.
- const finite = (estimate.usage > 0 || !(await isStoragePeristent()));
- if (finite) {
- document.querySelector('#storage-usage').textContent = storageBytesToLabeledString(estimate.usage);
- document.querySelector('#storage-quota').textContent = storageBytesToLabeledString(estimate.quota);
+ async updateStats() {
+ try {
+ this._isUpdating = true;
+
+ const estimate = await this._storageEstimate();
+ const valid = (estimate !== null);
+
+ if (valid) {
+ // Firefox reports usage as 0 when persistent storage is enabled.
+ const finite = (estimate.usage > 0 || !(await this._isStoragePeristent()));
+ if (finite) {
+ document.querySelector('#storage-usage').textContent = this._bytesToLabeledString(estimate.usage);
+ document.querySelector('#storage-quota').textContent = this._bytesToLabeledString(estimate.quota);
+ }
+ document.querySelector('#storage-use-finite').classList.toggle('storage-hidden', !finite);
+ document.querySelector('#storage-use-infinite').classList.toggle('storage-hidden', finite);
+ }
+
+ document.querySelector('#storage-use').classList.toggle('storage-hidden', !valid);
+ document.querySelector('#storage-error').classList.toggle('storage-hidden', valid);
+
+ return valid;
+ } finally {
+ this._isUpdating = false;
}
- document.querySelector('#storage-use-finite').classList.toggle('storage-hidden', !finite);
- document.querySelector('#storage-use-infinite').classList.toggle('storage-hidden', finite);
}
- storageUpdateStats.isUpdating = false;
- return valid;
-}
-storageUpdateStats.isUpdating = false;
-
-async function storageShowInfo() {
- storageSpinnerShow(true);
-
- const valid = await storageUpdateStats();
- document.querySelector('#storage-use').classList.toggle('storage-hidden', !valid);
- document.querySelector('#storage-error').classList.toggle('storage-hidden', valid);
+ // Private
- storageSpinnerShow(false);
-}
+ async _preparePersistentStorage() {
+ if (!(navigator.storage && navigator.storage.persist)) {
+ // Not supported
+ return;
+ }
-function storageSpinnerShow(show) {
- const spinner = $('#storage-spinner');
- if (show) {
- spinner.show();
- } else {
- spinner.hide();
+ const info = document.querySelector('#storage-persist-info');
+ const button = document.querySelector('#storage-persist-button');
+ const checkbox = document.querySelector('#storage-persist-button-checkbox');
+
+ info.classList.remove('storage-hidden');
+ button.classList.remove('storage-hidden');
+
+ let persisted = await this._isStoragePeristent();
+ checkbox.checked = persisted;
+
+ button.addEventListener('click', async () => {
+ if (persisted) {
+ return;
+ }
+ let result = false;
+ try {
+ result = await navigator.storage.persist();
+ } catch (e) {
+ // NOP
+ }
+
+ if (result) {
+ persisted = true;
+ checkbox.checked = true;
+ this.updateStats();
+ } else {
+ document.querySelector('.storage-persist-fail-warning').classList.remove('storage-hidden');
+ }
+ }, false);
}
-}
-async function storagePersistInitialize() {
- if (!(navigator.storage && navigator.storage.persist)) {
- // Not supported
- return;
+ async _storageEstimate() {
+ if (this._storageEstimateFailed && this._mostRecentStorageEstimate === null) {
+ return null;
+ }
+ try {
+ const value = await navigator.storage.estimate();
+ this._mostRecentStorageEstimate = value;
+ return value;
+ } catch (e) {
+ this._storageEstimateFailed = true;
+ }
+ return null;
}
- const info = document.querySelector('#storage-persist-info');
- const button = document.querySelector('#storage-persist-button');
- const checkbox = document.querySelector('#storage-persist-button-checkbox');
-
- info.classList.remove('storage-hidden');
- button.classList.remove('storage-hidden');
-
- let persisted = await isStoragePeristent();
- checkbox.checked = persisted;
-
- button.addEventListener('click', async () => {
- if (persisted) {
- return;
+ _bytesToLabeledString(size) {
+ const base = 1000;
+ const labels = [' bytes', 'KB', 'MB', 'GB'];
+ let labelIndex = 0;
+ while (size >= base) {
+ size /= base;
+ ++labelIndex;
}
- let result = false;
+ const label = labelIndex === 0 ? `${size}` : size.toFixed(1);
+ return `${label}${labels[labelIndex]}`;
+ }
+
+ async _isStoragePeristent() {
try {
- result = await navigator.storage.persist();
+ return await navigator.storage.persisted();
} catch (e) {
// NOP
}
-
- if (result) {
- persisted = true;
- checkbox.checked = true;
- storageShowInfo();
- } else {
- $('.storage-persist-fail-warning').removeClass('storage-hidden');
- }
- }, false);
+ return false;
+ }
}
diff --git a/ext/bg/js/template-renderer.js b/ext/bg/js/template-renderer.js
new file mode 100644
index 00000000..f4b50c3d
--- /dev/null
+++ b/ext/bg/js/template-renderer.js
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 2016-2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * Handlebars
+ * jp
+ */
+
+class TemplateRenderer {
+ constructor() {
+ this._cache = new Map();
+ this._cacheMaxSize = 5;
+ this._helpersRegistered = false;
+ }
+
+ async render(template, data) {
+ if (!this._helpersRegistered) {
+ this._registerHelpers();
+ this._helpersRegistered = true;
+ }
+
+ const cache = this._cache;
+ let instance = cache.get(template);
+ if (typeof instance === 'undefined') {
+ this._updateCacheSize(this._cacheMaxSize - 1);
+ instance = Handlebars.compile(template);
+ cache.set(template, instance);
+ }
+
+ return instance(data).trim();
+ }
+
+ // Private
+
+ _updateCacheSize(maxSize) {
+ const cache = this._cache;
+ let removeCount = cache.size - maxSize;
+ if (removeCount <= 0) { return; }
+
+ for (const key of cache.keys()) {
+ cache.delete(key);
+ if (--removeCount <= 0) { break; }
+ }
+ }
+
+ _registerHelpers() {
+ Handlebars.partials = Handlebars.templates;
+
+ const helpers = [
+ ['dumpObject', this._dumpObject.bind(this)],
+ ['furigana', this._furigana.bind(this)],
+ ['furiganaPlain', this._furiganaPlain.bind(this)],
+ ['kanjiLinks', this._kanjiLinks.bind(this)],
+ ['multiLine', this._multiLine.bind(this)],
+ ['sanitizeCssClass', this._sanitizeCssClass.bind(this)],
+ ['regexReplace', this._regexReplace.bind(this)],
+ ['regexMatch', this._regexMatch.bind(this)],
+ ['mergeTags', this._mergeTags.bind(this)]
+ ];
+
+ for (const [name, helper] of helpers) {
+ Handlebars.registerHelper(name, helper);
+ }
+ }
+
+ _escape(text) {
+ return Handlebars.Utils.escapeExpression(text);
+ }
+
+ _dumpObject(options) {
+ const dump = JSON.stringify(options.fn(this), null, 4);
+ return this._escape(dump);
+ }
+
+ _furigana(options) {
+ const definition = options.fn(this);
+ const segs = jp.distributeFurigana(definition.expression, definition.reading);
+
+ let result = '';
+ for (const seg of segs) {
+ if (seg.furigana) {
+ result += `<ruby>${seg.text}<rt>${seg.furigana}</rt></ruby>`;
+ } else {
+ result += seg.text;
+ }
+ }
+
+ return result;
+ }
+
+ _furiganaPlain(options) {
+ const definition = options.fn(this);
+ const segs = jp.distributeFurigana(definition.expression, definition.reading);
+
+ let result = '';
+ for (const seg of segs) {
+ if (seg.furigana) {
+ result += ` ${seg.text}[${seg.furigana}]`;
+ } else {
+ result += seg.text;
+ }
+ }
+
+ return result.trimLeft();
+ }
+
+ _kanjiLinks(options) {
+ let result = '';
+ for (const c of options.fn(this)) {
+ if (jp.isCodePointKanji(c.codePointAt(0))) {
+ result += `<a href="#" class="kanji-link">${c}</a>`;
+ } else {
+ result += c;
+ }
+ }
+
+ return result;
+ }
+
+ _multiLine(options) {
+ return options.fn(this).split('\n').join('<br>');
+ }
+
+ _sanitizeCssClass(options) {
+ return options.fn(this).replace(/[^_a-z0-9\u00a0-\uffff]/ig, '_');
+ }
+
+ _regexReplace(...args) {
+ // Usage:
+ // {{#regexReplace regex string [flags]}}content{{/regexReplace}}
+ // regex: regular expression string
+ // string: string to replace
+ // flags: optional flags for regular expression
+ // e.g. "i" for case-insensitive, "g" for replace all
+ let value = args[args.length - 1].fn(this);
+ if (args.length >= 3) {
+ try {
+ const flags = args.length > 3 ? args[2] : 'g';
+ const regex = new RegExp(args[0], flags);
+ value = value.replace(regex, args[1]);
+ } catch (e) {
+ return `${e}`;
+ }
+ }
+ return value;
+ }
+
+ _regexMatch(...args) {
+ // Usage:
+ // {{#regexMatch regex [flags]}}content{{/regexMatch}}
+ // regex: regular expression string
+ // flags: optional flags for regular expression
+ // e.g. "i" for case-insensitive, "g" for match all
+ let value = args[args.length - 1].fn(this);
+ if (args.length >= 2) {
+ try {
+ const flags = args.length > 2 ? args[1] : '';
+ const regex = new RegExp(args[0], flags);
+ const parts = [];
+ value.replace(regex, (g0) => parts.push(g0));
+ value = parts.join('');
+ } catch (e) {
+ return `${e}`;
+ }
+ }
+ return value;
+ }
+
+ _mergeTags(object, isGroupMode, isMergeMode) {
+ const tagSources = [];
+ if (isGroupMode || isMergeMode) {
+ for (const definition of object.definitions) {
+ tagSources.push(definition.definitionTags);
+ }
+ } else {
+ tagSources.push(object.definitionTags);
+ }
+
+ const tags = new Set();
+ for (const tagSource of tagSources) {
+ for (const tag of tagSource) {
+ tags.add(tag.name);
+ }
+ }
+
+ return [...tags].join(', ');
+ }
+}
diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js
index 8f86e47a..edc19c6e 100644
--- a/ext/bg/js/util.js
+++ b/ext/bg/js/util.js
@@ -65,12 +65,3 @@ function utilBackend() {
}
return backend;
}
-
-function utilReadFileArrayBuffer(file) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result);
- reader.onerror = () => reject(reader.error);
- reader.readAsArrayBuffer(file);
- });
-}