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/bg | |
parent | 0c5b9b1fa1599cbf769d96cdebc226310f9dd8bc (diff) | |
parent | 706c3edcffb0078d71fd5b58775f16cf5fc1205b (diff) |
Merge branch 'master' into testing
Diffstat (limited to 'ext/bg')
47 files changed, 2292 insertions, 1022 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/bg/js/japanese.js b/ext/bg/js/japanese.js new file mode 100644 index 00000000..abb32da4 --- /dev/null +++ b/ext/bg/js/japanese.js @@ -0,0 +1,455 @@ +/* + * Copyright (C) 2016-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 wanakana*/ + +const JP_HALFWIDTH_KATAKANA_MAPPING = new Map([ + ['ヲ', 'ヲヺ-'], + ['ァ', 'ァ--'], + ['ィ', 'ィ--'], + ['ゥ', 'ゥ--'], + ['ェ', 'ェ--'], + ['ォ', 'ォ--'], + ['ャ', 'ャ--'], + ['ュ', 'ュ--'], + ['ョ', 'ョ--'], + ['ッ', 'ッ--'], + ['ー', 'ー--'], + ['ア', 'ア--'], + ['イ', 'イ--'], + ['ウ', 'ウヴ-'], + ['エ', 'エ--'], + ['オ', 'オ--'], + ['カ', 'カガ-'], + ['キ', 'キギ-'], + ['ク', 'クグ-'], + ['ケ', 'ケゲ-'], + ['コ', 'コゴ-'], + ['サ', 'サザ-'], + ['シ', 'シジ-'], + ['ス', 'スズ-'], + ['セ', 'セゼ-'], + ['ソ', 'ソゾ-'], + ['タ', 'タダ-'], + ['チ', 'チヂ-'], + ['ツ', 'ツヅ-'], + ['テ', 'テデ-'], + ['ト', 'トド-'], + ['ナ', 'ナ--'], + ['ニ', 'ニ--'], + ['ヌ', 'ヌ--'], + ['ネ', 'ネ--'], + ['ノ', 'ノ--'], + ['ハ', 'ハバパ'], + ['ヒ', 'ヒビピ'], + ['フ', 'フブプ'], + ['ヘ', 'ヘベペ'], + ['ホ', 'ホボポ'], + ['マ', 'マ--'], + ['ミ', 'ミ--'], + ['ム', 'ム--'], + ['メ', 'メ--'], + ['モ', 'モ--'], + ['ヤ', 'ヤ--'], + ['ユ', 'ユ--'], + ['ヨ', 'ヨ--'], + ['ラ', 'ラ--'], + ['リ', 'リ--'], + ['ル', 'ル--'], + ['レ', 'レ--'], + ['ロ', 'ロ--'], + ['ワ', 'ワ--'], + ['ン', 'ン--'] +]); + +const JP_HIRAGANA_RANGE = [0x3040, 0x309f]; +const JP_KATAKANA_RANGE = [0x30a0, 0x30ff]; +const JP_KANA_RANGES = [JP_HIRAGANA_RANGE, JP_KATAKANA_RANGE]; + +const JP_CJK_COMMON_RANGE = [0x4e00, 0x9fff]; +const JP_CJK_RARE_RANGE = [0x3400, 0x4dbf]; +const JP_CJK_RANGES = [JP_CJK_COMMON_RANGE, JP_CJK_RARE_RANGE]; + +const JP_ITERATION_MARK_CHAR_CODE = 0x3005; + +// Japanese character ranges, roughly ordered in order of expected frequency +const JP_JAPANESE_RANGES = [ + JP_HIRAGANA_RANGE, + JP_KATAKANA_RANGE, + + JP_CJK_COMMON_RANGE, + JP_CJK_RARE_RANGE, + + [0xff66, 0xff9f], // Halfwidth katakana + + [0x30fb, 0x30fc], // Katakana punctuation + [0xff61, 0xff65], // Kana punctuation + [0x3000, 0x303f], // CJK punctuation + + [0xff10, 0xff19], // Fullwidth numbers + [0xff21, 0xff3a], // Fullwidth upper case Latin letters + [0xff41, 0xff5a], // Fullwidth lower case Latin letters + + [0xff01, 0xff0f], // Fullwidth punctuation 1 + [0xff1a, 0xff1f], // Fullwidth punctuation 2 + [0xff3b, 0xff3f], // Fullwidth punctuation 3 + [0xff5b, 0xff60], // Fullwidth punctuation 4 + [0xffe0, 0xffee] // Currency markers +]; + + +// Helper functions + +function _jpIsCharCodeInRanges(charCode, ranges) { + for (const [min, max] of ranges) { + if (charCode >= min && charCode <= max) { + return true; + } + } + return false; +} + + +// Character code testing functions + +function jpIsCharCodeKanji(charCode) { + return _jpIsCharCodeInRanges(charCode, JP_CJK_RANGES); +} + +function jpIsCharCodeKana(charCode) { + return _jpIsCharCodeInRanges(charCode, JP_KANA_RANGES); +} + +function jpIsCharCodeJapanese(charCode) { + return _jpIsCharCodeInRanges(charCode, JP_JAPANESE_RANGES); +} + + +// String testing functions + +function jpIsStringEntirelyKana(str) { + if (str.length === 0) { return false; } + for (let i = 0, ii = str.length; i < ii; ++i) { + if (!jpIsCharCodeKana(str.charCodeAt(i))) { + return false; + } + } + return true; +} + +function jpIsStringPartiallyJapanese(str) { + if (str.length === 0) { return false; } + for (let i = 0, ii = str.length; i < ii; ++i) { + if (jpIsCharCodeJapanese(str.charCodeAt(i))) { + return true; + } + } + return false; +} + + +// Conversion functions + +function jpKatakanaToHiragana(text) { + let result = ''; + for (const c of text) { + if (wanakana.isKatakana(c)) { + result += wanakana.toHiragana(c); + } else { + result += c; + } + } + + return result; +} + +function jpHiraganaToKatakana(text) { + let result = ''; + for (const c of text) { + if (wanakana.isHiragana(c)) { + result += wanakana.toKatakana(c); + } else { + result += c; + } + } + + return result; +} + +function jpToRomaji(text) { + return wanakana.toRomaji(text); +} + +function jpConvertReading(expressionFragment, readingFragment, readingMode) { + switch (readingMode) { + case 'hiragana': + return jpKatakanaToHiragana(readingFragment || ''); + case 'katakana': + return jpHiraganaToKatakana(readingFragment || ''); + case 'romaji': + if (readingFragment) { + return jpToRomaji(readingFragment); + } else { + if (jpIsStringEntirelyKana(expressionFragment)) { + return jpToRomaji(expressionFragment); + } + } + return readingFragment; + case 'none': + return null; + default: + return readingFragment; + } +} + +function jpDistributeFurigana(expression, reading) { + const fallback = [{furigana: reading, text: expression}]; + if (!reading) { + return fallback; + } + + let isAmbiguous = false; + const segmentize = (reading2, groups) => { + if (groups.length === 0 || isAmbiguous) { + return []; + } + + const group = groups[0]; + if (group.mode === 'kana') { + if (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); + } + } + } else { + let foundSegments = null; + for (let i = reading2.length; i >= group.text.length; --i) { + const readingUsed = reading2.substring(0, i); + const readingLeft = reading2.substring(i); + const segs = segmentize(readingLeft, groups.slice(1)); + if (segs) { + if (foundSegments !== null) { + // more than one way to segmentize the tail, mark as ambiguous + isAmbiguous = true; + return null; + } + foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs); + } + // there is only one way to segmentize the last non-kana group + if (groups.length === 1) { + break; + } + } + return foundSegments; + } + }; + + const groups = []; + let modePrev = null; + for (const c of expression) { + const charCode = c.charCodeAt(0); + const modeCurr = jpIsCharCodeKanji(charCode) || charCode === JP_ITERATION_MARK_CHAR_CODE ? 'kanji' : 'kana'; + if (modeCurr === modePrev) { + groups[groups.length - 1].text += c; + } else { + groups.push({mode: modeCurr, text: c}); + modePrev = modeCurr; + } + } + + const segments = segmentize(reading, groups); + if (segments && !isAmbiguous) { + return segments; + } + return fallback; +} + +function jpDistributeFuriganaInflected(expression, reading, source) { + const output = []; + + let stemLength = 0; + const shortest = Math.min(source.length, expression.length); + const sourceHiragana = jpKatakanaToHiragana(source); + const expressionHiragana = jpKatakanaToHiragana(expression); + while (stemLength < shortest && sourceHiragana[stemLength] === expressionHiragana[stemLength]) { + ++stemLength; + } + const offset = source.length - stemLength; + + const stemExpression = source.substring(0, source.length - offset); + const stemReading = reading.substring( + 0, + offset === 0 ? reading.length : reading.length - expression.length + stemLength + ); + for (const segment of jpDistributeFurigana(stemExpression, stemReading)) { + output.push(segment); + } + + if (stemLength !== source.length) { + output.push({text: source.substring(stemLength)}); + } + + return output; +} + +function jpConvertHalfWidthKanaToFullWidth(text, sourceMapping) { + let result = ''; + const ii = text.length; + const hasSourceMapping = Array.isArray(sourceMapping); + + for (let i = 0; i < ii; ++i) { + const c = text[i]; + const mapping = JP_HALFWIDTH_KATAKANA_MAPPING.get(c); + if (typeof mapping !== 'string') { + result += c; + continue; + } + + let index = 0; + switch (text.charCodeAt(i + 1)) { + case 0xff9e: // dakuten + index = 1; + break; + case 0xff9f: // handakuten + index = 2; + break; + } + + let c2 = mapping[index]; + if (index > 0) { + if (c2 === '-') { // invalid + index = 0; + c2 = mapping[0]; + } else { + ++i; + } + } + + if (hasSourceMapping && index > 0) { + index = result.length; + const v = sourceMapping.splice(index + 1, 1)[0]; + sourceMapping[index] += v; + } + result += c2; + } + + return result; +} + +function jpConvertNumericTofullWidth(text) { + let result = ''; + for (let i = 0, ii = text.length; i < ii; ++i) { + let c = text.charCodeAt(i); + if (c >= 0x30 && c <= 0x39) { // ['0', '9'] + c += 0xff10 - 0x30; // 0xff10 = '0' full width + result += String.fromCharCode(c); + } else { + result += text[i]; + } + } + return result; +} + +function jpConvertAlphabeticToKana(text, sourceMapping) { + let part = ''; + let result = ''; + const ii = text.length; + + if (sourceMapping.length === ii) { + sourceMapping.length = ii; + sourceMapping.fill(1); + } + + for (let i = 0; i < ii; ++i) { + // Note: 0x61 is the character code for 'a' + let c = text.charCodeAt(i); + if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z'] + c += (0x61 - 0x41); + } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z'] + // NOP; c += (0x61 - 0x61); + } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth + c += (0x61 - 0xff21); + } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth + c += (0x61 - 0xff41); + } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash + c = 0x2d; // '-' + } else { + if (part.length > 0) { + result += jpToHiragana(part, sourceMapping, result.length); + part = ''; + } + result += text[i]; + continue; + } + part += String.fromCharCode(c); + } + + if (part.length > 0) { + result += jpToHiragana(part, sourceMapping, result.length); + } + return result; +} + +function jpToHiragana(text, sourceMapping, sourceMappingStart) { + const result = wanakana.toHiragana(text); + + // Generate source mapping + if (Array.isArray(sourceMapping)) { + if (typeof sourceMappingStart !== 'number') { sourceMappingStart = 0; } + let i = 0; + let resultPos = 0; + const ii = text.length; + while (i < ii) { + // Find smallest matching substring + let iNext = i + 1; + let resultPosNext = result.length; + while (iNext < ii) { + const t = wanakana.toHiragana(text.substring(0, iNext)); + if (t === result.substring(0, t.length)) { + resultPosNext = t.length; + break; + } + ++iNext; + } + + // Merge characters + const removals = iNext - i - 1; + if (removals > 0) { + let sum = 0; + const vs = sourceMapping.splice(sourceMappingStart + 1, removals); + for (const v of vs) { sum += v; } + sourceMapping[sourceMappingStart] += sum; + } + ++sourceMappingStart; + + // Empty elements + const additions = resultPosNext - resultPos - 1; + for (let j = 0; j < additions; ++j) { + sourceMapping.splice(sourceMappingStart, 0, 0); + ++sourceMappingStart; + } + + i = iNext; + resultPos = resultPosNext; + } + } + + return result; +} 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> |