summaryrefslogtreecommitdiff
path: root/ext/bg
diff options
context:
space:
mode:
Diffstat (limited to 'ext/bg')
-rw-r--r--ext/bg/background.html8
-rw-r--r--ext/bg/context.html3
-rw-r--r--ext/bg/data/default-anki-field-templates.handlebars10
-rw-r--r--ext/bg/data/dictionary-term-bank-v3-schema.json81
-rw-r--r--ext/bg/data/dictionary-term-meta-bank-v3-schema.json26
-rw-r--r--ext/bg/data/options-schema.json8
-rw-r--r--ext/bg/guide.html3
-rw-r--r--ext/bg/js/anki-note-builder.js82
-rw-r--r--ext/bg/js/anki.js148
-rw-r--r--ext/bg/js/audio-uri-builder.js26
-rw-r--r--ext/bg/js/backend-api-forwarder.js6
-rw-r--r--ext/bg/js/backend.js647
-rw-r--r--ext/bg/js/background-main.js25
-rw-r--r--ext/bg/js/conditions.js37
-rw-r--r--ext/bg/js/context-main.js (renamed from ext/bg/js/context.js)13
-rw-r--r--ext/bg/js/database.js47
-rw-r--r--ext/bg/js/dictionary-importer.js92
-rw-r--r--ext/bg/js/handlebars.js21
-rw-r--r--ext/bg/js/japanese.js426
-rw-r--r--ext/bg/js/mecab.js2
-rw-r--r--ext/bg/js/media-utility.js98
-rw-r--r--ext/bg/js/options.js55
-rw-r--r--ext/bg/js/profile-conditions.js200
-rw-r--r--ext/bg/js/search-main.js (renamed from ext/bg/js/search-frontend.js)54
-rw-r--r--ext/bg/js/search-query-parser.js174
-rw-r--r--ext/bg/js/search.js63
-rw-r--r--ext/bg/js/settings/anki.js10
-rw-r--r--ext/bg/js/settings/audio.js9
-rw-r--r--ext/bg/js/settings/backup.js2
-rw-r--r--ext/bg/js/settings/conditions-ui.js163
-rw-r--r--ext/bg/js/settings/dictionaries.js32
-rw-r--r--ext/bg/js/settings/main.js23
-rw-r--r--ext/bg/js/settings/popup-preview-frame-main.js26
-rw-r--r--ext/bg/js/settings/popup-preview-frame.js57
-rw-r--r--ext/bg/js/settings/popup-preview.js23
-rw-r--r--ext/bg/js/settings/profiles.js14
-rw-r--r--ext/bg/js/translator.js24
-rw-r--r--ext/bg/js/util.js69
-rw-r--r--ext/bg/legal.html1
-rw-r--r--ext/bg/search.html9
-rw-r--r--ext/bg/settings-popup-preview.html6
-rw-r--r--ext/bg/settings.html25
42 files changed, 1698 insertions, 1150 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html
index afe9c5d1..ca35a3c6 100644
--- a/ext/bg/background.html
+++ b/ext/bg/background.html
@@ -6,6 +6,7 @@
<title>Background</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -20,10 +21,12 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
+ <script src="/mixed/js/environment.js"></script>
<script src="/mixed/js/japanese.js"></script>
<script src="/bg/js/anki.js"></script>
<script src="/bg/js/anki-note-builder.js"></script>
+ <script src="/bg/js/backend.js"></script>
<script src="/bg/js/mecab.js"></script>
<script src="/bg/js/audio-uri-builder.js"></script>
<script src="/bg/js/backend-api-forwarder.js"></script>
@@ -34,8 +37,8 @@
<script src="/bg/js/deinflector.js"></script>
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
- <script src="/bg/js/japanese.js"></script>
<script src="/bg/js/json-schema.js"></script>
+ <script src="/bg/js/media-utility.js"></script>
<script src="/bg/js/options.js"></script>
<script src="/bg/js/profile-conditions.js"></script>
<script src="/bg/js/request.js"></script>
@@ -43,7 +46,8 @@
<script src="/bg/js/translator.js"></script>
<script src="/bg/js/util.js"></script>
<script src="/mixed/js/audio-system.js"></script>
+ <script src="/mixed/js/object-property-accessor.js"></script>
- <script src="/bg/js/backend.js"></script>
+ <script src="/bg/js/background-main.js"></script>
</body>
</html>
diff --git a/ext/bg/context.html b/ext/bg/context.html
index 0e50ed7c..93012d70 100644
--- a/ext/bg/context.html
+++ b/ext/bg/context.html
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -185,6 +186,6 @@
<script src="/bg/js/options.js"></script>
<script src="/bg/js/util.js"></script>
- <script src="/bg/js/context.js"></script>
+ <script src="/bg/js/context-main.js"></script>
</body>
</html>
diff --git a/ext/bg/data/default-anki-field-templates.handlebars b/ext/bg/data/default-anki-field-templates.handlebars
index 6061851f..42deae23 100644
--- a/ext/bg/data/default-anki-field-templates.handlebars
+++ b/ext/bg/data/default-anki-field-templates.handlebars
@@ -14,7 +14,11 @@
{{~/if~}}
{{/inline}}
-{{#*inline "audio"}}{{/inline}}
+{{#*inline "audio"}}
+ {{~#if definition.audioFileName~}}
+ [sound:{{definition.audioFileName}}]
+ {{~/if~}}
+{{/inline}}
{{#*inline "character"}}
{{~definition.character~}}
@@ -147,7 +151,7 @@
{{/inline}}
{{#*inline "tags"}}
- {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}}
+ {{~#mergeTags definition group merge}}{{this}}{{/mergeTags~}}
{{/inline}}
{{#*inline "url"}}
@@ -162,4 +166,4 @@
{{~context.document.title~}}
{{/inline}}
-{{~> (lookup . "marker") ~}} \ No newline at end of file
+{{~> (lookup . "marker") ~}}
diff --git a/ext/bg/data/dictionary-term-bank-v3-schema.json b/ext/bg/data/dictionary-term-bank-v3-schema.json
index bb982e36..4790e561 100644
--- a/ext/bg/data/dictionary-term-bank-v3-schema.json
+++ b/ext/bg/data/dictionary-term-bank-v3-schema.json
@@ -31,8 +31,85 @@
"type": "array",
"description": "Array of definitions for the term/expression.",
"items": {
- "type": "string",
- "description": "Single definition for the term/expression."
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Single definition for the term/expression."
+ },
+ {
+ "type": "object",
+ "description": "Single detailed definition for the term/expression.",
+ "required": [
+ "type"
+ ],
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "The type of the data for this definition.",
+ "enum": ["text", "image"]
+ }
+ },
+ "oneOf": [
+ {
+ "required": [
+ "type",
+ "text"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["text"]
+ },
+ "text": {
+ "type": "string",
+ "description": "Single definition for the term/expression."
+ }
+ }
+ },
+ {
+ "required": [
+ "type",
+ "path"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["image"]
+ },
+ "path": {
+ "type": "string",
+ "description": "Path to the image file in the archive."
+ },
+ "width": {
+ "type": "integer",
+ "description": "Preferred width of the image.",
+ "minimum": 1
+ },
+ "height": {
+ "type": "integer",
+ "description": "Preferred width of the image.",
+ "minimum": 1
+ },
+ "title": {
+ "type": "string",
+ "description": "Hover text for the image."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description of the image."
+ },
+ "pixelated": {
+ "type": "boolean",
+ "description": "Whether or not the image should appear pixelated at sizes larger than the image's native resolution.",
+ "default": false
+ }
+ }
+ }
+ ]
+ }
+ ]
}
},
{
diff --git a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
index 8475db81..ffffb546 100644
--- a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
+++ b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
@@ -26,8 +26,30 @@
{},
{"enum": ["freq"]},
{
- "type": ["string", "number"],
- "description": "Frequency information for the term or expression."
+ "oneOf": [
+ {
+ "type": ["string", "number"],
+ "description": "Frequency information for the term or expression."
+ },
+ {
+ "type": ["object"],
+ "required": [
+ "reading",
+ "frequency"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "reading": {
+ "type": "string",
+ "description": "Reading for the term or expression."
+ },
+ "frequency": {
+ "type": ["string", "number"],
+ "description": "Frequency information for the term or expression."
+ }
+ }
+ }
+ ]
}
]
},
diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json
index 4f9e694d..656da989 100644
--- a/ext/bg/data/options-schema.json
+++ b/ext/bg/data/options-schema.json
@@ -351,7 +351,7 @@
},
"modifier": {
"type": "string",
- "enum": ["none", "alt", "ctrl", "shift"],
+ "enum": ["none", "alt", "ctrl", "shift", "meta"],
"default": "shift"
},
"deepDomScan": {
@@ -492,6 +492,7 @@
"screenshot",
"terms",
"kanji",
+ "duplicateScope",
"fieldTemplates"
],
"properties": {
@@ -587,6 +588,11 @@
}
}
},
+ "duplicateScope": {
+ "type": "string",
+ "default": "collection",
+ "enum": ["collection", "deck"]
+ },
"fieldTemplates": {
"type": ["string", "null"],
"default": null
diff --git a/ext/bg/guide.html b/ext/bg/guide.html
index ff9c71ee..cde520d1 100644
--- a/ext/bg/guide.html
+++ b/ext/bg/guide.html
@@ -6,6 +6,7 @@
<title>Welcome to Yomichan!</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -25,7 +26,7 @@
</p>
<ol>
- <li>Click on the <img src="/mixed/img/icon16.png" alt> icon in the browser toolbar to open the Yomichan actions dialog.</li>
+ <li>Click on the <img src="/mixed/img/yomichan-icon.svg" alt> icon in the browser toolbar to open the Yomichan actions dialog.</li>
<li>Click on the <em>monkey wrench</em> icon in the middle to open the options page.</li>
<li>Import the dictionaries you wish to use for term and Kanji searches.</li>
<li>Hold down <kbd>Shift</kbd> key or the middle mouse button as you move your mouse over text to display definitions.</li>
diff --git a/ext/bg/js/anki-note-builder.js b/ext/bg/js/anki-note-builder.js
index 8a707006..76199db7 100644
--- a/ext/bg/js/anki-note-builder.js
+++ b/ext/bg/js/anki-note-builder.js
@@ -16,7 +16,8 @@
*/
class AnkiNoteBuilder {
- constructor({audioSystem, renderTemplate}) {
+ constructor({anki, audioSystem, renderTemplate}) {
+ this._anki = anki;
this._audioSystem = audioSystem;
this._renderTemplate = renderTemplate;
}
@@ -31,32 +32,16 @@ class AnkiNoteBuilder {
fields: {},
tags,
deckName: modeOptions.deck,
- modelName: modeOptions.model
+ modelName: modeOptions.model,
+ options: {
+ duplicateScope: options.anki.duplicateScope
+ }
};
for (const [fieldName, fieldValue] of modeOptionsFieldEntries) {
note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, context, options, templates, null);
}
- if (!isKanji && definition.audio) {
- const audioFields = [];
-
- for (const [fieldName, fieldValue] of modeOptionsFieldEntries) {
- if (fieldValue.includes('{audio}')) {
- audioFields.push(fieldName);
- }
- }
-
- if (audioFields.length > 0) {
- note.audio = {
- url: definition.audio.url,
- filename: definition.audio.filename,
- skipHash: '7e2c2f954ef6051373ba916f000168dc', // hash of audio data that should be skipped
- fields: audioFields
- };
- }
- }
-
return note;
}
@@ -84,48 +69,64 @@ class AnkiNoteBuilder {
});
}
- async injectAudio(definition, fields, sources, optionsContext) {
+ async injectAudio(definition, fields, sources, customSourceUrl) {
if (!this._containsMarker(fields, 'audio')) { return; }
try {
const expressions = definition.expressions;
const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition;
- const {uri} = await this._audioSystem.getDefinitionAudio(audioSourceDefinition, sources, {tts: false, optionsContext});
- const filename = this._createInjectedAudioFileName(audioSourceDefinition);
- if (filename !== null) {
- definition.audio = {url: uri, filename};
- }
+ let fileName = this._createInjectedAudioFileName(audioSourceDefinition);
+ if (fileName === null) { return; }
+ fileName = AnkiNoteBuilder.replaceInvalidFileNameCharacters(fileName);
+
+ const {audio} = await this._audioSystem.getDefinitionAudio(
+ audioSourceDefinition,
+ sources,
+ {
+ textToSpeechVoice: null,
+ customSourceUrl,
+ binary: true,
+ disableCache: true
+ }
+ );
+
+ const data = AnkiNoteBuilder.arrayBufferToBase64(audio);
+ await this._anki.storeMediaFile(fileName, data);
+
+ definition.audioFileName = fileName;
} catch (e) {
// NOP
}
}
- async injectScreenshot(definition, fields, screenshot, anki) {
+ async injectScreenshot(definition, fields, screenshot) {
if (!this._containsMarker(fields, 'screenshot')) { return; }
const now = new Date(Date.now());
- const filename = `yomichan_browser_screenshot_${definition.reading}_${this._dateToString(now)}.${screenshot.format}`;
+ let fileName = `yomichan_browser_screenshot_${definition.reading}_${this._dateToString(now)}.${screenshot.format}`;
+ fileName = AnkiNoteBuilder.replaceInvalidFileNameCharacters(fileName);
const data = screenshot.dataUrl.replace(/^data:[\w\W]*?,/, '');
try {
- await anki.storeMediaFile(filename, data);
+ await this._anki.storeMediaFile(fileName, data);
} catch (e) {
return;
}
- definition.screenshotFileName = filename;
+ definition.screenshotFileName = fileName;
}
_createInjectedAudioFileName(definition) {
const {reading, expression} = definition;
if (!reading && !expression) { return null; }
- let filename = 'yomichan';
- if (reading) { filename += `_${reading}`; }
- if (expression) { filename += `_${expression}`; }
- filename += '.mp3';
- return filename;
+ let fileName = 'yomichan';
+ if (reading) { fileName += `_${reading}`; }
+ if (expression) { fileName += `_${expression}`; }
+ fileName += '.mp3';
+ fileName = fileName.replace(/\]/g, '');
+ return fileName;
}
_dateToString(date) {
@@ -148,6 +149,15 @@ class AnkiNoteBuilder {
return false;
}
+ static replaceInvalidFileNameCharacters(fileName) {
+ // eslint-disable-next-line no-control-regex
+ return fileName.replace(/[<>:"/\\|?*\x00-\x1F]/g, '-');
+ }
+
+ static arrayBufferToBase64(arrayBuffer) {
+ return window.btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
+ }
+
static stringReplaceAsync(str, regex, replacer) {
let match;
let index = 0;
diff --git a/ext/bg/js/anki.js b/ext/bg/js/anki.js
index c7f7c0cc..55953007 100644
--- a/ext/bg/js/anki.js
+++ b/ext/bg/js/anki.js
@@ -19,122 +19,118 @@
* requestJson
*/
-/*
- * AnkiConnect
- */
-
class AnkiConnect {
constructor(server) {
- this.server = server;
- this.localVersion = 2;
- this.remoteVersion = 0;
+ this._enabled = false;
+ this._server = server;
+ this._localVersion = 2;
+ this._remoteVersion = 0;
+ }
+
+ setServer(server) {
+ this._server = server;
+ }
+
+ getServer() {
+ return this._server;
+ }
+
+ setEnabled(enabled) {
+ this._enabled = enabled;
+ }
+
+ isEnabled() {
+ return this._enabled;
}
async addNote(note) {
- await this.checkVersion();
- return await this.ankiInvoke('addNote', {note});
+ if (!this._enabled) { return null; }
+ await this._checkVersion();
+ return await this._invoke('addNote', {note});
}
async canAddNotes(notes) {
- await this.checkVersion();
- return await this.ankiInvoke('canAddNotes', {notes});
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ return await this._invoke('canAddNotes', {notes});
}
async getDeckNames() {
- await this.checkVersion();
- return await this.ankiInvoke('deckNames');
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ return await this._invoke('deckNames');
}
async getModelNames() {
- await this.checkVersion();
- return await this.ankiInvoke('modelNames');
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ return await this._invoke('modelNames');
}
async getModelFieldNames(modelName) {
- await this.checkVersion();
- return await this.ankiInvoke('modelFieldNames', {modelName});
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ return await this._invoke('modelFieldNames', {modelName});
}
async guiBrowse(query) {
- await this.checkVersion();
- return await this.ankiInvoke('guiBrowse', {query});
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ return await this._invoke('guiBrowse', {query});
}
- async storeMediaFile(filename, dataBase64) {
- await this.checkVersion();
- return await this.ankiInvoke('storeMediaFile', {filename, data: dataBase64});
+ async storeMediaFile(fileName, dataBase64) {
+ if (!this._enabled) {
+ throw new Error('AnkiConnect not enabled');
+ }
+ await this._checkVersion();
+ return await this._invoke('storeMediaFile', {filename: fileName, data: dataBase64});
}
- async checkVersion() {
- if (this.remoteVersion < this.localVersion) {
- this.remoteVersion = await this.ankiInvoke('version');
- if (this.remoteVersion < this.localVersion) {
+ async findNoteIds(notes, duplicateScope) {
+ if (!this._enabled) { return []; }
+ await this._checkVersion();
+ const actions = notes.map((note) => {
+ let query = (duplicateScope === 'deck' ? `"deck:${this._escapeQuery(note.deckName)}" ` : '');
+ query += this._fieldsToQuery(note.fields);
+ return {action: 'findNotes', params: {query}};
+ });
+ return await this._invoke('multi', {actions});
+ }
+
+ // Private
+
+ async _checkVersion() {
+ if (this._remoteVersion < this._localVersion) {
+ this._remoteVersion = await this._invoke('version');
+ if (this._remoteVersion < this._localVersion) {
throw new Error('Extension and plugin versions incompatible');
}
}
}
- async findNoteIds(notes) {
- await this.checkVersion();
- const actions = notes.map((note) => ({
- action: 'findNotes',
- params: {
- query: `deck:"${AnkiConnect.escapeQuery(note.deckName)}" ${AnkiConnect.fieldsToQuery(note.fields)}`
+ async _invoke(action, params) {
+ const result = await requestJson(this._server, 'POST', {action, params, version: this._localVersion});
+ if (isObject(result)) {
+ const error = result.error;
+ if (typeof error !== 'undefined') {
+ throw new Error(`AnkiConnect error: ${error}`);
}
- }));
- return await this.ankiInvoke('multi', {actions});
- }
-
- ankiInvoke(action, params) {
- return requestJson(this.server, 'POST', {action, params, version: this.localVersion});
+ }
+ return result;
}
- static escapeQuery(text) {
+ _escapeQuery(text) {
return text.replace(/"/g, '');
}
- static fieldsToQuery(fields) {
+ _fieldsToQuery(fields) {
const fieldNames = Object.keys(fields);
if (fieldNames.length === 0) {
return '';
}
const key = fieldNames[0];
- return `${key.toLowerCase()}:"${AnkiConnect.escapeQuery(fields[key])}"`;
- }
-}
-
-
-/*
- * AnkiNull
- */
-
-class AnkiNull {
- async addNote() {
- return null;
- }
-
- async canAddNotes() {
- return [];
- }
-
- async getDeckNames() {
- return [];
- }
-
- async getModelNames() {
- return [];
- }
-
- async getModelFieldNames() {
- return [];
- }
-
- async guiBrowse() {
- return [];
- }
-
- async findNoteIds() {
- return [];
+ return `"${key.toLowerCase()}:${this._escapeQuery(fields[key])}"`;
}
}
diff --git a/ext/bg/js/audio-uri-builder.js b/ext/bg/js/audio-uri-builder.js
index dfd195d8..27e97680 100644
--- a/ext/bg/js/audio-uri-builder.js
+++ b/ext/bg/js/audio-uri-builder.js
@@ -49,11 +49,11 @@ class AudioUriBuilder {
return url;
}
- async getUri(definition, source, options) {
+ async getUri(definition, source, details) {
const handler = this._getUrlHandlers.get(source);
if (typeof handler === 'function') {
try {
- return await handler(definition, options);
+ return await handler(definition, details);
} catch (e) {
// NOP
}
@@ -132,26 +132,24 @@ class AudioUriBuilder {
throw new Error('Failed to find audio URL');
}
- async _getUriTextToSpeech(definition, options) {
- const voiceURI = options.audio.textToSpeechVoice;
- if (!voiceURI) {
+ async _getUriTextToSpeech(definition, {textToSpeechVoice}) {
+ if (!textToSpeechVoice) {
throw new Error('No voice');
}
-
- return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(voiceURI)}`;
+ return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(textToSpeechVoice)}`;
}
- async _getUriTextToSpeechReading(definition, options) {
- const voiceURI = options.audio.textToSpeechVoice;
- if (!voiceURI) {
+ async _getUriTextToSpeechReading(definition, {textToSpeechVoice}) {
+ if (!textToSpeechVoice) {
throw new Error('No voice');
}
-
- return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(voiceURI)}`;
+ return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(textToSpeechVoice)}`;
}
- async _getUriCustom(definition, options) {
- const customSourceUrl = options.audio.customSourceUrl;
+ async _getUriCustom(definition, {customSourceUrl}) {
+ if (typeof customSourceUrl !== 'string') {
+ throw new Error('No custom URL defined');
+ }
return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(definition, m1) ? `${definition[m1]}` : m0));
}
}
diff --git a/ext/bg/js/backend-api-forwarder.js b/ext/bg/js/backend-api-forwarder.js
index 93db77d7..4ac12730 100644
--- a/ext/bg/js/backend-api-forwarder.js
+++ b/ext/bg/js/backend-api-forwarder.js
@@ -17,11 +17,11 @@
class BackendApiForwarder {
- constructor() {
- chrome.runtime.onConnect.addListener(this.onConnect.bind(this));
+ prepare() {
+ chrome.runtime.onConnect.addListener(this._onConnect.bind(this));
}
- onConnect(port) {
+ _onConnect(port) {
if (port.name !== 'backend-api-forwarder') { return; }
let tabId;
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js
index 2265c1a9..20d31efc 100644
--- a/ext/bg/js/backend.js
+++ b/ext/bg/js/backend.js
@@ -18,24 +18,25 @@
/* global
* AnkiConnect
* AnkiNoteBuilder
- * AnkiNull
* AudioSystem
* AudioUriBuilder
* BackendApiForwarder
* ClipboardMonitor
* Database
* DictionaryImporter
+ * Environment
* JsonSchema
* Mecab
+ * ObjectPropertyAccessor
* Translator
* conditionsTestValue
- * dictConfigured
* dictTermsSort
* handlebarsRenderDynamic
* jp
* optionsLoad
* optionsSave
* profileConditionsDescriptor
+ * profileConditionsDescriptorPromise
* requestJson
* requestText
* utilIsolate
@@ -43,18 +44,23 @@
class Backend {
constructor() {
+ this.environment = new Environment();
this.database = new Database();
this.dictionaryImporter = new DictionaryImporter();
this.translator = new Translator(this.database);
- this.anki = new AnkiNull();
+ this.anki = new AnkiConnect();
this.mecab = new Mecab();
this.clipboardMonitor = new ClipboardMonitor({getClipboard: this._onApiClipboardGet.bind(this)});
this.options = null;
this.optionsSchema = null;
this.defaultAnkiFieldTemplates = null;
- this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)});
this.audioUriBuilder = new AudioUriBuilder();
+ this.audioSystem = new AudioSystem({
+ audioUriBuilder: this.audioUriBuilder,
+ useCache: false
+ });
this.ankiNoteBuilder = new AnkiNoteBuilder({
+ anki: this.anki,
audioSystem: this.audioSystem,
renderTemplate: this._renderTemplate.bind(this)
});
@@ -64,89 +70,128 @@ class Backend {
url: window.location.href
};
- this.isPrepared = false;
-
this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target');
this.popupWindow = null;
- this.apiForwarder = new BackendApiForwarder();
+ const apiForwarder = new BackendApiForwarder();
+ apiForwarder.prepare();
- this.messageToken = yomichan.generateId(16);
+ this._defaultBrowserActionTitle = null;
+ this._isPrepared = false;
+ this._prepareError = false;
+ this._badgePrepareDelayTimer = null;
+ this._logErrorLevel = null;
this._messageHandlers = new Map([
- ['yomichanCoreReady', {handler: this._onApiYomichanCoreReady.bind(this), async: false}],
- ['optionsSchemaGet', {handler: this._onApiOptionsSchemaGet.bind(this), async: false}],
- ['optionsGet', {handler: this._onApiOptionsGet.bind(this), async: false}],
- ['optionsGetFull', {handler: this._onApiOptionsGetFull.bind(this), async: false}],
- ['optionsSet', {handler: this._onApiOptionsSet.bind(this), async: true}],
- ['optionsSave', {handler: this._onApiOptionsSave.bind(this), async: true}],
- ['kanjiFind', {handler: this._onApiKanjiFind.bind(this), async: true}],
- ['termsFind', {handler: this._onApiTermsFind.bind(this), async: true}],
- ['textParse', {handler: this._onApiTextParse.bind(this), async: true}],
- ['definitionAdd', {handler: this._onApiDefinitionAdd.bind(this), async: true}],
- ['definitionsAddable', {handler: this._onApiDefinitionsAddable.bind(this), async: true}],
- ['noteView', {handler: this._onApiNoteView.bind(this), async: true}],
- ['templateRender', {handler: this._onApiTemplateRender.bind(this), async: true}],
- ['commandExec', {handler: this._onApiCommandExec.bind(this), async: false}],
- ['audioGetUri', {handler: this._onApiAudioGetUri.bind(this), async: true}],
- ['screenshotGet', {handler: this._onApiScreenshotGet.bind(this), async: true}],
- ['broadcastTab', {handler: this._onApiBroadcastTab.bind(this), async: false}],
- ['frameInformationGet', {handler: this._onApiFrameInformationGet.bind(this), async: true}],
- ['injectStylesheet', {handler: this._onApiInjectStylesheet.bind(this), async: true}],
- ['getEnvironmentInfo', {handler: this._onApiGetEnvironmentInfo.bind(this), async: true}],
- ['clipboardGet', {handler: this._onApiClipboardGet.bind(this), async: true}],
- ['getDisplayTemplatesHtml', {handler: this._onApiGetDisplayTemplatesHtml.bind(this), async: true}],
- ['getQueryParserTemplatesHtml', {handler: this._onApiGetQueryParserTemplatesHtml.bind(this), async: true}],
- ['getZoom', {handler: this._onApiGetZoom.bind(this), async: true}],
- ['getMessageToken', {handler: this._onApiGetMessageToken.bind(this), async: false}],
- ['getDefaultAnkiFieldTemplates', {handler: this._onApiGetDefaultAnkiFieldTemplates.bind(this), async: false}]
+ ['yomichanCoreReady', {async: false, contentScript: true, handler: this._onApiYomichanCoreReady.bind(this)}],
+ ['optionsSchemaGet', {async: false, contentScript: true, handler: this._onApiOptionsSchemaGet.bind(this)}],
+ ['optionsGet', {async: false, contentScript: true, handler: this._onApiOptionsGet.bind(this)}],
+ ['optionsGetFull', {async: false, contentScript: true, handler: this._onApiOptionsGetFull.bind(this)}],
+ ['optionsSave', {async: true, contentScript: true, handler: this._onApiOptionsSave.bind(this)}],
+ ['kanjiFind', {async: true, contentScript: true, handler: this._onApiKanjiFind.bind(this)}],
+ ['termsFind', {async: true, contentScript: true, handler: this._onApiTermsFind.bind(this)}],
+ ['textParse', {async: true, contentScript: true, handler: this._onApiTextParse.bind(this)}],
+ ['definitionAdd', {async: true, contentScript: true, handler: this._onApiDefinitionAdd.bind(this)}],
+ ['definitionsAddable', {async: true, contentScript: true, handler: this._onApiDefinitionsAddable.bind(this)}],
+ ['noteView', {async: true, contentScript: true, handler: this._onApiNoteView.bind(this)}],
+ ['templateRender', {async: true, contentScript: true, handler: this._onApiTemplateRender.bind(this)}],
+ ['commandExec', {async: false, contentScript: true, handler: this._onApiCommandExec.bind(this)}],
+ ['audioGetUri', {async: true, contentScript: true, handler: this._onApiAudioGetUri.bind(this)}],
+ ['screenshotGet', {async: true, contentScript: true, handler: this._onApiScreenshotGet.bind(this)}],
+ ['sendMessageToFrame', {async: false, contentScript: true, handler: this._onApiSendMessageToFrame.bind(this)}],
+ ['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)}],
+ ['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)}],
+ ['getQueryParserTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetQueryParserTemplatesHtml.bind(this)}],
+ ['getZoom', {async: true, contentScript: true, handler: this._onApiGetZoom.bind(this)}],
+ ['getDefaultAnkiFieldTemplates', {async: false, contentScript: true, handler: this._onApiGetDefaultAnkiFieldTemplates.bind(this)}],
+ ['getAnkiDeckNames', {async: true, contentScript: false, handler: this._onApiGetAnkiDeckNames.bind(this)}],
+ ['getAnkiModelNames', {async: true, contentScript: false, handler: this._onApiGetAnkiModelNames.bind(this)}],
+ ['getAnkiModelFieldNames', {async: true, contentScript: false, handler: this._onApiGetAnkiModelFieldNames.bind(this)}],
+ ['getDictionaryInfo', {async: true, contentScript: false, handler: this._onApiGetDictionaryInfo.bind(this)}],
+ ['getDictionaryCounts', {async: true, contentScript: false, handler: this._onApiGetDictionaryCounts.bind(this)}],
+ ['purgeDatabase', {async: true, contentScript: false, handler: this._onApiPurgeDatabase.bind(this)}],
+ ['getMedia', {async: true, contentScript: true, handler: this._onApiGetMedia.bind(this)}],
+ ['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)}]
+ ]);
+ this._messageHandlersWithProgress = new Map([
+ ['importDictionaryArchive', {async: true, contentScript: false, handler: this._onApiImportDictionaryArchive.bind(this)}],
+ ['deleteDictionary', {async: true, contentScript: false, handler: this._onApiDeleteDictionary.bind(this)}]
]);
this._commandHandlers = new Map([
- ['search', this._onCommandSearch.bind(this)],
- ['help', this._onCommandHelp.bind(this)],
+ ['search', this._onCommandSearch.bind(this)],
+ ['help', this._onCommandHelp.bind(this)],
['options', this._onCommandOptions.bind(this)],
- ['toggle', this._onCommandToggle.bind(this)]
+ ['toggle', this._onCommandToggle.bind(this)]
]);
}
async prepare() {
- await this.database.prepare();
- await this.translator.prepare();
-
- this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET');
- this.defaultAnkiFieldTemplates = await requestText(chrome.runtime.getURL('/bg/data/default-anki-field-templates.handlebars'), 'GET');
- this.options = await optionsLoad();
try {
+ this._defaultBrowserActionTitle = await this._getBrowserIconTitle();
+ this._badgePrepareDelayTimer = setTimeout(() => {
+ this._badgePrepareDelayTimer = null;
+ this._updateBadge();
+ }, 1000);
+ this._updateBadge();
+
+ await this.environment.prepare();
+ await this.database.prepare();
+ await this.translator.prepare();
+
+ await profileConditionsDescriptorPromise;
+
+ this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET');
+ this.defaultAnkiFieldTemplates = (await requestText(chrome.runtime.getURL('/bg/data/default-anki-field-templates.handlebars'), 'GET')).trim();
+ this.options = await optionsLoad();
this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, this.options);
- } catch (e) {
- // This shouldn't happen, but catch errors just in case of bugs
- logError(e);
- }
- this.onOptionsUpdated('background');
+ 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));
+ 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')});
+ }
- this.isPrepared = true;
+ this.clipboardMonitor.on('change', this._onClipboardText.bind(this));
- const options = this.getOptions(this.optionsContext);
- if (options.general.showGuide) {
- chrome.tabs.create({url: chrome.runtime.getURL('/bg/guide.html')});
- }
+ this._sendMessageAllTabs('backendPrepared');
+ const callback = () => this.checkLastError(chrome.runtime.lastError);
+ chrome.runtime.sendMessage({action: 'backendPrepared'}, callback);
- this.clipboardMonitor.on('change', this._onClipboardText.bind(this));
+ this._isPrepared = true;
+ } catch (e) {
+ this._prepareError = true;
+ yomichan.logError(e);
+ throw e;
+ } finally {
+ if (this._badgePrepareDelayTimer !== null) {
+ clearTimeout(this._badgePrepareDelayTimer);
+ this._badgePrepareDelayTimer = null;
+ }
- this._sendMessageAllTabs('backendPrepared');
- const callback = () => this.checkLastError(chrome.runtime.lastError);
- chrome.runtime.sendMessage({action: 'backendPrepared'}, callback);
+ this._updateBadge();
+ }
+ }
+
+ isPrepared() {
+ return this._isPrepared;
}
_sendMessageAllTabs(action, params={}) {
@@ -167,9 +212,13 @@ class Backend {
const messageHandler = this._messageHandlers.get(action);
if (typeof messageHandler === 'undefined') { return false; }
- const {handler, async} = messageHandler;
+ const {handler, async, contentScript} = messageHandler;
try {
+ if (!contentScript) {
+ this._validatePrivilegedMessageSender(sender);
+ }
+
const promiseOrResult = handler(params, sender);
if (async) {
promiseOrResult.then(
@@ -198,17 +247,10 @@ class Backend {
applyOptions() {
const options = this.getOptions(this.optionsContext);
- if (!options.general.enable) {
- this.setExtensionBadgeBackgroundColor('#555555');
- this.setExtensionBadgeText('off');
- } else if (!dictConfigured(options)) {
- this.setExtensionBadgeBackgroundColor('#f0ad4e');
- this.setExtensionBadgeText('!');
- } else {
- this.setExtensionBadgeText('');
- }
+ this._updateBadge();
- this.anki = options.anki.enable ? new AnkiConnect(options.anki.server) : new AnkiNull();
+ this.anki.setServer(options.anki.server);
+ this.anki.setEnabled(options.anki.enable);
if (options.parsing.enableMecabParser) {
this.mecab.startListener();
@@ -227,8 +269,9 @@ class Backend {
return this.optionsSchema;
}
- getFullOptions() {
- return this.options;
+ getFullOptions(useSchema=false) {
+ const options = this.options;
+ return useSchema ? JsonSchema.createProxy(options, this.optionsSchema) : options;
}
setFullOptions(options) {
@@ -236,25 +279,26 @@ class Backend {
this.options = JsonSchema.getValidValueOrDefault(this.optionsSchema, utilIsolate(options));
} catch (e) {
// This shouldn't happen, but catch errors just in case of bugs
- logError(e);
+ yomichan.logError(e);
}
}
- getOptions(optionsContext) {
- return this.getProfile(optionsContext).options;
+ getOptions(optionsContext, useSchema=false) {
+ return this.getProfile(optionsContext, useSchema).options;
}
- getProfile(optionsContext) {
- const profiles = this.options.profiles;
+ getProfile(optionsContext, useSchema=false) {
+ const options = this.getFullOptions(useSchema);
+ const profiles = options.profiles;
if (typeof optionsContext.index === 'number') {
return profiles[optionsContext.index];
}
- const profile = this.getProfileFromContext(optionsContext);
- return profile !== null ? profile : this.options.profiles[this.options.profileCurrent];
+ const profile = this.getProfileFromContext(options, optionsContext);
+ return profile !== null ? profile : options.profiles[options.profileCurrent];
}
- getProfileFromContext(optionsContext) {
- for (const profile of this.options.profiles) {
+ getProfileFromContext(options, optionsContext) {
+ for (const profile of options.profiles) {
const conditionGroups = profile.conditionGroups;
if (conditionGroups.length > 0 && Backend.testConditionGroups(conditionGroups, optionsContext)) {
return profile;
@@ -285,18 +329,6 @@ class Backend {
return true;
}
- setExtensionBadgeBackgroundColor(color) {
- if (typeof chrome.browserAction.setBadgeBackgroundColor === 'function') {
- chrome.browserAction.setBadgeBackgroundColor({color});
- }
- }
-
- setExtensionBadgeText(text) {
- if (typeof chrome.browserAction.setBadgeText === 'function') {
- chrome.browserAction.setBadgeText({text});
- }
- }
-
checkLastError() {
// NOP
}
@@ -394,46 +426,6 @@ class Backend {
return this.getFullOptions();
}
- async _onApiOptionsSet({changedOptions, optionsContext, source}) {
- const options = this.getOptions(optionsContext);
-
- function getValuePaths(obj) {
- const valuePaths = [];
- const nodes = [{obj, path: []}];
- while (nodes.length > 0) {
- const node = nodes.pop();
- for (const key of Object.keys(node.obj)) {
- const path = node.path.concat(key);
- const obj2 = node.obj[key];
- if (obj2 !== null && typeof obj2 === 'object') {
- nodes.unshift({obj: obj2, path});
- } else {
- valuePaths.push([obj2, path]);
- }
- }
- }
- return valuePaths;
- }
-
- function modifyOption(path, value) {
- let pivot = options;
- for (const key of path.slice(0, -1)) {
- if (!hasOwn(pivot, key)) {
- return false;
- }
- pivot = pivot[key];
- }
- pivot[path[path.length - 1]] = value;
- return true;
- }
-
- for (const [value, path] of getValuePaths(changedOptions)) {
- modifyOption(path, value);
- }
-
- await this._onApiOptionsSave({source});
- }
-
async _onApiOptionsSave({source}) {
const options = this.getFullOptions();
await optionsSave(options);
@@ -484,14 +476,15 @@ class Backend {
async _onApiDefinitionAdd({definition, mode, context, details, optionsContext}) {
const options = this.getOptions(optionsContext);
- const templates = this.defaultAnkiFieldTemplates;
+ const templates = this._getTemplates(options);
if (mode !== 'kanji') {
+ const {customSourceUrl} = options.audio;
await this.ankiNoteBuilder.injectAudio(
definition,
options.anki.terms.fields,
options.audio.sources,
- optionsContext
+ customSourceUrl
);
}
@@ -499,8 +492,7 @@ class Backend {
await this.ankiNoteBuilder.injectScreenshot(
definition,
options.anki.terms.fields,
- details.screenshot,
- this.anki
+ details.screenshot
);
}
@@ -510,7 +502,7 @@ class Backend {
async _onApiDefinitionsAddable({definitions, modes, context, optionsContext}) {
const options = this.getOptions(optionsContext);
- const templates = this.defaultAnkiFieldTemplates;
+ const templates = this._getTemplates(options);
const states = [];
try {
@@ -540,7 +532,7 @@ class Backend {
}
if (cannotAdd.length > 0) {
- const noteIdsArray = await this.anki.findNoteIds(cannotAdd.map((e) => e[0]));
+ const noteIdsArray = await this.anki.findNoteIds(cannotAdd.map((e) => e[0]), options.anki.duplicateScope);
for (let i = 0, ii = Math.min(cannotAdd.length, noteIdsArray.length); i < ii; ++i) {
const noteIds = noteIdsArray[i];
if (noteIds.length > 0) {
@@ -567,9 +559,8 @@ class Backend {
return this._runCommand(command, params);
}
- async _onApiAudioGetUri({definition, source, optionsContext}) {
- const options = this.getOptions(optionsContext);
- return await this.audioUriBuilder.getUri(definition, source, options);
+ async _onApiAudioGetUri({definition, source, details}) {
+ return await this.audioUriBuilder.getUri(definition, source, details);
}
_onApiScreenshotGet({options}, sender) {
@@ -583,6 +574,17 @@ class Backend {
});
}
+ _onApiSendMessageToFrame({frameId, action, params}, sender) {
+ if (!(sender && sender.tab)) {
+ return false;
+ }
+
+ const tabId = sender.tab.id;
+ const callback = () => this.checkLastError(chrome.runtime.lastError);
+ chrome.tabs.sendMessage(tabId, {action, params}, {frameId}, callback);
+ return true;
+ }
+
_onApiBroadcastTab({action, params}, sender) {
if (!(sender && sender.tab)) {
return false;
@@ -639,15 +641,8 @@ class Backend {
});
}
- async _onApiGetEnvironmentInfo() {
- const browser = await Backend._getBrowser();
- const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve));
- return {
- browser,
- platform: {
- os: platform.os
- }
- };
+ _onApiGetEnvironmentInfo() {
+ return this.environment.getInfo();
}
async _onApiClipboardGet() {
@@ -663,7 +658,7 @@ class Backend {
being an extension with clipboard permissions. It effectively asks for the
non-extension permission for clipboard access.
*/
- const browser = await Backend._getBrowser();
+ const {browser} = this.environment.getInfo();
if (browser === 'firefox' || browser === 'firefox-mobile') {
return await navigator.clipboard.readText();
} else {
@@ -714,16 +709,165 @@ class Backend {
});
}
- _onApiGetMessageToken() {
- return this.messageToken;
- }
-
_onApiGetDefaultAnkiFieldTemplates() {
return this.defaultAnkiFieldTemplates;
}
+ async _onApiGetAnkiDeckNames() {
+ return await this.anki.getDeckNames();
+ }
+
+ async _onApiGetAnkiModelNames() {
+ return await this.anki.getModelNames();
+ }
+
+ async _onApiGetAnkiModelFieldNames({modelName}) {
+ return await this.anki.getModelFieldNames(modelName);
+ }
+
+ async _onApiGetDictionaryInfo() {
+ return await this.translator.database.getDictionaryInfo();
+ }
+
+ async _onApiGetDictionaryCounts({dictionaryNames, getTotal}) {
+ return await this.translator.database.getDictionaryCounts(dictionaryNames, getTotal);
+ }
+
+ async _onApiPurgeDatabase() {
+ this.translator.clearDatabaseCaches();
+ await this.database.purge();
+ }
+
+ async _onApiGetMedia({targets}) {
+ return await this.database.getMedia(targets);
+ }
+
+ _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() {
+ if (this._logErrorLevel === null) { return; }
+ this._logErrorLevel = null;
+ this._updateBadge();
+ }
+
+ _onApiCreateActionPort(params, sender) {
+ if (!sender || !sender.tab) { throw new Error('Invalid sender'); }
+ const tabId = sender.tab.id;
+ if (typeof tabId !== 'number') { throw new Error('Sender has invalid tab ID'); }
+
+ const frameId = sender.frameId;
+ const id = yomichan.generateId(16);
+ const portName = `action-port-${id}`;
+
+ const port = chrome.tabs.connect(tabId, {name: portName, frameId});
+ try {
+ this._createActionListenerPort(port, sender, this._messageHandlersWithProgress);
+ } catch (e) {
+ port.disconnect();
+ throw e;
+ }
+
+ return portName;
+ }
+
+ async _onApiImportDictionaryArchive({archiveContent, details}, sender, onProgress) {
+ return await this.dictionaryImporter.import(this.database, archiveContent, details, onProgress);
+ }
+
+ async _onApiDeleteDictionary({dictionaryName}, sender, onProgress) {
+ this.translator.clearDatabaseCaches();
+ await this.database.deleteDictionary(dictionaryName, {rate: 1000}, onProgress);
+ }
+
+ async _onApiModifySettings({targets, source}) {
+ const results = [];
+ for (const target of targets) {
+ try {
+ this._modifySetting(target);
+ results.push({result: true});
+ } catch (e) {
+ results.push({error: errorToJson(e)});
+ }
+ }
+ await this._onApiOptionsSave({source});
+ return results;
+ }
+
// Command handlers
+ _createActionListenerPort(port, sender, handlers) {
+ let hasStarted = false;
+
+ const onProgress = (...data) => {
+ try {
+ if (port === null) { return; }
+ port.postMessage({type: 'progress', data});
+ } catch (e) {
+ // NOP
+ }
+ };
+
+ const onMessage = async ({action, params}) => {
+ if (hasStarted) { return; }
+ hasStarted = true;
+ port.onMessage.removeListener(onMessage);
+
+ try {
+ port.postMessage({type: 'ack'});
+
+ const messageHandler = handlers.get(action);
+ if (typeof messageHandler === 'undefined') {
+ throw new Error('Invalid action');
+ }
+ const {handler, async, contentScript} = messageHandler;
+
+ if (!contentScript) {
+ this._validatePrivilegedMessageSender(sender);
+ }
+
+ const promiseOrResult = handler(params, sender, onProgress);
+ 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();
+ }
+ };
+
+ const cleanup = () => {
+ if (port === null) { return; }
+ if (!hasStarted) {
+ port.onMessage.removeListener(onMessage);
+ }
+ port.onDisconnect.removeListener(cleanup);
+ port = null;
+ handlers = null;
+ };
+
+ port.onMessage.addListener(onMessage);
+ port.onDisconnect.addListener(cleanup);
+ }
+
+ _getErrorLevelValue(errorLevel) {
+ switch (errorLevel) {
+ case 'info': return 0;
+ case 'debug': return 0;
+ case 'warn': return 1;
+ case 'error': return 2;
+ default: return 0;
+ }
+ }
+
async _onCommandSearch(params) {
const {mode='existingOrNewTab', query} = params || {};
@@ -748,7 +892,9 @@ class Backend {
await Backend._focusTab(tab);
if (queryParams.query) {
await new Promise((resolve) => chrome.tabs.sendMessage(
- tab.id, {action: 'searchQueryUpdate', params: {text: queryParams.query}}, resolve
+ tab.id,
+ {action: 'searchQueryUpdate', params: {text: queryParams.query}},
+ resolve
));
}
return true;
@@ -818,20 +964,163 @@ class Backend {
// Utilities
- async _getAudioUri(definition, source, details) {
- let optionsContext = (typeof details === 'object' && details !== null ? details.optionsContext : null);
- if (!(typeof optionsContext === 'object' && optionsContext !== null)) {
- optionsContext = this.optionsContext;
+ _getModifySettingObject(target) {
+ const scope = target.scope;
+ switch (scope) {
+ case 'profile':
+ if (!isObject(target.optionsContext)) { throw new Error('Invalid optionsContext'); }
+ return this.getOptions(target.optionsContext, true);
+ case 'global':
+ return this.getFullOptions(true);
+ default:
+ throw new Error(`Invalid scope: ${scope}`);
}
+ }
- const options = this.getOptions(optionsContext);
- return await this.audioUriBuilder.getUri(definition, source, options);
+ async _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;
+ case 'delete':
+ {
+ const {path} = target;
+ if (typeof path !== 'string') { throw new Error('Invalid path'); }
+ accessor.delete(ObjectPropertyAccessor.getPathArray(path));
+ }
+ break;
+ 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;
+ 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;
+ default:
+ throw new Error(`Unknown action: ${action}`);
+ }
+ }
+
+ _validatePrivilegedMessageSender(sender) {
+ const url = sender.url;
+ if (!(typeof url === 'string' && yomichan.isExtensionUrl(url))) {
+ throw new Error('Invalid message sender');
+ }
+ }
+
+ _getBrowserIconTitle() {
+ return (
+ isObject(chrome.browserAction) &&
+ typeof chrome.browserAction.getTitle === 'function' ?
+ new Promise((resolve) => chrome.browserAction.getTitle({}, resolve)) :
+ Promise.resolve('')
+ );
+ }
+
+ _updateBadge() {
+ let title = this._defaultBrowserActionTitle;
+ if (title === null || !isObject(chrome.browserAction)) {
+ // Not ready or invalid
+ return;
+ }
+
+ let text = '';
+ let color = null;
+ let status = null;
+
+ if (this._logErrorLevel !== null) {
+ switch (this._logErrorLevel) {
+ case 'error':
+ text = '!!';
+ color = '#f04e4e';
+ status = 'Error';
+ break;
+ default: // 'warn'
+ text = '!';
+ color = '#f0ad4e';
+ status = 'Warning';
+ break;
+ }
+ } else if (!this._isPrepared) {
+ if (this._prepareError) {
+ text = '!!';
+ color = '#f04e4e';
+ status = 'Error';
+ } else if (this._badgePrepareDelayTimer === null) {
+ text = '...';
+ color = '#f0ad4e';
+ status = 'Loading';
+ }
+ } else if (!this._anyOptionsMatches((options) => options.general.enable)) {
+ text = 'off';
+ color = '#555555';
+ status = 'Disabled';
+ } else if (!this._anyOptionsMatches((options) => this._isAnyDictionaryEnabled(options))) {
+ text = '!';
+ color = '#f0ad4e';
+ status = 'No dictionaries installed';
+ }
+
+ if (color !== null && typeof chrome.browserAction.setBadgeBackgroundColor === 'function') {
+ chrome.browserAction.setBadgeBackgroundColor({color});
+ }
+ if (text !== null && typeof chrome.browserAction.setBadgeText === 'function') {
+ chrome.browserAction.setBadgeText({text});
+ }
+ if (typeof chrome.browserAction.setTitle === 'function') {
+ if (status !== null) {
+ title = `${title} - ${status}`;
+ }
+ chrome.browserAction.setTitle({title});
+ }
+ }
+
+ _isAnyDictionaryEnabled(options) {
+ for (const {enabled} of Object.values(options.dictionaries)) {
+ if (enabled) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ _anyOptionsMatches(predicate) {
+ for (const {options} of this.options.profiles) {
+ const value = predicate(options);
+ if (value) { return value; }
+ }
+ return false;
}
async _renderTemplate(template, data) {
return handlebarsRenderDynamic(template, data);
}
+ _getTemplates(options) {
+ const templates = options.anki.fieldTemplates;
+ return typeof templates === 'string' ? templates : this.defaultAnkiFieldTemplates;
+ }
+
static _getTabUrl(tab) {
return new Promise((resolve) => {
chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => {
@@ -921,26 +1210,4 @@ class Backend {
// Edge throws exception for no reason here.
}
}
-
- static async _getBrowser() {
- if (EXTENSION_IS_BROWSER_EDGE) {
- return 'edge';
- }
- if (typeof browser !== 'undefined') {
- try {
- const info = await browser.runtime.getBrowserInfo();
- if (info.name === 'Fennec') {
- return 'firefox-mobile';
- }
- } catch (e) {
- // NOP
- }
- return 'firefox';
- } else {
- return 'chrome';
- }
- }
}
-
-window.yomichanBackend = new Backend();
-window.yomichanBackend.prepare();
diff --git a/ext/bg/js/background-main.js b/ext/bg/js/background-main.js
new file mode 100644
index 00000000..24117f4e
--- /dev/null
+++ b/ext/bg/js/background-main.js
@@ -0,0 +1,25 @@
+/*
+ * 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
+ * Backend
+ */
+
+(async () => {
+ window.yomichanBackend = new Backend();
+ await window.yomichanBackend.prepare();
+})();
diff --git a/ext/bg/js/conditions.js b/ext/bg/js/conditions.js
index eb9582df..3f3c0a45 100644
--- a/ext/bg/js/conditions.js
+++ b/ext/bg/js/conditions.js
@@ -32,7 +32,15 @@ function conditionsValidateOptionValue(object, value) {
return value;
}
-function conditionsNormalizeOptionValue(descriptors, type, operator, optionValue) {
+function conditionsValidateOptionInputValue(object, value) {
+ if (hasOwn(object, 'transformInput')) {
+ return object.transformInput(value);
+ }
+
+ return null;
+}
+
+function conditionsNormalizeOptionValue(descriptors, type, operator, optionValue, isInput) {
if (!hasOwn(descriptors, type)) {
throw new Error('Invalid type');
}
@@ -44,13 +52,34 @@ function conditionsNormalizeOptionValue(descriptors, type, operator, optionValue
const operatorDescriptor = conditionDescriptor.operators[operator];
- let transformedValue = conditionsValidateOptionValue(conditionDescriptor, optionValue);
- transformedValue = conditionsValidateOptionValue(operatorDescriptor, transformedValue);
+ const descriptorArray = [conditionDescriptor, operatorDescriptor];
+
+ let transformedValue = optionValue;
+
+ let inputTransformedValue = null;
+ if (isInput) {
+ for (const descriptor of descriptorArray) {
+ let value = inputTransformedValue !== null ? inputTransformedValue : transformedValue;
+ value = conditionsValidateOptionInputValue(descriptor, value);
+ if (value !== null) {
+ inputTransformedValue = value;
+ }
+ }
+
+ if (inputTransformedValue !== null) {
+ transformedValue = inputTransformedValue;
+ }
+ }
+
+ for (const descriptor of descriptorArray) {
+ transformedValue = conditionsValidateOptionValue(descriptor, transformedValue);
+ }
if (hasOwn(operatorDescriptor, 'transformReverse')) {
transformedValue = operatorDescriptor.transformReverse(transformedValue);
}
- return transformedValue;
+
+ return [transformedValue, inputTransformedValue];
}
function conditionsTestValueThrowing(descriptors, type, operator, optionValue, value) {
diff --git a/ext/bg/js/context.js b/ext/bg/js/context-main.js
index e3d4ad4a..dbba0272 100644
--- a/ext/bg/js/context.js
+++ b/ext/bg/js/context-main.js
@@ -17,7 +17,9 @@
/* global
* apiCommandExec
+ * apiForwardLogsToBackend
* apiGetEnvironmentInfo
+ * apiLogIndicatorClear
* apiOptionsGet
*/
@@ -51,9 +53,12 @@ function setupButtonEvents(selector, command, url) {
}
}
-window.addEventListener('DOMContentLoaded', async () => {
+async function mainInner() {
+ apiForwardLogsToBackend();
await yomichan.prepare();
+ await apiLogIndicatorClear();
+
showExtensionInfo();
apiGetEnvironmentInfo().then(({browser}) => {
@@ -86,4 +91,8 @@ window.addEventListener('DOMContentLoaded', async () => {
}
}, 10);
});
-});
+}
+
+(async () => {
+ window.addEventListener('DOMContentLoaded', mainInner, false);
+})();
diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js
index 260c815a..930cd0d0 100644
--- a/ext/bg/js/database.js
+++ b/ext/bg/js/database.js
@@ -33,7 +33,7 @@ class Database {
}
try {
- this.db = await Database._open('dict', 5, (db, transaction, oldVersion) => {
+ this.db = await Database._open('dict', 6, (db, transaction, oldVersion) => {
Database._upgrade(db, transaction, oldVersion, [
{
version: 2,
@@ -90,12 +90,21 @@ class Database {
indices: ['dictionary', 'expression', 'reading', 'sequence', 'expressionReverse', 'readingReverse']
}
}
+ },
+ {
+ version: 6,
+ stores: {
+ media: {
+ primaryKey: {keyPath: 'id', autoIncrement: true},
+ indices: ['dictionary', 'path']
+ }
+ }
}
]);
});
return true;
} catch (e) {
- logError(e);
+ yomichan.logError(e);
return false;
}
}
@@ -120,7 +129,7 @@ class Database {
await this.prepare();
}
- async deleteDictionary(dictionaryName, onProgress, progressSettings) {
+ async deleteDictionary(dictionaryName, progressSettings, onProgress) {
this._validate();
const targets = [
@@ -268,6 +277,34 @@ class Database {
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);
+ }
+ };
+
+ const transaction = this.db.transaction(['media'], 'readonly');
+ const objectStore = transaction.objectStore('media');
+ const index = objectStore.index('path');
+
+ 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));
+ }
+
+ await Promise.all(promises);
+
+ return results;
+ }
+
async getDictionaryInfo() {
this._validate();
@@ -432,6 +469,10 @@ class Database {
return {character, mode, data, dictionary, index};
}
+ static _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);
diff --git a/ext/bg/js/dictionary-importer.js b/ext/bg/js/dictionary-importer.js
index bf6809ec..10e30cec 100644
--- a/ext/bg/js/dictionary-importer.js
+++ b/ext/bg/js/dictionary-importer.js
@@ -18,6 +18,7 @@
/* global
* JSZip
* JsonSchema
+ * mediaUtility
* requestJson
*/
@@ -26,7 +27,7 @@ class DictionaryImporter {
this._schemas = new Map();
}
- async import(database, archiveSource, onProgress, details) {
+ async import(database, archiveSource, details, onProgress) {
if (!database) {
throw new Error('Invalid database');
}
@@ -148,6 +149,22 @@ class DictionaryImporter {
}
}
+ // Extended data support
+ const extendedDataContext = {
+ archive,
+ media: new Map()
+ };
+ for (const entry of termList) {
+ const glossaryList = entry.glossary;
+ for (let i = 0, ii = glossaryList.length; i < ii; ++i) {
+ const glossary = glossaryList[i];
+ if (typeof glossary !== 'object' || glossary === null) { continue; }
+ glossaryList[i] = await this._formatDictionaryTermGlossaryObject(glossary, extendedDataContext, entry);
+ }
+ }
+
+ const media = [...extendedDataContext.media.values()];
+
// Add dictionary
const summary = this._createSummary(dictionaryTitle, version, index, {prefixWildcardsSupported});
@@ -188,6 +205,7 @@ class DictionaryImporter {
await bulkAdd('kanji', kanjiList);
await bulkAdd('kanjiMeta', kanjiMetaList);
await bulkAdd('tagMeta', tagList);
+ await bulkAdd('media', media);
return {result: summary, errors};
}
@@ -275,4 +293,76 @@ class DictionaryImporter {
return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank];
}
+
+ async _formatDictionaryTermGlossaryObject(data, context, entry) {
+ switch (data.type) {
+ case 'text':
+ return data.text;
+ case 'image':
+ return await this._formatDictionaryTermGlossaryImage(data, context, entry);
+ default:
+ throw new Error(`Unhandled data type: ${data.type}`);
+ }
+ }
+
+ async _formatDictionaryTermGlossaryImage(data, context, entry) {
+ const dictionary = entry.dictionary;
+ const {path, width: preferredWidth, height: preferredHeight, title, description, pixelated} = data;
+ if (context.media.has(path)) {
+ // Already exists
+ return data;
+ }
+
+ let errorSource = entry.expression;
+ if (entry.reading.length > 0) {
+ errorSource += ` (${entry.reading});`;
+ }
+
+ const file = context.archive.file(path);
+ if (file === null) {
+ throw new Error(`Could not find image at path ${JSON.stringify(path)} for ${errorSource}`);
+ }
+
+ const content = await file.async('base64');
+ const mediaType = mediaUtility.getImageMediaTypeFromFileName(path);
+ if (mediaType === null) {
+ throw new Error(`Could not determine media type for image at path ${JSON.stringify(path)} for ${errorSource}`);
+ }
+
+ let image;
+ try {
+ image = await mediaUtility.loadImageBase64(mediaType, content);
+ } catch (e) {
+ throw new Error(`Could not load image at path ${JSON.stringify(path)} for ${errorSource}`);
+ }
+
+ const width = image.naturalWidth;
+ const height = image.naturalHeight;
+
+ // Create image data
+ const mediaData = {
+ dictionary,
+ path,
+ mediaType,
+ width,
+ height,
+ content
+ };
+ context.media.set(path, mediaData);
+
+ // Create new data
+ const newData = {
+ type: 'image',
+ path,
+ width,
+ height
+ };
+ if (typeof preferredWidth === 'number') { newData.preferredWidth = preferredWidth; }
+ if (typeof preferredHeight === 'number') { newData.preferredHeight = preferredHeight; }
+ if (typeof title === 'string') { newData.title = title; }
+ if (typeof description === 'string') { newData.description = description; }
+ if (typeof pixelated === 'boolean') { newData.pixelated = pixelated; }
+
+ return newData;
+ }
}
diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js
index 860acb14..822174e2 100644
--- a/ext/bg/js/handlebars.js
+++ b/ext/bg/js/handlebars.js
@@ -123,6 +123,26 @@ function handlebarsRegexMatch(...args) {
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;
@@ -134,6 +154,7 @@ function handlebarsRegisterHelpers() {
Handlebars.registerHelper('sanitizeCssClass', handlebarsSanitizeCssClass);
Handlebars.registerHelper('regexReplace', handlebarsRegexReplace);
Handlebars.registerHelper('regexMatch', handlebarsRegexMatch);
+ Handlebars.registerHelper('mergeTags', handlebarsMergeTags);
}
}
diff --git a/ext/bg/js/japanese.js b/ext/bg/js/japanese.js
deleted file mode 100644
index ac81acb5..00000000
--- a/ext/bg/js/japanese.js
+++ /dev/null
@@ -1,426 +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
- * jp
- * wanakana
- */
-
-(() => {
- const HALFWIDTH_KATAKANA_MAPPING = new Map([
- ['ヲ', 'ヲヺ-'],
- ['ァ', 'ァ--'],
- ['ィ', 'ィ--'],
- ['ゥ', 'ゥ--'],
- ['ェ', 'ェ--'],
- ['ォ', 'ォ--'],
- ['ャ', 'ャ--'],
- ['ュ', 'ュ--'],
- ['ョ', 'ョ--'],
- ['ッ', 'ッ--'],
- ['ー', 'ー--'],
- ['ア', 'ア--'],
- ['イ', 'イ--'],
- ['ウ', 'ウヴ-'],
- ['エ', 'エ--'],
- ['オ', 'オ--'],
- ['カ', 'カガ-'],
- ['キ', 'キギ-'],
- ['ク', 'クグ-'],
- ['ケ', 'ケゲ-'],
- ['コ', 'コゴ-'],
- ['サ', 'サザ-'],
- ['シ', 'シジ-'],
- ['ス', 'スズ-'],
- ['セ', 'セゼ-'],
- ['ソ', 'ソゾ-'],
- ['タ', 'タダ-'],
- ['チ', 'チヂ-'],
- ['ツ', 'ツヅ-'],
- ['テ', 'テデ-'],
- ['ト', 'トド-'],
- ['ナ', 'ナ--'],
- ['ニ', 'ニ--'],
- ['ヌ', 'ヌ--'],
- ['ネ', 'ネ--'],
- ['ノ', 'ノ--'],
- ['ハ', 'ハバパ'],
- ['ヒ', 'ヒビピ'],
- ['フ', 'フブプ'],
- ['ヘ', 'ヘベペ'],
- ['ホ', 'ホボポ'],
- ['マ', 'マ--'],
- ['ミ', 'ミ--'],
- ['ム', 'ム--'],
- ['メ', 'メ--'],
- ['モ', 'モ--'],
- ['ヤ', 'ヤ--'],
- ['ユ', 'ユ--'],
- ['ヨ', 'ヨ--'],
- ['ラ', 'ラ--'],
- ['リ', 'リ--'],
- ['ル', 'ル--'],
- ['レ', 'レ--'],
- ['ロ', 'ロ--'],
- ['ワ', 'ワ--'],
- ['ン', 'ン--']
- ]);
-
- const ITERATION_MARK_CODE_POINT = 0x3005;
-
- const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063;
- const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3;
- const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc;
-
- // Existing functions
-
- const isCodePointKanji = jp.isCodePointKanji;
- const isStringEntirelyKana = jp.isStringEntirelyKana;
-
-
- // Conversion functions
-
- function convertKatakanaToHiragana(text) {
- let result = '';
- for (const c of text) {
- if (wanakana.isKatakana(c)) {
- result += wanakana.toHiragana(c);
- } else {
- result += c;
- }
- }
-
- return result;
- }
-
- function convertHiraganaToKatakana(text) {
- let result = '';
- for (const c of text) {
- if (wanakana.isHiragana(c)) {
- result += wanakana.toKatakana(c);
- } else {
- result += c;
- }
- }
-
- return result;
- }
-
- function convertToRomaji(text) {
- return wanakana.toRomaji(text);
- }
-
- function convertReading(expression, reading, readingMode) {
- switch (readingMode) {
- case 'hiragana':
- return convertKatakanaToHiragana(reading);
- case 'katakana':
- return convertHiraganaToKatakana(reading);
- case 'romaji':
- if (reading) {
- return convertToRomaji(reading);
- } else {
- if (isStringEntirelyKana(expression)) {
- return convertToRomaji(expression);
- }
- }
- return reading;
- case 'none':
- return '';
- default:
- return reading;
- }
- }
-
- function convertNumericToFullWidth(text) {
- let result = '';
- for (const char of text) {
- let c = char.codePointAt(0);
- if (c >= 0x30 && c <= 0x39) { // ['0', '9']
- c += 0xff10 - 0x30; // 0xff10 = '0' full width
- result += String.fromCodePoint(c);
- } else {
- result += char;
- }
- }
- return result;
- }
-
- function convertHalfWidthKanaToFullWidth(text, sourceMap=null) {
- let result = '';
-
- // This function is safe to use charCodeAt instead of codePointAt, since all
- // the relevant characters are represented with a single UTF-16 character code.
- for (let i = 0, ii = text.length; i < ii; ++i) {
- const c = text[i];
- const mapping = HALFWIDTH_KATAKANA_MAPPING.get(c);
- if (typeof mapping !== 'string') {
- result += c;
- continue;
- }
-
- let index = 0;
- switch (text.charCodeAt(i + 1)) {
- case 0xff9e: // dakuten
- index = 1;
- break;
- case 0xff9f: // handakuten
- index = 2;
- break;
- }
-
- let c2 = mapping[index];
- if (index > 0) {
- if (c2 === '-') { // invalid
- index = 0;
- c2 = mapping[0];
- } else {
- ++i;
- }
- }
-
- if (sourceMap !== null && index > 0) {
- sourceMap.combine(result.length, 1);
- }
- result += c2;
- }
-
- return result;
- }
-
- function convertAlphabeticToKana(text, sourceMap=null) {
- let part = '';
- let result = '';
-
- for (const char of text) {
- // Note: 0x61 is the character code for 'a'
- let c = char.codePointAt(0);
- if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z']
- c += (0x61 - 0x41);
- } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z']
- // NOP; c += (0x61 - 0x61);
- } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth
- c += (0x61 - 0xff21);
- } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth
- c += (0x61 - 0xff41);
- } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash
- c = 0x2d; // '-'
- } else {
- if (part.length > 0) {
- result += convertAlphabeticPartToKana(part, sourceMap, result.length);
- part = '';
- }
- result += char;
- continue;
- }
- part += String.fromCodePoint(c);
- }
-
- if (part.length > 0) {
- result += convertAlphabeticPartToKana(part, sourceMap, result.length);
- }
- return result;
- }
-
- function convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) {
- const result = wanakana.toHiragana(text);
-
- // Generate source mapping
- if (sourceMap !== null) {
- let i = 0;
- let resultPos = 0;
- const ii = text.length;
- while (i < ii) {
- // Find smallest matching substring
- let iNext = i + 1;
- let resultPosNext = result.length;
- while (iNext < ii) {
- const t = wanakana.toHiragana(text.substring(0, iNext));
- if (t === result.substring(0, t.length)) {
- resultPosNext = t.length;
- break;
- }
- ++iNext;
- }
-
- // Merge characters
- const removals = iNext - i - 1;
- if (removals > 0) {
- sourceMap.combine(sourceMapStart, removals);
- }
- ++sourceMapStart;
-
- // Empty elements
- const additions = resultPosNext - resultPos - 1;
- for (let j = 0; j < additions; ++j) {
- sourceMap.insert(sourceMapStart, 0);
- ++sourceMapStart;
- }
-
- i = iNext;
- resultPos = resultPosNext;
- }
- }
-
- return result;
- }
-
-
- // Furigana distribution
-
- function distributeFurigana(expression, reading) {
- const fallback = [{furigana: reading, text: expression}];
- if (!reading) {
- return fallback;
- }
-
- let isAmbiguous = false;
- const segmentize = (reading2, groups) => {
- if (groups.length === 0 || isAmbiguous) {
- return [];
- }
-
- const group = groups[0];
- if (group.mode === 'kana') {
- if (convertKatakanaToHiragana(reading2).startsWith(convertKatakanaToHiragana(group.text))) {
- const readingLeft = reading2.substring(group.text.length);
- const segs = segmentize(readingLeft, groups.splice(1));
- if (segs) {
- return [{text: group.text, furigana: ''}].concat(segs);
- }
- }
- } else {
- let foundSegments = null;
- for (let i = reading2.length; i >= group.text.length; --i) {
- const readingUsed = reading2.substring(0, i);
- const readingLeft = reading2.substring(i);
- const segs = segmentize(readingLeft, groups.slice(1));
- if (segs) {
- if (foundSegments !== null) {
- // more than one way to segmentize the tail, mark as ambiguous
- isAmbiguous = true;
- return null;
- }
- foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs);
- }
- // there is only one way to segmentize the last non-kana group
- if (groups.length === 1) {
- break;
- }
- }
- return foundSegments;
- }
- };
-
- const groups = [];
- let modePrev = null;
- for (const c of expression) {
- const codePoint = c.codePointAt(0);
- const modeCurr = isCodePointKanji(codePoint) || codePoint === ITERATION_MARK_CODE_POINT ? 'kanji' : 'kana';
- if (modeCurr === modePrev) {
- groups[groups.length - 1].text += c;
- } else {
- groups.push({mode: modeCurr, text: c});
- modePrev = modeCurr;
- }
- }
-
- const segments = segmentize(reading, groups);
- if (segments && !isAmbiguous) {
- return segments;
- }
- return fallback;
- }
-
- function distributeFuriganaInflected(expression, reading, source) {
- const output = [];
-
- let stemLength = 0;
- const shortest = Math.min(source.length, expression.length);
- const sourceHiragana = convertKatakanaToHiragana(source);
- const expressionHiragana = convertKatakanaToHiragana(expression);
- while (stemLength < shortest && sourceHiragana[stemLength] === expressionHiragana[stemLength]) {
- ++stemLength;
- }
- const offset = source.length - stemLength;
-
- const stemExpression = source.substring(0, source.length - offset);
- const stemReading = reading.substring(
- 0,
- offset === 0 ? reading.length : reading.length - expression.length + stemLength
- );
- for (const segment of distributeFurigana(stemExpression, stemReading)) {
- output.push(segment);
- }
-
- if (stemLength !== source.length) {
- output.push({text: source.substring(stemLength), furigana: ''});
- }
-
- return output;
- }
-
-
- // Miscellaneous
-
- function collapseEmphaticSequences(text, fullCollapse, sourceMap=null) {
- let result = '';
- let collapseCodePoint = -1;
- const hasSourceMap = (sourceMap !== null);
- for (const char of text) {
- const c = char.codePointAt(0);
- if (
- c === HIRAGANA_SMALL_TSU_CODE_POINT ||
- c === KATAKANA_SMALL_TSU_CODE_POINT ||
- c === KANA_PROLONGED_SOUND_MARK_CODE_POINT
- ) {
- if (collapseCodePoint !== c) {
- collapseCodePoint = c;
- if (!fullCollapse) {
- result += char;
- continue;
- }
- }
- } else {
- collapseCodePoint = -1;
- result += char;
- continue;
- }
-
- if (hasSourceMap) {
- sourceMap.combine(Math.max(0, result.length - 1), 1);
- }
- }
- return result;
- }
-
-
- // Exports
-
- Object.assign(jp, {
- convertKatakanaToHiragana,
- convertHiraganaToKatakana,
- convertToRomaji,
- convertReading,
- convertNumericToFullWidth,
- convertHalfWidthKanaToFullWidth,
- convertAlphabeticToKana,
- distributeFurigana,
- distributeFuriganaInflected,
- collapseEmphaticSequences
- });
-})();
diff --git a/ext/bg/js/mecab.js b/ext/bg/js/mecab.js
index 597dceae..815ee860 100644
--- a/ext/bg/js/mecab.js
+++ b/ext/bg/js/mecab.js
@@ -24,7 +24,7 @@ class Mecab {
}
onError(error) {
- logError(error, false);
+ yomichan.logError(error);
}
async checkVersion() {
diff --git a/ext/bg/js/media-utility.js b/ext/bg/js/media-utility.js
new file mode 100644
index 00000000..1f93b2b4
--- /dev/null
+++ b/ext/bg/js/media-utility.js
@@ -0,0 +1,98 @@
+/*
+ * 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/>.
+ */
+
+/**
+ * mediaUtility is an object containing helper methods related to media processing.
+ */
+const mediaUtility = (() => {
+ /**
+ * Gets the file extension of a file path. URL search queries and hash
+ * fragments are not handled.
+ * @param path The path to the file.
+ * @returns The file extension, including the '.', or an empty string
+ * if there is no file extension.
+ */
+ function getFileNameExtension(path) {
+ const match = /\.[^./\\]*$/.exec(path);
+ return match !== null ? match[0] : '';
+ }
+
+ /**
+ * Gets an image file's media type using a file path.
+ * @param path The path to the file.
+ * @returns The media type string if it can be determined from the file path,
+ * otherwise null.
+ */
+ function getImageMediaTypeFromFileName(path) {
+ switch (getFileNameExtension(path).toLowerCase()) {
+ case '.apng':
+ return 'image/apng';
+ case '.bmp':
+ return 'image/bmp';
+ case '.gif':
+ return 'image/gif';
+ case '.ico':
+ case '.cur':
+ return 'image/x-icon';
+ case '.jpg':
+ case '.jpeg':
+ case '.jfif':
+ case '.pjpeg':
+ case '.pjp':
+ return 'image/jpeg';
+ case '.png':
+ return 'image/png';
+ case '.svg':
+ return 'image/svg+xml';
+ case '.tif':
+ case '.tiff':
+ return 'image/tiff';
+ case '.webp':
+ return 'image/webp';
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Attempts to load an image using a base64 encoded content and a media type.
+ * @param mediaType The media type for the image content.
+ * @param content The binary content for the image, encoded in base64.
+ * @returns A Promise which resolves with an HTMLImageElement instance on
+ * successful load, otherwise an error is thrown.
+ */
+ function loadImageBase64(mediaType, content) {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ const eventListeners = new EventListenerCollection();
+ eventListeners.addEventListener(image, 'load', () => {
+ eventListeners.removeAllEventListeners();
+ resolve(image);
+ }, false);
+ eventListeners.addEventListener(image, 'error', () => {
+ eventListeners.removeAllEventListeners();
+ reject(new Error('Image failed to load'));
+ }, false);
+ image.src = `data:${mediaType};base64,${content}`;
+ });
+ }
+
+ return {
+ getImageMediaTypeFromFileName,
+ loadImageBase64
+ };
+})();
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index f3e5f60d..35fdde82 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -15,14 +15,23 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-/* global
- * utilStringHashCode
- */
-
/*
* Generic options functions
*/
+function optionsGetStringHashCode(string) {
+ let hashCode = 0;
+
+ if (typeof string !== 'string') { return hashCode; }
+
+ for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) {
+ hashCode = ((hashCode << 5) - hashCode) + charCode;
+ hashCode |= 0;
+ }
+
+ return hashCode;
+}
+
function optionsGenericApplyUpdates(options, updates) {
const targetVersion = updates.length;
const currentVersion = options.version;
@@ -63,12 +72,12 @@ const profileOptionsVersionUpdates = [
options.anki.fieldTemplates = null;
},
(options) => {
- if (utilStringHashCode(options.anki.fieldTemplates) === 1285806040) {
+ if (optionsGetStringHashCode(options.anki.fieldTemplates) === 1285806040) {
options.anki.fieldTemplates = null;
}
},
(options) => {
- if (utilStringHashCode(options.anki.fieldTemplates) === -250091611) {
+ if (optionsGetStringHashCode(options.anki.fieldTemplates) === -250091611) {
options.anki.fieldTemplates = null;
}
},
@@ -87,7 +96,7 @@ const profileOptionsVersionUpdates = [
(options) => {
// Version 12 changes:
// The preferred default value of options.anki.fieldTemplates has been changed to null.
- if (utilStringHashCode(options.anki.fieldTemplates) === 1444379824) {
+ if (optionsGetStringHashCode(options.anki.fieldTemplates) === 1444379824) {
options.anki.fieldTemplates = null;
}
},
@@ -99,6 +108,37 @@ const profileOptionsVersionUpdates = [
fieldTemplates += '\n\n{{#*inline "document-title"}}\n {{~context.document.title~}}\n{{/inline}}';
options.anki.fieldTemplates = fieldTemplates;
}
+ },
+ (options) => {
+ // Version 14 changes:
+ // Changed template for Anki audio and tags.
+ let fieldTemplates = options.anki.fieldTemplates;
+ if (typeof fieldTemplates !== 'string') { return; }
+
+ const replacements = [
+ [
+ '{{#*inline "audio"}}{{/inline}}',
+ '{{#*inline "audio"}}\n {{~#if definition.audioFileName~}}\n [sound:{{definition.audioFileName}}]\n {{~/if~}}\n{{/inline}}'
+ ],
+ [
+ '{{#*inline "tags"}}\n {{~#each definition.definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each~}}\n{{/inline}}',
+ '{{#*inline "tags"}}\n {{~#mergeTags definition group merge}}{{this}}{{/mergeTags~}}\n{{/inline}}'
+ ]
+ ];
+
+ for (const [pattern, replacement] of replacements) {
+ let replaced = false;
+ fieldTemplates = fieldTemplates.replace(new RegExp(escapeRegExp(pattern), 'g'), () => {
+ replaced = true;
+ return replacement;
+ });
+
+ if (!replaced) {
+ fieldTemplates += '\n\n' + replacement;
+ }
+ }
+
+ options.anki.fieldTemplates = fieldTemplates;
}
];
@@ -192,6 +232,7 @@ function profileOptionsCreateDefaults() {
screenshot: {format: 'png', quality: 92},
terms: {deck: '', model: '', fields: {}},
kanji: {deck: '', model: '', fields: {}},
+ duplicateScope: 'collection',
fieldTemplates: null
}
};
diff --git a/ext/bg/js/profile-conditions.js b/ext/bg/js/profile-conditions.js
index a0710bd1..97e09f1c 100644
--- a/ext/bg/js/profile-conditions.js
+++ b/ext/bg/js/profile-conditions.js
@@ -15,6 +15,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+/* global
+ * Environment
+ */
function _profileConditionTestDomain(urlDomain, domain) {
return (
@@ -36,69 +39,140 @@ function _profileConditionTestDomainList(url, domainList) {
return false;
}
-const profileConditionsDescriptor = {
- popupLevel: {
- name: 'Popup Level',
- description: 'Use profile depending on the level of the popup.',
- placeholder: 'Number',
- type: 'number',
- step: 1,
- defaultValue: 0,
- defaultOperator: 'equal',
- transform: (optionValue) => parseInt(optionValue, 10),
- transformReverse: (transformedOptionValue) => `${transformedOptionValue}`,
- validateTransformed: (transformedOptionValue) => Number.isFinite(transformedOptionValue),
- operators: {
- equal: {
- name: '=',
- test: ({depth}, optionValue) => (depth === optionValue)
- },
- notEqual: {
- name: '\u2260',
- test: ({depth}, optionValue) => (depth !== optionValue)
- },
- lessThan: {
- name: '<',
- test: ({depth}, optionValue) => (depth < optionValue)
- },
- greaterThan: {
- name: '>',
- test: ({depth}, optionValue) => (depth > optionValue)
- },
- lessThanOrEqual: {
- name: '\u2264',
- test: ({depth}, optionValue) => (depth <= optionValue)
- },
- greaterThanOrEqual: {
- name: '\u2265',
- test: ({depth}, optionValue) => (depth >= optionValue)
+let profileConditionsDescriptor = null;
+
+const profileConditionsDescriptorPromise = (async () => {
+ const environment = new Environment();
+ await environment.prepare();
+
+ const modifiers = environment.getInfo().modifiers;
+ const modifierSeparator = modifiers.separator;
+ const modifierKeyValues = modifiers.keys.map(
+ ({value, name}) => ({optionValue: value, name})
+ );
+
+ const modifierValueToName = new Map(
+ modifierKeyValues.map(({optionValue, name}) => [optionValue, name])
+ );
+
+ const modifierNameToValue = new Map(
+ modifierKeyValues.map(({optionValue, name}) => [name, optionValue])
+ );
+
+ profileConditionsDescriptor = {
+ popupLevel: {
+ name: 'Popup Level',
+ description: 'Use profile depending on the level of the popup.',
+ placeholder: 'Number',
+ type: 'number',
+ step: 1,
+ defaultValue: 0,
+ defaultOperator: 'equal',
+ transform: (optionValue) => parseInt(optionValue, 10),
+ transformReverse: (transformedOptionValue) => `${transformedOptionValue}`,
+ validateTransformed: (transformedOptionValue) => Number.isFinite(transformedOptionValue),
+ operators: {
+ equal: {
+ name: '=',
+ test: ({depth}, optionValue) => (depth === optionValue)
+ },
+ notEqual: {
+ name: '\u2260',
+ test: ({depth}, optionValue) => (depth !== optionValue)
+ },
+ lessThan: {
+ name: '<',
+ test: ({depth}, optionValue) => (depth < optionValue)
+ },
+ greaterThan: {
+ name: '>',
+ test: ({depth}, optionValue) => (depth > optionValue)
+ },
+ lessThanOrEqual: {
+ name: '\u2264',
+ test: ({depth}, optionValue) => (depth <= optionValue)
+ },
+ greaterThanOrEqual: {
+ name: '\u2265',
+ test: ({depth}, optionValue) => (depth >= optionValue)
+ }
}
- }
- },
- url: {
- name: 'URL',
- description: 'Use profile depending on the URL of the current website.',
- defaultOperator: 'matchDomain',
- operators: {
- matchDomain: {
- name: 'Matches Domain',
- placeholder: 'Comma separated list of domains',
- defaultValue: 'example.com',
- transformCache: {},
- transform: (optionValue) => optionValue.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0),
- transformReverse: (transformedOptionValue) => transformedOptionValue.join(', '),
- validateTransformed: (transformedOptionValue) => (transformedOptionValue.length > 0),
- test: ({url}, transformedOptionValue) => _profileConditionTestDomainList(url, transformedOptionValue)
- },
- matchRegExp: {
- name: 'Matches RegExp',
- placeholder: 'Regular expression',
- defaultValue: 'example\\.com',
- transformCache: {},
- transform: (optionValue) => new RegExp(optionValue, 'i'),
- transformReverse: (transformedOptionValue) => transformedOptionValue.source,
- test: ({url}, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(url))
+ },
+ url: {
+ name: 'URL',
+ description: 'Use profile depending on the URL of the current website.',
+ defaultOperator: 'matchDomain',
+ operators: {
+ matchDomain: {
+ name: 'Matches Domain',
+ placeholder: 'Comma separated list of domains',
+ defaultValue: 'example.com',
+ transformCache: {},
+ transform: (optionValue) => optionValue.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0),
+ transformReverse: (transformedOptionValue) => transformedOptionValue.join(', '),
+ validateTransformed: (transformedOptionValue) => (transformedOptionValue.length > 0),
+ test: ({url}, transformedOptionValue) => _profileConditionTestDomainList(url, transformedOptionValue)
+ },
+ matchRegExp: {
+ name: 'Matches RegExp',
+ placeholder: 'Regular expression',
+ defaultValue: 'example\\.com',
+ transformCache: {},
+ transform: (optionValue) => new RegExp(optionValue, 'i'),
+ transformReverse: (transformedOptionValue) => transformedOptionValue.source,
+ test: ({url}, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(url))
+ }
+ }
+ },
+ modifierKeys: {
+ name: 'Modifier Keys',
+ description: 'Use profile depending on the active modifier keys.',
+ values: modifierKeyValues,
+ defaultOperator: 'are',
+ operators: {
+ are: {
+ name: 'are',
+ placeholder: 'Press one or more modifier keys here',
+ defaultValue: [],
+ type: 'keyMulti',
+ keySeparator: modifierSeparator,
+ transformInput: (optionValue) => optionValue
+ .split(modifierSeparator)
+ .filter((v) => v.length > 0)
+ .map((v) => modifierNameToValue.get(v)),
+ transformReverse: (transformedOptionValue) => transformedOptionValue
+ .map((v) => modifierValueToName.get(v))
+ .join(modifierSeparator),
+ test: ({modifierKeys}, optionValue) => areSetsEqual(new Set(modifierKeys), new Set(optionValue))
+ },
+ areNot: {
+ name: 'are not',
+ placeholder: 'Press one or more modifier keys here',
+ defaultValue: [],
+ type: 'keyMulti',
+ keySeparator: modifierSeparator,
+ transformInput: (optionValue) => optionValue
+ .split(modifierSeparator)
+ .filter((v) => v.length > 0)
+ .map((v) => modifierNameToValue.get(v)),
+ transformReverse: (transformedOptionValue) => transformedOptionValue
+ .map((v) => modifierValueToName.get(v))
+ .join(modifierSeparator),
+ test: ({modifierKeys}, optionValue) => !areSetsEqual(new Set(modifierKeys), new Set(optionValue))
+ },
+ include: {
+ name: 'include',
+ type: 'select',
+ defaultValue: 'alt',
+ test: ({modifierKeys}, optionValue) => modifierKeys.includes(optionValue)
+ },
+ notInclude: {
+ name: 'don\'t include',
+ type: 'select',
+ defaultValue: 'alt',
+ test: ({modifierKeys}, optionValue) => !modifierKeys.includes(optionValue)
+ }
}
}
- }
-};
+ };
+})();
diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-main.js
index e534e771..54fa549d 100644
--- a/ext/bg/js/search-frontend.js
+++ b/ext/bg/js/search-main.js
@@ -16,64 +16,46 @@
*/
/* global
+ * DisplaySearch
+ * apiForwardLogsToBackend
* apiOptionsGet
+ * dynamicLoader
*/
-function injectSearchFrontend() {
- const scriptSrcs = [
+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-proxy-host.js',
+ '/fg/js/popup-factory.js',
'/fg/js/frontend.js',
- '/fg/js/frontend-initialize.js'
- ];
- for (const src of scriptSrcs) {
- const node = document.querySelector(`script[src='${src}']`);
- if (node !== null) { continue; }
-
- const script = document.createElement('script');
- script.async = false;
- script.src = src;
- document.body.appendChild(script);
- }
-
- const styleSrcs = [
- '/fg/css/client.css'
- ];
- for (const src of styleSrcs) {
- const style = document.createElement('link');
- style.rel = 'stylesheet';
- style.type = 'text/css';
- style.href = src;
- document.head.appendChild(style);
- }
+ '/fg/js/content-script-main.js'
+ ]);
}
-async function main() {
+(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 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};
- injectSearchFrontend();
-
- yomichan.off('optionsUpdated', applyOptions);
+ await injectSearchFrontend();
};
yomichan.on('optionsUpdated', applyOptions);
await applyOptions();
-}
-
-main();
+})();
diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js
index eb3b681c..e1e37d1c 100644
--- a/ext/bg/js/search-query-parser.js
+++ b/ext/bg/js/search-query-parser.js
@@ -18,56 +18,76 @@
/* global
* QueryParserGenerator
* TextScanner
- * apiOptionsSet
+ * apiModifySettings
* apiTermsFind
* apiTextParse
* docSentenceExtract
*/
-class QueryParser extends TextScanner {
+class QueryParser {
constructor({getOptionsContext, setContent, setSpinnerVisible}) {
- super(document.querySelector('#query-parser-content'), () => [], []);
+ this._options = null;
+ this._getOptionsContext = getOptionsContext;
+ this._setContent = setContent;
+ this._setSpinnerVisible = setSpinnerVisible;
+ this._parseResults = [];
+ this._queryParser = document.querySelector('#query-parser-content');
+ this._queryParserSelect = document.querySelector('#query-parser-select-container');
+ this._queryParserGenerator = new QueryParserGenerator();
+ this._textScanner = new TextScanner({
+ node: this._queryParser,
+ ignoreElements: () => [],
+ ignorePoint: null,
+ search: this._search.bind(this)
+ });
+ }
- this.getOptionsContext = getOptionsContext;
- this.setContent = setContent;
- this.setSpinnerVisible = setSpinnerVisible;
+ async prepare() {
+ await this._queryParserGenerator.prepare();
+ this._queryParser.addEventListener('click', this._onClick.bind(this));
+ }
- this.parseResults = [];
+ setOptions(options) {
+ this._options = options;
+ this._textScanner.setOptions(options);
+ this._textScanner.setEnabled(true);
+ this._queryParser.dataset.termSpacing = `${options.parsing.termSpacing}`;
+ }
- this.queryParser = document.querySelector('#query-parser-content');
- this.queryParserSelect = document.querySelector('#query-parser-select-container');
+ async setText(text) {
+ this._setSpinnerVisible(true);
- this.queryParserGenerator = new QueryParserGenerator();
- }
+ this._setPreview(text);
- async prepare() {
- await this.queryParserGenerator.prepare();
- }
+ this._parseResults = await apiTextParse(text, this._getOptionsContext());
+ this._refreshSelectedParser();
+
+ this._renderParserSelect();
+ this._renderParseResult();
- onError(error) {
- logError(error, false);
+ this._setSpinnerVisible(false);
}
- onClick(e) {
- super.onClick(e);
- this.searchAt(e.clientX, e.clientY, 'click');
+ // Private
+
+ _onClick(e) {
+ this._textScanner.searchAt(e.clientX, e.clientY, 'click');
}
- async onSearchSource(textSource, cause) {
+ async _search(textSource, cause) {
if (textSource === null) { return null; }
- this.setTextSourceScanLength(textSource, this.options.scanning.length);
- const searchText = textSource.text();
- if (searchText.length === 0) { return; }
+ const searchText = this._textScanner.getTextSourceContent(textSource, this._options.scanning.length);
+ if (searchText.length === 0) { return null; }
- const {definitions, length} = await apiTermsFind(searchText, {}, this.getOptionsContext());
+ const {definitions, length} = await apiTermsFind(searchText, {}, this._getOptionsContext());
if (definitions.length === 0) { return null; }
- const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt);
+ const sentence = docSentenceExtract(textSource, this._options.anki.sentenceExt);
textSource.setEndOffset(length);
- this.setContent('terms', {definitions, context: {
+ this._setContent('terms', {definitions, context: {
focus: false,
disableHistory: cause === 'mouse',
sentence,
@@ -77,89 +97,61 @@ class QueryParser extends TextScanner {
return {definitions, type: 'terms'};
}
- onParserChange(e) {
- const selectedParser = e.target.value;
- apiOptionsSet({parsing: {selectedParser}}, this.getOptionsContext());
- }
-
- getMouseEventListeners() {
- return [
- [this.node, 'click', this.onClick.bind(this)],
- [this.node, 'mousedown', this.onMouseDown.bind(this)],
- [this.node, 'mousemove', this.onMouseMove.bind(this)],
- [this.node, 'mouseover', this.onMouseOver.bind(this)],
- [this.node, 'mouseout', this.onMouseOut.bind(this)]
- ];
+ _onParserChange(e) {
+ const value = e.target.value;
+ apiModifySettings([{
+ action: 'set',
+ path: 'parsing.selectedParser',
+ value,
+ scope: 'profile',
+ optionsContext: this._getOptionsContext()
+ }], 'search');
}
- getTouchEventListeners() {
- return [
- [this.node, 'auxclick', this.onAuxClick.bind(this)],
- [this.node, 'touchstart', this.onTouchStart.bind(this)],
- [this.node, 'touchend', this.onTouchEnd.bind(this)],
- [this.node, 'touchcancel', this.onTouchCancel.bind(this)],
- [this.node, 'touchmove', this.onTouchMove.bind(this), {passive: false}],
- [this.node, 'contextmenu', this.onContextMenu.bind(this)]
- ];
- }
-
- setOptions(options) {
- super.setOptions(options);
- this.queryParser.dataset.termSpacing = `${options.parsing.termSpacing}`;
- }
-
- refreshSelectedParser() {
- if (this.parseResults.length > 0) {
- if (!this.getParseResult()) {
- const selectedParser = this.parseResults[0].id;
- apiOptionsSet({parsing: {selectedParser}}, this.getOptionsContext());
+ _refreshSelectedParser() {
+ if (this._parseResults.length > 0) {
+ if (!this._getParseResult()) {
+ const value = this._parseResults[0].id;
+ apiModifySettings([{
+ action: 'set',
+ path: 'parsing.selectedParser',
+ value,
+ scope: 'profile',
+ optionsContext: this._getOptionsContext()
+ }], 'search');
}
}
}
- getParseResult() {
- const {selectedParser} = this.options.parsing;
- return this.parseResults.find((r) => r.id === selectedParser);
- }
-
- async setText(text) {
- this.setSpinnerVisible(true);
-
- this.setPreview(text);
-
- this.parseResults = await apiTextParse(text, this.getOptionsContext());
- this.refreshSelectedParser();
-
- this.renderParserSelect();
- this.renderParseResult();
-
- this.setSpinnerVisible(false);
+ _getParseResult() {
+ const {selectedParser} = this._options.parsing;
+ return this._parseResults.find((r) => r.id === selectedParser);
}
- setPreview(text) {
+ _setPreview(text) {
const previewTerms = [];
for (let i = 0, ii = text.length; i < ii; i += 2) {
const tempText = text.substring(i, i + 2);
previewTerms.push([{text: tempText, reading: ''}]);
}
- this.queryParser.textContent = '';
- this.queryParser.appendChild(this.queryParserGenerator.createParseResult(previewTerms, true));
+ this._queryParser.textContent = '';
+ this._queryParser.appendChild(this._queryParserGenerator.createParseResult(previewTerms, true));
}
- renderParserSelect() {
- this.queryParserSelect.textContent = '';
- if (this.parseResults.length > 1) {
- const {selectedParser} = this.options.parsing;
- const select = this.queryParserGenerator.createParserSelect(this.parseResults, selectedParser);
- select.addEventListener('change', this.onParserChange.bind(this));
- this.queryParserSelect.appendChild(select);
+ _renderParserSelect() {
+ this._queryParserSelect.textContent = '';
+ if (this._parseResults.length > 1) {
+ const {selectedParser} = this._options.parsing;
+ const select = this._queryParserGenerator.createParserSelect(this._parseResults, selectedParser);
+ select.addEventListener('change', this._onParserChange.bind(this));
+ this._queryParserSelect.appendChild(select);
}
}
- renderParseResult() {
- const parseResult = this.getParseResult();
- this.queryParser.textContent = '';
+ _renderParseResult() {
+ const parseResult = this._getParseResult();
+ this._queryParser.textContent = '';
if (!parseResult) { return; }
- this.queryParser.appendChild(this.queryParserGenerator.createParseResult(parseResult.content));
+ this._queryParser.appendChild(this._queryParserGenerator.createParseResult(parseResult.content));
}
}
diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js
index 871c576b..96e8a70b 100644
--- a/ext/bg/js/search.js
+++ b/ext/bg/js/search.js
@@ -17,11 +17,13 @@
/* global
* ClipboardMonitor
+ * DOM
* Display
* QueryParser
* apiClipboardGet
- * apiOptionsSet
+ * apiModifySettings
* apiTermsFind
+ * wanakana
*/
class DisplaySearch extends Display {
@@ -72,15 +74,11 @@ class DisplaySearch extends Display {
]);
}
- static create() {
- const instance = new DisplaySearch();
- instance.prepare();
- return instance;
- }
-
async prepare() {
try {
await super.prepare();
+ await this.updateOptions();
+ yomichan.on('optionsUpdated', () => this.updateOptions());
await this.queryParser.prepare();
const {queryParams: {query='', mode=''}} = parseUrl(window.location.href);
@@ -89,7 +87,7 @@ class DisplaySearch extends Display {
if (this.options.general.enableWanakana === true) {
this.wanakanaEnable.checked = true;
- window.wanakana.bind(this.query);
+ wanakana.bind(this.query);
} else {
this.wanakanaEnable.checked = false;
}
@@ -125,10 +123,10 @@ class DisplaySearch extends Display {
}
onError(error) {
- logError(error, true);
+ yomichan.logError(error);
}
- onSearchClear() {
+ onEscape() {
if (this.query === null) {
return;
}
@@ -181,7 +179,7 @@ class DisplaySearch extends Display {
}
onKeyDown(e) {
- const key = Display.getKeyFromEvent(e);
+ const key = DOM.getKeyFromEvent(e);
const ignoreKeys = this._onKeyDownIgnoreKeys;
const activeModifierMap = new Map([
@@ -236,7 +234,7 @@ class DisplaySearch extends Display {
this.setIntroVisible(!valid, animate);
this.updateSearchButton();
if (valid) {
- const {definitions} = await apiTermsFind(query, details, this.optionsContext);
+ const {definitions} = await apiTermsFind(query, details, this.getOptionsContext());
this.setContent('terms', {definitions, context: {
focus: false,
disableHistory: true,
@@ -254,13 +252,19 @@ class DisplaySearch extends Display {
}
onWanakanaEnableChange(e) {
- const enableWanakana = e.target.checked;
- if (enableWanakana) {
- window.wanakana.bind(this.query);
+ const value = e.target.checked;
+ if (value) {
+ wanakana.bind(this.query);
} else {
- window.wanakana.unbind(this.query);
+ wanakana.unbind(this.query);
}
- apiOptionsSet({general: {enableWanakana}}, this.getOptionsContext());
+ apiModifySettings([{
+ action: 'set',
+ path: 'general.enableWanakana',
+ value,
+ scope: 'profile',
+ optionsContext: this.getOptionsContext()
+ }], 'search');
}
onClipboardMonitorEnableChange(e) {
@@ -270,7 +274,13 @@ class DisplaySearch extends Display {
(granted) => {
if (granted) {
this.clipboardMonitor.start();
- apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext());
+ apiModifySettings([{
+ action: 'set',
+ path: 'general.enableClipboardMonitor',
+ value: true,
+ scope: 'profile',
+ optionsContext: this.getOptionsContext()
+ }], 'search');
} else {
e.target.checked = false;
}
@@ -278,7 +288,13 @@ class DisplaySearch extends Display {
);
} else {
this.clipboardMonitor.stop();
- apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext());
+ apiModifySettings([{
+ action: 'set',
+ path: 'general.enableClipboardMonitor',
+ value: false,
+ scope: 'profile',
+ optionsContext: this.getOptionsContext()
+ }], 'search');
}
}
@@ -298,11 +314,16 @@ class DisplaySearch extends Display {
}
setQuery(query) {
- const interpretedQuery = this.isWanakanaEnabled() ? window.wanakana.toKana(query) : query;
+ const interpretedQuery = this.isWanakanaEnabled() ? wanakana.toKana(query) : query;
this.query.value = interpretedQuery;
this.queryParser.setText(interpretedQuery);
}
+ async setContent(type, details) {
+ this.query.blur();
+ await super.setContent(type, details);
+ }
+
setIntroVisible(visible, animate) {
if (this.introVisible === visible) {
return;
@@ -376,5 +397,3 @@ class DisplaySearch extends Display {
}
}
}
-
-DisplaySearch.instance = DisplaySearch.create();
diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js
index b32a9517..ff1277ed 100644
--- a/ext/bg/js/settings/anki.js
+++ b/ext/bg/js/settings/anki.js
@@ -16,13 +16,13 @@
*/
/* global
+ * apiGetAnkiDeckNames
+ * apiGetAnkiModelFieldNames
+ * apiGetAnkiModelNames
* getOptionsContext
* getOptionsMutable
* onFormOptionsChanged
* settingsSaveOptions
- * utilAnkiGetDeckNames
- * utilAnkiGetModelFieldNames
- * utilAnkiGetModelNames
* utilBackgroundIsolate
*/
@@ -107,7 +107,7 @@ async function _ankiDeckAndModelPopulate(options) {
const kanjiModel = {value: options.anki.kanji.model, selector: '#anki-kanji-model'};
try {
_ankiSpinnerShow(true);
- const [deckNames, modelNames] = await Promise.all([utilAnkiGetDeckNames(), utilAnkiGetModelNames()]);
+ const [deckNames, modelNames] = await Promise.all([apiGetAnkiDeckNames(), apiGetAnkiModelNames()]);
deckNames.sort();
modelNames.sort();
termsDeck.values = deckNames;
@@ -180,7 +180,7 @@ async function _onAnkiModelChanged(e) {
let fieldNames;
try {
const modelName = node.value;
- fieldNames = await utilAnkiGetModelFieldNames(modelName);
+ fieldNames = await apiGetAnkiModelFieldNames(modelName);
_ankiSetError(null);
} catch (error) {
_ankiSetError(error);
diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js
index 3c6e126c..ac2d82f3 100644
--- a/ext/bg/js/settings/audio.js
+++ b/ext/bg/js/settings/audio.js
@@ -18,7 +18,6 @@
/* global
* AudioSourceUI
* AudioSystem
- * apiAudioGetUri
* getOptionsContext
* getOptionsMutable
* settingsSaveOptions
@@ -29,10 +28,8 @@ let audioSystem = null;
async function audioSettingsInitialize() {
audioSystem = new AudioSystem({
- getAudioUri: async (definition, source) => {
- const optionsContext = getOptionsContext();
- return await apiAudioGetUri(definition, source, optionsContext);
- }
+ audioUriBuilder: null,
+ useCache: true
});
const optionsContext = getOptionsContext();
@@ -115,7 +112,7 @@ function textToSpeechTest() {
const text = document.querySelector('#text-to-speech-voice-test').dataset.speechText || '';
const voiceUri = document.querySelector('#text-to-speech-voice').value;
- const audio = audioSystem.createTextToSpeechAudio({text, voiceUri});
+ const audio = audioSystem.createTextToSpeechAudio(text, voiceUri);
audio.volume = 1.0;
audio.play();
} catch (e) {
diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js
index bdfef658..faf4e592 100644
--- a/ext/bg/js/settings/backup.js
+++ b/ext/bg/js/settings/backup.js
@@ -133,7 +133,7 @@ async function _settingsImportSetOptionsFull(optionsFull) {
}
function _showSettingsImportError(error) {
- logError(error);
+ yomichan.logError(error);
document.querySelector('#settings-import-error-modal-message').textContent = `${error}`;
$('#settings-import-error-modal').modal('show');
}
diff --git a/ext/bg/js/settings/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js
index 84498b42..031689a7 100644
--- a/ext/bg/js/settings/conditions-ui.js
+++ b/ext/bg/js/settings/conditions-ui.js
@@ -16,6 +16,7 @@
*/
/* global
+ * DOM
* conditionsNormalizeOptionValue
*/
@@ -103,11 +104,11 @@ ConditionsUI.Container = class Container {
if (hasOwn(conditionDescriptor.operators, operator)) {
const operatorDescriptor = conditionDescriptor.operators[operator];
if (hasOwn(operatorDescriptor, 'defaultValue')) {
- return {value: operatorDescriptor.defaultValue, fromOperator: true};
+ return {value: this.isolate(operatorDescriptor.defaultValue), fromOperator: true};
}
}
if (hasOwn(conditionDescriptor, 'defaultValue')) {
- return {value: conditionDescriptor.defaultValue, fromOperator: false};
+ return {value: this.isolate(conditionDescriptor.defaultValue), fromOperator: false};
}
}
return {fromOperator: false};
@@ -177,7 +178,8 @@ ConditionsUI.Condition = class Condition {
this.parent = parent;
this.condition = condition;
this.container = ConditionsUI.instantiateTemplate('#condition-template').appendTo(parent.container);
- this.input = this.container.find('input');
+ this.input = this.container.find('.condition-input');
+ this.inputInner = null;
this.typeSelect = this.container.find('.condition-type');
this.operatorSelect = this.container.find('.condition-operator');
this.removeButton = this.container.find('.condition-remove');
@@ -186,14 +188,13 @@ ConditionsUI.Condition = class Condition {
this.updateOperators();
this.updateInput();
- this.input.on('change', this.onInputChanged.bind(this));
this.typeSelect.on('change', this.onConditionTypeChanged.bind(this));
this.operatorSelect.on('change', this.onConditionOperatorChanged.bind(this));
this.removeButton.on('click', this.onRemoveClicked.bind(this));
}
cleanup() {
- this.input.off('change');
+ this.inputInner.off('change');
this.typeSelect.off('change');
this.operatorSelect.off('change');
this.removeButton.off('click');
@@ -204,6 +205,10 @@ ConditionsUI.Condition = class Condition {
this.parent.save();
}
+ isolate(object) {
+ return this.parent.isolate(object);
+ }
+
updateTypes() {
const conditionDescriptors = this.parent.parent.conditionDescriptors;
const optionGroup = this.typeSelect.find('optgroup');
@@ -236,21 +241,48 @@ ConditionsUI.Condition = class Condition {
updateInput() {
const conditionDescriptors = this.parent.parent.conditionDescriptors;
const {type, operator} = this.condition;
- const props = new Map([
- ['placeholder', ''],
- ['type', 'text']
- ]);
const objects = [];
+ let inputType = null;
if (hasOwn(conditionDescriptors, type)) {
const conditionDescriptor = conditionDescriptors[type];
objects.push(conditionDescriptor);
+ if (hasOwn(conditionDescriptor, 'type')) {
+ inputType = conditionDescriptor.type;
+ }
if (hasOwn(conditionDescriptor.operators, operator)) {
const operatorDescriptor = conditionDescriptor.operators[operator];
objects.push(operatorDescriptor);
+ if (hasOwn(operatorDescriptor, 'type')) {
+ inputType = operatorDescriptor.type;
+ }
}
}
+ this.input.empty();
+ if (inputType === 'select') {
+ this.inputInner = this.createSelectElement(objects);
+ } else if (inputType === 'keyMulti') {
+ this.inputInner = this.createInputKeyMultiElement(objects);
+ } else {
+ this.inputInner = this.createInputElement(objects);
+ }
+ this.inputInner.appendTo(this.input);
+ this.inputInner.on('change', this.onInputChanged.bind(this));
+
+ const {valid, value} = this.validateValue(this.condition.value);
+ this.inputInner.toggleClass('is-invalid', !valid);
+ this.inputInner.val(value);
+ }
+
+ createInputElement(objects) {
+ const inputInner = ConditionsUI.instantiateTemplate('#condition-input-text-template');
+
+ const props = new Map([
+ ['placeholder', ''],
+ ['type', 'text']
+ ]);
+
for (const object of objects) {
if (hasOwn(object, 'placeholder')) {
props.set('placeholder', object.placeholder);
@@ -266,35 +298,124 @@ ConditionsUI.Condition = class Condition {
}
for (const [prop, value] of props.entries()) {
- this.input.prop(prop, value);
+ inputInner.prop(prop, value);
}
- const {valid} = this.validateValue(this.condition.value);
- this.input.toggleClass('is-invalid', !valid);
- this.input.val(this.condition.value);
+ return inputInner;
}
- validateValue(value) {
+ createInputKeyMultiElement(objects) {
+ const inputInner = this.createInputElement(objects);
+
+ inputInner.prop('readonly', true);
+
+ let values = [];
+ let keySeparator = ' + ';
+ for (const object of objects) {
+ if (hasOwn(object, 'values')) {
+ values = object.values;
+ }
+ if (hasOwn(object, 'keySeparator')) {
+ keySeparator = object.keySeparator;
+ }
+ }
+
+ const pressedKeyIndices = new Set();
+
+ const onKeyDown = ({originalEvent}) => {
+ const pressedKeyEventName = DOM.getKeyFromEvent(originalEvent);
+ if (pressedKeyEventName === 'Escape' || pressedKeyEventName === 'Backspace') {
+ pressedKeyIndices.clear();
+ inputInner.val('');
+ inputInner.change();
+ return;
+ }
+
+ const pressedModifiers = DOM.getActiveModifiers(originalEvent);
+ // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey
+ // https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta
+ // It works with mouse events on some platforms, so try to determine if metaKey is pressed
+ // hack; only works when Shift and Alt are not pressed
+ const isMetaKeyChrome = (
+ pressedKeyEventName === 'Meta' &&
+ getSetDifference(new Set(['shift', 'alt']), pressedModifiers).size !== 0
+ );
+ if (isMetaKeyChrome) {
+ pressedModifiers.add('meta');
+ }
+
+ for (const modifier of pressedModifiers) {
+ const foundIndex = values.findIndex(({optionValue}) => optionValue === modifier);
+ if (foundIndex !== -1) {
+ pressedKeyIndices.add(foundIndex);
+ }
+ }
+
+ const inputValue = [...pressedKeyIndices].map((i) => values[i].name).join(keySeparator);
+ inputInner.val(inputValue);
+ inputInner.change();
+ };
+
+ inputInner.on('keydown', onKeyDown);
+
+ return inputInner;
+ }
+
+ createSelectElement(objects) {
+ const inputInner = ConditionsUI.instantiateTemplate('#condition-input-select-template');
+
+ const data = new Map([
+ ['values', []],
+ ['defaultValue', null]
+ ]);
+
+ for (const object of objects) {
+ if (hasOwn(object, 'values')) {
+ data.set('values', object.values);
+ }
+ if (hasOwn(object, 'defaultValue')) {
+ data.set('defaultValue', this.isolate(object.defaultValue));
+ }
+ }
+
+ for (const {optionValue, name} of data.get('values')) {
+ const option = ConditionsUI.instantiateTemplate('#condition-input-option-template');
+ option.attr('value', optionValue);
+ option.text(name);
+ option.appendTo(inputInner);
+ }
+
+ const defaultValue = data.get('defaultValue');
+ if (defaultValue !== null) {
+ inputInner.val(this.isolate(defaultValue));
+ }
+
+ return inputInner;
+ }
+
+ validateValue(value, isInput=false) {
const conditionDescriptors = this.parent.parent.conditionDescriptors;
let valid = true;
+ let inputTransformedValue = null;
try {
- value = conditionsNormalizeOptionValue(
+ [value, inputTransformedValue] = conditionsNormalizeOptionValue(
conditionDescriptors,
this.condition.type,
this.condition.operator,
- value
+ value,
+ isInput
);
} catch (e) {
valid = false;
}
- return {valid, value};
+ return {valid, value, inputTransformedValue};
}
onInputChanged() {
- const {valid, value} = this.validateValue(this.input.val());
- this.input.toggleClass('is-invalid', !valid);
- this.input.val(value);
- this.condition.value = value;
+ const {valid, value, inputTransformedValue} = this.validateValue(this.inputInner.val(), true);
+ this.inputInner.toggleClass('is-invalid', !valid);
+ this.inputInner.val(value);
+ this.condition.value = inputTransformedValue !== null ? inputTransformedValue : value;
this.save();
}
diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js
index 1a6d452b..632c01ea 100644
--- a/ext/bg/js/settings/dictionaries.js
+++ b/ext/bg/js/settings/dictionaries.js
@@ -17,8 +17,13 @@
/* global
* PageExitPrevention
+ * apiDeleteDictionary
+ * apiGetDictionaryCounts
+ * apiGetDictionaryInfo
+ * apiImportDictionaryArchive
* apiOptionsGet
* apiOptionsGetFull
+ * apiPurgeDatabase
* getOptionsContext
* getOptionsFullMutable
* getOptionsMutable
@@ -26,11 +31,6 @@
* storageEstimate
* storageUpdateStats
* utilBackgroundIsolate
- * utilDatabaseDeleteDictionary
- * utilDatabaseGetDictionaryCounts
- * utilDatabaseGetDictionaryInfo
- * utilDatabaseImport
- * utilDatabasePurge
*/
let dictionaryUI = null;
@@ -312,7 +312,7 @@ class SettingsDictionaryEntryUI {
progressBar.style.width = `${percent}%`;
};
- await utilDatabaseDeleteDictionary(this.dictionaryInfo.title, onProgress, {rate: 1000});
+ await apiDeleteDictionary(this.dictionaryInfo.title, onProgress);
} catch (e) {
dictionaryErrorsShow([e]);
} finally {
@@ -431,7 +431,7 @@ async function onDictionaryOptionsChanged() {
async function onDatabaseUpdated() {
try {
- const dictionaries = await utilDatabaseGetDictionaryInfo();
+ const dictionaries = await apiGetDictionaryInfo();
dictionaryUI.setDictionaries(dictionaries);
document.querySelector('#dict-warning').hidden = (dictionaries.length > 0);
@@ -439,7 +439,7 @@ async function onDatabaseUpdated() {
updateMainDictionarySelectOptions(dictionaries);
await updateMainDictionarySelectValue();
- const {counts, total} = await utilDatabaseGetDictionaryCounts(dictionaries.map((v) => v.title), true);
+ const {counts, total} = await apiGetDictionaryCounts(dictionaries.map((v) => v.title), true);
dictionaryUI.setCounts(counts, total);
} catch (e) {
dictionaryErrorsShow([e]);
@@ -554,7 +554,7 @@ function dictionaryErrorsShow(errors) {
if (errors !== null && errors.length > 0) {
const uniqueErrors = new Map();
for (let e of errors) {
- logError(e);
+ yomichan.logError(e);
e = dictionaryErrorToString(e);
let count = uniqueErrors.get(e);
if (typeof count === 'undefined') {
@@ -618,7 +618,7 @@ async function onDictionaryPurge(e) {
dictionaryErrorsShow(null);
dictionarySpinnerShow(true);
- await utilDatabasePurge();
+ await apiPurgeDatabase();
for (const {options} of toIterable((await getOptionsFullMutable()).profiles)) {
options.dictionaries = utilBackgroundIsolate({});
options.general.mainDictionary = '';
@@ -679,7 +679,8 @@ async function onDictionaryImport(e) {
dictImportInfo.textContent = `(${i + 1} of ${ii})`;
}
- const {result, errors} = await utilDatabaseImport(files[i], updateProgress, importDetails);
+ 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;
@@ -713,6 +714,15 @@ async function onDictionaryImport(e) {
}
}
+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();
diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js
index 308e92eb..61395b1c 100644
--- a/ext/bg/js/settings/main.js
+++ b/ext/bg/js/settings/main.js
@@ -21,6 +21,8 @@
* ankiInitialize
* ankiTemplatesInitialize
* ankiTemplatesUpdateValue
+ * apiForwardLogsToBackend
+ * apiGetEnvironmentInfo
* apiOptionsSave
* appearanceInitialize
* audioSettingsInitialize
@@ -130,6 +132,7 @@ async function formRead(options) {
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);
@@ -211,6 +214,7 @@ async function formWrite(options) {
$('#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);
@@ -282,12 +286,31 @@ function showExtensionInformation() {
node.textContent = `${manifest.name} v${manifest.version}`;
}
+async function settingsPopulateModifierKeys() {
+ const scanModifierKeySelect = document.querySelector('#scan-modifier-key');
+ scanModifierKeySelect.textContent = '';
+
+ const environment = await apiGetEnvironmentInfo();
+ const modifierKeys = [
+ {value: 'none', name: 'None'},
+ ...environment.modifiers.keys
+ ];
+ for (const {value, name} of modifierKeys) {
+ const option = document.createElement('option');
+ option.value = value;
+ option.textContent = name;
+ scanModifierKeySelect.appendChild(option);
+ }
+}
+
async function onReady() {
+ apiForwardLogsToBackend();
await yomichan.prepare();
showExtensionInformation();
+ await settingsPopulateModifierKeys();
formSetupEventListeners();
appearanceInitialize();
await audioSettingsInitialize();
diff --git a/ext/bg/js/settings/popup-preview-frame-main.js b/ext/bg/js/settings/popup-preview-frame-main.js
new file mode 100644
index 00000000..8228125f
--- /dev/null
+++ b/ext/bg/js/settings/popup-preview-frame-main.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright (C) 2019-2020 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * SettingsPopupPreview
+ * apiForwardLogsToBackend
+ */
+
+(() => {
+ apiForwardLogsToBackend();
+ new SettingsPopupPreview();
+})();
diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js
index fba114e2..8901a0c4 100644
--- a/ext/bg/js/settings/popup-preview-frame.js
+++ b/ext/bg/js/settings/popup-preview-frame.js
@@ -18,8 +18,9 @@
/* global
* Frontend
* Popup
- * PopupProxyHost
+ * PopupFactory
* TextSourceRange
+ * apiFrameInformationGet
* apiOptionsGet
*/
@@ -32,46 +33,46 @@ class SettingsPopupPreview {
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)]
+ ['setCustomOuterCss', ({css}) => this.setCustomOuterCss(css)],
+ ['updateOptionsContext', ({optionsContext}) => this.updateOptionsContext(optionsContext)]
]);
- }
- static create() {
- const instance = new SettingsPopupPreview();
- instance.prepare();
- return instance;
+ window.addEventListener('message', this.onMessage.bind(this), false);
}
- async prepare() {
- // Setup events
- window.addEventListener('message', this.onMessage.bind(this), false);
+ async prepare(optionsContext) {
+ this.optionsContext = optionsContext;
+ // Setup events
document.querySelector('#theme-dark-checkbox').addEventListener('change', this.onThemeDarkCheckboxChanged.bind(this), false);
// Overwrite API functions
window.apiOptionsGet = this.apiOptionsGet.bind(this);
// Overwrite frontend
- const popupHost = new PopupProxyHost();
- await popupHost.prepare();
+ const {frameId} = await apiFrameInformationGet();
+
+ const popupFactory = new PopupFactory(frameId);
+ await popupFactory.prepare();
- this.popup = popupHost.getOrCreatePopup();
+ this.popup = popupFactory.getOrCreatePopup();
this.popup.setChildrenSupported(false);
this.popupSetCustomOuterCssOld = this.popup.setCustomOuterCss;
this.popup.setCustomOuterCss = this.popupSetCustomOuterCss.bind(this);
this.frontend = new Frontend(this.popup);
-
- this.frontend.setEnabled = () => {};
- this.frontend.searchClear = () => {};
-
+ this.frontend.getOptionsContext = async () => this.optionsContext;
await this.frontend.prepare();
+ this.frontend.setDisabledOverride(true);
+ this.frontend.canClearSelection = false;
// Update search
this.updateSearch();
@@ -122,7 +123,7 @@ class SettingsPopupPreview {
}
this.themeChangeTimeout = setTimeout(() => {
this.themeChangeTimeout = null;
- this.frontend.popup.updateTheme();
+ this.popup.updateTheme();
}, 300);
}
@@ -143,12 +144,18 @@ class SettingsPopupPreview {
setCustomCss(css) {
if (this.frontend === null) { return; }
- this.frontend.popup.setCustomCss(css);
+ this.popup.setCustomCss(css);
}
setCustomOuterCss(css) {
if (this.frontend === null) { return; }
- this.frontend.popup.setCustomOuterCss(css, false);
+ this.popup.setCustomOuterCss(css, false);
+ }
+
+ async updateOptionsContext(optionsContext) {
+ this.optionsContext = optionsContext;
+ await this.frontend.updateOptions();
+ await this.updateSearch();
}
async updateSearch() {
@@ -163,23 +170,17 @@ class SettingsPopupPreview {
const source = new TextSourceRange(range, range.toString(), null, null);
try {
- await this.frontend.onSearchSource(source, 'script');
- this.frontend.setCurrentTextSource(source);
+ await this.frontend.setTextSource(source);
} finally {
source.cleanup();
}
this.textSource = source;
await this.frontend.showContentCompleted();
- if (this.frontend.popup.isVisibleSync()) {
+ if (this.popup.isVisibleSync()) {
this.popupShown = true;
}
this.setInfoVisible(!this.popupShown);
}
}
-
-SettingsPopupPreview.instance = SettingsPopupPreview.create();
-
-
-
diff --git a/ext/bg/js/settings/popup-preview.js b/ext/bg/js/settings/popup-preview.js
index 091872be..fdc3dd94 100644
--- a/ext/bg/js/settings/popup-preview.js
+++ b/ext/bg/js/settings/popup-preview.js
@@ -15,6 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+/* global
+ * getOptionsContext
+ * wanakana
+ */
function appearanceInitialize() {
let previewVisible = false;
@@ -37,7 +41,7 @@ function showAppearancePreview() {
frame.src = '/bg/settings-popup-preview.html';
frame.id = 'settings-popup-preview-frame';
- window.wanakana.bind(text[0]);
+ wanakana.bind(text[0]);
const targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
@@ -57,6 +61,23 @@ function showAppearancePreview() {
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', '');
diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js
index 867b17aa..bdf5a13d 100644
--- a/ext/bg/js/settings/profiles.js
+++ b/ext/bg/js/settings/profiles.js
@@ -23,6 +23,7 @@
* getOptionsFullMutable
* getOptionsMutable
* profileConditionsDescriptor
+ * profileConditionsDescriptorPromise
* settingsSaveOptions
* utilBackgroundIsolate
*/
@@ -98,6 +99,7 @@ async function profileFormWrite(optionsFull) {
profileConditionsContainer.cleanup();
}
+ await profileConditionsDescriptorPromise;
profileConditionsContainer = new ConditionsUI.Container(
profileConditionsDescriptor,
'popupLevel',
@@ -128,7 +130,7 @@ function profileOptionsPopulateSelect(select, profiles, currentValue, ignoreIndi
}
async function profileOptionsUpdateTarget(optionsFull) {
- profileFormWrite(optionsFull);
+ await profileFormWrite(optionsFull);
const optionsContext = getOptionsContext();
const options = await getOptionsMutable(optionsContext);
@@ -190,6 +192,8 @@ async function onTargetProfileChanged() {
currentProfileIndex = index;
await profileOptionsUpdateTarget(optionsFull);
+
+ yomichan.trigger('modifyingProfileChange');
}
async function onProfileAdd() {
@@ -197,9 +201,13 @@ async function onProfileAdd() {
const profile = utilBackgroundIsolate(optionsFull.profiles[currentProfileIndex]);
profile.name = profileOptionsCreateCopyName(profile.name, optionsFull.profiles, 100);
optionsFull.profiles.push(profile);
+
currentProfileIndex = optionsFull.profiles.length - 1;
+
await profileOptionsUpdateTarget(optionsFull);
await settingsSaveOptions();
+
+ yomichan.trigger('modifyingProfileChange');
}
async function onProfileRemove(e) {
@@ -238,6 +246,8 @@ async function onProfileRemoveConfirm() {
await profileOptionsUpdateTarget(optionsFull);
await settingsSaveOptions();
+
+ yomichan.trigger('modifyingProfileChange');
}
function onProfileNameChanged() {
@@ -263,6 +273,8 @@ async function onProfileMove(offset) {
await profileOptionsUpdateTarget(optionsFull);
await settingsSaveOptions();
+
+ yomichan.trigger('modifyingProfileChange');
}
async function onProfileCopy() {
diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js
index aaa1a0ec..3fd329d1 100644
--- a/ext/bg/js/translator.js
+++ b/ext/bg/js/translator.js
@@ -45,14 +45,8 @@ class Translator {
this.deinflector = new Deinflector(reasons);
}
- async purgeDatabase() {
+ clearDatabaseCaches() {
this.tagCache.clear();
- await this.database.purge();
- }
-
- async deleteDictionary(dictionaryName) {
- this.tagCache.clear();
- await this.database.deleteDictionary(dictionaryName);
}
async getSequencedDefinitions(definitions, mainDictionary) {
@@ -482,7 +476,9 @@ class Translator {
switch (mode) {
case 'freq':
for (const term of termsUnique[index]) {
- term.frequencies.push({expression, frequency: data, dictionary});
+ const frequencyData = this.getFrequencyData(expression, data, dictionary, term);
+ if (frequencyData === null) { continue; }
+ term.frequencies.push(frequencyData);
}
break;
case 'pitch':
@@ -575,6 +571,18 @@ class Translator {
return tagMetaList;
}
+ getFrequencyData(expression, data, dictionary, term) {
+ if (data !== null && typeof data === 'object') {
+ const {frequency, reading} = data;
+
+ const termReading = term.reading || expression;
+ if (reading !== termReading) { return null; }
+
+ return {expression, frequency, dictionary};
+ }
+ return {expression, frequency: data, dictionary};
+ }
+
async getPitchData(expression, data, dictionary, term) {
const reading = data.reading;
const termReading = term.reading || expression;
diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js
index 69536f02..8f86e47a 100644
--- a/ext/bg/js/util.js
+++ b/ext/bg/js/util.js
@@ -58,81 +58,14 @@ function utilBackgroundFunctionIsolate(func) {
return backgroundPage.utilFunctionIsolate(func);
}
-function utilStringHashCode(string) {
- let hashCode = 0;
-
- if (typeof string !== 'string') { return hashCode; }
-
- for (let i = 0, charCode = string.charCodeAt(i); i < string.length; charCode = string.charCodeAt(++i)) {
- hashCode = ((hashCode << 5) - hashCode) + charCode;
- hashCode |= 0;
- }
-
- return hashCode;
-}
-
function utilBackend() {
const backend = chrome.extension.getBackgroundPage().yomichanBackend;
- if (!backend.isPrepared) {
+ if (!backend.isPrepared()) {
throw new Error('Backend not ready yet');
}
return backend;
}
-async function utilAnkiGetModelNames() {
- return utilIsolate(await utilBackend().anki.getModelNames());
-}
-
-async function utilAnkiGetDeckNames() {
- return utilIsolate(await utilBackend().anki.getDeckNames());
-}
-
-async function utilDatabaseGetDictionaryInfo() {
- return utilIsolate(await utilBackend().translator.database.getDictionaryInfo());
-}
-
-async function utilDatabaseGetDictionaryCounts(dictionaryNames, getTotal) {
- return utilIsolate(await utilBackend().translator.database.getDictionaryCounts(
- utilBackgroundIsolate(dictionaryNames),
- utilBackgroundIsolate(getTotal)
- ));
-}
-
-async function utilAnkiGetModelFieldNames(modelName) {
- return utilIsolate(await utilBackend().anki.getModelFieldNames(
- utilBackgroundIsolate(modelName)
- ));
-}
-
-async function utilDatabasePurge() {
- return utilIsolate(await utilBackend().translator.purgeDatabase());
-}
-
-async function utilDatabaseDeleteDictionary(dictionaryName, onProgress) {
- return utilIsolate(await utilBackend().translator.database.deleteDictionary(
- utilBackgroundIsolate(dictionaryName),
- utilBackgroundFunctionIsolate(onProgress)
- ));
-}
-
-async function utilDatabaseImport(data, onProgress, details) {
- data = await utilReadFile(data);
- return utilIsolate(await utilBackend().importDictionary(
- utilBackgroundIsolate(data),
- utilBackgroundFunctionIsolate(onProgress),
- utilBackgroundIsolate(details)
- ));
-}
-
-function utilReadFile(file) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result);
- reader.onerror = () => reject(reader.error);
- reader.readAsBinaryString(file);
- });
-}
-
function utilReadFileArrayBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
diff --git a/ext/bg/legal.html b/ext/bg/legal.html
index 78acf79a..1ee9a28c 100644
--- a/ext/bg/legal.html
+++ b/ext/bg/legal.html
@@ -6,6 +6,7 @@
<title>Yomichan Legal</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
diff --git a/ext/bg/search.html b/ext/bg/search.html
index eacc1893..f3f156d8 100644
--- a/ext/bg/search.html
+++ b/ext/bg/search.html
@@ -6,6 +6,7 @@
<title>Yomichan Search</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -13,8 +14,6 @@
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/lib/bootstrap/css/bootstrap-theme.min.css">
<link rel="stylesheet" type="text/css" href="/mixed/css/display.css">
- <link rel="stylesheet" type="text/css" href="/mixed/css/display-default.css" data-yomichan-theme-name="default">
- <link rel="stylesheet alternate" type="text/css" href="/mixed/css/display-dark.css" data-yomichan-theme-name="dark">
</head>
<body>
<div class="container">
@@ -78,13 +77,14 @@
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
- <script src="/bg/js/japanese.js"></script>
<script src="/fg/js/document.js"></script>
<script src="/fg/js/source.js"></script>
<script src="/mixed/js/audio-system.js"></script>
<script src="/mixed/js/display-context.js"></script>
<script src="/mixed/js/display.js"></script>
<script src="/mixed/js/display-generator.js"></script>
+ <script src="/mixed/js/dynamic-loader.js"></script>
+ <script src="/mixed/js/media-loader.js"></script>
<script src="/mixed/js/scroll.js"></script>
<script src="/mixed/js/text-scanner.js"></script>
<script src="/mixed/js/template-handler.js"></script>
@@ -93,6 +93,7 @@
<script src="/bg/js/search-query-parser.js"></script>
<script src="/bg/js/clipboard-monitor.js"></script>
<script src="/bg/js/search.js"></script>
- <script src="/bg/js/search-frontend.js"></script>
+
+ <script src="/bg/js/search-main.js"></script>
</body>
</html>
diff --git a/ext/bg/settings-popup-preview.html b/ext/bg/settings-popup-preview.html
index f33ecedf..2f0b841b 100644
--- a/ext/bg/settings-popup-preview.html
+++ b/ext/bg/settings-popup-preview.html
@@ -6,6 +6,7 @@
<title>Yomichan Popup Preview</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -120,14 +121,17 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
<script src="/mixed/js/api.js"></script>
+ <script src="/mixed/js/dynamic-loader.js"></script>
<script src="/mixed/js/text-scanner.js"></script>
<script src="/fg/js/document.js"></script>
<script src="/fg/js/frontend-api-receiver.js"></script>
<script src="/fg/js/popup.js"></script>
<script src="/fg/js/source.js"></script>
- <script src="/fg/js/popup-proxy-host.js"></script>
+ <script src="/fg/js/popup-factory.js"></script>
<script src="/fg/js/frontend.js"></script>
<script src="/bg/js/settings/popup-preview-frame.js"></script>
+
+ <script src="/bg/js/settings/popup-preview-frame-main.js"></script>
</body>
</html>
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index 96c1db82..3ce91f12 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -6,6 +6,7 @@
<title>Yomichan Options</title>
<link rel="icon" type="image/png" href="/mixed/img/icon16.png" sizes="16x16">
<link rel="icon" type="image/png" href="/mixed/img/icon19.png" sizes="19x19">
+ <link rel="icon" type="image/png" href="/mixed/img/icon32.png" sizes="32x32">
<link rel="icon" type="image/png" href="/mixed/img/icon38.png" sizes="38x38">
<link rel="icon" type="image/png" href="/mixed/img/icon48.png" sizes="48x48">
<link rel="icon" type="image/png" href="/mixed/img/icon64.png" sizes="64x64">
@@ -116,7 +117,7 @@
<div class="input-group-btn"><select class="form-control btn btn-default condition-type"><optgroup label="Type"></optgroup></select></div>
<div class="input-group-btn"><select class="form-control btn btn-default condition-operator"><optgroup label="Operator"></optgroup></select></div>
<div class="condition-line-break"></div>
- <div class="condition-input"><input type="text" class="form-control" /></div>
+ <div class="condition-input"></div>
<div class="input-group-btn"><button class="btn btn-danger condition-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button></div>
</div></template>
<template id="condition-group-separator-template"><div class="input-group">
@@ -125,6 +126,9 @@
<template id="condition-group-options-template"><div class="condition-group-options">
<button class="btn btn-default condition-add"><span class="glyphicon glyphicon-plus"></span></button>
</div></template>
+ <template id="condition-input-text-template"><input type="text" class="form-control condition-input-inner" /></template>
+ <template id="condition-input-select-template"><select class="form-control condition-input-inner"></select></template>
+ <template id="condition-input-option-template"><option></option></template>
</div>
<div>
@@ -408,12 +412,7 @@
<div class="form-group">
<label for="scan-modifier-key">Scan modifier key</label>
- <select class="form-control" id="scan-modifier-key">
- <option value="none">None</option>
- <option value="alt">Alt</option>
- <option value="ctrl">Ctrl</option>
- <option value="shift">Shift</option>
- </select>
+ <select class="form-control" id="scan-modifier-key"></select>
</div>
</div>
@@ -820,6 +819,14 @@
</div>
<div class="form-group options-advanced">
+ <label for="duplicate-scope">Duplicate scope</label>
+ <select class="form-control" id="duplicate-scope">
+ <option value="collection">Collection</option>
+ <option value="deck">Deck</option>
+ </select>
+ </div>
+
+ <div class="form-group options-advanced">
<label for="screenshot-format">Screenshot format</label>
<select class="form-control" id="screenshot-format">
<option value="png">PNG</option>
@@ -838,6 +845,8 @@
As Anki requires the first field in the model to be unique, it is recommended
that you set it to <code>{expression}</code> for term flashcards and <code>{character}</code> for
Kanji flashcards. You can use multiple markers per field by typing them in directly.
+ See <a href="https://foosoft.net/projects/yomichan#flashcard-configuration" target="_blank" rel="noopener">Flashcard Configuration</a>
+ on the Yomichan homepage for descriptions of the available markers.
</p>
<ul class="nav nav-tabs">
@@ -1116,6 +1125,7 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
+ <script src="/mixed/js/environment.js"></script>
<script src="/mixed/js/api.js"></script>
<script src="/mixed/js/japanese.js"></script>
@@ -1124,7 +1134,6 @@
<script src="/bg/js/conditions.js"></script>
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
- <script src="/bg/js/japanese.js"></script>
<script src="/bg/js/options.js"></script>
<script src="/bg/js/page-exit-prevention.js"></script>
<script src="/bg/js/profile-conditions.js"></script>