diff options
author | Alex Yatskov <alex@foosoft.net> | 2020-02-24 21:31:14 -0800 |
---|---|---|
committer | Alex Yatskov <alex@foosoft.net> | 2020-02-24 21:31:14 -0800 |
commit | d32f4def0eeed1599857bc04c973337a2a13dd8b (patch) | |
tree | 61149656f361dd2d9998d67d68249dc184b73fbb /ext | |
parent | 0c5b9b1fa1599cbf769d96cdebc226310f9dd8bc (diff) | |
parent | 706c3edcffb0078d71fd5b58775f16cf5fc1205b (diff) |
Merge branch 'master' into testing
Diffstat (limited to 'ext')
70 files changed, 2497 insertions, 1418 deletions
diff --git a/ext/bg/background.html b/ext/bg/background.html index af87eddb..7fd1c477 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -26,20 +26,20 @@ <script src="/bg/js/mecab.js"></script> <script src="/bg/js/audio.js"></script> <script src="/bg/js/backend-api-forwarder.js"></script> + <script src="/bg/js/clipboard-monitor.js"></script> <script src="/bg/js/conditions.js"></script> <script src="/bg/js/database.js"></script> <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/options.js"></script> <script src="/bg/js/profile-conditions.js"></script> <script src="/bg/js/request.js"></script> - <script src="/bg/js/templates.js"></script> <script src="/bg/js/translator.js"></script> <script src="/bg/js/util.js"></script> <script src="/mixed/js/audio.js"></script> - <script src="/mixed/js/japanese.js"></script> <script src="/bg/js/backend.js"></script> </body> diff --git a/ext/bg/css/settings.css b/ext/bg/css/settings.css index 815a88fa..d686e8f8 100644 --- a/ext/bg/css/settings.css +++ b/ext/bg/css/settings.css @@ -222,6 +222,20 @@ html:root[data-operating-system=openbsd] [data-show-for-operating-system~=openbs display: initial; } +html:root[data-browser=edge] [data-hide-for-browser~=edge], +html:root[data-browser=chrome] [data-hide-for-browser~=chrome], +html:root[data-browser=firefox] [data-hide-for-browser~=firefox], +html:root[data-browser=firefox-mobile] [data-hide-for-browser~=firefox-mobile], +html:root[data-operating-system=mac] [data-hide-for-operating-system~=mac], +html:root[data-operating-system=win] [data-hide-for-operating-system~=win], +html:root[data-operating-system=android] [data-hide-for-operating-system~=android], +html:root[data-operating-system=cros] [data-hide-for-operating-system~=cros], +html:root[data-operating-system=linux] [data-hide-for-operating-system~=linux], +html:root[data-operating-system=openbsd] [data-hide-for-operating-system~=openbsd] { + display: none; +} + + @media screen and (max-width: 740px) { .col-xs-6 { float: none; diff --git a/ext/bg/data/dictionary-index-schema.json b/ext/bg/data/dictionary-index-schema.json new file mode 100644 index 00000000..9311f14c --- /dev/null +++ b/ext/bg/data/dictionary-index-schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "Index file containing information about the data contained in the dictionary.", + "required": [ + "title", + "revision" + ], + "properties": { + "title": { + "type": "string", + "description": "Title of the dictionary." + }, + "revision": { + "type": "string", + "description": "Revision of the dictionary. This value is only used for displaying information." + }, + "sequenced": { + "type": "boolean", + "default": false, + "description": "Whether or not this dictionary can be used as the primary dictionary. Primary dictionaries typically contain term/expression definitions." + }, + "format": { + "type": "integer", + "description": "Format of data found in the JSON data files.", + "enum": [1, 2, 3] + }, + "version": { + "type": "integer", + "description": "Alias for format.", + "enum": [1, 2, 3] + }, + "tagMeta": { + "type": "object", + "description": "Tag information for terms and kanji. This object is obsolete and individual tag files should be used instead.", + "additionalProperties": { + "type": "object", + "description": "Information about a single tag. The object key is the name of the tag.", + "properties": { + "category": { + "type": "string", + "description": "Category for the tag." + }, + "order": { + "type": "number", + "description": "Sorting order for the tag." + }, + "notes": { + "type": "string", + "description": "Notes for the tag." + }, + "score": { + "type": "number", + "description": "Score used to determine popularity. Negative values are more rare and positive values are more frequent. This score is also used to sort search results." + } + }, + "additionalProperties": false + } + } + }, + "anyOf": [ + { + "required": ["format"] + }, + { + "required": ["version"] + } + ] +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-kanji-bank-v1-schema.json b/ext/bg/data/dictionary-kanji-bank-v1-schema.json new file mode 100644 index 00000000..6dad5a7a --- /dev/null +++ b/ext/bg/data/dictionary-kanji-bank-v1-schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "description": "Data file containing kanji information.", + "additionalItems": { + "type": "array", + "description": "Information about a single kanji character.", + "minItems": 4, + "items": [ + { + "type": "string", + "description": "Kanji character.", + "minLength": 1 + }, + { + "type": "string", + "description": "String of space-separated onyomi readings for the kanji character. An empty string is treated as no readings." + }, + { + "type": "string", + "description": "String of space-separated kunyomi readings for the kanji character. An empty string is treated as no readings." + }, + { + "type": "string", + "description": "String of space-separated tags for the kanji character. An empty string is treated as no tags." + } + ], + "additionalItems": { + "type": "string", + "description": "A meaning for the kanji character." + } + } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-kanji-bank-v3-schema.json b/ext/bg/data/dictionary-kanji-bank-v3-schema.json new file mode 100644 index 00000000..a5b82039 --- /dev/null +++ b/ext/bg/data/dictionary-kanji-bank-v3-schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "description": "Data file containing kanji information.", + "additionalItems": { + "type": "array", + "description": "Information about a single kanji character.", + "minItems": 6, + "items": [ + { + "type": "string", + "description": "Kanji character.", + "minLength": 1 + }, + { + "type": "string", + "description": "String of space-separated onyomi readings for the kanji character. An empty string is treated as no readings." + }, + { + "type": "string", + "description": "String of space-separated kunyomi readings for the kanji character. An empty string is treated as no readings." + }, + { + "type": "string", + "description": "String of space-separated tags for the kanji character. An empty string is treated as no tags." + }, + { + "type": "array", + "description": "Array of meanings for the kanji character.", + "items": { + "type": "string", + "description": "A meaning for the kanji character." + } + }, + { + "type": "object", + "description": "Various stats for the kanji character.", + "additionalProperties": { + "type": "string" + } + } + ] + } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-kanji-meta-bank-v3-schema.json b/ext/bg/data/dictionary-kanji-meta-bank-v3-schema.json new file mode 100644 index 00000000..62479026 --- /dev/null +++ b/ext/bg/data/dictionary-kanji-meta-bank-v3-schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "description": "Custom metadata for kanji characters.", + "additionalItems": { + "type": "array", + "description": "Metadata about a single kanji character.", + "minItems": 3, + "items": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "string", + "enum": ["freq"], + "description": "Type of data. \"freq\" corresponds to frequency information." + }, + { + "type": ["string", "number"], + "description": "Data for the character." + } + ] + } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-tag-bank-v3-schema.json b/ext/bg/data/dictionary-tag-bank-v3-schema.json new file mode 100644 index 00000000..ee5ca64d --- /dev/null +++ b/ext/bg/data/dictionary-tag-bank-v3-schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "description": "Data file containing tag information for terms and kanji.", + "additionalItems": { + "type": "array", + "description": "Information about a single tag.", + "minItems": 5, + "items": [ + { + "type": "string", + "description": "Tag name." + }, + { + "type": "string", + "description": "Category for the tag." + }, + { + "type": "number", + "description": "Sorting order for the tag." + }, + { + "type": "string", + "description": "Notes for the tag." + }, + { + "type": "number", + "description": "Score used to determine popularity. Negative values are more rare and positive values are more frequent. This score is also used to sort search results." + } + ] + } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-term-bank-v1-schema.json b/ext/bg/data/dictionary-term-bank-v1-schema.json new file mode 100644 index 00000000..6ffb26e6 --- /dev/null +++ b/ext/bg/data/dictionary-term-bank-v1-schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "description": "Data file containing term and expression information.", + "additionalItems": { + "type": "array", + "description": "Information about a single term/expression.", + "minItems": 5, + "items": [ + { + "type": "string", + "description": "Term or expression." + }, + { + "type": "string", + "description": "Reading of the term/expression, or an empty string if the reading is the same as the term/expression." + }, + { + "type": ["string", "null"], + "description": "String of space-separated tags for the definition. An empty string is treated as no tags." + }, + { + "type": "string", + "description": "String of space-separated rule identifiers for the definition which is used to validate delinflection. Valid rule identifiers are: v1: ichidan verb; v5: godan verb; vs: suru verb; vk: kuru verb; adj-i: i-adjective. An empty string corresponds to words which aren't inflected, such as nouns." + }, + { + "type": "number", + "description": "Score used to determine popularity. Negative values are more rare and positive values are more frequent. This score is also used to sort search results." + } + ], + "additionalItems": { + "type": "string", + "description": "Single definition for the term/expression." + } + } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-term-bank-v3-schema.json b/ext/bg/data/dictionary-term-bank-v3-schema.json new file mode 100644 index 00000000..bb982e36 --- /dev/null +++ b/ext/bg/data/dictionary-term-bank-v3-schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "description": "Data file containing term and expression information.", + "additionalItems": { + "type": "array", + "description": "Information about a single term/expression.", + "minItems": 8, + "items": [ + { + "type": "string", + "description": "Term or expression." + }, + { + "type": "string", + "description": "Reading of the term/expression, or an empty string if the reading is the same as the term/expression." + }, + { + "type": ["string", "null"], + "description": "String of space-separated tags for the definition. An empty string is treated as no tags." + }, + { + "type": "string", + "description": "String of space-separated rule identifiers for the definition which is used to validate delinflection. Valid rule identifiers are: v1: ichidan verb; v5: godan verb; vs: suru verb; vk: kuru verb; adj-i: i-adjective. An empty string corresponds to words which aren't inflected, such as nouns." + }, + { + "type": "number", + "description": "Score used to determine popularity. Negative values are more rare and positive values are more frequent. This score is also used to sort search results." + }, + { + "type": "array", + "description": "Array of definitions for the term/expression.", + "items": { + "type": "string", + "description": "Single definition for the term/expression." + } + }, + { + "type": "integer", + "description": "Sequence number for the term/expression. Terms/expressions with the same sequence number can be shown together when the \"resultOutputMode\" option is set to \"merge\"." + }, + { + "type": "string", + "description": "String of space-separated tags for the term/expression. An empty string is treated as no tags." + } + ] + } +}
\ No newline at end of file diff --git a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json new file mode 100644 index 00000000..1cc0557f --- /dev/null +++ b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "description": "Custom metadata for terms/expressions.", + "additionalItems": { + "type": "array", + "description": "Metadata about a single term/expression.", + "minItems": 3, + "items": [ + { + "type": "string", + "description": "Term or expression." + }, + { + "type": "string", + "enum": ["freq"], + "description": "Type of data. \"freq\" corresponds to frequency information." + }, + { + "type": ["string", "number"], + "description": "Data for the term/expression." + } + ] + } +}
\ No newline at end of file diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json index a20a0619..d6207952 100644 --- a/ext/bg/data/options-schema.json +++ b/ext/bg/data/options-schema.json @@ -79,6 +79,7 @@ "type": "object", "required": [ "enable", + "enableClipboardPopups", "resultOutputMode", "debugInfo", "maxResults", @@ -111,6 +112,10 @@ "type": "boolean", "default": true }, + "enableClipboardPopups": { + "type": "boolean", + "default": false + }, "resultOutputMode": { "type": "string", "enum": ["group", "merge", "split"], @@ -290,7 +295,8 @@ "popupNestingMaxDepth", "enablePopupSearch", "enableOnPopupExpressions", - "enableOnSearchPage" + "enableOnSearchPage", + "enableSearchTags" ], "properties": { "middleMouse": { @@ -348,6 +354,10 @@ "enableOnSearchPage": { "type": "boolean", "default": true + }, + "enableSearchTags": { + "type": "boolean", + "default": false } } }, diff --git a/ext/bg/js/anki.js b/ext/bg/js/anki.js index 10a07061..39c6ad51 100644 --- a/ext/bg/js/anki.js +++ b/ext/bg/js/anki.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global requestJson*/ /* * AnkiConnect diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js index 285b8016..0c244ffa 100644 --- a/ext/bg/js/api.js +++ b/ext/bg/js/api.js @@ -17,16 +17,16 @@ */ -function apiTemplateRender(template, data, dynamic) { - return _apiInvoke('templateRender', {data, template, dynamic}); +function apiTemplateRender(template, data) { + return _apiInvoke('templateRender', {data, template}); } function apiAudioGetUrl(definition, source, optionsContext) { return _apiInvoke('audioGetUrl', {definition, source, optionsContext}); } -function apiGetDisplayTemplatesHtml() { - return _apiInvoke('getDisplayTemplatesHtml'); +function apiClipboardGet() { + return _apiInvoke('clipboardGet'); } function _apiInvoke(action, params={}) { diff --git a/ext/bg/js/audio.js b/ext/bg/js/audio.js index 36ac413b..d300570b 100644 --- a/ext/bg/js/audio.js +++ b/ext/bg/js/audio.js @@ -16,13 +16,14 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global jpIsStringEntirelyKana, audioGetFromSources*/ const audioUrlBuilders = new Map([ ['jpod101', async (definition) => { let kana = definition.reading; let kanji = definition.expression; - if (!kana && wanakana.isHiragana(kanji)) { + if (!kana && jpIsStringEntirelyKana(kanji)) { kana = kanji; kanji = null; } @@ -51,7 +52,7 @@ const audioUrlBuilders = new Map([ for (const row of dom.getElementsByClassName('dc-result-row')) { try { const url = row.querySelector('audio>source[src]').getAttribute('src'); - const reading = row.getElementsByClassName('dc-vocab_kana').item(0).innerText; + const reading = row.getElementsByClassName('dc-vocab_kana').item(0).textContent; if (url && reading && (!definition.reading || definition.reading === reading)) { return audioUrlNormalize(url, 'https://www.japanesepod101.com', '/learningcenter/reference/'); } @@ -167,10 +168,8 @@ async function audioInject(definition, fields, sources, optionsContext) { } try { - let audioSourceDefinition = definition; - if (hasOwn(definition, 'expressions')) { - audioSourceDefinition = definition.expressions[0]; - } + const expressions = definition.expressions; + const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition; const {url} = await audioGetFromSources(audioSourceDefinition, sources, optionsContext, true); if (url !== null) { diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index eeab68a5..e3bf7bda 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -16,12 +16,21 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global optionsSave, utilIsolate +conditionsTestValue, profileConditionsDescriptor, profileOptionsGetDefaultFieldTemplates +handlebarsRenderDynamic +requestText, requestJson, optionsLoad +dictConfigured, dictTermsSort, dictEnabledSet, dictNoteFormat +audioGetUrl, audioInject +jpConvertReading, jpDistributeFuriganaInflected, jpKatakanaToHiragana +Translator, AnkiConnect, AnkiNull, Mecab, BackendApiForwarder, JsonSchema, ClipboardMonitor*/ class Backend { constructor() { this.translator = new Translator(); this.anki = new AnkiNull(); this.mecab = new Mecab(); + this.clipboardMonitor = new ClipboardMonitor(); this.options = null; this.optionsSchema = null; this.optionsContext = { @@ -34,7 +43,11 @@ class Backend { this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target'); + this.popupWindow = null; + this.apiForwarder = new BackendApiForwarder(); + + this.messageToken = yomichan.generateId(16); } async prepare() { @@ -67,6 +80,8 @@ class Backend { this.isPreparedResolve(); this.isPreparedResolve = null; this.isPreparedPromise = null; + + this.clipboardMonitor.onClipboardText = (text) => this._onClipboardText(text); } onOptionsUpdated(source) { @@ -75,7 +90,7 @@ class Backend { const callback = () => this.checkLastError(chrome.runtime.lastError); chrome.tabs.query({}, (tabs) => { for (const tab of tabs) { - chrome.tabs.sendMessage(tab.id, {action: 'optionsUpdate', params: {source}}, callback); + chrome.tabs.sendMessage(tab.id, {action: 'optionsUpdated', params: {source}}, callback); } }); } @@ -97,6 +112,10 @@ class Backend { } } + _onClipboardText(text) { + this._onCommandSearch({mode: 'popup', query: text}); + } + _onZoomChange({tabId, oldZoomFactor, newZoomFactor}) { const callback = () => this.checkLastError(chrome.runtime.lastError); chrome.tabs.sendMessage(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}, callback); @@ -121,6 +140,12 @@ class Backend { } else { this.mecab.stopListener(); } + + if (options.general.enableClipboardPopups) { + this.clipboardMonitor.start(); + } else { + this.clipboardMonitor.stop(); + } } async getOptionsSchema() { @@ -249,18 +274,18 @@ class Backend { const node = nodes.pop(); for (const key of Object.keys(node.obj)) { const path = node.path.concat(key); - const obj = node.obj[key]; - if (obj !== null && typeof obj === 'object') { - nodes.unshift({obj, path}); + const obj2 = node.obj[key]; + if (obj2 !== null && typeof obj2 === 'object') { + nodes.unshift({obj: obj2, path}); } else { - valuePaths.push([obj, path]); + valuePaths.push([obj2, path]); } } } return valuePaths; } - function modifyOption(path, value, options) { + function modifyOption(path, value) { let pivot = options; for (const key of path.slice(0, -1)) { if (!hasOwn(pivot, key)) { @@ -273,7 +298,7 @@ class Backend { } for (const [value, path] of getValuePaths(changedOptions)) { - modifyOption(path, value, options); + modifyOption(path, value); } await this._onApiOptionsSave({source}); @@ -294,7 +319,8 @@ class Backend { async _onApiTermsFind({text, details, optionsContext}) { const options = await this.getOptions(optionsContext); - const [definitions, length] = await this.translator.findTerms(text, details, options); + const mode = options.general.resultOutputMode; + const [definitions, length] = await this.translator.findTerms(mode, text, details, options); definitions.splice(options.general.maxResults); return {length, definitions}; } @@ -304,9 +330,9 @@ class Backend { const results = []; while (text.length > 0) { const term = []; - const [definitions, sourceLength] = await this.translator.findTermsInternal( + const [definitions, sourceLength] = await this.translator.findTerms( + 'simple', text.substring(0, options.scanning.length), - dictEnabledSet(options), {}, options ); @@ -314,9 +340,9 @@ class Backend { dictTermsSort(definitions); const {expression, reading} = definitions[0]; const source = text.substring(0, sourceLength); - for (const {text, furigana} of jpDistributeFuriganaInflected(expression, reading, source)) { - const reading = jpConvertReading(text, furigana, options.parsing.readingMode); - term.push({text, reading}); + for (const {text: text2, furigana} of jpDistributeFuriganaInflected(expression, reading, source)) { + const reading2 = jpConvertReading(text2, furigana, options.parsing.readingMode); + term.push({text: text2, reading: reading2}); } text = text.substring(source.length); } else { @@ -339,17 +365,17 @@ class Backend { for (const {expression, reading, source} of parsedLine) { const term = []; if (expression !== null && reading !== null) { - for (const {text, furigana} of jpDistributeFuriganaInflected( + for (const {text: text2, furigana} of jpDistributeFuriganaInflected( expression, jpKatakanaToHiragana(reading), source )) { - const reading = jpConvertReading(text, furigana, options.parsing.readingMode); - term.push({text, reading}); + const reading2 = jpConvertReading(text2, furigana, options.parsing.readingMode); + term.push({text: text2, reading: reading2}); } } else { - const reading = jpConvertReading(source, null, options.parsing.readingMode); - term.push({text: source, reading}); + const reading2 = jpConvertReading(source, null, options.parsing.readingMode); + term.push({text: source, reading: reading2}); } result.push(term); } @@ -436,12 +462,8 @@ class Backend { return this.anki.guiBrowse(`nid:${noteId}`); } - async _onApiTemplateRender({template, data, dynamic}) { - return ( - dynamic ? - handlebarsRenderDynamic(template, data) : - handlebarsRenderStatic(template, data) - ); + async _onApiTemplateRender({template, data}) { + return handlebarsRenderDynamic(template, data); } async _onApiCommandExec({command, params}) { @@ -480,19 +502,30 @@ class Backend { return Promise.resolve({frameId}); } - _onApiInjectStylesheet({css}, sender) { + _onApiInjectStylesheet({type, value}, sender) { if (!sender.tab) { return Promise.reject(new Error('Invalid tab')); } const tabId = sender.tab.id; const frameId = sender.frameId; - const details = { - code: css, - runAt: 'document_start', - cssOrigin: 'user', - allFrames: false - }; + const details = ( + type === 'file' ? + { + file: value, + runAt: 'document_start', + cssOrigin: 'author', + allFrames: false, + matchAboutBlank: true + } : + { + code: value, + runAt: 'document_start', + cssOrigin: 'user', + allFrames: false, + matchAboutBlank: true + } + ); if (typeof frameId === 'number') { details.frameId = frameId; } @@ -521,13 +554,30 @@ class Backend { } async _onApiClipboardGet() { - const clipboardPasteTarget = this.clipboardPasteTarget; - clipboardPasteTarget.value = ''; - clipboardPasteTarget.focus(); - document.execCommand('paste'); - const result = clipboardPasteTarget.value; - clipboardPasteTarget.value = ''; - return result; + /* + Notes: + document.execCommand('paste') doesn't work on Firefox. + This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985 + Therefore, navigator.clipboard.readText() is used on Firefox. + + navigator.clipboard.readText() can't be used in Chrome for two reasons: + * Requires page to be focused, else it rejects with an exception. + * When the page is focused, Chrome will request clipboard permission, despite already + being an extension with clipboard permissions. It effectively asks for the + non-extension permission for clipboard access. + */ + const browser = await Backend._getBrowser(); + if (browser === 'firefox' || browser === 'firefox-mobile') { + return await navigator.clipboard.readText(); + } else { + const clipboardPasteTarget = this.clipboardPasteTarget; + clipboardPasteTarget.value = ''; + clipboardPasteTarget.focus(); + document.execCommand('paste'); + const result = clipboardPasteTarget.value; + clipboardPasteTarget.value = ''; + return result; + } } async _onApiGetDisplayTemplatesHtml() { @@ -535,6 +585,11 @@ class Backend { return await requestText(url, 'GET'); } + async _onApiGetQueryParserTemplatesHtml() { + const url = chrome.runtime.getURL('/bg/query-parser-templates.html'); + return await requestText(url, 'GET'); + } + _onApiGetZoom(params, sender) { if (!sender || !sender.tab) { return Promise.reject(new Error('Invalid tab')); @@ -562,26 +617,75 @@ class Backend { }); } + async _onApiGetMessageToken() { + return this.messageToken; + } + // Command handlers async _onCommandSearch(params) { - const url = chrome.runtime.getURL('/bg/search.html'); - if (!(params && params.newTab)) { - try { - const tab = await Backend._findTab(1000, (url2) => ( - url2 !== null && - url2.startsWith(url) && - (url2.length === url.length || url2[url.length] === '?' || url2[url.length] === '#') - )); - if (tab !== null) { - await Backend._focusTab(tab); - return; + const {mode='existingOrNewTab', query} = params || {}; + + const options = await this.getOptions(this.optionsContext); + const {popupWidth, popupHeight} = options.general; + + const baseUrl = chrome.runtime.getURL('/bg/search.html'); + const queryParams = {mode}; + if (query && query.length > 0) { queryParams.query = query; } + const queryString = new URLSearchParams(queryParams).toString(); + const url = `${baseUrl}?${queryString}`; + + const isTabMatch = (url2) => { + if (url2 === null || !url2.startsWith(baseUrl)) { return false; } + const {baseUrl: baseUrl2, queryParams: queryParams2} = parseUrl(url2); + return baseUrl2 === baseUrl && (queryParams2.mode === mode || (!queryParams2.mode && mode === 'existingOrNewTab')); + }; + + const openInTab = async () => { + const tab = await Backend._findTab(1000, isTabMatch); + if (tab !== null) { + await Backend._focusTab(tab); + if (queryParams.query) { + await new Promise((resolve) => chrome.tabs.sendMessage( + tab.id, {action: 'searchQueryUpdate', params: {query: queryParams.query}}, resolve + )); } - } catch (e) { - // NOP + return true; } + }; + + switch (mode) { + case 'existingOrNewTab': + try { + if (await openInTab()) { return; } + } catch (e) { + // NOP + } + chrome.tabs.create({url}); + return; + case 'newTab': + chrome.tabs.create({url}); + return; + case 'popup': + try { + // chrome.windows not supported (e.g. on Firefox mobile) + if (!isObject(chrome.windows)) { return; } + if (await openInTab()) { return; } + // if the previous popup is open in an invalid state, close it + if (this.popupWindow !== null) { + const callback = () => this.checkLastError(chrome.runtime.lastError); + chrome.windows.remove(this.popupWindow.id, callback); + } + // open new popup + this.popupWindow = await new Promise((resolve) => chrome.windows.create( + {url, width: popupWidth, height: popupHeight, type: 'popup'}, + resolve + )); + } catch (e) { + // NOP + } + return; } - chrome.tabs.create({url}); } _onCommandHelp() { @@ -697,8 +801,11 @@ class Backend { await new Promise((resolve, reject) => { chrome.tabs.update(tab.id, {active: true}, () => { const e = chrome.runtime.lastError; - if (e) { reject(e); } - else { resolve(); } + if (e) { + reject(new Error(e.message)); + } else { + resolve(); + } }); }); @@ -708,19 +815,25 @@ class Backend { } try { - const tabWindow = await new Promise((resolve) => { - chrome.windows.get(tab.windowId, {}, (tabWindow) => { + const tabWindow = await new Promise((resolve, reject) => { + chrome.windows.get(tab.windowId, {}, (value) => { const e = chrome.runtime.lastError; - if (e) { reject(e); } - else { resolve(tabWindow); } + if (e) { + reject(new Error(e.message)); + } else { + resolve(value); + } }); }); if (!tabWindow.focused) { await new Promise((resolve, reject) => { chrome.windows.update(tab.windowId, {focused: true}, () => { const e = chrome.runtime.lastError; - if (e) { reject(e); } - else { resolve(); } + if (e) { + reject(new Error(e.message)); + } else { + resolve(); + } }); }); } @@ -777,7 +890,9 @@ Backend._messageHandlers = new Map([ ['getEnvironmentInfo', (self, ...args) => self._onApiGetEnvironmentInfo(...args)], ['clipboardGet', (self, ...args) => self._onApiClipboardGet(...args)], ['getDisplayTemplatesHtml', (self, ...args) => self._onApiGetDisplayTemplatesHtml(...args)], - ['getZoom', (self, ...args) => self._onApiGetZoom(...args)] + ['getQueryParserTemplatesHtml', (self, ...args) => self._onApiGetQueryParserTemplatesHtml(...args)], + ['getZoom', (self, ...args) => self._onApiGetZoom(...args)], + ['getMessageToken', (self, ...args) => self._onApiGetMessageToken(...args)] ]); Backend._commandHandlers = new Map([ diff --git a/ext/bg/js/clipboard-monitor.js b/ext/bg/js/clipboard-monitor.js new file mode 100644 index 00000000..c2f41385 --- /dev/null +++ b/ext/bg/js/clipboard-monitor.js @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2020 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 apiClipboardGet, jpIsStringPartiallyJapanese*/ + +class ClipboardMonitor { + constructor() { + this.timerId = null; + this.timerToken = null; + this.interval = 250; + this.previousText = null; + } + + onClipboardText(_text) { + throw new Error('Override me'); + } + + start() { + this.stop(); + + // The token below is used as a unique identifier to ensure that a new clipboard monitor + // hasn't been started during the await call. The check below the await apiClipboardGet() + // call will exit early if the reference has changed. + const token = {}; + const intervalCallback = async () => { + this.timerId = null; + + let text = null; + try { + text = await apiClipboardGet(); + } catch (e) { + // NOP + } + if (this.timerToken !== token) { return; } + + if ( + typeof text === 'string' && + (text = text.trim()).length > 0 && + text !== this.previousText + ) { + this.previousText = text; + if (jpIsStringPartiallyJapanese(text)) { + this.onClipboardText(text); + } + } + + this.timerId = setTimeout(intervalCallback, this.interval); + }; + + this.timerToken = token; + + intervalCallback(); + } + + stop() { + this.timerToken = null; + if (this.timerId !== null) { + clearTimeout(this.timerId); + this.timerId = null; + } + } + + setPreviousText(text) { + this.previousText = text; + } +} diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js index 834174bf..bec964fb 100644 --- a/ext/bg/js/context.js +++ b/ext/bg/js/context.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiCommandExec, apiGetEnvironmentInfo, apiOptionsGet*/ function showExtensionInfo() { const node = document.getElementById('extension-info'); @@ -30,12 +31,12 @@ function setupButtonEvents(selector, command, url) { for (const node of nodes) { node.addEventListener('click', (e) => { if (e.button !== 0) { return; } - apiCommandExec(command, {newTab: e.ctrlKey}); + apiCommandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'}); e.preventDefault(); }, false); node.addEventListener('auxclick', (e) => { if (e.button !== 1) { return; } - apiCommandExec(command, {newTab: true}); + apiCommandExec(command, {mode: 'newTab'}); e.preventDefault(); }, false); diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js index e87cc64b..558f3ceb 100644 --- a/ext/bg/js/database.js +++ b/ext/bg/js/database.js @@ -16,20 +16,24 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global dictFieldSplit, requestJson, JsonSchema, JSZip*/ class Database { constructor() { this.db = null; + this._schemas = new Map(); } + // Public + async prepare() { if (this.db !== null) { throw new Error('Database already initialized'); } try { - this.db = await Database.open('dict', 5, (db, transaction, oldVersion) => { - Database.upgrade(db, transaction, oldVersion, [ + this.db = await Database._open('dict', 5, (db, transaction, oldVersion) => { + Database._upgrade(db, transaction, oldVersion, [ { version: 2, stores: { @@ -95,18 +99,24 @@ class Database { } } + async close() { + this._validate(); + this.db.close(); + this.db = null; + } + async purge() { - this.validate(); + this._validate(); this.db.close(); - await Database.deleteDatabase(this.db.name); + await Database._deleteDatabase(this.db.name); this.db = null; await this.prepare(); } async deleteDictionary(dictionaryName, onProgress, progressSettings) { - this.validate(); + this._validate(); const targets = [ ['dictionaries', 'title'], @@ -133,22 +143,22 @@ class Database { const dbObjectStore = dbTransaction.objectStore(objectStoreName); const dbIndex = dbObjectStore.index(index); const only = IDBKeyRange.only(dictionaryName); - promises.push(Database.deleteValues(dbObjectStore, dbIndex, only, onProgress, progressData, progressRate)); + promises.push(Database._deleteValues(dbObjectStore, dbIndex, only, onProgress, progressData, progressRate)); } await Promise.all(promises); } - async findTermsBulk(termList, titles, wildcard) { - this.validate(); + async findTermsBulk(termList, dictionaries, wildcard) { + this._validate(); const promises = []; - const visited = {}; + const visited = new Set(); const results = []; const processRow = (row, index) => { - if (titles.includes(row.dictionary) && !hasOwn(visited, row.id)) { - visited[row.id] = true; - results.push(Database.createTerm(row, index)); + if (dictionaries.has(row.dictionary) && !visited.has(row.id)) { + visited.add(row.id); + results.push(Database._createTerm(row, index)); } }; @@ -164,8 +174,8 @@ class Database { const term = prefixWildcard ? stringReverse(termList[i]) : termList[i]; const query = useWildcard ? IDBKeyRange.bound(term, `${term}\uffff`, false, false) : IDBKeyRange.only(term); promises.push( - Database.getAll(dbIndex1, query, i, processRow), - Database.getAll(dbIndex2, query, i, processRow) + Database._getAll(dbIndex1, query, i, processRow), + Database._getAll(dbIndex2, query, i, processRow) ); } @@ -174,14 +184,14 @@ class Database { return results; } - async findTermsExactBulk(termList, readingList, titles) { - this.validate(); + async findTermsExactBulk(termList, readingList, dictionaries) { + this._validate(); const promises = []; const results = []; const processRow = (row, index) => { - if (row.reading === readingList[index] && titles.includes(row.dictionary)) { - results.push(Database.createTerm(row, index)); + if (row.reading === readingList[index] && dictionaries.has(row.dictionary)) { + results.push(Database._createTerm(row, index)); } }; @@ -191,7 +201,7 @@ class Database { for (let i = 0; i < termList.length; ++i) { const only = IDBKeyRange.only(termList[i]); - promises.push(Database.getAll(dbIndex, only, i, processRow)); + promises.push(Database._getAll(dbIndex, only, i, processRow)); } await Promise.all(promises); @@ -200,13 +210,13 @@ class Database { } async findTermsBySequenceBulk(sequenceList, mainDictionary) { - this.validate(); + this._validate(); const promises = []; const results = []; const processRow = (row, index) => { if (row.dictionary === mainDictionary) { - results.push(Database.createTerm(row, index)); + results.push(Database._createTerm(row, index)); } }; @@ -216,7 +226,7 @@ class Database { for (let i = 0; i < sequenceList.length; ++i) { const only = IDBKeyRange.only(sequenceList[i]); - promises.push(Database.getAll(dbIndex, only, i, processRow)); + promises.push(Database._getAll(dbIndex, only, i, processRow)); } await Promise.all(promises); @@ -224,52 +234,27 @@ class Database { return results; } - async findTermMetaBulk(termList, titles) { - return this.findGenericBulk('termMeta', 'expression', termList, titles, Database.createTermMeta); + async findTermMetaBulk(termList, dictionaries) { + return this._findGenericBulk('termMeta', 'expression', termList, dictionaries, Database._createTermMeta); } - async findKanjiBulk(kanjiList, titles) { - return this.findGenericBulk('kanji', 'character', kanjiList, titles, Database.createKanji); + async findKanjiBulk(kanjiList, dictionaries) { + return this._findGenericBulk('kanji', 'character', kanjiList, dictionaries, Database._createKanji); } - async findKanjiMetaBulk(kanjiList, titles) { - return this.findGenericBulk('kanjiMeta', 'character', kanjiList, titles, Database.createKanjiMeta); - } - - async findGenericBulk(tableName, indexName, indexValueList, titles, createResult) { - this.validate(); - - const promises = []; - const results = []; - const processRow = (row, index) => { - if (titles.includes(row.dictionary)) { - results.push(createResult(row, index)); - } - }; - - const dbTransaction = this.db.transaction([tableName], 'readonly'); - const dbTerms = dbTransaction.objectStore(tableName); - const dbIndex = dbTerms.index(indexName); - - for (let i = 0; i < indexValueList.length; ++i) { - const only = IDBKeyRange.only(indexValueList[i]); - promises.push(Database.getAll(dbIndex, only, i, processRow)); - } - - await Promise.all(promises); - - return results; + async findKanjiMetaBulk(kanjiList, dictionaries) { + return this._findGenericBulk('kanjiMeta', 'character', kanjiList, dictionaries, Database._createKanjiMeta); } async findTagForTitle(name, title) { - this.validate(); + this._validate(); let result = null; const dbTransaction = this.db.transaction(['tagMeta'], 'readonly'); const dbTerms = dbTransaction.objectStore('tagMeta'); const dbIndex = dbTerms.index('name'); const only = IDBKeyRange.only(name); - await Database.getAll(dbIndex, only, null, (row) => { + await Database._getAll(dbIndex, only, null, (row) => { if (title === row.dictionary) { result = row; } @@ -279,19 +264,19 @@ class Database { } async getDictionaryInfo() { - this.validate(); + this._validate(); const results = []; const dbTransaction = this.db.transaction(['dictionaries'], 'readonly'); const dbDictionaries = dbTransaction.objectStore('dictionaries'); - await Database.getAll(dbDictionaries, null, null, (info) => results.push(info)); + await Database._getAll(dbDictionaries, null, null, (info) => results.push(info)); return results; } async getDictionaryCounts(dictionaryNames, getTotal) { - this.validate(); + this._validate(); const objectStoreNames = [ 'kanji', @@ -312,7 +297,7 @@ class Database { // Query is required for Edge, otherwise index.count throws an exception. const query1 = IDBKeyRange.lowerBound('', false); - const totalPromise = getTotal ? Database.getCounts(targets, query1) : null; + const totalPromise = getTotal ? Database._getCounts(targets, query1) : null; const counts = []; const countPromises = []; @@ -320,7 +305,7 @@ class Database { counts.push(null); const index = i; const query2 = IDBKeyRange.only(dictionaryNames[i]); - const countPromise = Database.getCounts(targets, query2).then((v) => counts[index] = v); + const countPromise = Database._getCounts(targets, query2).then((v) => counts[index] = v); countPromises.push(countPromise); } await Promise.all(countPromises); @@ -332,278 +317,287 @@ class Database { return result; } - async importDictionary(archive, progressCallback, details) { - this.validate(); + async importDictionary(archiveSource, onProgress, details) { + this._validate(); + const db = this.db; + const hasOnProgress = (typeof onProgress === 'function'); - const errors = []; - const prefixWildcardsSupported = details.prefixWildcardsSupported; + // Read archive + const archive = await JSZip.loadAsync(archiveSource); - const maxTransactionLength = 1000; - const bulkAdd = async (objectStoreName, items, total, current) => { - const db = this.db; - for (let i = 0; i < items.length; i += maxTransactionLength) { - if (progressCallback) { - progressCallback(total, current + i / items.length); - } + // Read and validate index + const indexFileName = 'index.json'; + const indexFile = archive.files[indexFileName]; + if (!indexFile) { + throw new Error('No dictionary index found in archive'); + } - try { - const count = Math.min(maxTransactionLength, items.length - i); - const transaction = db.transaction([objectStoreName], 'readwrite'); - const objectStore = transaction.objectStore(objectStoreName); - await Database.bulkAdd(objectStore, items, i, count); - } catch (e) { - errors.push(e); - } - } - }; + const index = JSON.parse(await indexFile.async('string')); - const indexDataLoaded = async (summary) => { - if (summary.version > 3) { - throw new Error('Unsupported dictionary version'); - } + const indexSchema = await this._getSchema('/bg/data/dictionary-index-schema.json'); + Database._validateJsonSchema(index, indexSchema, indexFileName); - const db = this.db; - const dbCountTransaction = db.transaction(['dictionaries'], 'readonly'); - const dbIndex = dbCountTransaction.objectStore('dictionaries').index('title'); - const only = IDBKeyRange.only(summary.title); - const count = await Database.getCount(dbIndex, only); + const dictionaryTitle = index.title; + const version = index.format || index.version; - if (count > 0) { - throw new Error('Dictionary is already imported'); - } + if (!dictionaryTitle || !index.revision) { + throw new Error('Unrecognized dictionary format'); + } - const transaction = db.transaction(['dictionaries'], 'readwrite'); - const objectStore = transaction.objectStore('dictionaries'); - await Database.bulkAdd(objectStore, [summary], 0, 1); - }; + // Verify database is not already imported + if (await this._dictionaryExists(dictionaryTitle)) { + throw new Error('Dictionary is already imported'); + } - const termDataLoaded = async (summary, entries, total, current) => { - const rows = []; - if (summary.version === 1) { - for (const [expression, reading, definitionTags, rules, score, ...glossary] of entries) { - rows.push({ - expression, - reading, - definitionTags, - rules, - score, - glossary, - dictionary: summary.title - }); - } + // Data format converters + const convertTermBankEntry = (entry) => { + if (version === 1) { + const [expression, reading, definitionTags, rules, score, ...glossary] = entry; + return {expression, reading, definitionTags, rules, score, glossary}; } else { - for (const [expression, reading, definitionTags, rules, score, glossary, sequence, termTags] of entries) { - rows.push({ - expression, - reading, - definitionTags, - rules, - score, - glossary, - sequence, - termTags, - dictionary: summary.title - }); - } - } - - if (prefixWildcardsSupported) { - for (const row of rows) { - row.expressionReverse = stringReverse(row.expression); - row.readingReverse = stringReverse(row.reading); - } + const [expression, reading, definitionTags, rules, score, glossary, sequence, termTags] = entry; + return {expression, reading, definitionTags, rules, score, glossary, sequence, termTags}; } + }; - await bulkAdd('terms', rows, total, current); + const convertTermMetaBankEntry = (entry) => { + const [expression, mode, data] = entry; + return {expression, mode, data}; }; - const termMetaDataLoaded = async (summary, entries, total, current) => { - const rows = []; - for (const [expression, mode, data] of entries) { - rows.push({ - expression, - mode, - data, - dictionary: summary.title - }); + const convertKanjiBankEntry = (entry) => { + if (version === 1) { + const [character, onyomi, kunyomi, tags, ...meanings] = entry; + return {character, onyomi, kunyomi, tags, meanings}; + } else { + const [character, onyomi, kunyomi, tags, meanings, stats] = entry; + return {character, onyomi, kunyomi, tags, meanings, stats}; } + }; - await bulkAdd('termMeta', rows, total, current); + const convertKanjiMetaBankEntry = (entry) => { + const [character, mode, data] = entry; + return {character, mode, data}; }; - const kanjiDataLoaded = async (summary, entries, total, current) => { - const rows = []; - if (summary.version === 1) { - for (const [character, onyomi, kunyomi, tags, ...meanings] of entries) { - rows.push({ - character, - onyomi, - kunyomi, - tags, - meanings, - dictionary: summary.title - }); - } - } else { - for (const [character, onyomi, kunyomi, tags, meanings, stats] of entries) { - rows.push({ - character, - onyomi, - kunyomi, - tags, - meanings, - stats, - dictionary: summary.title - }); + const convertTagBankEntry = (entry) => { + const [name, category, order, notes, score] = entry; + return {name, category, order, notes, score}; + }; + + // Archive file reading + const readFileSequence = async (fileNameFormat, convertEntry, schema) => { + const results = []; + for (let i = 1; true; ++i) { + const fileName = fileNameFormat.replace(/\?/, `${i}`); + const file = archive.files[fileName]; + if (!file) { break; } + + const entries = JSON.parse(await file.async('string')); + Database._validateJsonSchema(entries, schema, fileName); + + for (let entry of entries) { + entry = convertEntry(entry); + entry.dictionary = dictionaryTitle; + results.push(entry); } } - - await bulkAdd('kanji', rows, total, current); + return results; }; - const kanjiMetaDataLoaded = async (summary, entries, total, current) => { - const rows = []; - for (const [character, mode, data] of entries) { - rows.push({ - character, - mode, - data, - dictionary: summary.title - }); + // Load schemas + const dataBankSchemaPaths = this.constructor._getDataBankSchemaPaths(version); + const dataBankSchemas = await Promise.all(dataBankSchemaPaths.map((path) => this._getSchema(path))); + + // Load data + const termList = await readFileSequence('term_bank_?.json', convertTermBankEntry, dataBankSchemas[0]); + const termMetaList = await readFileSequence('term_meta_bank_?.json', convertTermMetaBankEntry, dataBankSchemas[1]); + const kanjiList = await readFileSequence('kanji_bank_?.json', convertKanjiBankEntry, dataBankSchemas[2]); + const kanjiMetaList = await readFileSequence('kanji_meta_bank_?.json', convertKanjiMetaBankEntry, dataBankSchemas[3]); + const tagList = await readFileSequence('tag_bank_?.json', convertTagBankEntry, dataBankSchemas[4]); + + // Old tags + const indexTagMeta = index.tagMeta; + if (typeof indexTagMeta === 'object' && indexTagMeta !== null) { + for (const name of Object.keys(indexTagMeta)) { + const {category, order, notes, score} = indexTagMeta[name]; + tagList.push({name, category, order, notes, score}); } + } - await bulkAdd('kanjiMeta', rows, total, current); - }; - - const tagDataLoaded = async (summary, entries, total, current) => { - const rows = []; - for (const [name, category, order, notes, score] of entries) { - const row = dictTagSanitize({ - name, - category, - order, - notes, - score, - dictionary: summary.title - }); - - rows.push(row); + // Prefix wildcard support + const prefixWildcardsSupported = !!details.prefixWildcardsSupported; + if (prefixWildcardsSupported) { + for (const entry of termList) { + entry.expressionReverse = stringReverse(entry.expression); + entry.readingReverse = stringReverse(entry.reading); } + } - await bulkAdd('tagMeta', rows, total, current); + // Add dictionary + const summary = { + title: dictionaryTitle, + revision: index.revision, + sequenced: index.sequenced, + version, + prefixWildcardsSupported }; - const result = await Database.importDictionaryZip( - archive, - indexDataLoaded, - termDataLoaded, - termMetaDataLoaded, - kanjiDataLoaded, - kanjiMetaDataLoaded, - tagDataLoaded, - details + { + const transaction = db.transaction(['dictionaries'], 'readwrite'); + const objectStore = transaction.objectStore('dictionaries'); + await Database._bulkAdd(objectStore, [summary], 0, 1); + } + + // Add data + const errors = []; + const total = ( + termList.length + + termMetaList.length + + kanjiList.length + + kanjiMetaList.length + + tagList.length ); + let loadedCount = 0; + const maxTransactionLength = 1000; + + const bulkAdd = async (objectStoreName, entries) => { + const ii = entries.length; + for (let i = 0; i < ii; i += maxTransactionLength) { + const count = Math.min(maxTransactionLength, ii - i); + + try { + const transaction = db.transaction([objectStoreName], 'readwrite'); + const objectStore = transaction.objectStore(objectStoreName); + await Database._bulkAdd(objectStore, entries, i, count); + } catch (e) { + errors.push(e); + } - return {result, errors}; + loadedCount += count; + if (hasOnProgress) { + onProgress(total, loadedCount); + } + } + }; + + await bulkAdd('terms', termList); + await bulkAdd('termMeta', termMetaList); + await bulkAdd('kanji', kanjiList); + await bulkAdd('kanjiMeta', kanjiMetaList); + await bulkAdd('tagMeta', tagList); + + return {result: summary, errors}; } - validate() { + // Private + + _validate() { if (this.db === null) { throw new Error('Database not initialized'); } } - static async importDictionaryZip( - archive, - indexDataLoaded, - termDataLoaded, - termMetaDataLoaded, - kanjiDataLoaded, - kanjiMetaDataLoaded, - tagDataLoaded, - details - ) { - const zip = await JSZip.loadAsync(archive); - - const indexFile = zip.files['index.json']; - if (!indexFile) { - throw new Error('No dictionary index found in archive'); + async _getSchema(fileName) { + let schemaPromise = this._schemas.get(fileName); + if (typeof schemaPromise !== 'undefined') { + return schemaPromise; } - const index = JSON.parse(await indexFile.async('string')); - if (!index.title || !index.revision) { - throw new Error('Unrecognized dictionary format'); + schemaPromise = requestJson(chrome.runtime.getURL(fileName), 'GET'); + this._schemas.set(fileName, schemaPromise); + return schemaPromise; + } + + static _validateJsonSchema(value, schema, fileName) { + try { + JsonSchema.validate(value, schema); + } catch (e) { + throw Database._formatSchemaError(e, fileName); } + } - const summary = { - title: index.title, - revision: index.revision, - sequenced: index.sequenced, - version: index.format || index.version, - prefixWildcardsSupported: !!details.prefixWildcardsSupported - }; + static _formatSchemaError(e, fileName) { + const valuePathString = Database._getSchemaErrorPathString(e.info.valuePath, 'dictionary'); + const schemaPathString = Database._getSchemaErrorPathString(e.info.schemaPath, 'schema'); - await indexDataLoaded(summary); + const e2 = new Error(`Dictionary has invalid data in '${fileName}' for value '${valuePathString}', validated against '${schemaPathString}': ${e.message}`); + e2.data = e; - const buildTermBankName = (index) => `term_bank_${index + 1}.json`; - const buildTermMetaBankName = (index) => `term_meta_bank_${index + 1}.json`; - const buildKanjiBankName = (index) => `kanji_bank_${index + 1}.json`; - const buildKanjiMetaBankName = (index) => `kanji_meta_bank_${index + 1}.json`; - const buildTagBankName = (index) => `tag_bank_${index + 1}.json`; + return e2; + } - const countBanks = (namer) => { - let count = 0; - while (zip.files[namer(count)]) { - ++count; + static _getSchemaErrorPathString(infoList, base='') { + let result = base; + for (const [part] of infoList) { + switch (typeof part) { + case 'string': + if (result.length > 0) { + result += '.'; + } + result += part; + break; + case 'number': + result += `[${part}]`; + break; } + } + return result; + } - return count; - }; + static _getDataBankSchemaPaths(version) { + const termBank = ( + version === 1 ? + '/bg/data/dictionary-term-bank-v1-schema.json' : + '/bg/data/dictionary-term-bank-v3-schema.json' + ); + const termMetaBank = '/bg/data/dictionary-term-meta-bank-v3-schema.json'; + const kanjiBank = ( + version === 1 ? + '/bg/data/dictionary-kanji-bank-v1-schema.json' : + '/bg/data/dictionary-kanji-bank-v3-schema.json' + ); + const kanjiMetaBank = '/bg/data/dictionary-kanji-meta-bank-v3-schema.json'; + const tagBank = '/bg/data/dictionary-tag-bank-v3-schema.json'; - const termBankCount = countBanks(buildTermBankName); - const termMetaBankCount = countBanks(buildTermMetaBankName); - const kanjiBankCount = countBanks(buildKanjiBankName); - const kanjiMetaBankCount = countBanks(buildKanjiMetaBankName); - const tagBankCount = countBanks(buildTagBankName); - - let bankLoadedCount = 0; - let bankTotalCount = - termBankCount + - termMetaBankCount + - kanjiBankCount + - kanjiMetaBankCount + - tagBankCount; - - if (tagDataLoaded && index.tagMeta) { - const bank = []; - for (const name in index.tagMeta) { - const tag = index.tagMeta[name]; - bank.push([name, tag.category, tag.order, tag.notes, tag.score]); - } + return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank]; + } - tagDataLoaded(summary, bank, ++bankTotalCount, bankLoadedCount++); - } + async _dictionaryExists(title) { + const db = this.db; + const dbCountTransaction = db.transaction(['dictionaries'], 'readonly'); + const dbIndex = dbCountTransaction.objectStore('dictionaries').index('title'); + const only = IDBKeyRange.only(title); + const count = await Database._getCount(dbIndex, only); + return count > 0; + } - const loadBank = async (summary, namer, count, callback) => { - if (callback) { - for (let i = 0; i < count; ++i) { - const bankFile = zip.files[namer(i)]; - const bank = JSON.parse(await bankFile.async('string')); - await callback(summary, bank, bankTotalCount, bankLoadedCount++); - } + async _findGenericBulk(tableName, indexName, indexValueList, dictionaries, createResult) { + this._validate(); + + const promises = []; + const results = []; + const processRow = (row, index) => { + if (dictionaries.has(row.dictionary)) { + results.push(createResult(row, index)); } }; - await loadBank(summary, buildTermBankName, termBankCount, termDataLoaded); - await loadBank(summary, buildTermMetaBankName, termMetaBankCount, termMetaDataLoaded); - await loadBank(summary, buildKanjiBankName, kanjiBankCount, kanjiDataLoaded); - await loadBank(summary, buildKanjiMetaBankName, kanjiMetaBankCount, kanjiMetaDataLoaded); - await loadBank(summary, buildTagBankName, tagBankCount, tagDataLoaded); + const dbTransaction = this.db.transaction([tableName], 'readonly'); + const dbTerms = dbTransaction.objectStore(tableName); + const dbIndex = dbTerms.index(indexName); + + for (let i = 0; i < indexValueList.length; ++i) { + const only = IDBKeyRange.only(indexValueList[i]); + promises.push(Database._getAll(dbIndex, only, i, processRow)); + } + + await Promise.all(promises); - return summary; + return results; } - static createTerm(row, index) { + static _createTerm(row, index) { return { index, expression: row.expression, @@ -619,7 +613,7 @@ class Database { }; } - static createKanji(row, index) { + static _createKanji(row, index) { return { index, character: row.character, @@ -632,20 +626,20 @@ class Database { }; } - static createTermMeta({expression, mode, data, dictionary}, index) { + static _createTermMeta({expression, mode, data, dictionary}, index) { return {expression, mode, data, dictionary, index}; } - static createKanjiMeta({character, mode, data, dictionary}, index) { + static _createKanjiMeta({character, mode, data, dictionary}, index) { return {character, mode, data, dictionary, index}; } - static getAll(dbIndex, query, context, processRow) { - const fn = typeof dbIndex.getAll === 'function' ? Database.getAllFast : Database.getAllUsingCursor; + static _getAll(dbIndex, query, context, processRow) { + const fn = typeof dbIndex.getAll === 'function' ? Database._getAllFast : Database._getAllUsingCursor; return fn(dbIndex, query, context, processRow); } - static getAllFast(dbIndex, query, context, processRow) { + static _getAllFast(dbIndex, query, context, processRow) { return new Promise((resolve, reject) => { const request = dbIndex.getAll(query); request.onerror = (e) => reject(e); @@ -658,7 +652,7 @@ class Database { }); } - static getAllUsingCursor(dbIndex, query, context, processRow) { + static _getAllUsingCursor(dbIndex, query, context, processRow) { return new Promise((resolve, reject) => { const request = dbIndex.openCursor(query, 'next'); request.onerror = (e) => reject(e); @@ -674,18 +668,18 @@ class Database { }); } - static getCounts(targets, query) { + static _getCounts(targets, query) { const countPromises = []; const counts = {}; for (const [objectStoreName, index] of targets) { const n = objectStoreName; - const countPromise = Database.getCount(index, query).then((count) => counts[n] = count); + const countPromise = Database._getCount(index, query).then((count) => counts[n] = count); countPromises.push(countPromise); } return Promise.all(countPromises).then(() => counts); } - static getCount(dbIndex, query) { + static _getCount(dbIndex, query) { return new Promise((resolve, reject) => { const request = dbIndex.count(query); request.onerror = (e) => reject(e); @@ -693,12 +687,12 @@ class Database { }); } - static getAllKeys(dbIndex, query) { - const fn = typeof dbIndex.getAllKeys === 'function' ? Database.getAllKeysFast : Database.getAllKeysUsingCursor; + static _getAllKeys(dbIndex, query) { + const fn = typeof dbIndex.getAllKeys === 'function' ? Database._getAllKeysFast : Database._getAllKeysUsingCursor; return fn(dbIndex, query); } - static getAllKeysFast(dbIndex, query) { + static _getAllKeysFast(dbIndex, query) { return new Promise((resolve, reject) => { const request = dbIndex.getAllKeys(query); request.onerror = (e) => reject(e); @@ -706,7 +700,7 @@ class Database { }); } - static getAllKeysUsingCursor(dbIndex, query) { + static _getAllKeysUsingCursor(dbIndex, query) { return new Promise((resolve, reject) => { const primaryKeys = []; const request = dbIndex.openKeyCursor(query, 'next'); @@ -723,9 +717,9 @@ class Database { }); } - static async deleteValues(dbObjectStore, dbIndex, query, onProgress, progressData, progressRate) { + static async _deleteValues(dbObjectStore, dbIndex, query, onProgress, progressData, progressRate) { const hasProgress = (typeof onProgress === 'function'); - const count = await Database.getCount(dbIndex, query); + const count = await Database._getCount(dbIndex, query); ++progressData.storesProcesed; progressData.count += count; if (hasProgress) { @@ -744,16 +738,16 @@ class Database { ); const promises = []; - const primaryKeys = await Database.getAllKeys(dbIndex, query); + const primaryKeys = await Database._getAllKeys(dbIndex, query); for (const key of primaryKeys) { - const promise = Database.deleteValue(dbObjectStore, key).then(onValueDeleted); + const promise = Database._deleteValue(dbObjectStore, key).then(onValueDeleted); promises.push(promise); } await Promise.all(promises); } - static deleteValue(dbObjectStore, key) { + static _deleteValue(dbObjectStore, key) { return new Promise((resolve, reject) => { const request = dbObjectStore.delete(key); request.onerror = (e) => reject(e); @@ -761,7 +755,7 @@ class Database { }); } - static bulkAdd(objectStore, items, start, count) { + static _bulkAdd(objectStore, items, start, count) { return new Promise((resolve, reject) => { if (start + count > items.length) { count = items.length - start; @@ -789,7 +783,7 @@ class Database { }); } - static open(name, version, onUpgradeNeeded) { + static _open(name, version, onUpgradeNeeded) { return new Promise((resolve, reject) => { const request = window.indexedDB.open(name, version * 10); @@ -807,7 +801,7 @@ class Database { }); } - static upgrade(db, transaction, oldVersion, upgrades) { + static _upgrade(db, transaction, oldVersion, upgrades) { for (const {version, stores} of upgrades) { if (oldVersion >= version) { continue; } @@ -815,15 +809,15 @@ class Database { for (const objectStoreName of objectStoreNames) { const {primaryKey, indices} = stores[objectStoreName]; - const objectStoreNames = transaction.objectStoreNames || db.objectStoreNames; + const objectStoreNames2 = transaction.objectStoreNames || db.objectStoreNames; const objectStore = ( - Database.listContains(objectStoreNames, objectStoreName) ? + Database._listContains(objectStoreNames2, objectStoreName) ? transaction.objectStore(objectStoreName) : db.createObjectStore(objectStoreName, primaryKey) ); for (const indexName of indices) { - if (Database.listContains(objectStore.indexNames, indexName)) { continue; } + if (Database._listContains(objectStore.indexNames, indexName)) { continue; } objectStore.createIndex(indexName, indexName, {}); } @@ -831,7 +825,7 @@ class Database { } } - static deleteDatabase(dbName) { + static _deleteDatabase(dbName) { return new Promise((resolve, reject) => { const request = indexedDB.deleteDatabase(dbName); request.onerror = (e) => reject(e); @@ -839,7 +833,7 @@ class Database { }); } - static listContains(list, value) { + static _listContains(list, value) { for (let i = 0, ii = list.length; i < ii; ++i) { if (list[i] === value) { return true; } } diff --git a/ext/bg/js/deinflector.js b/ext/bg/js/deinflector.js index 33b2a8b3..e2ced965 100644 --- a/ext/bg/js/deinflector.js +++ b/ext/bg/js/deinflector.js @@ -76,17 +76,19 @@ class Deinflector { const ruleTypes = Deinflector.ruleTypes; let value = 0; for (const rule of rules) { - value |= ruleTypes[rule]; + const ruleBits = ruleTypes.get(rule); + if (typeof ruleBits === 'undefined') { continue; } + value |= ruleBits; } return value; } } -Deinflector.ruleTypes = { - 'v1': 0b0000001, // Verb ichidan - 'v5': 0b0000010, // Verb godan - 'vs': 0b0000100, // Verb suru - 'vk': 0b0001000, // Verb kuru - 'adj-i': 0b0010000, // Adjective i - 'iru': 0b0100000 // Intermediate -iru endings for progressive or perfect tense -}; +Deinflector.ruleTypes = new Map([ + ['v1', 0b0000001], // Verb ichidan + ['v5', 0b0000010], // Verb godan + ['vs', 0b0000100], // Verb suru + ['vk', 0b0001000], // Verb kuru + ['adj-i', 0b0010000], // Adjective i + ['iru', 0b0100000] // Intermediate -iru endings for progressive or perfect tense +]); diff --git a/ext/bg/js/dictionary.js b/ext/bg/js/dictionary.js index 67128725..f5c5b21b 100644 --- a/ext/bg/js/dictionary.js +++ b/ext/bg/js/dictionary.js @@ -16,17 +16,21 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiTemplateRender*/ function dictEnabledSet(options) { - const dictionaries = {}; - for (const title in options.dictionaries) { - const dictionary = options.dictionaries[title]; - if (dictionary.enabled) { - dictionaries[title] = dictionary; - } + const enabledDictionaryMap = new Map(); + const optionsDictionaries = options.dictionaries; + for (const title in optionsDictionaries) { + if (!hasOwn(optionsDictionaries, title)) { continue; } + const dictionary = optionsDictionaries[title]; + if (!dictionary.enabled) { continue; } + enabledDictionaryMap.set(title, { + priority: dictionary.priority || 0, + allowSecondarySearches: !!dictionary.allowSecondarySearches + }); } - - return dictionaries; + return enabledDictionaryMap; } function dictConfigured(options) { @@ -39,28 +43,15 @@ function dictConfigured(options) { return false; } -function dictRowsSort(rows, options) { - return rows.sort((ra, rb) => { - const pa = (options.dictionaries[ra.title] || {}).priority || 0; - const pb = (options.dictionaries[rb.title] || {}).priority || 0; - if (pa > pb) { - return -1; - } else if (pa < pb) { - return 1; - } else { - return 0; - } - }); -} - function dictTermsSort(definitions, dictionaries=null) { return definitions.sort((v1, v2) => { let i; if (dictionaries !== null) { - i = ( - ((dictionaries[v2.dictionary] || {}).priority || 0) - - ((dictionaries[v1.dictionary] || {}).priority || 0) - ); + const dictionaryInfo1 = dictionaries.get(v1.dictionary); + const dictionaryInfo2 = dictionaries.get(v2.dictionary); + const priority1 = typeof dictionaryInfo1 !== 'undefined' ? dictionaryInfo1.priority : 0; + const priority2 = typeof dictionaryInfo2 !== 'undefined' ? dictionaryInfo2.priority : 0; + i = priority2 - priority1; if (i !== 0) { return i; } } @@ -78,20 +69,16 @@ function dictTermsSort(definitions, dictionaries=null) { } function dictTermsUndupe(definitions) { - const definitionGroups = {}; + const definitionGroups = new Map(); for (const definition of definitions) { - const definitionExisting = definitionGroups[definition.id]; - if (!hasOwn(definitionGroups, definition.id) || definition.expression.length > definitionExisting.expression.length) { - definitionGroups[definition.id] = definition; + const id = definition.id; + const definitionExisting = definitionGroups.get(id); + if (typeof definitionExisting === 'undefined' || definition.expression.length > definitionExisting.expression.length) { + definitionGroups.set(id, definition); } } - const definitionsUnique = []; - for (const key in definitionGroups) { - definitionsUnique.push(definitionGroups[key]); - } - - return definitionsUnique; + return [...definitionGroups.values()]; } function dictTermsCompressTags(definitions) { @@ -122,35 +109,35 @@ function dictTermsCompressTags(definitions) { } function dictTermsGroup(definitions, dictionaries) { - const groups = {}; + const groups = new Map(); for (const definition of definitions) { - const key = [definition.source, definition.expression]; - key.push(...definition.reasons); + const key = [definition.source, definition.expression, ...definition.reasons]; if (definition.reading) { key.push(definition.reading); } const keyString = key.toString(); - if (hasOwn(groups, keyString)) { - groups[keyString].push(definition); - } else { - groups[keyString] = [definition]; + let groupDefinitions = groups.get(keyString); + if (typeof groupDefinitions === 'undefined') { + groupDefinitions = []; + groups.set(keyString, groupDefinitions); } + + groupDefinitions.push(definition); } const results = []; - for (const key in groups) { - const groupDefs = groups[key]; - const firstDef = groupDefs[0]; - dictTermsSort(groupDefs, dictionaries); + for (const groupDefinitions of groups.values()) { + const firstDef = groupDefinitions[0]; + dictTermsSort(groupDefinitions, dictionaries); results.push({ - definitions: groupDefs, + definitions: groupDefinitions, expression: firstDef.expression, reading: firstDef.reading, furiganaSegments: firstDef.furiganaSegments, reasons: firstDef.reasons, termTags: firstDef.termTags, - score: groupDefs.reduce((p, v) => v.score > p ? v.score : p, Number.MIN_SAFE_INTEGER), + score: groupDefinitions.reduce((p, v) => v.score > p ? v.score : p, Number.MIN_SAFE_INTEGER), source: firstDef.source }); } @@ -158,14 +145,41 @@ function dictTermsGroup(definitions, dictionaries) { return dictTermsSort(results); } +function dictAreSetsEqual(set1, set2) { + if (set1.size !== set2.size) { + return false; + } + + for (const value of set1) { + if (!set2.has(value)) { + return false; + } + } + + return true; +} + +function dictGetSetIntersection(set1, set2) { + const result = []; + for (const value of set1) { + if (set2.has(value)) { + result.push(value); + } + } + return result; +} + function dictTermsMergeBySequence(definitions, mainDictionary) { - const definitionsBySequence = {'-1': []}; + const sequencedDefinitions = new Map(); + const nonSequencedDefinitions = []; for (const definition of definitions) { - if (mainDictionary === definition.dictionary && definition.sequence >= 0) { - if (!definitionsBySequence[definition.sequence]) { - definitionsBySequence[definition.sequence] = { + const sequence = definition.sequence; + if (mainDictionary === definition.dictionary && sequence >= 0) { + let sequencedDefinition = sequencedDefinitions.get(sequence); + if (typeof sequencedDefinition === 'undefined') { + sequencedDefinition = { reasons: definition.reasons, - score: Number.MIN_SAFE_INTEGER, + score: definition.score, expression: new Set(), reading: new Set(), expressions: new Map(), @@ -173,100 +187,115 @@ function dictTermsMergeBySequence(definitions, mainDictionary) { dictionary: definition.dictionary, definitions: [] }; + sequencedDefinitions.set(sequence, sequencedDefinition); + } else { + sequencedDefinition.score = Math.max(sequencedDefinition.score, definition.score); } - const score = Math.max(definitionsBySequence[definition.sequence].score, definition.score); - definitionsBySequence[definition.sequence].score = score; } else { - definitionsBySequence['-1'].push(definition); + nonSequencedDefinitions.push(definition); } } - return definitionsBySequence; + return [sequencedDefinitions, nonSequencedDefinitions]; } -function dictTermsMergeByGloss(result, definitions, appendTo, mergedIndices) { - const definitionsByGloss = appendTo || {}; - for (const [index, definition] of definitions.entries()) { - if (appendTo) { - let match = false; - for (const expression of result.expressions.keys()) { - if (definition.expression === expression) { - for (const reading of result.expressions.get(expression).keys()) { - if (definition.reading === reading) { - match = true; - break; - } - } - } - if (match) { - break; - } - } +function dictTermsMergeByGloss(result, definitions, appendTo=null, mergedIndices=null) { + const definitionsByGloss = appendTo !== null ? appendTo : new Map(); - if (!match) { - continue; - } else if (mergedIndices) { + const resultExpressionsMap = result.expressions; + const resultExpressionSet = result.expression; + const resultReadingSet = result.reading; + const resultSource = result.source; + + for (const [index, definition] of definitions.entries()) { + const {expression, reading} = definition; + + if (mergedIndices !== null) { + const expressionMap = resultExpressionsMap.get(expression); + if ( + typeof expressionMap !== 'undefined' && + typeof expressionMap.get(reading) !== 'undefined' + ) { mergedIndices.add(index); + } else { + continue; } } const gloss = JSON.stringify(definition.glossary.concat(definition.dictionary)); - if (!definitionsByGloss[gloss]) { - definitionsByGloss[gloss] = { + let glossDefinition = definitionsByGloss.get(gloss); + if (typeof glossDefinition === 'undefined') { + glossDefinition = { expression: new Set(), reading: new Set(), definitionTags: [], glossary: definition.glossary, - source: result.source, + source: resultSource, reasons: [], score: definition.score, id: definition.id, dictionary: definition.dictionary }; + definitionsByGloss.set(gloss, glossDefinition); } - definitionsByGloss[gloss].expression.add(definition.expression); - definitionsByGloss[gloss].reading.add(definition.reading); + glossDefinition.expression.add(expression); + glossDefinition.reading.add(reading); - result.expression.add(definition.expression); - result.reading.add(definition.reading); + resultExpressionSet.add(expression); + resultReadingSet.add(reading); for (const tag of definition.definitionTags) { - if (!definitionsByGloss[gloss].definitionTags.find((existingTag) => existingTag.name === tag.name)) { - definitionsByGloss[gloss].definitionTags.push(tag); + if (!glossDefinition.definitionTags.find((existingTag) => existingTag.name === tag.name)) { + glossDefinition.definitionTags.push(tag); } } - if (!appendTo) { - // result->expressions[ Expression1[ Reading1[ Tag1, Tag2 ] ], Expression2, ... ] - if (!result.expressions.has(definition.expression)) { - result.expressions.set(definition.expression, new Map()); + if (appendTo === null) { + /* + Data layout: + resultExpressionsMap = new Map([ + [expression, new Map([ + [reading, new Map([ + [tagName, tagInfo], + ... + ])], + ... + ])], + ... + ]); + */ + let readingMap = resultExpressionsMap.get(expression); + if (typeof readingMap === 'undefined') { + readingMap = new Map(); + resultExpressionsMap.set(expression, readingMap); } - if (!result.expressions.get(definition.expression).has(definition.reading)) { - result.expressions.get(definition.expression).set(definition.reading, []); + + let termTagsMap = readingMap.get(reading); + if (typeof termTagsMap === 'undefined') { + termTagsMap = new Map(); + readingMap.set(reading, termTagsMap); } for (const tag of definition.termTags) { - if (!result.expressions.get(definition.expression).get(definition.reading).find((existingTag) => existingTag.name === tag.name)) { - result.expressions.get(definition.expression).get(definition.reading).push(tag); + if (!termTagsMap.has(tag.name)) { + termTagsMap.set(tag.name, tag); } } } } - for (const gloss in definitionsByGloss) { - const definition = definitionsByGloss[gloss]; - definition.only = []; - if (!utilSetEqual(definition.expression, result.expression)) { - for (const expression of utilSetIntersection(definition.expression, result.expression)) { - definition.only.push(expression); - } + for (const definition of definitionsByGloss.values()) { + const only = []; + const expressionSet = definition.expression; + const readingSet = definition.reading; + if (!dictAreSetsEqual(expressionSet, resultExpressionSet)) { + only.push(...dictGetSetIntersection(expressionSet, resultExpressionSet)); } - if (!utilSetEqual(definition.reading, result.reading)) { - for (const reading of utilSetIntersection(definition.reading, result.reading)) { - definition.only.push(reading); - } + if (!dictAreSetsEqual(readingSet, resultReadingSet)) { + only.push(...dictGetSetIntersection(readingSet, resultReadingSet)); } + definition.only = only; } return definitionsByGloss; @@ -330,7 +359,7 @@ async function dictFieldFormat(field, definition, mode, options, templates, exce } data.marker = marker; try { - return await apiTemplateRender(templates, data, true); + return await apiTemplateRender(templates, data); } catch (e) { if (exceptions) { exceptions.push(e); } return `{${marker}-render-error}`; diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js index 62f89ee4..b1443447 100644 --- a/ext/bg/js/handlebars.js +++ b/ext/bg/js/handlebars.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global jpIsCharCodeKanji, jpDistributeFurigana, Handlebars*/ function handlebarsEscape(text) { return Handlebars.Utils.escapeExpression(text); @@ -134,11 +135,6 @@ function handlebarsRegisterHelpers() { } } -function handlebarsRenderStatic(name, data) { - handlebarsRegisterHelpers(); - return Handlebars.templates[name](data).trim(); -} - function handlebarsRenderDynamic(template, data) { handlebarsRegisterHelpers(); const cache = handlebarsRenderDynamic._cache; diff --git a/ext/mixed/js/japanese.js b/ext/bg/js/japanese.js index 0da822d7..abb32da4 100644 --- a/ext/mixed/js/japanese.js +++ b/ext/bg/js/japanese.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global wanakana*/ const JP_HALFWIDTH_KATAKANA_MAPPING = new Map([ ['ヲ', 'ヲヺ-'], @@ -108,7 +109,7 @@ const JP_JAPANESE_RANGES = [ [0xff1a, 0xff1f], // Fullwidth punctuation 2 [0xff3b, 0xff3f], // Fullwidth punctuation 3 [0xff5b, 0xff60], // Fullwidth punctuation 4 - [0xffe0, 0xffee], // Currency markers + [0xffe0, 0xffee] // Currency markers ]; @@ -223,15 +224,15 @@ function jpDistributeFurigana(expression, reading) { } let isAmbiguous = false; - const segmentize = (reading, groups) => { + const segmentize = (reading2, groups) => { if (groups.length === 0 || isAmbiguous) { return []; } const group = groups[0]; if (group.mode === 'kana') { - if (jpKatakanaToHiragana(reading).startsWith(jpKatakanaToHiragana(group.text))) { - const readingLeft = reading.substring(group.text.length); + if (jpKatakanaToHiragana(reading2).startsWith(jpKatakanaToHiragana(group.text))) { + const readingLeft = reading2.substring(group.text.length); const segs = segmentize(readingLeft, groups.splice(1)); if (segs) { return [{text: group.text}].concat(segs); @@ -239,9 +240,9 @@ function jpDistributeFurigana(expression, reading) { } } else { let foundSegments = null; - for (let i = reading.length; i >= group.text.length; --i) { - const readingUsed = reading.substring(0, i); - const readingLeft = reading.substring(i); + 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) { diff --git a/ext/bg/js/json-schema.js b/ext/bg/js/json-schema.js index 5d596a8b..58f804fd 100644 --- a/ext/bg/js/json-schema.js +++ b/ext/bg/js/json-schema.js @@ -64,7 +64,7 @@ class JsonSchemaProxyHandler { } } - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property); + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property, target); if (propertySchema === null) { return; } @@ -86,17 +86,14 @@ class JsonSchemaProxyHandler { } } - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property); + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(this._schema, property, target); if (propertySchema === null) { throw new Error(`Property ${property} not supported`); } value = JsonSchema.isolate(value); - const error = JsonSchemaProxyHandler.validate(value, propertySchema); - if (error !== null) { - throw new Error(`Invalid value: ${error}`); - } + JsonSchemaProxyHandler.validate(value, propertySchema, new JsonSchemaTraversalInfo(value, propertySchema)); target[property] = value; return true; @@ -122,151 +119,329 @@ class JsonSchemaProxyHandler { throw new Error('construct not supported'); } - static getPropertySchema(schema, property) { - const type = schema.type; - if (Array.isArray(type)) { - throw new Error(`Ambiguous property type for ${property}`); - } + static getPropertySchema(schema, property, value, path=null) { + const type = JsonSchemaProxyHandler.getSchemaOrValueType(schema, value); switch (type) { case 'object': { const properties = schema.properties; - if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) { - if (Object.prototype.hasOwnProperty.call(properties, property)) { - return properties[property]; + if (JsonSchemaProxyHandler.isObject(properties)) { + const propertySchema = properties[property]; + if (JsonSchemaProxyHandler.isObject(propertySchema)) { + if (path !== null) { path.push(['properties', properties], [property, propertySchema]); } + return propertySchema; } } const additionalProperties = schema.additionalProperties; - return (additionalProperties !== null && typeof additionalProperties === 'object' && !Array.isArray(additionalProperties)) ? additionalProperties : null; + if (additionalProperties === false) { + return null; + } else if (JsonSchemaProxyHandler.isObject(additionalProperties)) { + if (path !== null) { path.push(['additionalProperties', additionalProperties]); } + return additionalProperties; + } else { + const result = JsonSchemaProxyHandler._unconstrainedSchema; + if (path !== null) { path.push([null, result]); } + return result; + } } case 'array': { const items = schema.items; - return (items !== null && typeof items === 'object' && !Array.isArray(items)) ? items : null; + if (JsonSchemaProxyHandler.isObject(items)) { + return items; + } + if (Array.isArray(items)) { + if (property >= 0 && property < items.length) { + const propertySchema = items[property]; + if (JsonSchemaProxyHandler.isObject(propertySchema)) { + if (path !== null) { path.push(['items', items], [property, propertySchema]); } + return propertySchema; + } + } + } + + const additionalItems = schema.additionalItems; + if (additionalItems === false) { + return null; + } else if (JsonSchemaProxyHandler.isObject(additionalItems)) { + if (path !== null) { path.push(['additionalItems', additionalItems]); } + return additionalItems; + } else { + const result = JsonSchemaProxyHandler._unconstrainedSchema; + if (path !== null) { path.push([null, result]); } + return result; + } } default: return null; } } - static validate(value, schema) { + static getSchemaOrValueType(schema, value) { + const type = schema.type; + + if (Array.isArray(type)) { + if (typeof value !== 'undefined') { + const valueType = JsonSchemaProxyHandler.getValueType(value); + if (type.indexOf(valueType) >= 0) { + return valueType; + } + } + return null; + } + + if (typeof type === 'undefined') { + if (typeof value !== 'undefined') { + return JsonSchemaProxyHandler.getValueType(value); + } + return null; + } + + return type; + } + + static validate(value, schema, info) { + JsonSchemaProxyHandler.validateSingleSchema(value, schema, info); + JsonSchemaProxyHandler.validateConditional(value, schema, info); + JsonSchemaProxyHandler.validateAllOf(value, schema, info); + JsonSchemaProxyHandler.validateAnyOf(value, schema, info); + JsonSchemaProxyHandler.validateOneOf(value, schema, info); + JsonSchemaProxyHandler.validateNoneOf(value, schema, info); + } + + static validateConditional(value, schema, info) { + const ifSchema = schema.if; + if (!JsonSchemaProxyHandler.isObject(ifSchema)) { return; } + + let okay = true; + info.schemaPush('if', ifSchema); + try { + JsonSchemaProxyHandler.validate(value, ifSchema, info); + } catch (e) { + okay = false; + } + info.schemaPop(); + + const nextSchema = okay ? schema.then : schema.else; + if (JsonSchemaProxyHandler.isObject(nextSchema)) { + info.schemaPush(okay ? 'then' : 'else', nextSchema); + JsonSchemaProxyHandler.validate(value, nextSchema, info); + info.schemaPop(); + } + } + + static validateAllOf(value, schema, info) { + const subSchemas = schema.allOf; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('allOf', subSchemas); + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + JsonSchemaProxyHandler.validate(value, subSchema, info); + info.schemaPop(); + } + info.schemaPop(); + } + + static validateAnyOf(value, schema, info) { + const subSchemas = schema.anyOf; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('anyOf', subSchemas); + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + try { + JsonSchemaProxyHandler.validate(value, subSchema, info); + return; + } catch (e) { + // NOP + } + info.schemaPop(); + } + + throw new JsonSchemaValidationError('0 anyOf schemas matched', value, schema, info); + // info.schemaPop(); // Unreachable + } + + static validateOneOf(value, schema, info) { + const subSchemas = schema.oneOf; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('oneOf', subSchemas); + let count = 0; + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + try { + JsonSchemaProxyHandler.validate(value, subSchema, info); + ++count; + } catch (e) { + // NOP + } + info.schemaPop(); + } + + if (count !== 1) { + throw new JsonSchemaValidationError(`${count} oneOf schemas matched`, value, schema, info); + } + + info.schemaPop(); + } + + static validateNoneOf(value, schema, info) { + const subSchemas = schema.not; + if (!Array.isArray(subSchemas)) { return; } + + info.schemaPush('not', subSchemas); + for (let i = 0; i < subSchemas.length; ++i) { + const subSchema = subSchemas[i]; + info.schemaPush(i, subSchema); + try { + JsonSchemaProxyHandler.validate(value, subSchema, info); + } catch (e) { + info.schemaPop(); + continue; + } + throw new JsonSchemaValidationError(`not[${i}] schema matched`, value, schema, info); + } + info.schemaPop(); + } + + static validateSingleSchema(value, schema, info) { const type = JsonSchemaProxyHandler.getValueType(value); const schemaType = schema.type; if (!JsonSchemaProxyHandler.isValueTypeAny(value, type, schemaType)) { - return `Value type ${type} does not match schema type ${schemaType}`; + throw new JsonSchemaValidationError(`Value type ${type} does not match schema type ${schemaType}`, value, schema, info); } const schemaEnum = schema.enum; if (Array.isArray(schemaEnum) && !JsonSchemaProxyHandler.valuesAreEqualAny(value, schemaEnum)) { - return 'Invalid enum value'; + throw new JsonSchemaValidationError('Invalid enum value', value, schema, info); } switch (type) { case 'number': - return JsonSchemaProxyHandler.validateNumber(value, schema); + JsonSchemaProxyHandler.validateNumber(value, schema, info); + break; case 'string': - return JsonSchemaProxyHandler.validateString(value, schema); + JsonSchemaProxyHandler.validateString(value, schema, info); + break; case 'array': - return JsonSchemaProxyHandler.validateArray(value, schema); + JsonSchemaProxyHandler.validateArray(value, schema, info); + break; case 'object': - return JsonSchemaProxyHandler.validateObject(value, schema); - default: - return null; + JsonSchemaProxyHandler.validateObject(value, schema, info); + break; } } - static validateNumber(value, schema) { + static validateNumber(value, schema, info) { const multipleOf = schema.multipleOf; if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { - return `Number is not a multiple of ${multipleOf}`; + throw new JsonSchemaValidationError(`Number is not a multiple of ${multipleOf}`, value, schema, info); } const minimum = schema.minimum; if (typeof minimum === 'number' && value < minimum) { - return `Number is less than ${minimum}`; + throw new JsonSchemaValidationError(`Number is less than ${minimum}`, value, schema, info); } const exclusiveMinimum = schema.exclusiveMinimum; if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) { - return `Number is less than or equal to ${exclusiveMinimum}`; + throw new JsonSchemaValidationError(`Number is less than or equal to ${exclusiveMinimum}`, value, schema, info); } const maximum = schema.maximum; if (typeof maximum === 'number' && value > maximum) { - return `Number is greater than ${maximum}`; + throw new JsonSchemaValidationError(`Number is greater than ${maximum}`, value, schema, info); } const exclusiveMaximum = schema.exclusiveMaximum; if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) { - return `Number is greater than or equal to ${exclusiveMaximum}`; + throw new JsonSchemaValidationError(`Number is greater than or equal to ${exclusiveMaximum}`, value, schema, info); } - - return null; } - static validateString(value, schema) { + static validateString(value, schema, info) { const minLength = schema.minLength; if (typeof minLength === 'number' && value.length < minLength) { - return 'String length too short'; + throw new JsonSchemaValidationError('String length too short', value, schema, info); } - const maxLength = schema.minLength; + const maxLength = schema.maxLength; if (typeof maxLength === 'number' && value.length > maxLength) { - return 'String length too long'; + throw new JsonSchemaValidationError('String length too long', value, schema, info); } - - return null; } - static validateArray(value, schema) { + static validateArray(value, schema, info) { const minItems = schema.minItems; if (typeof minItems === 'number' && value.length < minItems) { - return 'Array length too short'; + throw new JsonSchemaValidationError('Array length too short', value, schema, info); } const maxItems = schema.maxItems; if (typeof maxItems === 'number' && value.length > maxItems) { - return 'Array length too long'; + throw new JsonSchemaValidationError('Array length too long', value, schema, info); } - return null; + for (let i = 0, ii = value.length; i < ii; ++i) { + const schemaPath = []; + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i, value, schemaPath); + if (propertySchema === null) { + throw new JsonSchemaValidationError(`No schema found for array[${i}]`, value, schema, info); + } + + const propertyValue = value[i]; + + for (const [p, s] of schemaPath) { info.schemaPush(p, s); } + info.valuePush(i, propertyValue); + JsonSchemaProxyHandler.validate(propertyValue, propertySchema, info); + info.valuePop(); + for (let j = 0, jj = schemaPath.length; j < jj; ++j) { info.schemaPop(); } + } } - static validateObject(value, schema) { + static validateObject(value, schema, info) { const properties = new Set(Object.getOwnPropertyNames(value)); const required = schema.required; if (Array.isArray(required)) { for (const property of required) { if (!properties.has(property)) { - return `Missing property ${property}`; + throw new JsonSchemaValidationError(`Missing property ${property}`, value, schema, info); } } } const minProperties = schema.minProperties; if (typeof minProperties === 'number' && properties.length < minProperties) { - return 'Not enough object properties'; + throw new JsonSchemaValidationError('Not enough object properties', value, schema, info); } const maxProperties = schema.maxProperties; if (typeof maxProperties === 'number' && properties.length > maxProperties) { - return 'Too many object properties'; + throw new JsonSchemaValidationError('Too many object properties', value, schema, info); } for (const property of properties) { - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + const schemaPath = []; + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value, schemaPath); if (propertySchema === null) { - return `No schema found for ${property}`; - } - const error = JsonSchemaProxyHandler.validate(value[property], propertySchema); - if (error !== null) { - return error; + throw new JsonSchemaValidationError(`No schema found for ${property}`, value, schema, info); } - } - return null; + const propertyValue = value[property]; + + for (const [p, s] of schemaPath) { info.schemaPush(p, s); } + info.valuePush(property, propertyValue); + JsonSchemaProxyHandler.validate(propertyValue, propertySchema, info); + info.valuePop(); + for (let i = 0; i < schemaPath.length; ++i) { info.schemaPop(); } + } } static isValueTypeAny(value, type, schemaTypes) { @@ -372,14 +547,14 @@ class JsonSchemaProxyHandler { for (const property of required) { properties.delete(property); - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value); if (propertySchema === null) { continue; } value[property] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[property]); } } for (const property of properties) { - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property); + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, property, value); if (propertySchema === null) { Reflect.deleteProperty(value, property); } else { @@ -392,13 +567,53 @@ class JsonSchemaProxyHandler { static populateArrayDefaults(value, schema) { for (let i = 0, ii = value.length; i < ii; ++i) { - const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i); + const propertySchema = JsonSchemaProxyHandler.getPropertySchema(schema, i, value); if (propertySchema === null) { continue; } value[i] = JsonSchemaProxyHandler.getValidValueOrDefault(propertySchema, value[i]); } return value; } + + static isObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } +} + +JsonSchemaProxyHandler._unconstrainedSchema = {}; + +class JsonSchemaTraversalInfo { + constructor(value, schema) { + this.valuePath = []; + this.schemaPath = []; + this.valuePush(null, value); + this.schemaPush(null, schema); + } + + valuePush(path, value) { + this.valuePath.push([path, value]); + } + + valuePop() { + this.valuePath.pop(); + } + + schemaPush(path, schema) { + this.schemaPath.push([path, schema]); + } + + schemaPop() { + this.schemaPath.pop(); + } +} + +class JsonSchemaValidationError extends Error { + constructor(message, value, schema, info) { + super(message); + this.value = value; + this.schema = schema; + this.info = info; + } } class JsonSchema { @@ -406,6 +621,10 @@ class JsonSchema { return new Proxy(target, new JsonSchemaProxyHandler(schema)); } + static validate(value, schema) { + return JsonSchemaProxyHandler.validate(value, schema, new JsonSchemaTraversalInfo(value, schema)); + } + static getValidValueOrDefault(schema, value) { return JsonSchemaProxyHandler.getValidValueOrDefault(schema, value); } diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index d93862bf..f9db99a2 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global utilStringHashCode*/ /* * Generic options functions @@ -266,6 +267,7 @@ function profileOptionsCreateDefaults() { return { general: { enable: true, + enableClipboardPopups: false, resultOutputMode: 'group', debugInfo: false, maxResults: 32, @@ -316,7 +318,8 @@ function profileOptionsCreateDefaults() { popupNestingMaxDepth: 0, enablePopupSearch: false, enableOnPopupExpressions: false, - enableOnSearchPage: true + enableOnSearchPage: true, + enableSearchTags: false }, translation: { diff --git a/ext/bg/js/page-exit-prevention.js b/ext/bg/js/page-exit-prevention.js index 3a320db3..be06c495 100644 --- a/ext/bg/js/page-exit-prevention.js +++ b/ext/bg/js/page-exit-prevention.js @@ -18,43 +18,43 @@ class PageExitPrevention { - constructor() { - } - - start() { - PageExitPrevention._addInstance(this); - } - - end() { - PageExitPrevention._removeInstance(this); - } - - static _addInstance(instance) { - const size = PageExitPrevention._instances.size; - PageExitPrevention._instances.set(instance, true); - if (size === 0) { - window.addEventListener('beforeunload', PageExitPrevention._onBeforeUnload); - } - } - - static _removeInstance(instance) { - if ( - PageExitPrevention._instances.delete(instance) && - PageExitPrevention._instances.size === 0 - ) { - window.removeEventListener('beforeunload', PageExitPrevention._onBeforeUnload); - } - } - - static _onBeforeUnload(e) { - if (PageExitPrevention._instances.size === 0) { - return; - } - - e.preventDefault(); - e.returnValue = ''; - return ''; - } + constructor() { + } + + start() { + PageExitPrevention._addInstance(this); + } + + end() { + PageExitPrevention._removeInstance(this); + } + + static _addInstance(instance) { + const size = PageExitPrevention._instances.size; + PageExitPrevention._instances.set(instance, true); + if (size === 0) { + window.addEventListener('beforeunload', PageExitPrevention._onBeforeUnload); + } + } + + static _removeInstance(instance) { + if ( + PageExitPrevention._instances.delete(instance) && + PageExitPrevention._instances.size === 0 + ) { + window.removeEventListener('beforeunload', PageExitPrevention._onBeforeUnload); + } + } + + static _onBeforeUnload(e) { + if (PageExitPrevention._instances.size === 0) { + return; + } + + e.preventDefault(); + e.returnValue = ''; + return ''; + } } PageExitPrevention._instances = new Map(); diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js index e453ccef..509c4009 100644 --- a/ext/bg/js/search-frontend.js +++ b/ext/bg/js/search-frontend.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiOptionsGet*/ async function searchFrontendSetup() { const optionsContext = { diff --git a/ext/bg/js/search-query-parser-generator.js b/ext/bg/js/search-query-parser-generator.js new file mode 100644 index 00000000..1ab23a82 --- /dev/null +++ b/ext/bg/js/search-query-parser-generator.js @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2020 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 apiGetQueryParserTemplatesHtml, TemplateHandler*/ + +class QueryParserGenerator { + constructor() { + this._templateHandler = null; + } + + async prepare() { + const html = await apiGetQueryParserTemplatesHtml(); + this._templateHandler = new TemplateHandler(html); + } + + createParseResult(terms, preview=false) { + const fragment = document.createDocumentFragment(); + for (const term of terms) { + const termContainer = this._templateHandler.instantiate(preview ? 'term-preview' : 'term'); + for (const segment of term) { + if (!segment.text.trim()) { continue; } + if (!segment.reading || !segment.reading.trim()) { + termContainer.appendChild(this.createSegmentText(segment.text)); + } else { + termContainer.appendChild(this.createSegment(segment)); + } + } + fragment.appendChild(termContainer); + } + return fragment; + } + + createSegment(segment) { + const segmentContainer = this._templateHandler.instantiate('segment'); + const segmentTextContainer = segmentContainer.querySelector('.query-parser-segment-text'); + const segmentReadingContainer = segmentContainer.querySelector('.query-parser-segment-reading'); + segmentTextContainer.appendChild(this.createSegmentText(segment.text)); + segmentReadingContainer.textContent = segment.reading; + return segmentContainer; + } + + createSegmentText(text) { + const fragment = document.createDocumentFragment(); + for (const chr of text) { + const charContainer = this._templateHandler.instantiate('char'); + charContainer.textContent = chr; + fragment.appendChild(charContainer); + } + return fragment; + } + + createParserSelect(parseResults, selectedParser) { + const selectContainer = this._templateHandler.instantiate('select'); + for (const parseResult of parseResults) { + const optionContainer = this._templateHandler.instantiate('select-option'); + optionContainer.value = parseResult.id; + optionContainer.textContent = parseResult.name; + optionContainer.defaultSelected = selectedParser === parseResult.id; + selectContainer.appendChild(optionContainer); + } + return selectContainer; + } +} diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js index e8e6d11f..0d4aaa50 100644 --- a/ext/bg/js/search-query-parser.js +++ b/ext/bg/js/search-query-parser.js @@ -16,17 +16,24 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiTermsFind, apiOptionsSet, apiTextParse, apiTextParseMecab, TextScanner, QueryParserGenerator*/ class QueryParser extends TextScanner { constructor(search) { - super(document.querySelector('#query-parser'), [], [], []); + super(document.querySelector('#query-parser-content'), [], [], []); this.search = search; this.parseResults = []; this.selectedParser = null; - this.queryParser = document.querySelector('#query-parser'); - this.queryParserSelect = document.querySelector('#query-parser-select'); + this.queryParser = document.querySelector('#query-parser-content'); + this.queryParserSelect = document.querySelector('#query-parser-select-container'); + + this.queryParserGenerator = new QueryParserGenerator(); + } + + async prepare() { + await this.queryParserGenerator.prepare(); } onError(error) { @@ -52,7 +59,7 @@ class QueryParser extends TextScanner { this.search.setContent('terms', {definitions, context: { focus: false, - disableHistory: cause === 'mouse' ? true : false, + disableHistory: cause === 'mouse', sentence: {text: searchText, offset: 0}, url: window.location.href }}); @@ -64,7 +71,7 @@ class QueryParser extends TextScanner { const selectedParser = e.target.value; this.selectedParser = selectedParser; apiOptionsSet({parsing: {selectedParser}}, this.search.getOptionsContext()); - this.renderParseResult(this.getParseResult()); + this.renderParseResult(); } getMouseEventListeners() { @@ -113,13 +120,13 @@ class QueryParser extends TextScanner { async setText(text) { this.search.setSpinnerVisible(true); - await this.setPreview(text); + this.setPreview(text); this.parseResults = await this.parseText(text); this.refreshSelectedParser(); this.renderParserSelect(); - await this.renderParseResult(); + this.renderParseResult(); this.search.setSpinnerVisible(false); } @@ -146,57 +153,29 @@ class QueryParser extends TextScanner { return results; } - async 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.split('')}]); + previewTerms.push([{text: tempText}]); } - this.queryParser.innerHTML = await apiTemplateRender('query-parser.html', { - terms: previewTerms, - preview: true - }); + this.queryParser.textContent = ''; + this.queryParser.appendChild(this.queryParserGenerator.createParseResult(previewTerms, true)); } renderParserSelect() { - this.queryParserSelect.innerHTML = ''; + this.queryParserSelect.textContent = ''; if (this.parseResults.length > 1) { - const select = document.createElement('select'); - select.classList.add('form-control'); - for (const parseResult of this.parseResults) { - const option = document.createElement('option'); - option.value = parseResult.id; - option.innerText = parseResult.name; - option.defaultSelected = this.selectedParser === parseResult.id; - select.appendChild(option); - } + const select = this.queryParserGenerator.createParserSelect(this.parseResults, this.selectedParser); select.addEventListener('change', this.onParserChange.bind(this)); this.queryParserSelect.appendChild(select); } } - async renderParseResult() { + renderParseResult() { const parseResult = this.getParseResult(); - if (!parseResult) { - this.queryParser.innerHTML = ''; - return; - } - - this.queryParser.innerHTML = await apiTemplateRender( - 'query-parser.html', - {terms: QueryParser.processParseResultForDisplay(parseResult.parsedText)} - ); - } - - static processParseResultForDisplay(result) { - return result.map((term) => { - return term.filter((part) => part.text.trim()).map((part) => { - return { - text: part.text.split(''), - reading: part.reading, - raw: !part.reading || !part.reading.trim() - }; - }); - }); + this.queryParser.textContent = ''; + if (!parseResult) { return; } + this.queryParser.appendChild(this.queryParserGenerator.createParseResult(parseResult.parsedText)); } } diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index f5c641a8..98e167ad 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -16,6 +16,8 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiOptionsSet, apiTermsFind, Display, QueryParser, ClipboardMonitor*/ + class DisplaySearch extends Display { constructor() { super(document.querySelector('#spinner'), document.querySelector('#content')); @@ -36,12 +38,7 @@ class DisplaySearch extends Display { this.introVisible = true; this.introAnimationTimer = null; - this.isFirefox = false; - - this.clipboardMonitorTimerId = null; - this.clipboardMonitorTimerToken = null; - this.clipboardInterval = 250; - this.clipboardPreviousText = null; + this.clipboardMonitor = new ClipboardMonitor(); } static create() { @@ -52,13 +49,17 @@ class DisplaySearch extends Display { async prepare() { try { - await this.initialize(); - this.isFirefox = await DisplaySearch._isFirefox(); + const superPromise = super.prepare(); + const queryParserPromise = this.queryParser.prepare(); + await Promise.all([superPromise, queryParserPromise]); + + const {queryParams: {query='', mode=''}} = parseUrl(window.location.href); if (this.search !== null) { this.search.addEventListener('click', (e) => this.onSearch(e), false); } if (this.query !== null) { + document.documentElement.dataset.searchMode = mode; this.query.addEventListener('input', () => this.onSearchInput(), false); if (this.wanakanaEnable !== null) { @@ -69,34 +70,26 @@ class DisplaySearch extends Display { this.wanakanaEnable.checked = false; } this.wanakanaEnable.addEventListener('change', (e) => { - const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || ''; + const {queryParams: {query: query2=''}} = parseUrl(window.location.href); if (e.target.checked) { window.wanakana.bind(this.query); - this.setQuery(window.wanakana.toKana(query)); apiOptionsSet({general: {enableWanakana: true}}, this.getOptionsContext()); } else { window.wanakana.unbind(this.query); - this.setQuery(query); apiOptionsSet({general: {enableWanakana: false}}, this.getOptionsContext()); } + this.setQuery(query2); this.onSearchQueryUpdated(this.query.value, false); }); } - const query = DisplaySearch.getSearchQueryFromLocation(window.location.href); - if (query !== null) { - if (this.isWanakanaEnabled()) { - this.setQuery(window.wanakana.toKana(query)); - } else { - this.setQuery(query); - } - this.onSearchQueryUpdated(this.query.value, false); - } + this.setQuery(query); + this.onSearchQueryUpdated(this.query.value, false); } - if (this.clipboardMonitorEnable !== null) { + if (this.clipboardMonitorEnable !== null && mode !== 'popup') { if (this.options.general.enableClipboardMonitor === true) { this.clipboardMonitorEnable.checked = true; - this.startClipboardMonitor(); + this.clipboardMonitor.start(); } else { this.clipboardMonitorEnable.checked = false; } @@ -106,7 +99,7 @@ class DisplaySearch extends Display { {permissions: ['clipboardRead']}, (granted) => { if (granted) { - this.startClipboardMonitor(); + this.clipboardMonitor.start(); apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext()); } else { e.target.checked = false; @@ -114,16 +107,20 @@ class DisplaySearch extends Display { } ); } else { - this.stopClipboardMonitor(); + this.clipboardMonitor.stop(); apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext()); } }); } + chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); + window.addEventListener('popstate', (e) => this.onPopState(e)); + window.addEventListener('copy', (e) => this.onCopy(e)); + + this.clipboardMonitor.onClipboardText = (text) => this.onExternalSearchUpdate(text); this.updateSearchButton(); - this.initClipboardMonitor(); } catch (e) { this.onError(e); } @@ -159,25 +156,32 @@ class DisplaySearch extends Display { e.preventDefault(); const query = this.query.value; + this.queryParser.setText(query); - const queryString = query.length > 0 ? `?query=${encodeURIComponent(query)}` : ''; - window.history.pushState(null, '', `${window.location.pathname}${queryString}`); + + const url = new URL(window.location.href); + url.searchParams.set('query', query); + window.history.pushState(null, '', url.toString()); + this.onSearchQueryUpdated(query, true); } onPopState() { - const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || ''; - if (this.query !== null) { - if (this.isWanakanaEnabled()) { - this.setQuery(window.wanakana.toKana(query)); - } else { - this.setQuery(query); - } - } - + const {queryParams: {query='', mode=''}} = parseUrl(window.location.href); + document.documentElement.dataset.searchMode = mode; + this.setQuery(query); this.onSearchQueryUpdated(this.query.value, false); } + onRuntimeMessage({action, params}, sender, callback) { + const handler = DisplaySearch._runtimeMessageHandlers.get(action); + if (typeof handler !== 'function') { return false; } + + const result = handler(this, params, sender); + callback(result); + return false; + } + onKeyDown(e) { const key = Display.getKeyFromEvent(e); const ignoreKeys = DisplaySearch.onKeyDownIgnoreKeys; @@ -202,6 +206,19 @@ class DisplaySearch extends Display { } } + onCopy() { + // ignore copy from search page + this.clipboardMonitor.setPreviousText(document.getSelection().toString().trim()); + } + + onExternalSearchUpdate(text) { + this.setQuery(text); + const url = new URL(window.location.href); + url.searchParams.set('query', text); + window.history.pushState(null, '', url.toString()); + this.onSearchQueryUpdated(this.query.value, true); + } + async onSearchQueryUpdated(query, animate) { try { const details = {}; @@ -241,74 +258,6 @@ class DisplaySearch extends Display { this.queryParser.setOptions(this.options); } - initClipboardMonitor() { - // ignore copy from search page - window.addEventListener('copy', () => { - this.clipboardPreviousText = document.getSelection().toString().trim(); - }); - } - - startClipboardMonitor() { - // The token below is used as a unique identifier to ensure that a new clipboard monitor - // hasn't been started during the await call. The check below the await this.getClipboardText() - // call will exit early if the reference has changed. - const token = {}; - const intervalCallback = async () => { - this.clipboardMonitorTimerId = null; - - let text = await this.getClipboardText(); - if (this.clipboardMonitorTimerToken !== token) { return; } - - if ( - typeof text === 'string' && - (text = text.trim()).length > 0 && - text !== this.clipboardPreviousText - ) { - this.clipboardPreviousText = text; - if (jpIsStringPartiallyJapanese(text)) { - this.setQuery(this.isWanakanaEnabled() ? window.wanakana.toKana(text) : text); - window.history.pushState(null, '', `${window.location.pathname}?query=${encodeURIComponent(text)}`); - this.onSearchQueryUpdated(this.query.value, true); - } - } - - this.clipboardMonitorTimerId = setTimeout(intervalCallback, this.clipboardInterval); - }; - - this.clipboardMonitorTimerToken = token; - - intervalCallback(); - } - - stopClipboardMonitor() { - this.clipboardMonitorTimerToken = null; - if (this.clipboardMonitorTimerId !== null) { - clearTimeout(this.clipboardMonitorTimerId); - this.clipboardMonitorTimerId = null; - } - } - - async getClipboardText() { - /* - Notes: - apiClipboardGet doesn't work on Firefox because document.execCommand('paste') - results in an empty string on the web extension background page. - This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985 - Therefore, navigator.clipboard.readText() is used on Firefox. - - navigator.clipboard.readText() can't be used in Chrome for two reasons: - * Requires page to be focused, else it rejects with an exception. - * When the page is focused, Chrome will request clipboard permission, despite already - being an extension with clipboard permissions. It effectively asks for the - non-extension permission for clipboard access. - */ - try { - return this.isFirefox ? await navigator.clipboard.readText() : await apiClipboardGet(); - } catch (e) { - return null; - } - } - isWanakanaEnabled() { return this.wanakanaEnable !== null && this.wanakanaEnable.checked; } @@ -318,8 +267,9 @@ class DisplaySearch extends Display { } setQuery(query) { - this.query.value = query; - this.queryParser.setText(query); + const interpretedQuery = this.isWanakanaEnabled() ? window.wanakana.toKana(query) : query; + this.query.value = interpretedQuery; + this.queryParser.setText(interpretedQuery); } setIntroVisible(visible, animate) { @@ -394,22 +344,6 @@ class DisplaySearch extends Display { document.title = `${text} - Yomichan Search`; } } - - static getSearchQueryFromLocation(url) { - const match = /^[^?#]*\?(?:[^&#]*&)?query=([^&#]*)/.exec(url); - return match !== null ? decodeURIComponent(match[1]) : null; - } - - static async _isFirefox() { - const {browser} = await apiGetEnvironmentInfo(); - switch (browser) { - case 'firefox': - case 'firefox-mobile': - return true; - default: - return false; - } - } } DisplaySearch.onKeyDownIgnoreKeys = { @@ -427,4 +361,8 @@ DisplaySearch.onKeyDownIgnoreKeys = { 'Shift': [] }; +DisplaySearch._runtimeMessageHandlers = new Map([ + ['searchQueryUpdate', (self, {query}) => { self.onExternalSearchUpdate(query); }] +]); + DisplaySearch.instance = DisplaySearch.create(); diff --git a/ext/bg/js/settings/anki-templates.js b/ext/bg/js/settings/anki-templates.js index 5e74358f..2e80e334 100644 --- a/ext/bg/js/settings/anki-templates.js +++ b/ext/bg/js/settings/anki-templates.js @@ -16,6 +16,9 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global getOptionsContext, getOptionsMutable, settingsSaveOptions +profileOptionsGetDefaultFieldTemplates, ankiGetFieldMarkers, ankiGetFieldMarkersHtml, dictFieldFormat +apiOptionsGet, apiTermsFind*/ function onAnkiFieldTemplatesReset(e) { e.preventDefault(); diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js index 9adb2f2a..4263fc51 100644 --- a/ext/bg/js/settings/anki.js +++ b/ext/bg/js/settings/anki.js @@ -16,6 +16,9 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global getOptionsContext, getOptionsMutable, settingsSaveOptions +utilBackgroundIsolate, utilAnkiGetDeckNames, utilAnkiGetModelNames, utilAnkiGetModelFieldNames +onFormOptionsChanged*/ // Private @@ -33,14 +36,27 @@ function _ankiSpinnerShow(show) { function _ankiSetError(error) { const node = document.querySelector('#anki-error'); - if (!node) { return; } + const node2 = document.querySelector('#anki-invalid-response-error'); if (error) { - node.hidden = false; - node.textContent = `${error}`; - _ankiSetErrorData(node, error); + const errorString = `${error}`; + if (node !== null) { + node.hidden = false; + node.textContent = errorString; + _ankiSetErrorData(node, error); + } + + if (node2 !== null) { + node2.hidden = (errorString.indexOf('Invalid response') < 0); + } } else { - node.hidden = true; - node.textContent = ''; + if (node !== null) { + node.hidden = true; + node.textContent = ''; + } + + if (node2 !== null) { + node2.hidden = true; + } } } diff --git a/ext/bg/js/settings/audio-ui.js b/ext/bg/js/settings/audio-ui.js index 711c2291..555380b4 100644 --- a/ext/bg/js/settings/audio-ui.js +++ b/ext/bg/js/settings/audio-ui.js @@ -16,7 +16,6 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ - class AudioSourceUI { static instantiateTemplate(templateSelector) { const template = document.querySelector(templateSelector); diff --git a/ext/bg/js/settings/audio.js b/ext/bg/js/settings/audio.js index cff3f521..588d9a11 100644 --- a/ext/bg/js/settings/audio.js +++ b/ext/bg/js/settings/audio.js @@ -16,6 +16,8 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global getOptionsContext, getOptionsMutable, settingsSaveOptions +AudioSourceUI, audioGetTextToSpeechVoice*/ let audioSourceUI = null; diff --git a/ext/bg/js/settings/backup.js b/ext/bg/js/settings/backup.js index becdc568..f4d622a4 100644 --- a/ext/bg/js/settings/backup.js +++ b/ext/bg/js/settings/backup.js @@ -16,6 +16,10 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiOptionsGetFull, apiGetEnvironmentInfo +utilBackend, utilIsolate, utilBackgroundIsolate, utilReadFileArrayBuffer +optionsGetDefault, optionsUpdateVersion +profileOptionsGetDefaultFieldTemplates*/ // Exporting @@ -159,7 +163,6 @@ async function _showSettingsImportWarnings(warnings) { sanitize: e.currentTarget.dataset.importSanitize === 'true' }); modalNode.modal('hide'); - }; const onModalHide = () => { complete({result: false}); diff --git a/ext/bg/js/settings/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js index 4d041451..5a271321 100644 --- a/ext/bg/js/settings/conditions-ui.js +++ b/ext/bg/js/settings/conditions-ui.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global conditionsNormalizeOptionValue*/ class ConditionsUI { static instantiateTemplate(templateSelector) { diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js index ed171ae9..70a22a16 100644 --- a/ext/bg/js/settings/dictionaries.js +++ b/ext/bg/js/settings/dictionaries.js @@ -16,6 +16,11 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global getOptionsContext, getOptionsMutable, getOptionsFullMutable, settingsSaveOptions, apiOptionsGetFull, apiOptionsGet +utilBackgroundIsolate, utilDatabaseDeleteDictionary, utilDatabaseGetDictionaryInfo, utilDatabaseGetDictionaryCounts +utilDatabasePurge, utilDatabaseImport +storageUpdateStats, storageEstimate +PageExitPrevention*/ let dictionaryUI = null; @@ -161,7 +166,7 @@ class SettingsDictionaryListUI { delete n.dataset.dict; $(n).modal('hide'); - const index = this.dictionaryEntries.findIndex((e) => e.dictionaryInfo.title === title); + const index = this.dictionaryEntries.findIndex((entry) => entry.dictionaryInfo.title === title); if (index >= 0) { this.dictionaryEntries[index].deleteDictionary(); } @@ -174,7 +179,7 @@ class SettingsDictionaryEntryUI { this.dictionaryInfo = dictionaryInfo; this.optionsDictionary = optionsDictionary; this.counts = null; - this.eventListeners = []; + this.eventListeners = new EventListenerCollection(); this.isDeleting = false; this.content = content; @@ -193,10 +198,10 @@ class SettingsDictionaryEntryUI { this.applyValues(); - this.addEventListener(this.enabledCheckbox, 'change', (e) => this.onEnabledChanged(e), false); - this.addEventListener(this.allowSecondarySearchesCheckbox, 'change', (e) => this.onAllowSecondarySearchesChanged(e), false); - this.addEventListener(this.priorityInput, 'change', (e) => this.onPriorityChanged(e), false); - this.addEventListener(this.deleteButton, 'click', (e) => this.onDeleteButtonClicked(e), false); + this.eventListeners.addEventListener(this.enabledCheckbox, 'change', (e) => this.onEnabledChanged(e), false); + this.eventListeners.addEventListener(this.allowSecondarySearchesCheckbox, 'change', (e) => this.onAllowSecondarySearchesChanged(e), false); + this.eventListeners.addEventListener(this.priorityInput, 'change', (e) => this.onPriorityChanged(e), false); + this.eventListeners.addEventListener(this.deleteButton, 'click', (e) => this.onDeleteButtonClicked(e), false); } cleanup() { @@ -207,7 +212,7 @@ class SettingsDictionaryEntryUI { this.content = null; } this.dictionaryInfo = null; - this.clearEventListeners(); + this.eventListeners.removeAllEventListeners(); } setCounts(counts) { @@ -224,18 +229,6 @@ class SettingsDictionaryEntryUI { this.parent.save(); } - addEventListener(node, type, listener, options) { - node.addEventListener(type, listener, options); - this.eventListeners.push([node, type, listener, options]); - } - - clearEventListeners() { - for (const [node, type, listener, options] of this.eventListeners) { - node.removeEventListener(type, listener, options); - } - this.eventListeners = []; - } - applyValues() { this.enabledCheckbox.checked = this.optionsDictionary.enabled; this.allowSecondarySearchesCheckbox.checked = this.optionsDictionary.allowSecondarySearches; @@ -272,9 +265,7 @@ class SettingsDictionaryEntryUI { this.isDeleting = false; progress.hidden = true; - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - onDatabaseUpdated(options); + onDatabaseUpdated(); } } @@ -359,28 +350,33 @@ async function dictSettingsInitialize() { document.querySelector('#dict-main').addEventListener('change', (e) => onDictionaryMainChanged(e), false); document.querySelector('#database-enable-prefix-wildcard-searches').addEventListener('change', (e) => onDatabaseEnablePrefixWildcardSearchesChanged(e), false); - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - onDictionaryOptionsChanged(options); - onDatabaseUpdated(options); + await onDictionaryOptionsChanged(); + await onDatabaseUpdated(); } -async function onDictionaryOptionsChanged(options) { +async function onDictionaryOptionsChanged() { if (dictionaryUI === null) { return; } + + const optionsContext = getOptionsContext(); + const options = await getOptionsMutable(optionsContext); + dictionaryUI.setOptionsDictionaries(options.dictionaries); const optionsFull = await apiOptionsGetFull(); document.querySelector('#database-enable-prefix-wildcard-searches').checked = optionsFull.global.database.prefixWildcardsSupported; + + await updateMainDictionarySelectValue(); } -async function onDatabaseUpdated(options) { +async function onDatabaseUpdated() { try { const dictionaries = await utilDatabaseGetDictionaryInfo(); dictionaryUI.setDictionaries(dictionaries); document.querySelector('#dict-warning').hidden = (dictionaries.length > 0); - updateMainDictionarySelect(options, dictionaries); + updateMainDictionarySelectOptions(dictionaries); + await updateMainDictionarySelectValue(); const {counts, total} = await utilDatabaseGetDictionaryCounts(dictionaries.map((v) => v.title), true); dictionaryUI.setCounts(counts, total); @@ -389,7 +385,7 @@ async function onDatabaseUpdated(options) { } } -async function updateMainDictionarySelect(options, dictionaries) { +function updateMainDictionarySelectOptions(dictionaries) { const select = document.querySelector('#dict-main'); select.textContent = ''; // Empty @@ -399,8 +395,6 @@ async function updateMainDictionarySelect(options, dictionaries) { option.textContent = 'Not selected'; select.appendChild(option); - let value = ''; - const currentValue = options.general.mainDictionary; for (const {title, sequenced} of toIterable(dictionaries)) { if (!sequenced) { continue; } @@ -408,26 +402,56 @@ async function updateMainDictionarySelect(options, dictionaries) { option.value = title; option.textContent = title; select.appendChild(option); + } +} + +async function updateMainDictionarySelectValue() { + const optionsContext = getOptionsContext(); + const options = await apiOptionsGet(optionsContext); - if (title === currentValue) { - value = title; + const value = options.general.mainDictionary; + + const select = document.querySelector('#dict-main'); + let selectValue = null; + for (const child of select.children) { + if (child.nodeName.toUpperCase() === 'OPTION' && child.value === value) { + selectValue = value; + break; } } - select.value = value; - - if (options.general.mainDictionary !== value) { - options.general.mainDictionary = value; - settingsSaveOptions(); + let missingNodeOption = select.querySelector('option[data-not-installed=true]'); + if (selectValue === null) { + if (missingNodeOption === null) { + missingNodeOption = document.createElement('option'); + missingNodeOption.className = 'text-muted'; + missingNodeOption.value = value; + missingNodeOption.textContent = `${value} (Not installed)`; + missingNodeOption.dataset.notInstalled = 'true'; + select.appendChild(missingNodeOption); + } + } else { + if (missingNodeOption !== null) { + missingNodeOption.parentNode.removeChild(missingNodeOption); + } } + + select.value = value; } async function onDictionaryMainChanged(e) { - const value = e.target.value; + const select = e.target; + const value = select.value; + + const missingNodeOption = select.querySelector('option[data-not-installed=true]'); + if (missingNodeOption !== null && missingNodeOption.value !== value) { + missingNodeOption.parentNode.removeChild(missingNodeOption); + } + const optionsContext = getOptionsContext(); const options = await getOptionsMutable(optionsContext); options.general.mainDictionary = value; - settingsSaveOptions(); + await settingsSaveOptions(); } @@ -467,15 +491,18 @@ function dictionaryErrorsShow(errors) { dialog.textContent = ''; if (errors !== null && errors.length > 0) { - const uniqueErrors = {}; + const uniqueErrors = new Map(); for (let e of errors) { console.error(e); e = dictionaryErrorToString(e); - uniqueErrors[e] = hasOwn(uniqueErrors, e) ? uniqueErrors[e] + 1 : 1; + let count = uniqueErrors.get(e); + if (typeof count === 'undefined') { + count = 0; + } + uniqueErrors.set(e, count + 1); } - for (const e in uniqueErrors) { - const count = uniqueErrors[e]; + for (const [e, count] of uniqueErrors.entries()) { const div = document.createElement('p'); if (count > 1) { div.textContent = `${e} `; @@ -537,9 +564,7 @@ async function onDictionaryPurge(e) { } await settingsSaveOptions(); - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - onDatabaseUpdated(options); + onDatabaseUpdated(); } catch (err) { dictionaryErrorsShow([err]); } finally { @@ -611,9 +636,7 @@ async function onDictionaryImport(e) { dictionaryErrorsShow(errors); } - const optionsContext = getOptionsContext(); - const options = await getOptionsMutable(optionsContext); - onDatabaseUpdated(options); + onDatabaseUpdated(); } } catch (err) { dictionaryErrorsShow([err]); diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js index 3bf65eda..d1ad2c6b 100644 --- a/ext/bg/js/settings/main.js +++ b/ext/bg/js/settings/main.js @@ -16,6 +16,14 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global getOptionsContext, apiOptionsSave +utilBackend, utilIsolate, utilBackgroundIsolate +ankiErrorShown, ankiFieldsToDict +ankiTemplatesUpdateValue, onAnkiOptionsChanged, onDictionaryOptionsChanged +appearanceInitialize, audioSettingsInitialize, profileOptionsSetup, dictSettingsInitialize +ankiInitialize, ankiTemplatesInitialize, storageInfoInitialize +*/ + function getOptionsMutable(optionsContext) { return utilBackend().getOptions( utilBackgroundIsolate(optionsContext) @@ -28,6 +36,22 @@ function getOptionsFullMutable() { async function formRead(options) { options.general.enable = $('#enable').prop('checked'); + const enableClipboardPopups = $('#enable-clipboard-popups').prop('checked'); + if (enableClipboardPopups) { + options.general.enableClipboardPopups = await new Promise((resolve, _reject) => { + chrome.permissions.request( + {permissions: ['clipboardRead']}, + (granted) => { + if (!granted) { + $('#enable-clipboard-popups').prop('checked', false); + } + resolve(granted); + } + ); + }); + } else { + options.general.enableClipboardPopups = false; + } options.general.showGuide = $('#show-usage-guide').prop('checked'); options.general.compactTags = $('#compact-tags').prop('checked'); options.general.compactGlossaries = $('#compact-glossaries').prop('checked'); @@ -44,7 +68,7 @@ async function formRead(options) { options.general.popupVerticalOffset = parseInt($('#popup-vertical-offset').val(), 10); options.general.popupHorizontalOffset2 = parseInt($('#popup-horizontal-offset2').val(), 0); options.general.popupVerticalOffset2 = parseInt($('#popup-vertical-offset2').val(), 10); - options.general.popupScalingFactor = parseInt($('#popup-scaling-factor').val(), 10); + options.general.popupScalingFactor = parseFloat($('#popup-scaling-factor').val()); options.general.popupScaleRelativeToPageZoom = $('#popup-scale-relative-to-page-zoom').prop('checked'); options.general.popupScaleRelativeToVisualViewport = $('#popup-scale-relative-to-visual-viewport').prop('checked'); options.general.popupTheme = $('#popup-theme').val(); @@ -67,6 +91,7 @@ async function formRead(options) { options.scanning.enablePopupSearch = $('#enable-search-within-first-popup').prop('checked'); options.scanning.enableOnPopupExpressions = $('#enable-scanning-of-popup-expressions').prop('checked'); options.scanning.enableOnSearchPage = $('#enable-scanning-on-search-page').prop('checked'); + options.scanning.enableSearchTags = $('#enable-search-tags').prop('checked'); options.scanning.delay = parseInt($('#scan-delay').val(), 10); options.scanning.length = parseInt($('#scan-length').val(), 10); options.scanning.modifier = $('#scan-modifier-key').val(); @@ -103,6 +128,7 @@ async function formRead(options) { async function formWrite(options) { $('#enable').prop('checked', options.general.enable); + $('#enable-clipboard-popups').prop('checked', options.general.enableClipboardPopups); $('#show-usage-guide').prop('checked', options.general.showGuide); $('#compact-tags').prop('checked', options.general.compactTags); $('#compact-glossaries').prop('checked', options.general.compactGlossaries); @@ -142,6 +168,7 @@ async function formWrite(options) { $('#enable-search-within-first-popup').prop('checked', options.scanning.enablePopupSearch); $('#enable-scanning-of-popup-expressions').prop('checked', options.scanning.enableOnPopupExpressions); $('#enable-scanning-on-search-page').prop('checked', options.scanning.enableOnSearchPage); + $('#enable-search-tags').prop('checked', options.scanning.enableSearchTags); $('#scan-delay').val(options.scanning.delay); $('#scan-length').val(options.scanning.length); $('#scan-modifier-key').val(options.scanning.modifier); @@ -167,7 +194,7 @@ async function formWrite(options) { await ankiTemplatesUpdateValue(); await onAnkiOptionsChanged(options); - await onDictionaryOptionsChanged(options); + await onDictionaryOptionsChanged(); formUpdateVisibility(options); } @@ -215,7 +242,7 @@ async function settingsSaveOptions() { await apiOptionsSave(source); } -async function onOptionsUpdate({source}) { +async function onOptionsUpdated({source}) { const thisSource = await settingsGetSource(); if (source === thisSource) { return; } @@ -247,7 +274,7 @@ async function onReady() { storageInfoInitialize(); - yomichan.on('optionsUpdate', onOptionsUpdate); + yomichan.on('optionsUpdated', onOptionsUpdated); } $(document).ready(() => onReady()); diff --git a/ext/bg/js/settings/popup-preview-frame.js b/ext/bg/js/settings/popup-preview-frame.js index 37a4b416..aa2b6100 100644 --- a/ext/bg/js/settings/popup-preview-frame.js +++ b/ext/bg/js/settings/popup-preview-frame.js @@ -16,15 +16,18 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiOptionsGet, Popup, PopupProxyHost, Frontend, TextSourceRange*/ class SettingsPopupPreview { constructor() { this.frontend = null; this.apiOptionsGetOld = apiOptionsGet; - this.popupInjectOuterStylesheetOld = Popup.injectOuterStylesheet; + this.popup = null; + this.popupSetCustomOuterCssOld = null; this.popupShown = false; this.themeChangeTimeout = null; this.textSource = null; + this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); } static create() { @@ -49,18 +52,18 @@ class SettingsPopupPreview { const popupHost = new PopupProxyHost(); await popupHost.prepare(); - const popup = popupHost.createPopup(null, 0); - popup.setChildrenSupported(false); + this.popup = popupHost.getOrCreatePopup(); + this.popup.setChildrenSupported(false); - this.frontend = new Frontend(popup); + this.popupSetCustomOuterCssOld = this.popup.setCustomOuterCss; + this.popup.setCustomOuterCss = (...args) => this.popupSetCustomOuterCss(...args); - this.frontend.setEnabled = function () {}; - this.frontend.searchClear = function () {}; + this.frontend = new Frontend(this.popup); - await this.frontend.prepare(); + this.frontend.setEnabled = () => {}; + this.frontend.searchClear = () => {}; - // Overwrite popup - Popup.injectOuterStylesheet = (...args) => this.popupInjectOuterStylesheet(...args); + await this.frontend.prepare(); // Update search this.updateSearch(); @@ -82,20 +85,21 @@ class SettingsPopupPreview { return options; } - popupInjectOuterStylesheet(...args) { + async popupSetCustomOuterCss(...args) { // This simulates the stylesheet priorities when injecting using the web extension API. - const result = this.popupInjectOuterStylesheetOld(...args); + const result = await this.popupSetCustomOuterCssOld.call(this.popup, ...args); - const outerStylesheet = Popup.outerStylesheet; const node = document.querySelector('#client-css'); - if (node !== null && outerStylesheet !== null) { - node.parentNode.insertBefore(outerStylesheet, node); + if (node !== null && result !== null) { + node.parentNode.insertBefore(result, node); } return result; } onMessage(e) { + if (e.origin !== this._targetOrigin) { return; } + const {action, params} = e.data; const handler = SettingsPopupPreview._messageHandlers.get(action); if (typeof handler !== 'function') { return; } @@ -136,7 +140,7 @@ class SettingsPopupPreview { setCustomOuterCss(css) { if (this.frontend === null) { return; } - this.frontend.popup.setCustomOuterCss(css, true); + this.frontend.popup.setCustomOuterCss(css, false); } async updateSearch() { diff --git a/ext/bg/js/settings/popup-preview.js b/ext/bg/js/settings/popup-preview.js index 0d20471e..d1d2ff5e 100644 --- a/ext/bg/js/settings/popup-preview.js +++ b/ext/bg/js/settings/popup-preview.js @@ -40,20 +40,22 @@ function showAppearancePreview() { window.wanakana.bind(text[0]); + const targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + text.on('input', () => { const action = 'setText'; const params = {text: text.val()}; - frame.contentWindow.postMessage({action, params}, '*'); + frame.contentWindow.postMessage({action, params}, targetOrigin); }); customCss.on('input', () => { const action = 'setCustomCss'; const params = {css: customCss.val()}; - frame.contentWindow.postMessage({action, params}, '*'); + frame.contentWindow.postMessage({action, params}, targetOrigin); }); customOuterCss.on('input', () => { const action = 'setCustomOuterCss'; const params = {css: customOuterCss.val()}; - frame.contentWindow.postMessage({action, params}, '*'); + frame.contentWindow.postMessage({action, params}, targetOrigin); }); container.append(frame); diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js index c4e68b53..3e589809 100644 --- a/ext/bg/js/settings/profiles.js +++ b/ext/bg/js/settings/profiles.js @@ -16,6 +16,10 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global getOptionsMutable, getOptionsFullMutable, settingsSaveOptions, apiOptionsGetFull +utilBackgroundIsolate, formWrite +conditionsClearCaches, ConditionsUI, profileConditionsDescriptor*/ + let currentProfileIndex = 0; let profileConditionsContainer = null; diff --git a/ext/bg/js/settings/storage.js b/ext/bg/js/settings/storage.js index 6c10f665..cbe1bb4d 100644 --- a/ext/bg/js/settings/storage.js +++ b/ext/bg/js/settings/storage.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiGetEnvironmentInfo*/ function storageBytesToLabeledString(size) { const base = 1000; diff --git a/ext/bg/js/templates.js b/ext/bg/js/templates.js deleted file mode 100644 index 2f65be31..00000000 --- a/ext/bg/js/templates.js +++ /dev/null @@ -1,55 +0,0 @@ -(function() { - var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {}; -templates['query-parser.html'] = template({"1":function(container,depth0,helpers,partials,data) { - var stack1, alias1=depth0 != null ? depth0 : (container.nullContext || {}); - - return ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.preview : depth0),{"name":"if","hash":{},"fn":container.program(2, data, 0),"inverse":container.program(4, data, 0),"data":data})) != null ? stack1 : "") - + ((stack1 = helpers.each.call(alias1,depth0,{"name":"each","hash":{},"fn":container.program(6, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + "</span>"; -},"2":function(container,depth0,helpers,partials,data) { - return "<span class=\"query-parser-term-preview\">"; -},"4":function(container,depth0,helpers,partials,data) { - return "<span class=\"query-parser-term\">"; -},"6":function(container,depth0,helpers,partials,data) { - var stack1; - - return ((stack1 = container.invokePartial(partials.part,depth0,{"name":"part","data":data,"helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : ""); -},"8":function(container,depth0,helpers,partials,data) { - var stack1; - - return ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.raw : depth0),{"name":"if","hash":{},"fn":container.program(9, data, 0),"inverse":container.program(12, data, 0),"data":data})) != null ? stack1 : ""); -},"9":function(container,depth0,helpers,partials,data) { - var stack1; - - return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.text : depth0),{"name":"each","hash":{},"fn":container.program(10, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : ""); -},"10":function(container,depth0,helpers,partials,data) { - return "<span class=\"query-parser-char\">" - + container.escapeExpression(container.lambda(depth0, depth0)) - + "</span>"; -},"12":function(container,depth0,helpers,partials,data) { - var stack1, helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}); - - return "<ruby>" - + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.text : depth0),{"name":"each","hash":{},"fn":container.program(10, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + "<rt>" - + container.escapeExpression(((helper = (helper = helpers.reading || (depth0 != null ? depth0.reading : depth0)) != null ? helper : helpers.helperMissing),(typeof helper === "function" ? helper.call(alias1,{"name":"reading","hash":{},"data":data}) : helper))) - + "</rt></ruby>"; -},"14":function(container,depth0,helpers,partials,data,blockParams,depths) { - var stack1; - - return ((stack1 = container.invokePartial(partials.term,depth0,{"name":"term","hash":{"preview":(depths[1] != null ? depths[1].preview : depths[1])},"data":data,"helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : ""); -},"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data,blockParams,depths) { - var stack1; - - return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.terms : depth0),{"name":"each","hash":{},"fn":container.program(14, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : ""); -},"main_d": function(fn, props, container, depth0, data, blockParams, depths) { - - var decorators = container.decorators; - - fn = decorators.inline(fn,props,container,{"name":"inline","hash":{},"fn":container.program(1, data, 0, blockParams, depths),"inverse":container.noop,"args":["term"],"data":data}) || fn; - fn = decorators.inline(fn,props,container,{"name":"inline","hash":{},"fn":container.program(8, data, 0, blockParams, depths),"inverse":container.noop,"args":["part"],"data":data}) || fn; - return fn; - } - -,"useDecorators":true,"usePartial":true,"useData":true,"useDepths":true}); -})();
\ No newline at end of file diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js index dfec54ac..a675a9f7 100644 --- a/ext/bg/js/translator.js +++ b/ext/bg/js/translator.js @@ -16,12 +16,18 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global requestJson +dictTermsMergeBySequence, dictTagBuildSource, dictTermsMergeByGloss, dictTermsSort, dictTagsSort +dictEnabledSet, dictTermsGroup, dictTermsCompressTags, dictTermsUndupe, dictTagSanitize +jpDistributeFurigana, jpConvertHalfWidthKanaToFullWidth, jpConvertNumericTofullWidth +jpConvertAlphabeticToKana, jpHiraganaToKatakana, jpKatakanaToHiragana, jpIsCharCodeJapanese +Database, Deinflector*/ class Translator { constructor() { this.database = null; this.deinflector = null; - this.tagCache = {}; + this.tagCache = new Map(); } async prepare() { @@ -38,24 +44,24 @@ class Translator { } async purgeDatabase() { - this.tagCache = {}; + this.tagCache.clear(); await this.database.purge(); } async deleteDictionary(dictionaryName) { - this.tagCache = {}; + this.tagCache.clear(); await this.database.deleteDictionary(dictionaryName); } async getSequencedDefinitions(definitions, mainDictionary) { - const definitionsBySequence = dictTermsMergeBySequence(definitions, mainDictionary); - const defaultDefinitions = definitionsBySequence['-1']; + const [definitionsBySequence, defaultDefinitions] = dictTermsMergeBySequence(definitions, mainDictionary); - const sequenceList = Object.keys(definitionsBySequence).map((v) => Number(v)).filter((v) => v >= 0); - const sequencedDefinitions = sequenceList.map((key) => ({ - definitions: definitionsBySequence[key], - rawDefinitions: [] - })); + const sequenceList = []; + const sequencedDefinitions = []; + for (const [key, value] of definitionsBySequence.entries()) { + sequenceList.push(key); + sequencedDefinitions.push({definitions: value, rawDefinitions: []}); + } for (const definition of await this.database.findTermsBySequenceBulk(sequenceList, mainDictionary)) { sequencedDefinitions[definition.index].rawDefinitions.push(definition); @@ -64,8 +70,8 @@ class Translator { return {sequencedDefinitions, defaultDefinitions}; } - async getMergedSecondarySearchResults(text, expressionsMap, secondarySearchTitles) { - if (secondarySearchTitles.length === 0) { + async getMergedSecondarySearchResults(text, expressionsMap, secondarySearchDictionaries) { + if (secondarySearchDictionaries.size === 0) { return []; } @@ -79,7 +85,7 @@ class Translator { } } - const definitions = await this.database.findTermsExactBulk(expressionList, readingList, secondarySearchTitles); + const definitions = await this.database.findTermsExactBulk(expressionList, readingList, secondarySearchDictionaries); for (const definition of definitions) { const definitionTags = await this.expandTags(definition.definitionTags, definition.dictionary); definitionTags.push(dictTagBuildSource(definition.dictionary)); @@ -95,7 +101,7 @@ class Translator { return definitions; } - async getMergedDefinition(text, dictionaries, sequencedDefinition, defaultDefinitions, secondarySearchTitles, mergedByTermIndices) { + async getMergedDefinition(text, dictionaries, sequencedDefinition, defaultDefinitions, secondarySearchDictionaries, mergedByTermIndices) { const result = sequencedDefinition.definitions; const rawDefinitionsBySequence = sequencedDefinition.rawDefinitions; @@ -108,12 +114,11 @@ class Translator { } const definitionsByGloss = dictTermsMergeByGloss(result, rawDefinitionsBySequence); - const secondarySearchResults = await this.getMergedSecondarySearchResults(text, result.expressions, secondarySearchTitles); + const secondarySearchResults = await this.getMergedSecondarySearchResults(text, result.expressions, secondarySearchDictionaries); dictTermsMergeByGloss(result, defaultDefinitions.concat(secondarySearchResults), definitionsByGloss, mergedByTermIndices); - for (const gloss in definitionsByGloss) { - const definition = definitionsByGloss[gloss]; + for (const definition of definitionsByGloss.values()) { dictTagsSort(definition.definitionTags); result.definitions.push(definition); } @@ -122,7 +127,8 @@ class Translator { const expressions = []; for (const [expression, readingMap] of result.expressions.entries()) { - for (const [reading, termTags] of readingMap.entries()) { + for (const [reading, termTagsMap] of readingMap.entries()) { + const termTags = [...termTagsMap.values()]; const score = termTags.map((tag) => tag.score).reduce((p, v) => p + v, 0); expressions.push(Translator.createExpression(expression, reading, dictTagsSort(termTags), Translator.scoreToTermFrequency(score))); } @@ -135,14 +141,16 @@ class Translator { return result; } - async findTerms(text, details, options) { - switch (options.general.resultOutputMode) { + async findTerms(mode, text, details, options) { + switch (mode) { case 'group': return await this.findTermsGrouped(text, details, options); case 'merge': return await this.findTermsMerged(text, details, options); case 'split': return await this.findTermsSplit(text, details, options); + case 'simple': + return await this.findTermsSimple(text, details, options); default: return [[], 0]; } @@ -150,11 +158,10 @@ class Translator { async findTermsGrouped(text, details, options) { const dictionaries = dictEnabledSet(options); - const titles = Object.keys(dictionaries); const [definitions, length] = await this.findTermsInternal(text, dictionaries, details, options); const definitionsGrouped = dictTermsGroup(definitions, dictionaries); - await this.buildTermMeta(definitionsGrouped, titles); + await this.buildTermMeta(definitionsGrouped, dictionaries); if (options.general.compactTags) { for (const definition of definitionsGrouped) { @@ -167,8 +174,12 @@ class Translator { async findTermsMerged(text, details, options) { const dictionaries = dictEnabledSet(options); - const secondarySearchTitles = Object.keys(options.dictionaries).filter((dict) => options.dictionaries[dict].allowSecondarySearches); - const titles = Object.keys(dictionaries); + const secondarySearchDictionaries = new Map(); + for (const [title, dictionary] of dictionaries.entries()) { + if (!dictionary.allowSecondarySearches) { continue; } + secondarySearchDictionaries.set(title, dictionary); + } + const [definitions, length] = await this.findTermsInternal(text, dictionaries, details, options); const {sequencedDefinitions, defaultDefinitions} = await this.getSequencedDefinitions(definitions, options.general.mainDictionary); const definitionsMerged = []; @@ -180,7 +191,7 @@ class Translator { dictionaries, sequencedDefinition, defaultDefinitions, - secondarySearchTitles, + secondarySearchDictionaries, mergedByTermIndices ); definitionsMerged.push(result); @@ -192,7 +203,7 @@ class Translator { definitionsMerged.push(groupedDefinition); } - await this.buildTermMeta(definitionsMerged, titles); + await this.buildTermMeta(definitionsMerged, dictionaries); if (options.general.compactTags) { for (const definition of definitionsMerged) { @@ -205,25 +216,28 @@ class Translator { async findTermsSplit(text, details, options) { const dictionaries = dictEnabledSet(options); - const titles = Object.keys(dictionaries); const [definitions, length] = await this.findTermsInternal(text, dictionaries, details, options); - await this.buildTermMeta(definitions, titles); + await this.buildTermMeta(definitions, dictionaries); return [definitions, length]; } + async findTermsSimple(text, details, options) { + const dictionaries = dictEnabledSet(options); + return await this.findTermsInternal(text, dictionaries, details, options); + } + async findTermsInternal(text, dictionaries, details, options) { text = Translator.getSearchableText(text, options); if (text.length === 0) { return [[], 0]; } - const titles = Object.keys(dictionaries); const deinflections = ( details.wildcard ? - await this.findTermWildcard(text, titles, details.wildcard) : - await this.findTermDeinflections(text, titles, options) + await this.findTermWildcard(text, dictionaries, details.wildcard) : + await this.findTermDeinflections(text, dictionaries, options) ); let definitions = []; @@ -265,8 +279,8 @@ class Translator { return [definitions, length]; } - async findTermWildcard(text, titles, wildcard) { - const definitions = await this.database.findTermsBulk([text], titles, wildcard); + async findTermWildcard(text, dictionaries, wildcard) { + const definitions = await this.database.findTermsBulk([text], dictionaries, wildcard); if (definitions.length === 0) { return []; } @@ -281,7 +295,7 @@ class Translator { }]; } - async findTermDeinflections(text, titles, options) { + async findTermDeinflections(text, dictionaries, options) { const deinflections = this.getAllDeinflections(text, options); if (deinflections.length === 0) { @@ -303,7 +317,7 @@ class Translator { deinflectionArray.push(deinflection); } - const definitions = await this.database.findTermsBulk(uniqueDeinflectionTerms, titles, null); + const definitions = await this.database.findTermsBulk(uniqueDeinflectionTerms, dictionaries, null); for (const definition of definitions) { const definitionRules = Deinflector.rulesToRuleFlags(definition.rules); @@ -393,17 +407,12 @@ class Translator { async findKanji(text, options) { const dictionaries = dictEnabledSet(options); - const titles = Object.keys(dictionaries); - const kanjiUnique = {}; - const kanjiList = []; + const kanjiUnique = new Set(); for (const c of text) { - if (!hasOwn(kanjiUnique, c)) { - kanjiList.push(c); - kanjiUnique[c] = true; - } + kanjiUnique.add(c); } - const definitions = await this.database.findKanjiBulk(kanjiList, titles); + const definitions = await this.database.findKanjiBulk([...kanjiUnique], dictionaries); if (definitions.length === 0) { return definitions; } @@ -423,12 +432,12 @@ class Translator { definition.stats = stats; } - await this.buildKanjiMeta(definitions, titles); + await this.buildKanjiMeta(definitions, dictionaries); return definitions; } - async buildTermMeta(definitions, titles) { + async buildTermMeta(definitions, dictionaries) { const terms = []; for (const definition of definitions) { if (definition.expressions) { @@ -454,7 +463,7 @@ class Translator { termList = []; expressionsUnique.push(expression); termsUnique.push(termList); - termsUniqueMap[expression] = termList; + termsUniqueMap.set(expression, termList); } termList.push(term); @@ -462,7 +471,7 @@ class Translator { term.frequencies = []; } - const metas = await this.database.findTermMetaBulk(expressionsUnique, titles); + const metas = await this.database.findTermMetaBulk(expressionsUnique, dictionaries); for (const {expression, mode, data, dictionary, index} of metas) { switch (mode) { case 'freq': @@ -474,14 +483,14 @@ class Translator { } } - async buildKanjiMeta(definitions, titles) { + async buildKanjiMeta(definitions, dictionaries) { const kanjiList = []; for (const definition of definitions) { kanjiList.push(definition.character); definition.frequencies = []; } - const metas = await this.database.findKanjiMetaBulk(kanjiList, titles); + const metas = await this.database.findKanjiMetaBulk(kanjiList, dictionaries); for (const {character, mode, data, dictionary, index} of metas) { switch (mode) { case 'freq': @@ -504,49 +513,50 @@ class Translator { const names = Object.keys(items); const tagMetaList = await this.getTagMetaList(names, title); - const stats = {}; + const statsGroups = new Map(); for (let i = 0; i < names.length; ++i) { const name = names[i]; const meta = tagMetaList[i]; if (meta === null) { continue; } const category = meta.category; - const group = ( - hasOwn(stats, category) ? - stats[category] : - (stats[category] = []) - ); + let group = statsGroups.get(category); + if (typeof group === 'undefined') { + group = []; + statsGroups.set(category, group); + } const stat = Object.assign({}, meta, {name, value: items[name]}); group.push(dictTagSanitize(stat)); } + const stats = {}; const sortCompare = (a, b) => a.notes - b.notes; - for (const category in stats) { - stats[category].sort(sortCompare); + for (const [category, group] of statsGroups.entries()) { + group.sort(sortCompare); + stats[category] = group; } - return stats; } async getTagMetaList(names, title) { const tagMetaList = []; - const cache = ( - hasOwn(this.tagCache, title) ? - this.tagCache[title] : - (this.tagCache[title] = {}) - ); + let cache = this.tagCache.get(title); + if (typeof cache === 'undefined') { + cache = new Map(); + this.tagCache.set(title, cache); + } for (const name of names) { const base = Translator.getNameBase(name); - if (hasOwn(cache, base)) { - tagMetaList.push(cache[base]); - } else { - const tagMeta = await this.database.findTagForTitle(base, title); - cache[base] = tagMeta; - tagMetaList.push(tagMeta); + let tagMeta = cache.get(base); + if (typeof tagMeta === 'undefined') { + tagMeta = await this.database.findTagForTitle(base, title); + cache.set(base, tagMeta); } + + tagMetaList.push(tagMeta); } return tagMetaList; diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js index 333e814b..5ce4b08c 100644 --- a/ext/bg/js/util.js +++ b/ext/bg/js/util.js @@ -33,7 +33,7 @@ function utilIsolate(value) { } function utilFunctionIsolate(func) { - return function (...args) { + return function isolatedFunction(...args) { try { args = args.map((v) => utilIsolate(v)); return func.call(this, ...args); @@ -59,32 +59,6 @@ function utilBackgroundFunctionIsolate(func) { return backgroundPage.utilFunctionIsolate(func); } -function utilSetEqual(setA, setB) { - if (setA.size !== setB.size) { - return false; - } - - for (const value of setA) { - if (!setB.has(value)) { - return false; - } - } - - return true; -} - -function utilSetIntersection(setA, setB) { - return new Set( - [...setA].filter((value) => setB.has(value)) - ); -} - -function utilSetDifference(setA, setB) { - return new Set( - [...setA].filter((value) => !setB.has(value)) - ); -} - function utilStringHashCode(string) { let hashCode = 0; diff --git a/ext/bg/query-parser-templates.html b/ext/bg/query-parser-templates.html new file mode 100644 index 00000000..7cab16a9 --- /dev/null +++ b/ext/bg/query-parser-templates.html @@ -0,0 +1,11 @@ +<!DOCTYPE html><html><head></head><body> + +<template id="term-template"><span class="query-parser-term" data-type="normal"></span></template> +<template id="term-preview-template"><span class="query-parser-term" data-type="preview"></span></template> +<template id="segment-template"><ruby class="query-parser-segment"><span class="query-parser-segment-text"></span><rt class="query-parser-segment-reading"></rt></ruby></template> +<template id="char-template"><span class="query-parser-char"></span></template> + +<template id="select-template"><select class="query-parser-select form-control"></select></template> +<template id="select-option-template"><option class="query-parser-select-option"></option></template> + +</body></html> diff --git a/ext/bg/search.html b/ext/bg/search.html index 74afbb68..d6336826 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -25,29 +25,31 @@ <p style="margin-bottom: 0;">Search your installed dictionaries by entering a Japanese expression into the field below.</p> </div> - <div class="input-group" style="padding-top: 20px;"> - <span title="Enable kana input method" class="input-group-text"> - <input type="checkbox" id="wanakana-enable" class="icon-checkbox" /> - <label for="wanakana-enable" class="scan-disable">あ</label> - </span> - <span title="Enable clipboard monitor" class="input-group-text"> - <input type="checkbox" id="clipboard-monitor-enable" class="icon-checkbox" /> - <label for="clipboard-monitor-enable"><span class="glyphicon glyphicon-paste"></span></label> - </span> - </div> + <div class="search-input"> + <div class="input-group" style="padding-top: 20px;"> + <span title="Enable kana input method" class="input-group-text"> + <input type="checkbox" id="wanakana-enable" class="icon-checkbox" /> + <label for="wanakana-enable" class="scan-disable">あ</label> + </span> + <span title="Enable clipboard monitor" class="input-group-text"> + <input type="checkbox" id="clipboard-monitor-enable" class="icon-checkbox" /> + <label for="clipboard-monitor-enable"><span class="glyphicon glyphicon-paste"></span></label> + </span> + </div> - <form class="input-group"> - <input type="text" class="form-control" placeholder="Search for..." id="query" autofocus> - <span class="input-group-btn"> - <input type="submit" class="btn btn-default form-control" id="search" value="Search"> - </span> - </form> + <form class="input-group"> + <input type="text" class="form-control" placeholder="Search for..." id="query" autofocus> + <span class="input-group-btn"> + <input type="submit" class="btn btn-default form-control" id="search" value="Search"> + </span> + </form> + </div> <div id="spinner" hidden><img src="/mixed/img/spinner.gif"></div> <div class="scan-disable"> - <div id="query-parser-select" class="input-group"></div> - <div id="query-parser"></div> + <div id="query-parser-select-container" class="input-group"></div> + <div id="query-parser-content"></div> </div> <hr> @@ -75,18 +77,20 @@ <script src="/bg/js/dictionary.js"></script> <script src="/bg/js/handlebars.js"></script> - <script src="/bg/js/templates.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.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/japanese.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> + <script src="/bg/js/search-query-parser-generator.js"></script> <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> </body> diff --git a/ext/bg/settings.html b/ext/bg/settings.html index 3e06d4b5..b048a36c 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -134,6 +134,10 @@ <label><input type="checkbox" id="enable"> Enable content scanning</label> </div> + <div class="checkbox" data-hide-for-browser="firefox-mobile"> + <label><input type="checkbox" id="enable-clipboard-popups"> Enable native popups when copying Japanese text</label> + </div> + <div class="checkbox"> <label><input type="checkbox" id="show-usage-guide"> Show usage guide on startup</label> </div> @@ -481,7 +485,7 @@ </p> <div class="checkbox"> - <label><input type="checkbox" id="enable-search-within-first-popup"> Enable search when clicking glossary entries</label> + <label><input type="checkbox" id="enable-search-within-first-popup"> Enable search when clicking glossary entries and tags</label> </div> <div class="checkbox"> @@ -492,6 +496,10 @@ <label><input type="checkbox" id="enable-scanning-of-popup-expressions"> Enable scanning of expressions in search results</label> </div> + <div class="checkbox"> + <label><input type="checkbox" id="enable-search-tags"> Enable clickable and scannable tags for searching expressions and their readings</label> + </div> + <div class="form-group"> <label for="popup-nesting-max-depth">Maximum number of additional popups</label> <input type="number" min="0" step="1" id="popup-nesting-max-depth" class="form-control"> @@ -760,6 +768,13 @@ <div class="alert alert-danger" id="anki-error" hidden></div> + <div class="alert alert-danger" id="anki-invalid-response-error" hidden> + Attempting to connect to Anki can sometimes return an error message which includes "Invalid response", + which may indicate that the value of the <strong>Interface server</strong> option is incorrect. + The <strong>Show advanced options</strong> checkbox under General Options must be ticked ticked to show this option. + Resetting it to the default value may fix issues that are occurring. + </div> + <div class="form-group"> <label for="card-tags">Card tags <span class="label-light">(comma or space separated)</span></label> <input type="text" id="card-tags" class="form-control"> @@ -771,7 +786,7 @@ </div> <div class="form-group options-advanced"> - <label for="interface-server">Interface server</label> + <label for="interface-server">Interface server <span class="label-light">(Default: http://127.0.0.1:8765)</span></label> <input type="text" id="interface-server" class="form-control"> </div> @@ -1073,16 +1088,15 @@ <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/japanese.js"></script> <script src="/bg/js/anki.js"></script> <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> - <script src="/bg/js/templates.js"></script> <script src="/bg/js/util.js"></script> <script src="/mixed/js/audio.js"></script> diff --git a/ext/fg/float.html b/ext/fg/float.html index bec5ae68..352a866a 100644 --- a/ext/fg/float.html +++ b/ext/fg/float.html @@ -35,7 +35,7 @@ <h1>Yomichan Updated!</h1> <p> The Yomichan extension has been updated to a new version! In order to continue - viewing definitions on this page you must reload this tab or restart your browser. + viewing definitions on this page, you must reload this tab or restart your browser. </p> </div> </div> @@ -51,6 +51,7 @@ <script src="/mixed/js/display.js"></script> <script src="/mixed/js/display-generator.js"></script> <script src="/mixed/js/scroll.js"></script> + <script src="/mixed/js/template-handler.js"></script> <script src="/fg/js/float.js"></script> diff --git a/ext/fg/js/document.js b/ext/fg/js/document.js index 71654b29..35861475 100644 --- a/ext/fg/js/document.js +++ b/ext/fg/js/document.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global TextSourceElement, TextSourceRange, DOM*/ const REGEX_TRANSPARENT_COLOR = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/; @@ -49,7 +50,9 @@ function docImposterCreate(element, isTextarea) { const imposter = document.createElement('div'); const imposterStyle = imposter.style; - imposter.innerText = element.value; + let value = element.value; + if (value.endsWith('\n')) { value += '\n'; } + imposter.textContent = value; for (let i = 0, ii = elementStyle.length; i < ii; ++i) { const property = elementStyle[i]; @@ -191,8 +194,7 @@ function docSentenceExtract(source, extent) { if (terminators.includes(c)) { endPos = i + 1; break; - } - else if (c in quotesBwd) { + } else if (c in quotesBwd) { endPos = i; break; } diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js index 8d61d8f6..8f21a9c5 100644 --- a/ext/fg/js/float.js +++ b/ext/fg/js/float.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global popupNestedInitialize, apiForward, apiGetMessageToken, Display*/ class DisplayFloat extends Display { constructor() { @@ -28,11 +29,33 @@ class DisplayFloat extends Display { }; this._orphaned = false; + this._prepareInvoked = false; + this._messageToken = null; + this._messageTokenPromise = null; yomichan.on('orphaned', () => this.onOrphaned()); window.addEventListener('message', (e) => this.onMessage(e), false); } + async prepare(options, popupInfo, url, childrenSupported, scale, uniqueId) { + if (this._prepareInvoked) { return; } + this._prepareInvoked = true; + + await super.prepare(options); + + const {id, depth, parentFrameId} = popupInfo; + this.optionsContext.depth = depth; + this.optionsContext.url = url; + + if (childrenSupported) { + popupNestedInitialize(id, depth, parentFrameId, url); + } + + this.setContentScale(scale); + + apiForward('popupPrepareCompleted', {uniqueId}); + } + onError(error) { if (this._orphaned) { this.setContent('orphaned'); @@ -54,11 +77,23 @@ class DisplayFloat extends Display { } onMessage(e) { - const {action, params} = e.data; - const handler = DisplayFloat._messageHandlers.get(action); - if (typeof handler !== 'function') { return; } - - handler(this, params); + const data = e.data; + if (typeof data !== 'object' || data === null) { return; } // Invalid data + + const token = data.token; + if (typeof token !== 'string') { return; } // Invalid data + + if (this._messageToken === null) { + // Async + this.getMessageToken() + .then( + () => { this.handleAction(token, data); }, + () => {} + ); + } else { + // Sync + this.handleAction(token, data); + } } onKeyDown(e) { @@ -73,6 +108,30 @@ class DisplayFloat extends Display { return super.onKeyDown(e); } + async getMessageToken() { + // this._messageTokenPromise is used to ensure that only one call to apiGetMessageToken is made. + if (this._messageTokenPromise === null) { + this._messageTokenPromise = apiGetMessageToken(); + } + const messageToken = await this._messageTokenPromise; + if (this._messageToken === null) { + this._messageToken = messageToken; + } + this._messageTokenPromise = null; + } + + handleAction(token, {action, params}) { + if (token !== this._messageToken) { + // Invalid token + return; + } + + const handler = DisplayFloat._messageHandlers.get(action); + if (typeof handler !== 'function') { return; } + + handler(this, params); + } + getOptionsContext() { return this.optionsContext; } @@ -92,20 +151,6 @@ class DisplayFloat extends Display { setContentScale(scale) { document.body.style.fontSize = `${scale}em`; } - - async initialize(options, popupInfo, url, childrenSupported, scale) { - await super.initialize(options); - - const {id, depth, parentFrameId} = popupInfo; - this.optionsContext.depth = depth; - this.optionsContext.url = url; - - if (childrenSupported) { - popupNestedInitialize(id, depth, parentFrameId, url); - } - - this.setContentScale(scale); - } } DisplayFloat._onKeyDownHandlers = new Map([ @@ -122,7 +167,7 @@ DisplayFloat._messageHandlers = new Map([ ['setContent', (self, {type, details}) => self.setContent(type, details)], ['clearAutoPlayTimer', (self) => self.clearAutoPlayTimer()], ['setCustomCss', (self, {css}) => self.setCustomCss(css)], - ['initialize', (self, {options, popupInfo, url, childrenSupported, scale}) => self.initialize(options, popupInfo, url, childrenSupported, scale)], + ['prepare', (self, {options, popupInfo, url, childrenSupported, scale, uniqueId}) => self.prepare(options, popupInfo, url, childrenSupported, scale, uniqueId)], ['setContentScale', (self, {scale}) => self.setContentScale(scale)] ]); diff --git a/ext/fg/js/frontend-api-sender.js b/ext/fg/js/frontend-api-sender.js index 93c2e593..8dc6aaf3 100644 --- a/ext/fg/js/frontend-api-sender.js +++ b/ext/fg/js/frontend-api-sender.js @@ -19,7 +19,7 @@ class FrontendApiSender { constructor() { - this.senderId = FrontendApiSender.generateId(16); + this.senderId = yomichan.generateId(16); this.ackTimeout = 3000; // 3 seconds this.responseTimeout = 10000; // 10 seconds this.callbacks = new Map(); @@ -123,12 +123,4 @@ class FrontendApiSender { info.timer = null; info.reject(new Error(reason)); } - - static generateId(length) { - let id = ''; - for (let i = 0; i < length; ++i) { - id += Math.floor(Math.random() * 256).toString(16).padStart(2, '0'); - } - return id; - } } diff --git a/ext/fg/js/frontend-initialize.js b/ext/fg/js/frontend-initialize.js index 9c923fea..54b874f2 100644 --- a/ext/fg/js/frontend-initialize.js +++ b/ext/fg/js/frontend-initialize.js @@ -16,18 +16,22 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global PopupProxyHost, PopupProxy, Frontend*/ async function main() { const data = window.frontendInitializationData || {}; const {id, depth=0, parentFrameId, ignoreNodes, url, proxy=false} = data; - let popupHost = null; - if (!proxy) { - popupHost = new PopupProxyHost(); + let popup; + if (proxy) { + popup = new PopupProxy(null, depth + 1, id, parentFrameId, url); + } else { + const popupHost = new PopupProxyHost(); await popupHost.prepare(); + + popup = popupHost.getOrCreatePopup(); } - const popup = proxy ? new PopupProxy(depth + 1, id, parentFrameId, url) : popupHost.createPopup(null, depth); const frontend = new Frontend(popup, ignoreNodes); await frontend.prepare(); } diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index 2286bf19..67045241 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiGetZoom, apiOptionsGet, apiTermsFind, apiKanjiFind, docSentenceExtract, TextScanner*/ class Frontend extends TextScanner { constructor(popup, ignoreNodes) { @@ -55,7 +56,7 @@ class Frontend extends TextScanner { } yomichan.on('orphaned', () => this.onOrphaned()); - yomichan.on('optionsUpdate', () => this.updateOptions()); + yomichan.on('optionsUpdated', () => this.updateOptions()); yomichan.on('zoomChanged', (e) => this.onZoomChanged(e)); chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/popup-nested.js index 3f3c945e..3e5f5b80 100644 --- a/ext/fg/js/popup-nested.js +++ b/ext/fg/js/popup-nested.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiOptionsGet*/ let popupNestedInitialized = false; diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js index 427172c6..e55801ff 100644 --- a/ext/fg/js/popup-proxy-host.js +++ b/ext/fg/js/popup-proxy-host.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiFrameInformationGet, FrontendApiReceiver, Popup*/ class PopupProxyHost { constructor() { @@ -33,7 +34,7 @@ class PopupProxyHost { if (typeof frameId !== 'number') { return; } this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${frameId}`, new Map([ - ['createNestedPopup', ({parentId}) => this._onApiCreateNestedPopup(parentId)], + ['getOrCreatePopup', ({id, parentId}) => this._onApiGetOrCreatePopup(id, parentId)], ['setOptions', ({id, options}) => this._onApiSetOptions(id, options)], ['hide', ({id, changeFocus}) => this._onApiHide(id, changeFocus)], ['isVisible', ({id}) => this._onApiIsVisibleAsync(id)], @@ -46,14 +47,51 @@ class PopupProxyHost { ])); } - createPopup(parentId, depth) { - return this._createPopupInternal(parentId, depth).popup; + getOrCreatePopup(id=null, parentId=null) { + // Find by existing id + if (id !== null) { + const popup = this._popups.get(id); + if (typeof popup !== 'undefined') { + return popup; + } + } + + // Find by existing parent id + let parent = null; + if (parentId !== null) { + parent = this._popups.get(parentId); + if (typeof parent !== 'undefined') { + const popup = parent.child; + if (popup !== null) { + return popup; + } + } else { + parent = null; + } + } + + // New unique id + if (id === null) { + id = this._nextId++; + } + + // Create new popup + const depth = (parent !== null ? parent.depth + 1 : 0); + const popup = new Popup(id, depth, this._frameIdPromise); + if (parent !== null) { + popup.setParent(parent); + } + this._popups.set(id, popup); + return popup; } // Message handlers - async _onApiCreateNestedPopup(parentId) { - return this._createPopupInternal(parentId, 0).id; + async _onApiGetOrCreatePopup(id, parentId) { + const popup = this.getOrCreatePopup(id, parentId); + return { + id: popup.id + }; } async _onApiSetOptions(id, options) { @@ -105,25 +143,10 @@ class PopupProxyHost { // Private functions - _createPopupInternal(parentId, depth) { - const parent = (typeof parentId === 'string' && this._popups.has(parentId) ? this._popups.get(parentId) : null); - const id = `${this._nextId}`; - if (parent !== null) { - depth = parent.depth + 1; - } - ++this._nextId; - const popup = new Popup(id, depth, this._frameIdPromise); - if (parent !== null) { - popup.setParent(parent); - } - this._popups.set(id, popup); - return {popup, id}; - } - _getPopup(id) { const popup = this._popups.get(id); if (typeof popup === 'undefined') { - throw new Error('Invalid popup ID'); + throw new Error(`Invalid popup ID ${id}`); } return popup; } diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js index 4cacee53..093cdd2e 100644 --- a/ext/fg/js/popup-proxy.js +++ b/ext/fg/js/popup-proxy.js @@ -16,12 +16,13 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global FrontendApiSender*/ class PopupProxy { - constructor(depth, parentId, parentFrameId, url) { + constructor(id, depth, parentId, parentFrameId, url) { this._parentId = parentId; this._parentFrameId = parentFrameId; - this._id = null; + this._id = id; this._idPromise = null; this._depth = depth; this._url = url; @@ -69,7 +70,7 @@ class PopupProxy { if (this._id === null) { return; } - this._invokeHostApi('setVisibleOverride', {id, visible}); + this._invokeHostApi('setVisibleOverride', {id: this._id, visible}); } async containsPoint(x, y) { @@ -112,7 +113,7 @@ class PopupProxy { } async _getPopupIdAsync() { - const id = await this._invokeHostApi('createNestedPopup', {parentId: this._parentId}); + const {id} = await this._invokeHostApi('getOrCreatePopup', {id: this._id, parentId: this._parentId}); this._id = id; return id; } diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js index e7dae93e..4927f4bd 100644 --- a/ext/fg/js/popup.js +++ b/ext/fg/js/popup.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiInjectStylesheet, apiGetMessageToken*/ class Popup { constructor(id, depth, frameIdPromise) { @@ -27,32 +28,40 @@ class Popup { this._child = null; this._childrenSupported = true; this._injectPromise = null; - this._isInjected = false; - this._isInjectedAndLoaded = false; this._visible = false; this._visibleOverride = null; this._options = null; - this._stylesheetInjectedViaApi = false; this._contentScale = 1.0; this._containerSizeContentScale = null; + this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + this._messageToken = null; this._container = document.createElement('iframe'); this._container.className = 'yomichan-float'; this._container.addEventListener('mousedown', (e) => e.stopPropagation()); this._container.addEventListener('scroll', (e) => e.stopPropagation()); - this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html')); this._container.style.width = '0px'; this._container.style.height = '0px'; + this._fullscreenEventListeners = new EventListenerCollection(); + this._updateVisibility(); } // Public properties + get id() { + return this._id; + } + get parent() { return this._parent; } + get child() { + return this._child; + } + get depth() { return this._depth; } @@ -117,16 +126,12 @@ class Popup { } clearAutoPlayTimer() { - if (this._isInjectedAndLoaded) { - this._invokeApi('clearAutoPlayTimer'); - } + this._invokeApi('clearAutoPlayTimer'); } setContentScale(scale) { this._contentScale = scale; - if (this._isInjectedAndLoaded) { - this._invokeApi('setContentScale', {scale}); - } + this._invokeApi('setContentScale', {scale}); } // Popup-only public functions @@ -146,7 +151,7 @@ class Popup { } isVisibleSync() { - return this._isInjected && (this._visibleOverride !== null ? this._visibleOverride : this._visible); + return (this._visibleOverride !== null ? this._visibleOverride : this._visible); } updateTheme() { @@ -154,21 +159,13 @@ class Popup { this._container.dataset.yomichanSiteColor = this._getSiteColor(); } - async setCustomOuterCss(css, injectDirectly) { - // Cannot repeatedly inject stylesheets using web extension APIs since there is no way to remove them. - if (this._stylesheetInjectedViaApi) { return; } - - if (injectDirectly || Popup._isOnExtensionPage()) { - Popup.injectOuterStylesheet(css); - } else { - if (!css) { return; } - try { - await apiInjectStylesheet(css); - this._stylesheetInjectedViaApi = true; - } catch (e) { - // NOP - } - } + async setCustomOuterCss(css, useWebExtensionApi) { + return await Popup._injectStylesheet( + 'yomichan-popup-outer-user-stylesheet', + 'code', + css, + useWebExtensionApi + ); } setChildrenSupported(value) { @@ -183,26 +180,6 @@ class Popup { return this._container.getBoundingClientRect(); } - static injectOuterStylesheet(css) { - if (Popup.outerStylesheet === null) { - if (!css) { return; } - Popup.outerStylesheet = document.createElement('style'); - Popup.outerStylesheet.id = 'yomichan-popup-outer-stylesheet'; - } - - const outerStylesheet = Popup.outerStylesheet; - if (css) { - outerStylesheet.textContent = css; - - const par = document.head; - if (par && outerStylesheet.parentNode !== par) { - par.appendChild(outerStylesheet); - } - } else { - outerStylesheet.textContent = ''; - } - } - // Private functions _inject() { @@ -222,11 +199,18 @@ class Popup { // NOP } + if (this._messageToken === null) { + this._messageToken = await apiGetMessageToken(); + } + return new Promise((resolve) => { const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null); + this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html')); this._container.addEventListener('load', () => { - this._isInjectedAndLoaded = true; - this._invokeApi('initialize', { + const uniqueId = yomichan.generateId(32); + Popup._listenForDisplayPrepareCompleted(uniqueId, resolve); + + this._invokeApi('prepare', { options: this._options, popupInfo: { id: this._id, @@ -235,17 +219,60 @@ class Popup { }, url: this.url, childrenSupported: this._childrenSupported, - scale: this._contentScale + scale: this._contentScale, + uniqueId }); - resolve(); }); - this._observeFullscreen(); + this._observeFullscreen(true); this._onFullscreenChanged(); - this.setCustomOuterCss(this._options.general.customPopupOuterCss, false); - this._isInjected = true; + this._injectStyles(); }); } + async _injectStyles() { + try { + await Popup._injectStylesheet('yomichan-popup-outer-stylesheet', 'file', '/fg/css/client.css', true); + } catch (e) { + // NOP + } + + try { + await this.setCustomOuterCss(this._options.general.customPopupOuterCss, true); + } catch (e) { + // NOP + } + } + + _observeFullscreen(observe) { + if (!observe) { + this._fullscreenEventListeners.removeAllEventListeners(); + return; + } + + if (this._fullscreenEventListeners.size > 0) { + // Already observing + return; + } + + const fullscreenEvents = [ + 'fullscreenchange', + 'MSFullscreenChange', + 'mozfullscreenchange', + 'webkitfullscreenchange' + ]; + const onFullscreenChanged = () => this._onFullscreenChanged(); + for (const eventName of fullscreenEvents) { + this._fullscreenEventListeners.addEventListener(document, eventName, onFullscreenChanged, false); + } + } + + _onFullscreenChanged() { + const parent = (Popup._getFullscreenElement() || document.body || null); + if (parent !== null && this._container.parentNode !== parent) { + parent.appendChild(this._container); + } + } + async _show(elementRect, writingMode) { await this._inject(); @@ -327,38 +354,38 @@ class Popup { } _invokeApi(action, params={}) { - if (!this._isInjectedAndLoaded) { - throw new Error('Frame not loaded'); - } - this._container.contentWindow.postMessage({action, params}, '*'); - } + const token = this._messageToken; + const contentWindow = this._container.contentWindow; + if (token === null || contentWindow === null) { return; } - _observeFullscreen() { - const fullscreenEvents = [ - 'fullscreenchange', - 'MSFullscreenChange', - 'mozfullscreenchange', - 'webkitfullscreenchange' - ]; - for (const eventName of fullscreenEvents) { - document.addEventListener(eventName, () => this._onFullscreenChanged(), false); - } + contentWindow.postMessage({action, params, token}, this._targetOrigin); } - _getFullscreenElement() { + static _getFullscreenElement() { return ( document.fullscreenElement || document.msFullscreenElement || document.mozFullScreenElement || - document.webkitFullscreenElement + document.webkitFullscreenElement || + null ); } - _onFullscreenChanged() { - const parent = (this._getFullscreenElement() || document.body || null); - if (parent !== null && this._container.parentNode !== parent) { - parent.appendChild(this._container); - } + static _listenForDisplayPrepareCompleted(uniqueId, resolve) { + const runtimeMessageCallback = ({action, params}, sender, callback) => { + if ( + action === 'popupPrepareCompleted' && + typeof params === 'object' && + params !== null && + params.uniqueId === uniqueId + ) { + chrome.runtime.onMessage.removeListener(runtimeMessageCallback); + callback(); + resolve(); + return false; + } + }; + chrome.runtime.onMessage.addListener(runtimeMessageCallback); } static _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) { @@ -492,15 +519,6 @@ class Popup { ]; } - static _isOnExtensionPage() { - try { - const url = chrome.runtime.getURL('/'); - return window.location.href.substring(0, url.length) === url; - } catch (e) { - // NOP - } - } - static _getViewport(useVisualViewport) { const visualViewport = window.visualViewport; if (visualViewport !== null && typeof visualViewport === 'object') { @@ -533,6 +551,80 @@ class Popup { bottom: window.innerHeight }; } + + static _isOnExtensionPage() { + try { + const url = chrome.runtime.getURL('/'); + return window.location.href.substring(0, url.length) === url; + } catch (e) { + // NOP + } + } + + static async _injectStylesheet(id, type, value, useWebExtensionApi) { + const injectedStylesheets = Popup._injectedStylesheets; + + if (Popup._isOnExtensionPage()) { + // Permissions error will occur if trying to use the WebExtension API to inject + // into an extension page. + useWebExtensionApi = false; + } + + let styleNode = injectedStylesheets.get(id); + if (typeof styleNode !== 'undefined') { + if (styleNode === null) { + // Previously injected via WebExtension API + throw new Error(`Stylesheet with id ${id} has already been injected using the WebExtension API`); + } + } else { + styleNode = null; + } + + if (useWebExtensionApi) { + // Inject via WebExtension API + if (styleNode !== null && styleNode.parentNode !== null) { + styleNode.parentNode.removeChild(styleNode); + } + + await apiInjectStylesheet(type, value); + + injectedStylesheets.set(id, null); + return null; + } + + // Create node in document + const parentNode = document.head; + if (parentNode === null) { + throw new Error('No parent node'); + } + + // Create or reuse node + const isFile = (type === 'file'); + const tagName = isFile ? 'link' : 'style'; + if (styleNode === null || styleNode.nodeName.toLowerCase() !== tagName) { + if (styleNode !== null && styleNode.parentNode !== null) { + styleNode.parentNode.removeChild(styleNode); + } + styleNode = document.createElement(tagName); + styleNode.id = id; + } + + // Update node style + if (isFile) { + styleNode.rel = value; + } else { + styleNode.textContent = value; + } + + // Update parent + if (styleNode.parentNode !== parentNode) { + parentNode.appendChild(styleNode); + } + + // Add to map + injectedStylesheets.set(id, styleNode); + return styleNode; + } } -Popup.outerStylesheet = null; +Popup._injectedStylesheets = new Map(); diff --git a/ext/fg/js/source.js b/ext/fg/js/source.js index 11d3ff0e..6dc482bd 100644 --- a/ext/fg/js/source.js +++ b/ext/fg/js/source.js @@ -82,7 +82,11 @@ class TextSourceRange { } equals(other) { - if (other === null) { + if (!( + typeof other === 'object' && + other !== null && + other instanceof TextSourceRange + )) { return false; } if (this.imposterSourceElement !== null) { @@ -362,7 +366,7 @@ class TextSourceElement { setEndOffset(length) { switch (this.element.nodeName.toUpperCase()) { case 'BUTTON': - this.content = this.element.innerHTML; + this.content = this.element.textContent; break; case 'IMG': this.content = this.element.getAttribute('alt'); @@ -409,6 +413,12 @@ class TextSourceElement { } equals(other) { - return other && other.element === this.element && other.content === this.content; + return ( + typeof other === 'object' && + other !== null && + other instanceof TextSourceElement && + other.element === this.element && + other.content === this.content + ); } } diff --git a/ext/manifest.json b/ext/manifest.json index 31729992..fd9b6fec 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Yomichan (testing)", - "version": "20.1.26.0", + "version": "20.2.24.0", "description": "Japanese dictionary with Anki integration (testing)", "icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"}, @@ -30,7 +30,7 @@ "fg/js/frontend.js", "fg/js/frontend-initialize.js" ], - "css": ["fg/css/client.css"], + "match_about_blank": true, "all_frames": true }], "minimum_chrome_version": "57.0.0.0", diff --git a/ext/mixed/css/display-dark.css b/ext/mixed/css/display-dark.css index 088fc741..c9cd9f90 100644 --- a/ext/mixed/css/display-dark.css +++ b/ext/mixed/css/display-dark.css @@ -38,6 +38,7 @@ body { background-color: #1e1e1e; color: #d4d4d4; } .tag[data-category=dictionary] { background-color: #9057ad; } .tag[data-category=frequency] { background-color: #489148; } .tag[data-category=partOfSpeech] { background-color: #565656; } +.tag[data-category=search] { background-color: #69696e; } .term-reasons { color: #888888; } diff --git a/ext/mixed/css/display-default.css b/ext/mixed/css/display-default.css index 69141c9d..6eee43c4 100644 --- a/ext/mixed/css/display-default.css +++ b/ext/mixed/css/display-default.css @@ -38,6 +38,7 @@ body { background-color: #ffffff; color: #333333; } .tag[data-category=dictionary] { background-color: #aa66cc; } .tag[data-category=frequency] { background-color: #5cb85c; } .tag[data-category=partOfSpeech] { background-color: #565656; } +.tag[data-category=search] { background-color: #8a8a91; } .term-reasons { color: #777777; } diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css index add2583e..6a5383bc 100644 --- a/ext/mixed/css/display.css +++ b/ext/mixed/css/display.css @@ -127,15 +127,19 @@ html:root[data-yomichan-page=float] .navigation-header:not([hidden])~.navigation user-select: none; } -#query-parser { +#query-parser-content { margin-top: 0.5em; font-size: 2em; } -#query-parser[data-term-spacing=true] .query-parser-term { +#query-parser-content[data-term-spacing=true] .query-parser-term { margin-right: 0.2em; } +html:root[data-yomichan-page=search][data-search-mode=popup] .search-input { + display: none; +} + /* * Entries @@ -208,19 +212,27 @@ button.action-button { } .tag { - display: inline; + display: inline-block; padding: 0.2em 0.6em 0.3em; font-size: 75%; font-weight: 700; - line-height: 1; + line-height: 1.25; text-align: center; white-space: nowrap; vertical-align: baseline; border-radius: 0.25em; } -.tag-list>.tag+.tag { - margin-left: 0.375em; +.tag-inner { + display: block; +} + +.tag-list>.tag:not(:last-child) { + margin-right: 0.375em; +} + +html:root:not([data-enable-search-tags=true]) .tag[data-category=search] { + display: none; } .entry-header2, @@ -237,7 +249,7 @@ button.action-button { border-top-style: solid; } -.entry[data-type=term][data-expression-multi=true]:not([data-expression-count="1"]) .actions>.action-play-audio { +.entry[data-type=term][data-expression-multi=true] .actions>.action-play-audio { display: none; } @@ -245,8 +257,9 @@ button.action-button { display: inline-block; } -.term-reasons>.term-reason+.term-reason:before { +.term-reasons>.term-reason+.term-reason-separator+.term-reason:before { content: " \00AB "; /* The two spaces is not a typo */ + white-space: pre-wrap; display: inline; } @@ -284,13 +297,13 @@ button.action-button { content: "\3001"; } -.term-expression-list>.term-expression:last-of-type:not(:first-of-type):after { +.term-expression-list[data-multi=true]>.term-expression:last-of-type:after { font-size: 2em; content: "\3000"; visibility: hidden; } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression-details { +.term-expression-list[data-multi=true] .term-expression-details { display: inline-block; position: relative; width: 0; @@ -298,21 +311,21 @@ button.action-button { visibility: hidden; } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression:hover .term-expression-details { +.term-expression-list[data-multi=true] .term-expression:hover .term-expression-details { visibility: visible; } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression-details>.action-play-audio { +.term-expression-list[data-multi=true] .term-expression-details>.action-play-audio { position: absolute; left: 0; bottom: 0.5em; } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression-details>.action-play-audio { +.term-expression-list[data-multi=true] .term-expression-details>.action-play-audio { display: block; } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression-details>.tags { +.term-expression-list[data-multi=true] .term-expression-details>.tags { display: block; position: absolute; left: 0; @@ -320,7 +333,7 @@ button.action-button { white-space: nowrap; } -.term-expression-list[data-multi=true]:not([data-count="1"]) .term-expression-details>.frequencies { +.term-expression-list[data-multi=true] .term-expression-details>.frequencies { display: block; position: absolute; left: 0; @@ -385,7 +398,7 @@ button.action-button { :root[data-compact-glossaries=true] .term-definition-tag-list, :root[data-compact-glossaries=true] .term-definition-only-list:not([data-count="0"]) { - display: inline-block; + display: inline; } :root[data-compact-glossaries=true] .term-glossary-list { @@ -399,9 +412,24 @@ button.action-button { } :root[data-compact-glossaries=true] .term-glossary-list>li:not(:first-child):before { + white-space: pre-wrap; content: " | "; + display: inline; } +.term-glossary-separator, +.term-reason-separator { + display: inline; + font-size: 0; + opacity: 0; + white-space: pre-wrap; +} + +.term-special-tags>.frequencies { + display: inline; +} + + /* * Kanji */ diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html index 62f3c69c..7ae51a62 100644 --- a/ext/mixed/display-templates.html +++ b/ext/mixed/display-templates.html @@ -15,7 +15,7 @@ </div> <div class="term-reasons"></div> </div> - <div class="frequencies"></div> + <div class="term-special-tags"><div class="frequencies tag-list"></div></div> </div> <div class="term-definition-container"><ol class="term-definition-list"></ol></div> <pre class="debug-info"></pre> @@ -31,8 +31,8 @@ <ul class="term-glossary-list"></ul> </li></template> <template id="term-definition-only-template"><span class="term-definition-only"></span></template> -<template id="term-glossary-item-template"><li class="term-glossary-item"><span class="term-glossary"></span></li></template> -<template id="term-reason-template"><span class="term-reason"></span></template> +<template id="term-glossary-item-template"><li class="term-glossary-item"><span class="term-glossary-separator"> </span><span class="term-glossary"></span></li></template> +<template id="term-reason-template"><span class="term-reason"></span><span class="term-reason-separator"> </span></template> <template id="kanji-entry-template"><div class="entry" data-type="kanji"> <div class="entry-header1"> @@ -75,7 +75,8 @@ <template id="kanji-glossary-item-template"><li class="kanji-glossary-item"><span class="kanji-glossary"></span></li></template> <template id="kanji-reading-template"><dd class="kanji-reading"></dd></template> -<template id="tag-template"><span class="tag"></span></template> -<template id="tag-frequency-template"><span class="tag" data-category="frequency"><span class="term-frequency-dictionary-name"></span><span class="term-frequency-separator"></span><span class="term-frequency-value"></span></template> +<template id="tag-template"><span class="tag"><span class="tag-inner"></span></span></template> +<template id="tag-frequency-template"><span class="tag" data-category="frequency"><span class="tag-inner"><span class="term-frequency-dictionary-name"></span><span class="term-frequency-separator"></span><span class="term-frequency-value"></span></span></template> +<template id="tag-search-template"><span class="tag" data-category="search"></span></template> </body></html> diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js index 5ec93b01..7ea68d59 100644 --- a/ext/mixed/js/api.js +++ b/ext/mixed/js/api.js @@ -58,15 +58,15 @@ function apiDefinitionAdd(definition, mode, context, optionsContext) { } function apiDefinitionsAddable(definitions, modes, optionsContext) { - return _apiInvoke('definitionsAddable', {definitions, modes, optionsContext}).catch(() => null); + return _apiInvoke('definitionsAddable', {definitions, modes, optionsContext}); } function apiNoteView(noteId) { return _apiInvoke('noteView', {noteId}); } -function apiTemplateRender(template, data, dynamic) { - return _apiInvoke('templateRender', {data, template, dynamic}); +function apiTemplateRender(template, data) { + return _apiInvoke('templateRender', {data, template}); } function apiAudioGetUrl(definition, source, optionsContext) { @@ -89,8 +89,8 @@ function apiFrameInformationGet() { return _apiInvoke('frameInformationGet'); } -function apiInjectStylesheet(css) { - return _apiInvoke('injectStylesheet', {css}); +function apiInjectStylesheet(type, value) { + return _apiInvoke('injectStylesheet', {type, value}); } function apiGetEnvironmentInfo() { @@ -105,10 +105,18 @@ function apiGetDisplayTemplatesHtml() { return _apiInvoke('getDisplayTemplatesHtml'); } +function apiGetQueryParserTemplatesHtml() { + return _apiInvoke('getQueryParserTemplatesHtml'); +} + function apiGetZoom() { return _apiInvoke('getZoom'); } +function apiGetMessageToken() { + return _apiInvoke('getMessageToken'); +} + function _apiInvoke(action, params={}) { const data = {action, params}; return new Promise((resolve, reject) => { diff --git a/ext/mixed/js/audio.js b/ext/mixed/js/audio.js index b0c5fa82..b5a025be 100644 --- a/ext/mixed/js/audio.js +++ b/ext/mixed/js/audio.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global apiAudioGetUrl*/ class TextToSpeechAudio { constructor(text, voice) { @@ -53,7 +54,6 @@ class TextToSpeechAudio { speechSynthesis.cancel(); speechSynthesis.speak(this._utterance); - } catch (e) { // NOP } @@ -71,21 +71,16 @@ class TextToSpeechAudio { const m = /^tts:[^#?]*\?([^#]*)/.exec(ttsUri); if (m === null) { return null; } - const searchParameters = {}; - for (const group of m[1].split('&')) { - const sep = group.indexOf('='); - if (sep < 0) { continue; } - searchParameters[decodeURIComponent(group.substring(0, sep))] = decodeURIComponent(group.substring(sep + 1)); - } - - if (!searchParameters.text) { return null; } + const searchParameters = new URLSearchParams(m[1]); + const text = searchParameters.get('text'); + let voice = searchParameters.get('voice'); + if (text === null || voice === null) { return null; } - const voice = audioGetTextToSpeechVoice(searchParameters.voice); + voice = audioGetTextToSpeechVoice(voice); if (voice === null) { return null; } - return new TextToSpeechAudio(searchParameters.text, voice); + return new TextToSpeechAudio(text, voice); } - } function audioGetFromUrl(url, willDownload) { @@ -113,8 +108,11 @@ function audioGetFromUrl(url, willDownload) { async function audioGetFromSources(expression, sources, optionsContext, willDownload, cache=null) { const key = `${expression.expression}:${expression.reading}`; - if (cache !== null && hasOwn(cache, expression)) { - return cache[key]; + if (cache !== null) { + const cacheValue = cache.get(expression); + if (typeof cacheValue !== 'undefined') { + return cacheValue; + } } for (let i = 0, ii = sources.length; i < ii; ++i) { @@ -132,7 +130,7 @@ async function audioGetFromSources(expression, sources, optionsContext, willDown } const result = {audio, url, source}; if (cache !== null) { - cache[key] = result; + cache.set(key, result); } return result; } catch (e) { diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index 0142d594..83813796 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -113,11 +113,7 @@ function toIterable(value) { if (value !== null && typeof value === 'object') { const length = value.length; if (typeof length === 'number' && Number.isFinite(length)) { - const array = []; - for (let i = 0; i < length; ++i) { - array.push(value[i]); - } - return array; + return Array.from(value); } } @@ -128,6 +124,14 @@ function stringReverse(string) { return string.split('').reverse().join('').replace(/([\uDC00-\uDFFF])([\uD800-\uDBFF])/g, '$2$1'); } +function parseUrl(url) { + const parsedUrl = new URL(url); + const baseUrl = `${parsedUrl.origin}${parsedUrl.pathname}`; + const queryParams = Array.from(parsedUrl.searchParams.entries()) + .reduce((a, [k, v]) => Object.assign({}, a, {[k]: v}), {}); + return {baseUrl, queryParams}; +} + /* * Async utilities @@ -156,9 +160,9 @@ function promiseTimeout(delay, resolveValue) { const resolve = (value) => complete(promiseResolve, value); const reject = (value) => complete(promiseReject, value); - const promise = new Promise((resolve, reject) => { - promiseResolve = resolve; - promiseReject = reject; + const promise = new Promise((resolve2, reject2) => { + promiseResolve = resolve2; + promiseReject = reject2; }); timer = window.setTimeout(() => { timer = null; @@ -232,6 +236,29 @@ class EventDispatcher { } } +class EventListenerCollection { + constructor() { + this._eventListeners = []; + } + + get size() { + return this._eventListeners.length; + } + + addEventListener(node, type, listener, options) { + node.addEventListener(type, listener, options); + this._eventListeners.push([node, type, listener, options]); + } + + removeAllEventListeners() { + if (this._eventListeners.length === 0) { return; } + for (const [node, type, listener, options] of this._eventListeners) { + node.removeEventListener(type, listener, options); + } + this._eventListeners = []; + } +} + /* * Default message handlers @@ -244,7 +271,7 @@ const yomichan = (() => { this._messageHandlers = new Map([ ['getUrl', this._onMessageGetUrl.bind(this)], - ['optionsUpdate', this._onMessageOptionsUpdate.bind(this)], + ['optionsUpdated', this._onMessageOptionsUpdated.bind(this)], ['zoomChanged', this._onMessageZoomChanged.bind(this)] ]); @@ -253,6 +280,16 @@ const yomichan = (() => { // Public + generateId(length) { + const array = new Uint8Array(length); + window.crypto.getRandomValues(array); + let id = ''; + for (const value of array) { + id += value.toString(16).padStart(2, '0'); + } + return id; + } + triggerOrphaned(error) { this.trigger('orphaned', {error}); } @@ -272,8 +309,8 @@ const yomichan = (() => { return {url: window.location.href}; } - _onMessageOptionsUpdate({source}) { - this.trigger('optionsUpdate', {source}); + _onMessageOptionsUpdated({source}) { + this.trigger('optionsUpdated', {source}); } _onMessageZoomChanged({oldZoomFactor, newZoomFactor}) { diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js index e1710488..d7e77cc0 100644 --- a/ext/mixed/js/display-generator.js +++ b/ext/mixed/js/display-generator.js @@ -16,46 +16,20 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. */ +/*global apiGetDisplayTemplatesHtml, TemplateHandler*/ class DisplayGenerator { constructor() { - this._isInitialized = false; - this._initializationPromise = null; - - this._termEntryTemplate = null; - this._termExpressionTemplate = null; - this._termDefinitionItemTemplate = null; - this._termDefinitionOnlyTemplate = null; - this._termGlossaryItemTemplate = null; - this._termReasonTemplate = null; - - this._kanjiEntryTemplate = null; - this._kanjiInfoTableTemplate = null; - this._kanjiInfoTableItemTemplate = null; - this._kanjiInfoTableEmptyTemplate = null; - this._kanjiGlossaryItemTemplate = null; - this._kanjiReadingTemplate = null; - - this._tagTemplate = null; - this._tagFrequencyTemplate = null; + this._templateHandler = null; } - isInitialized() { - return this._isInitialized; - } - - initialize() { - if (this._isInitialized) { - return Promise.resolve(); - } - if (this._initializationPromise === null) { - this._initializationPromise = this._initializeInternal(); - } - return this._initializationPromise; + async prepare() { + const html = await apiGetDisplayTemplatesHtml(); + this._templateHandler = new TemplateHandler(html); } createTermEntry(details) { - const node = DisplayGenerator._instantiateTemplate(this._termEntryTemplate); + const node = this._templateHandler.instantiate('term-entry'); const expressionsContainer = node.querySelector('.term-expression-list'); const reasonsContainer = node.querySelector('.term-reasons'); @@ -71,7 +45,11 @@ class DisplayGenerator { node.dataset.expressionCount = `${expressionMulti ? details.expressions.length : 1}`; node.dataset.definitionCount = `${definitionMulti ? details.definitions.length : 1}`; - DisplayGenerator._appendMultiple(expressionsContainer, this.createTermExpression.bind(this), details.expressions, [details]); + const termTags = details.termTags; + let expressions = details.expressions; + expressions = Array.isArray(expressions) ? expressions.map((e) => [e, termTags]) : null; + + DisplayGenerator._appendMultiple(expressionsContainer, this.createTermExpression.bind(this), expressions, [[details, termTags]]); DisplayGenerator._appendMultiple(reasonsContainer, this.createTermReason.bind(this), details.reasons); DisplayGenerator._appendMultiple(frequenciesContainer, this.createFrequencyTag.bind(this), details.frequencies); DisplayGenerator._appendMultiple(definitionsContainer, this.createTermDefinitionItem.bind(this), details.definitions, [details]); @@ -83,8 +61,8 @@ class DisplayGenerator { return node; } - createTermExpression(details) { - const node = DisplayGenerator._instantiateTemplate(this._termExpressionTemplate); + createTermExpression([details, termTags]) { + const node = this._templateHandler.instantiate('term-expression'); const expressionContainer = node.querySelector('.term-expression-text'); const tagContainer = node.querySelector('.tags'); @@ -103,21 +81,30 @@ class DisplayGenerator { DisplayGenerator._appendFurigana(expressionContainer, furiganaSegments, this._appendKanjiLinks.bind(this)); } - DisplayGenerator._appendMultiple(tagContainer, this.createTag.bind(this), details.termTags); + if (!Array.isArray(termTags)) { + // Fallback + termTags = details.termTags; + } + const searchQueries = [details.expression, details.reading] + .filter((x) => !!x) + .map((x) => ({query: x})); + DisplayGenerator._appendMultiple(tagContainer, this.createTag.bind(this), termTags); + DisplayGenerator._appendMultiple(tagContainer, this.createSearchTag.bind(this), searchQueries); DisplayGenerator._appendMultiple(frequencyContainer, this.createFrequencyTag.bind(this), details.frequencies); return node; } createTermReason(reason) { - const node = DisplayGenerator._instantiateTemplate(this._termReasonTemplate); + const fragment = this._templateHandler.instantiateFragment('term-reason'); + const node = fragment.querySelector('.term-reason'); node.textContent = reason; node.dataset.reason = reason; - return node; + return fragment; } createTermDefinitionItem(details) { - const node = DisplayGenerator._instantiateTemplate(this._termDefinitionItemTemplate); + const node = this._templateHandler.instantiate('term-definition-item'); const tagListContainer = node.querySelector('.term-definition-tag-list'); const onlyListContainer = node.querySelector('.term-definition-only-list'); @@ -133,7 +120,7 @@ class DisplayGenerator { } createTermGlossaryItem(glossary) { - const node = DisplayGenerator._instantiateTemplate(this._termGlossaryItemTemplate); + const node = this._templateHandler.instantiate('term-glossary-item'); const container = node.querySelector('.term-glossary'); if (container !== null) { DisplayGenerator._appendMultilineText(container, glossary); @@ -142,7 +129,7 @@ class DisplayGenerator { } createTermOnly(only) { - const node = DisplayGenerator._instantiateTemplate(this._termDefinitionOnlyTemplate); + const node = this._templateHandler.instantiate('term-definition-only'); node.dataset.only = only; node.textContent = only; return node; @@ -157,7 +144,7 @@ class DisplayGenerator { } createKanjiEntry(details) { - const node = DisplayGenerator._instantiateTemplate(this._kanjiEntryTemplate); + const node = this._templateHandler.instantiate('kanji-entry'); const glyphContainer = node.querySelector('.kanji-glyph'); const frequenciesContainer = node.querySelector('.frequencies'); @@ -202,7 +189,7 @@ class DisplayGenerator { } createKanjiGlossaryItem(glossary) { - const node = DisplayGenerator._instantiateTemplate(this._kanjiGlossaryItemTemplate); + const node = this._templateHandler.instantiate('kanji-glossary-item'); const container = node.querySelector('.kanji-glossary'); if (container !== null) { DisplayGenerator._appendMultilineText(container, glossary); @@ -211,13 +198,13 @@ class DisplayGenerator { } createKanjiReading(reading) { - const node = DisplayGenerator._instantiateTemplate(this._kanjiReadingTemplate); + const node = this._templateHandler.instantiate('kanji-reading'); node.textContent = reading; return node; } createKanjiInfoTable(details) { - const node = DisplayGenerator._instantiateTemplate(this._kanjiInfoTableTemplate); + const node = this._templateHandler.instantiate('kanji-info-table'); const container = node.querySelector('.kanji-info-table-body'); @@ -233,7 +220,7 @@ class DisplayGenerator { } createKanjiInfoTableItem(details) { - const node = DisplayGenerator._instantiateTemplate(this._kanjiInfoTableItemTemplate); + const node = this._templateHandler.instantiate('kanji-info-table-item'); const nameNode = node.querySelector('.kanji-info-table-item-header'); const valueNode = node.querySelector('.kanji-info-table-item-value'); if (nameNode !== null) { @@ -246,21 +233,33 @@ class DisplayGenerator { } createKanjiInfoTableItemEmpty() { - return DisplayGenerator._instantiateTemplate(this._kanjiInfoTableEmptyTemplate); + return this._templateHandler.instantiate('kanji-info-table-empty'); } createTag(details) { - const node = DisplayGenerator._instantiateTemplate(this._tagTemplate); + const node = this._templateHandler.instantiate('tag'); + + const inner = node.querySelector('.tag-inner'); node.title = details.notes; - node.textContent = details.name; + inner.textContent = details.name; node.dataset.category = details.category; return node; } + createSearchTag(details) { + const node = this._templateHandler.instantiate('tag-search'); + + node.textContent = details.query; + + node.dataset.query = details.query; + + return node; + } + createFrequencyTag(details) { - const node = DisplayGenerator._instantiateTemplate(this._tagFrequencyTemplate); + const node = this._templateHandler.instantiate('tag-frequency'); let n = node.querySelector('.term-frequency-dictionary-name'); if (n !== null) { @@ -278,31 +277,6 @@ class DisplayGenerator { return node; } - async _initializeInternal() { - const html = await apiGetDisplayTemplatesHtml(); - const doc = new DOMParser().parseFromString(html, 'text/html'); - this._setTemplates(doc); - } - - _setTemplates(doc) { - this._termEntryTemplate = doc.querySelector('#term-entry-template'); - this._termExpressionTemplate = doc.querySelector('#term-expression-template'); - this._termDefinitionItemTemplate = doc.querySelector('#term-definition-item-template'); - this._termDefinitionOnlyTemplate = doc.querySelector('#term-definition-only-template'); - this._termGlossaryItemTemplate = doc.querySelector('#term-glossary-item-template'); - this._termReasonTemplate = doc.querySelector('#term-reason-template'); - - this._kanjiEntryTemplate = doc.querySelector('#kanji-entry-template'); - this._kanjiInfoTableTemplate = doc.querySelector('#kanji-info-table-template'); - this._kanjiInfoTableItemTemplate = doc.querySelector('#kanji-info-table-item-template'); - this._kanjiInfoTableEmptyTemplate = doc.querySelector('#kanji-info-table-empty-template'); - this._kanjiGlossaryItemTemplate = doc.querySelector('#kanji-glossary-item-template'); - this._kanjiReadingTemplate = doc.querySelector('#kanji-reading-template'); - - this._tagTemplate = doc.querySelector('#tag-template'); - this._tagFrequencyTemplate = doc.querySelector('#tag-frequency-template'); - } - _appendKanjiLinks(container, text) { let part = ''; for (const c of text) { @@ -372,8 +346,4 @@ class DisplayGenerator { container.appendChild(document.createTextNode(parts[i])); } } - - static _instantiateTemplate(template) { - return document.importNode(template.content.firstChild, true); - } } diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index c4be02f2..5d3076ee 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -16,6 +16,11 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global docRangeFromPoint, docSentenceExtract +apiKanjiFind, apiTermsFind, apiNoteView, apiOptionsGet, apiDefinitionsAddable, apiDefinitionAdd +apiScreenshotGet, apiForward +audioPrepareTextToSpeech, audioGetFromSources +DisplayGenerator, WindowScroll, DisplayContext, DOM*/ class Display { constructor(spinner, container) { @@ -27,11 +32,11 @@ class Display { this.index = 0; this.audioPlaying = null; this.audioFallback = null; - this.audioCache = {}; + this.audioCache = new Map(); this.styleNode = null; - this.eventListeners = []; - this.persistentEventListeners = []; + this.eventListeners = new EventListenerCollection(); + this.persistentEventListeners = new EventListenerCollection(); this.interactive = false; this.eventListenersActive = false; this.clickScanPrevent = false; @@ -43,6 +48,13 @@ class Display { this.setInteractive(true); } + async prepare(options=null) { + const displayGeneratorPromise = this.displayGenerator.prepare(); + const updateOptionsPromise = this.updateOptions(options); + await Promise.all([displayGeneratorPromise, updateOptionsPromise]); + yomichan.on('optionsUpdated', () => this.updateOptions(null)); + } + onError(_error) { throw new Error('Override me'); } @@ -174,15 +186,24 @@ class Display { e.preventDefault(); const link = e.currentTarget; const entry = link.closest('.entry'); - const definitionIndex = this.entryIndexFind(entry); + const index = this.entryIndexFind(entry); + if (index < 0 || index >= this.definitions.length) { return; } + const expressionIndex = Display.indexOf(entry.querySelectorAll('.term-expression .action-play-audio'), link); - this.audioPlay(this.definitions[definitionIndex], expressionIndex, definitionIndex); + this.audioPlay( + this.definitions[index], + // expressionIndex is used in audioPlay to detect result output mode + Math.max(expressionIndex, this.options.general.resultOutputMode === 'merge' ? 0 : -1), + index + ); } onNoteAdd(e) { e.preventDefault(); const link = e.currentTarget; const index = this.entryIndexFind(link); + if (index < 0 || index >= this.definitions.length) { return; } + this.noteAdd(this.definitions[index], link.dataset.mode); } @@ -216,13 +237,16 @@ class Display { } onHistoryWheel(e) { + if (e.altKey) { return; } const delta = -e.deltaX || e.deltaY; if (delta > 0) { this.sourceTermView(); e.preventDefault(); + e.stopPropagation(); } else if (delta < 0) { this.nextTermView(); e.preventDefault(); + e.stopPropagation(); } } @@ -230,15 +254,6 @@ class Display { throw new Error('Override me'); } - isInitialized() { - return this.options !== null; - } - - async initialize(options=null) { - await this.updateOptions(options); - yomichan.on('optionsUpdate', () => this.updateOptions(null)); - } - async updateOptions(options) { this.options = options ? options : await apiOptionsGet(this.getOptionsContext()); this.updateDocumentOptions(this.options); @@ -252,6 +267,7 @@ class Display { data.ankiEnabled = `${options.anki.enable}`; data.audioEnabled = `${options.audio.enable}`; data.compactGlossaries = `${options.general.compactGlossaries}`; + data.enableSearchTags = `${options.scanning.enableSearchTags}`; data.debug = `${options.general.debugInfo}`; } @@ -285,13 +301,24 @@ class Display { this.interactive = interactive; if (interactive) { - Display.addEventListener(this.persistentEventListeners, document, 'keydown', this.onKeyDown.bind(this), false); - Display.addEventListener(this.persistentEventListeners, document, 'wheel', this.onWheel.bind(this), {passive: false}); - Display.addEventListener(this.persistentEventListeners, document.querySelector('.action-previous'), 'click', this.onSourceTermView.bind(this)); - Display.addEventListener(this.persistentEventListeners, document.querySelector('.action-next'), 'click', this.onNextTermView.bind(this)); - Display.addEventListener(this.persistentEventListeners, document.querySelector('.navigation-header'), 'wheel', this.onHistoryWheel.bind(this), {passive: false}); + const actionPrevious = document.querySelector('.action-previous'); + const actionNext = document.querySelector('.action-next'); + // const navigationHeader = document.querySelector('.navigation-header'); + + this.persistentEventListeners.addEventListener(document, 'keydown', this.onKeyDown.bind(this), false); + this.persistentEventListeners.addEventListener(document, 'wheel', this.onWheel.bind(this), {passive: false}); + if (actionPrevious !== null) { + this.persistentEventListeners.addEventListener(actionPrevious, 'click', this.onSourceTermView.bind(this)); + } + if (actionNext !== null) { + this.persistentEventListeners.addEventListener(actionNext, 'click', this.onNextTermView.bind(this)); + } + // temporarily disabled + // if (navigationHeader !== null) { + // this.persistentEventListeners.addEventListener(navigationHeader, 'wheel', this.onHistoryWheel.bind(this), {passive: false}); + // } } else { - Display.clearEventListeners(this.persistentEventListeners); + this.persistentEventListeners.removeAllEventListeners(); } this.setEventListenersActive(this.eventListenersActive); } @@ -302,23 +329,23 @@ class Display { this.eventListenersActive = active; if (active) { - this.addEventListeners('.action-add-note', 'click', this.onNoteAdd.bind(this)); - this.addEventListeners('.action-view-note', 'click', this.onNoteView.bind(this)); - this.addEventListeners('.action-play-audio', 'click', this.onAudioPlay.bind(this)); - this.addEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this)); + this.addMultipleEventListeners('.action-add-note', 'click', this.onNoteAdd.bind(this)); + this.addMultipleEventListeners('.action-view-note', 'click', this.onNoteView.bind(this)); + this.addMultipleEventListeners('.action-play-audio', 'click', this.onAudioPlay.bind(this)); + this.addMultipleEventListeners('.kanji-link', 'click', this.onKanjiLookup.bind(this)); if (this.options.scanning.enablePopupSearch) { - this.addEventListeners('.glossary-item', 'mouseup', this.onGlossaryMouseUp.bind(this)); - this.addEventListeners('.glossary-item', 'mousedown', this.onGlossaryMouseDown.bind(this)); - this.addEventListeners('.glossary-item', 'mousemove', this.onGlossaryMouseMove.bind(this)); + this.addMultipleEventListeners('.term-glossary-item, .tag', 'mouseup', this.onGlossaryMouseUp.bind(this)); + this.addMultipleEventListeners('.term-glossary-item, .tag', 'mousedown', this.onGlossaryMouseDown.bind(this)); + this.addMultipleEventListeners('.term-glossary-item, .tag', 'mousemove', this.onGlossaryMouseMove.bind(this)); } } else { - Display.clearEventListeners(this.eventListeners); + this.eventListeners.removeAllEventListeners(); } } - addEventListeners(selector, type, listener, options) { + addMultipleEventListeners(selector, type, listener, options) { for (const node of this.container.querySelectorAll(selector)) { - Display.addEventListener(this.eventListeners, node, type, listener, options); + this.eventListeners.addEventListener(node, type, listener, options); } } @@ -348,7 +375,6 @@ class Display { async setContentTerms(definitions, context, token) { if (!context) { throw new Error('Context expected'); } - if (!this.isInitialized()) { return; } this.setEventListenersActive(false); @@ -356,11 +382,6 @@ class Display { window.focus(); } - if (!this.displayGenerator.isInitialized()) { - await this.displayGenerator.initialize(); - if (this.setContentToken !== token) { return; } - } - this.definitions = definitions; if (context.disableHistory) { delete context.disableHistory; @@ -404,7 +425,7 @@ class Display { this.setEventListenersActive(true); - const states = await apiDefinitionsAddable(definitions, ['term-kanji', 'term-kana'], this.getOptionsContext()); + const states = await this.getDefinitionsAddable(definitions, ['term-kanji', 'term-kana']); if (this.setContentToken !== token) { return; } this.updateAdderButtons(states); @@ -412,7 +433,6 @@ class Display { async setContentKanji(definitions, context, token) { if (!context) { throw new Error('Context expected'); } - if (!this.isInitialized()) { return; } this.setEventListenersActive(false); @@ -420,11 +440,6 @@ class Display { window.focus(); } - if (!this.displayGenerator.isInitialized()) { - await this.displayGenerator.initialize(); - if (this.setContentToken !== token) { return; } - } - this.definitions = definitions; if (context.disableHistory) { delete context.disableHistory; @@ -446,7 +461,7 @@ class Display { for (let i = 0, ii = definitions.length; i < ii; ++i) { if (i > 0) { - await promiseTimeout(0); + await promiseTimeout(1); if (this.setContentToken !== token) { return; } } @@ -459,7 +474,7 @@ class Display { this.setEventListenersActive(true); - const states = await apiDefinitionsAddable(definitions, ['kanji'], this.getOptionsContext()); + const states = await this.getDefinitionsAddable(definitions, ['kanji']); if (this.setContentToken !== token) { return; } this.updateAdderButtons(states); @@ -498,6 +513,8 @@ class Display { } autoPlayAudio() { + if (this.definitions.length === 0) { return; } + this.audioPlay(this.definitions[0], this.firstExpressionIndex, 0); } @@ -597,9 +614,12 @@ class Display { } noteTryAdd(mode) { - const button = this.adderButtonFind(this.index, mode); + const index = this.index; + if (index < 0 || index >= this.definitions.length) { return; } + + const button = this.adderButtonFind(index, mode); if (button !== null && !button.classList.contains('disabled')) { - this.noteAdd(this.definitions[this.index], mode); + this.noteAdd(this.definitions[index], mode); } } @@ -698,7 +718,7 @@ class Display { async getScreenshot() { try { await this.setPopupVisibleOverride(false); - await Display.delay(1); // Wait for popup to be hidden. + await promiseTimeout(1); // Wait for popup to be hidden. const {format, quality} = this.options.anki.screenshot; const dataUrl = await apiScreenshotGet({format, quality}); @@ -767,8 +787,12 @@ class Display { return entry !== null ? entry.querySelector('.action-play-audio>img') : null; } - static delay(time) { - return new Promise((resolve) => setTimeout(resolve, time)); + async getDefinitionsAddable(definitions, modes) { + try { + return await apiDefinitionsAddable(definitions, modes, this.getOptionsContext()); + } catch (e) { + return []; + } } static indexOf(nodeList, node) { @@ -780,19 +804,6 @@ class Display { return -1; } - static addEventListener(eventListeners, object, type, listener, options) { - if (object === null) { return; } - object.addEventListener(type, listener, options); - eventListeners.push([object, type, listener, options]); - } - - static clearEventListeners(eventListeners) { - for (const [object, type, listener, options] of eventListeners) { - object.removeEventListener(type, listener, options); - } - eventListeners.length = 0; - } - static getElementTop(element) { const elementRect = element.getBoundingClientRect(); const documentRect = document.documentElement.getBoundingClientRect(); @@ -901,9 +912,12 @@ Display._onKeyDownHandlers = new Map([ ['P', (self, e) => { if (e.altKey) { - const entry = self.getEntry(self.index); + const index = self.index; + if (index < 0 || index >= self.definitions.length) { return; } + + const entry = self.getEntry(index); if (entry !== null && entry.dataset.type === 'term') { - self.audioPlay(self.definitions[self.index], self.firstExpressionIndex, self.index); + self.audioPlay(self.definitions[index], self.firstExpressionIndex, index); } return true; } diff --git a/ext/mixed/js/template-handler.js b/ext/mixed/js/template-handler.js new file mode 100644 index 00000000..a5a62937 --- /dev/null +++ b/ext/mixed/js/template-handler.js @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2020 Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * 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 <http://www.gnu.org/licenses/>. + */ + + +class TemplateHandler { + constructor(html) { + this._templates = new Map(); + + const doc = new DOMParser().parseFromString(html, 'text/html'); + for (const template of doc.querySelectorAll('template')) { + this._setTemplate(template); + } + } + + _setTemplate(template) { + const idMatch = template.id.match(/^([a-z-]+)-template$/); + if (!idMatch) { + throw new Error(`Invalid template ID: ${template.id}`); + } + this._templates.set(idMatch[1], template); + } + + instantiate(name) { + const template = this._templates.get(name); + return document.importNode(template.content.firstChild, true); + } + + instantiateFragment(name) { + const template = this._templates.get(name); + return document.importNode(template.content, true); + } +} diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index 88f1e27a..ff0eac8b 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -16,6 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/*global docRangeFromPoint, TextSourceRange, DOM*/ class TextScanner { constructor(node, ignoreNodes, ignoreElements, ignorePoints) { @@ -30,7 +31,7 @@ class TextScanner { this.options = null; this.enabled = false; - this.eventListeners = []; + this.eventListeners = new EventListenerCollection(); this.primaryTouchIdentifier = null; this.preventNextContextMenu = false; @@ -140,24 +141,24 @@ class TextScanner { const textSourceCurrentPrevious = this.textSourceCurrent !== null ? this.textSourceCurrent.clone() : null; this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchStart') - .then(() => { - if ( - this.textSourceCurrent === null || - this.textSourceCurrent.equals(textSourceCurrentPrevious) - ) { - return; - } + .then(() => { + if ( + this.textSourceCurrent === null || + this.textSourceCurrent.equals(textSourceCurrentPrevious) + ) { + return; + } - this.preventScroll = true; - this.preventNextContextMenu = true; - this.preventNextMouseDown = true; - }); + this.preventScroll = true; + this.preventNextContextMenu = true; + this.preventNextMouseDown = true; + }); } onTouchEnd(e) { if ( this.primaryTouchIdentifier === null || - TextScanner.getIndexOfTouch(e.changedTouches, this.primaryTouchIdentifier) < 0 + TextScanner.getTouch(e.changedTouches, this.primaryTouchIdentifier) === null ) { return; } @@ -180,13 +181,11 @@ class TextScanner { return; } - const touches = e.changedTouches; - const index = TextScanner.getIndexOfTouch(touches, this.primaryTouchIdentifier); - if (index < 0) { + const primaryTouch = TextScanner.getTouch(e.changedTouches, this.primaryTouchIdentifier); + if (primaryTouch === null) { return; } - const primaryTouch = touches[index]; this.searchAt(primaryTouch.clientX, primaryTouch.clientY, 'touchMove'); e.preventDefault(); // Disable scroll @@ -228,7 +227,7 @@ class TextScanner { } } else { if (this.enabled) { - this.clearEventListeners(); + this.eventListeners.removeAllEventListeners(); this.enabled = false; } this.onSearchClear(false); @@ -236,13 +235,13 @@ class TextScanner { } hookEvents() { - let eventListeners = this.getMouseEventListeners(); + let eventListenerInfos = this.getMouseEventListeners(); if (this.options.scanning.touchInputEnabled) { - eventListeners = eventListeners.concat(this.getTouchEventListeners()); + eventListenerInfos = eventListenerInfos.concat(this.getTouchEventListeners()); } - for (const [node, type, listener, options] of eventListeners) { - this.addEventListener(node, type, listener, options); + for (const [node, type, listener, options] of eventListenerInfos) { + this.eventListeners.addEventListener(node, type, listener, options); } } @@ -267,18 +266,6 @@ class TextScanner { ]; } - addEventListener(node, type, listener, options) { - node.addEventListener(type, listener, options); - this.eventListeners.push([node, type, listener, options]); - } - - clearEventListeners() { - for (const [node, type, listener, options] of this.eventListeners) { - node.removeEventListener(type, listener, options); - } - this.eventListeners = []; - } - setOptions(options) { this.options = options; this.setEnabled(this.options.general.enable); @@ -367,13 +354,12 @@ class TextScanner { } } - static getIndexOfTouch(touchList, identifier) { - for (const i in touchList) { - const t = touchList[i]; - if (t.identifier === identifier) { - return i; + static getTouch(touchList, identifier) { + for (const touch of touchList) { + if (touch.identifier === identifier) { + return touch; } } - return -1; + return null; } } |