aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.json3
-rw-r--r--.gitattributes2
-rw-r--r--README.md2
-rw-r--r--ext/bg/background.html3
-rw-r--r--ext/bg/css/settings.css37
-rw-r--r--ext/bg/data/default-anki-field-templates.handlebars4
-rw-r--r--ext/bg/data/dictionary-index-schema.json16
-rw-r--r--ext/bg/data/dictionary-term-meta-bank-v3-schema.json64
-rw-r--r--ext/bg/data/options-schema.json22
-rw-r--r--ext/bg/js/anki-note-builder.js76
-rw-r--r--ext/bg/js/audio-uri-builder.js4
-rw-r--r--ext/bg/js/backend.js128
-rw-r--r--ext/bg/js/clipboard-monitor.js4
-rw-r--r--ext/bg/js/database.js297
-rw-r--r--ext/bg/js/dictionary-importer.js279
-rw-r--r--ext/bg/js/dictionary.js32
-rw-r--r--ext/bg/js/handlebars.js9
-rw-r--r--ext/bg/js/japanese.js705
-rw-r--r--ext/bg/js/options.js15
-rw-r--r--ext/bg/js/search-frontend.js8
-rw-r--r--ext/bg/js/search-query-parser.js48
-rw-r--r--ext/bg/js/search.js27
-rw-r--r--ext/bg/js/settings/anki-templates.js7
-rw-r--r--ext/bg/js/settings/anki.js2
-rw-r--r--ext/bg/js/settings/dictionaries.js58
-rw-r--r--ext/bg/js/settings/main.js8
-rw-r--r--ext/bg/js/text-source-map.js115
-rw-r--r--ext/bg/js/translator.js90
-rw-r--r--ext/bg/js/util.js2
-rw-r--r--ext/bg/search.html1
-rw-r--r--ext/bg/settings.html21
-rw-r--r--ext/fg/float.html1
-rw-r--r--ext/fg/js/float.js44
-rw-r--r--ext/fg/js/frame-offset-forwarder.js102
-rw-r--r--ext/fg/js/frontend-initialize.js34
-rw-r--r--ext/fg/js/frontend.js38
-rw-r--r--ext/fg/js/popup-nested.js7
-rw-r--r--ext/fg/js/popup-proxy-host.js15
-rw-r--r--ext/fg/js/popup-proxy.js108
-rw-r--r--ext/fg/js/popup.js87
-rw-r--r--ext/manifest.json5
-rw-r--r--ext/mixed/css/display-dark.css27
-rw-r--r--ext/mixed/css/display-default.css27
-rw-r--r--ext/mixed/css/display.css190
-rw-r--r--ext/mixed/display-templates.html17
-rw-r--r--ext/mixed/js/api.js8
-rw-r--r--ext/mixed/js/core.js75
-rw-r--r--ext/mixed/js/display-generator.js257
-rw-r--r--ext/mixed/js/display.js42
-rw-r--r--ext/mixed/js/japanese.js148
-rw-r--r--ext/mixed/js/object-property-accessor.js244
-rw-r--r--ext/mixed/js/text-scanner.js7
-rw-r--r--package-lock.json82
-rw-r--r--package.json4
-rw-r--r--test/data/dictionaries/valid-dictionary1/tag_bank_3.json4
-rw-r--r--test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json36
-rw-r--r--test/data/html/test-document2-frame1.html42
-rw-r--r--test/data/html/test-document2-script.js41
-rw-r--r--test/data/html/test-document2.html81
-rw-r--r--test/data/html/test-stylesheet.css19
-rw-r--r--test/test-database.js28
-rw-r--r--test/test-japanese.js471
-rw-r--r--test/test-object-property-accessor.js289
-rw-r--r--test/test-text-source-map.js234
64 files changed, 3779 insertions, 1124 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
index db8ff1fa..8882cb42 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -87,6 +87,8 @@
"stringReverse": "readonly",
"promiseTimeout": "readonly",
"parseUrl": "readonly",
+ "areSetsEqual": "readonly",
+ "getSetIntersection": "readonly",
"EventDispatcher": "readonly",
"EventListenerCollection": "readonly",
"EXTENSION_IS_BROWSER_EDGE": "readonly"
@@ -106,6 +108,7 @@
},
{
"files": ["test/**/*.js"],
+ "excludedFiles": ["test/data/html/*.js"],
"parserOptions": {
"ecmaVersion": 8,
"sourceType": "module"
diff --git a/.gitattributes b/.gitattributes
index 2000050e..cb9d99bb 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1 @@
-tmpl/*.html text eol=lf
+*.handlebars text eol=lf
diff --git a/README.md b/README.md
index 631f5a8b..bf217679 100644
--- a/README.md
+++ b/README.md
@@ -156,6 +156,7 @@ Flashcard fields can be configured with the following steps:
`{cloze-prefix}` | Text for the containing `{sentence}` from the start up to the value of `{cloze-body}`.
`{cloze-suffix}` | Text for the containing `{sentence}` from the value of `{cloze-body}` to the end.
`{dictionary}` | Name of the dictionary from which the card is being created (unavailable in *grouped* mode).
+ `{document-title}` | Title of the web page that the term appeared in.
`{expression}` | Term expressed as Kanji (will be displayed in Kana if Kanji is not available).
`{furigana}` | Term expressed as Kanji with Furigana displayed above it (e.g. <ruby>日本語<rt>にほんご</rt></ruby>).
`{furigana-plain}` | Term expressed as Kanji with Furigana displayed next to it in brackets (e.g. 日本語[にほんご]).
@@ -175,6 +176,7 @@ Flashcard fields can be configured with the following steps:
`{cloze-prefix}` | Text for the containing `{sentence}` from the start up to the value of `{cloze-body}`.
`{cloze-suffix}` | Text for the containing `{sentence}` from the value of `{cloze-body}` to the end.
`{dictionary}` | Name of the dictionary from which the card is being created.
+ `{document-title}` | Title of the web page that the Kanji appeared in.
`{glossary}` | List of definitions for the Kanji.
`{kunyomi}` | Kunyomi (Japanese reading) for the Kanji expressed as Katakana.
`{onyomi}` | Onyomi (Chinese reading) for the Kanji expressed as Hiragana.
diff --git a/ext/bg/background.html b/ext/bg/background.html
index 44abe8fd..afe9c5d1 100644
--- a/ext/bg/background.html
+++ b/ext/bg/background.html
@@ -20,6 +20,7 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
+ <script src="/mixed/js/japanese.js"></script>
<script src="/bg/js/anki.js"></script>
<script src="/bg/js/anki-note-builder.js"></script>
@@ -29,6 +30,7 @@
<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/dictionary-importer.js"></script>
<script src="/bg/js/deinflector.js"></script>
<script src="/bg/js/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
@@ -37,6 +39,7 @@
<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/text-source-map.js"></script>
<script src="/bg/js/translator.js"></script>
<script src="/bg/js/util.js"></script>
<script src="/mixed/js/audio-system.js"></script>
diff --git a/ext/bg/css/settings.css b/ext/bg/css/settings.css
index d686e8f8..6344bd38 100644
--- a/ext/bg/css/settings.css
+++ b/ext/bg/css/settings.css
@@ -235,6 +235,43 @@ html:root[data-operating-system=openbsd] [data-hide-for-operating-system~=openbs
display: none;
}
+.dict-details-container {
+ margin: 0.5em 0;
+}
+
+.dict-details-toggle-link {
+ cursor: pointer;
+}
+
+.dict-details {
+ margin-left: 1em;
+}
+
+.dict-details-table {
+ display: table;
+ width: 100%
+}
+
+.dict-details-entry {
+ display: table-row;
+}
+
+.dict-details-entry+.dict-details-entry>* {
+ padding-top: 0.25em;
+}
+
+.dict-details-entry-label {
+ display: table-cell;
+ font-weight: bold;
+ white-space: nowrap;
+ padding-right: 0.5em;
+}
+
+.dict-details-entry-info {
+ display: table-cell;
+ white-space: pre-line;
+}
+
@media screen and (max-width: 740px) {
.col-xs-6 {
diff --git a/ext/bg/data/default-anki-field-templates.handlebars b/ext/bg/data/default-anki-field-templates.handlebars
index 0442f7c5..6061851f 100644
--- a/ext/bg/data/default-anki-field-templates.handlebars
+++ b/ext/bg/data/default-anki-field-templates.handlebars
@@ -158,4 +158,8 @@
<img src="{{definition.screenshotFileName}}" />
{{/inline}}
+{{#*inline "document-title"}}
+ {{~context.document.title~}}
+{{/inline}}
+
{{~> (lookup . "marker") ~}} \ No newline at end of file
diff --git a/ext/bg/data/dictionary-index-schema.json b/ext/bg/data/dictionary-index-schema.json
index 9311f14c..09cff711 100644
--- a/ext/bg/data/dictionary-index-schema.json
+++ b/ext/bg/data/dictionary-index-schema.json
@@ -30,6 +30,22 @@
"description": "Alias for format.",
"enum": [1, 2, 3]
},
+ "author": {
+ "type": "string",
+ "description": "Creator of the dictionary."
+ },
+ "url": {
+ "type": "string",
+ "description": "URL for the source of the dictionary."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description of the dictionary data."
+ },
+ "attribution": {
+ "type": "string",
+ "description": "Attribution information for the dictionary data."
+ },
"tagMeta": {
"type": "object",
"description": "Tag information for terms and kanji. This object is obsolete and individual tag files should be used instead.",
diff --git a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
index 1cc0557f..8475db81 100644
--- a/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
+++ b/ext/bg/data/dictionary-term-meta-bank-v3-schema.json
@@ -13,13 +13,71 @@
},
{
"type": "string",
- "enum": ["freq"],
- "description": "Type of data. \"freq\" corresponds to frequency information."
+ "enum": ["freq", "pitch"],
+ "description": "Type of data. \"freq\" corresponds to frequency information; \"pitch\" corresponds to pitch information."
},
{
- "type": ["string", "number"],
"description": "Data for the term/expression."
}
+ ],
+ "oneOf": [
+ {
+ "items": [
+ {},
+ {"enum": ["freq"]},
+ {
+ "type": ["string", "number"],
+ "description": "Frequency information for the term or expression."
+ }
+ ]
+ },
+ {
+ "items": [
+ {},
+ {"enum": ["pitch"]},
+ {
+ "type": ["object"],
+ "description": "Pitch accent information for the term or expression.",
+ "required": [
+ "reading",
+ "pitches"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "reading": {
+ "type": "string",
+ "description": "Reading for the term or expression."
+ },
+ "pitches": {
+ "type": "array",
+ "description": "List of different pitch accent information for the term and reading combination.",
+ "additionalItems": {
+ "type": "object",
+ "required": [
+ "position"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "position": {
+ "type": "integer",
+ "description": "Mora position of the pitch accent downstep. A value of 0 indicates that the word does not have a downstep (heiban).",
+ "minimum": 0
+ },
+ "tags": {
+ "type": "array",
+ "description": "List of tags for this pitch accent.",
+ "items": {
+ "type": "string",
+ "description": "Tag for this pitch accent. This typically corresponds to a certain type of part of speech."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+ }
]
}
} \ No newline at end of file
diff --git a/ext/bg/data/options-schema.json b/ext/bg/data/options-schema.json
index d6207952..da1f1ce0 100644
--- a/ext/bg/data/options-schema.json
+++ b/ext/bg/data/options-schema.json
@@ -105,7 +105,11 @@
"customPopupCss",
"customPopupOuterCss",
"enableWanakana",
- "enableClipboardMonitor"
+ "enableClipboardMonitor",
+ "showPitchAccentDownstepNotation",
+ "showPitchAccentPositionNotation",
+ "showPitchAccentGraph",
+ "showIframePopupsInRootFrame"
],
"properties": {
"enable": {
@@ -227,6 +231,22 @@
"enableClipboardMonitor": {
"type": "boolean",
"default": false
+ },
+ "showPitchAccentDownstepNotation": {
+ "type": "boolean",
+ "default": true
+ },
+ "showPitchAccentPositionNotation": {
+ "type": "boolean",
+ "default": true
+ },
+ "showPitchAccentGraph": {
+ "type": "boolean",
+ "default": false
+ },
+ "showIframePopupsInRootFrame": {
+ "type": "boolean",
+ "default": false
}
}
},
diff --git a/ext/bg/js/anki-note-builder.js b/ext/bg/js/anki-note-builder.js
index d0ff8205..244aaab8 100644
--- a/ext/bg/js/anki-note-builder.js
+++ b/ext/bg/js/anki-note-builder.js
@@ -17,11 +17,12 @@
*/
class AnkiNoteBuilder {
- constructor({renderTemplate}) {
+ constructor({audioSystem, renderTemplate}) {
+ this._audioSystem = audioSystem;
this._renderTemplate = renderTemplate;
}
- async createNote(definition, mode, options, templates) {
+ async createNote(definition, mode, context, options, templates) {
const isKanji = (mode === 'kanji');
const tags = options.anki.tags;
const modeOptions = isKanji ? options.anki.kanji : options.anki.terms;
@@ -35,7 +36,7 @@ class AnkiNoteBuilder {
};
for (const [fieldName, fieldValue] of modeOptionsFieldEntries) {
- note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, options, templates, null);
+ note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, context, options, templates, null);
}
if (!isKanji && definition.audio) {
@@ -60,7 +61,7 @@ class AnkiNoteBuilder {
return note;
}
- async formatField(field, definition, mode, options, templates, errors=null) {
+ async formatField(field, definition, mode, context, options, templates, errors=null) {
const data = {
marker: null,
definition,
@@ -69,7 +70,8 @@ class AnkiNoteBuilder {
modeTermKanji: mode === 'term-kanji',
modeTermKana: mode === 'term-kana',
modeKanji: mode === 'kanji',
- compactGlossaries: options.general.compactGlossaries
+ compactGlossaries: options.general.compactGlossaries,
+ context
};
const pattern = /\{([\w-]+)\}/g;
return await AnkiNoteBuilder.stringReplaceAsync(field, pattern, async (g0, marker) => {
@@ -83,6 +85,70 @@ class AnkiNoteBuilder {
});
}
+ async injectAudio(definition, fields, sources, optionsContext) {
+ if (!this._containsMarker(fields, 'audio')) { return; }
+
+ try {
+ const expressions = definition.expressions;
+ const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition;
+
+ const {uri} = await this._audioSystem.getDefinitionAudio(audioSourceDefinition, sources, {tts: false, optionsContext});
+ const filename = this._createInjectedAudioFileName(audioSourceDefinition);
+ if (filename !== null) {
+ definition.audio = {url: uri, filename};
+ }
+ } catch (e) {
+ // NOP
+ }
+ }
+
+ async injectScreenshot(definition, fields, screenshot, anki) {
+ if (!this._containsMarker(fields, 'screenshot')) { return; }
+
+ const now = new Date(Date.now());
+ const filename = `yomichan_browser_screenshot_${definition.reading}_${this._dateToString(now)}.${screenshot.format}`;
+ const data = screenshot.dataUrl.replace(/^data:[\w\W]*?,/, '');
+
+ try {
+ await anki.storeMediaFile(filename, data);
+ } catch (e) {
+ return;
+ }
+
+ definition.screenshotFileName = filename;
+ }
+
+ _createInjectedAudioFileName(definition) {
+ const {reading, expression} = definition;
+ if (!reading && !expression) { return null; }
+
+ let filename = 'yomichan';
+ if (reading) { filename += `_${reading}`; }
+ if (expression) { filename += `_${expression}`; }
+ filename += '.mp3';
+ return filename;
+ }
+
+ _dateToString(date) {
+ const year = date.getUTCFullYear();
+ const month = date.getUTCMonth().toString().padStart(2, '0');
+ const day = date.getUTCDate().toString().padStart(2, '0');
+ const hours = date.getUTCHours().toString().padStart(2, '0');
+ const minutes = date.getUTCMinutes().toString().padStart(2, '0');
+ const seconds = date.getUTCSeconds().toString().padStart(2, '0');
+ return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`;
+ }
+
+ _containsMarker(fields, marker) {
+ marker = `{${marker}}`;
+ for (const fieldValue of Object.values(fields)) {
+ if (fieldValue.includes(marker)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
static stringReplaceAsync(str, regex, replacer) {
let match;
let index = 0;
diff --git a/ext/bg/js/audio-uri-builder.js b/ext/bg/js/audio-uri-builder.js
index 499c3441..158006bb 100644
--- a/ext/bg/js/audio-uri-builder.js
+++ b/ext/bg/js/audio-uri-builder.js
@@ -17,7 +17,7 @@
*/
/* global
- * jpIsStringEntirelyKana
+ * jp
*/
class AudioUriBuilder {
@@ -66,7 +66,7 @@ class AudioUriBuilder {
let kana = definition.reading;
let kanji = definition.expression;
- if (!kana && jpIsStringEntirelyKana(kanji)) {
+ if (!kana && jp.isStringEntirelyKana(kanji)) {
kana = kanji;
kanji = null;
}
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js
index 978c5a4a..1fa7ede1 100644
--- a/ext/bg/js/backend.js
+++ b/ext/bg/js/backend.js
@@ -24,6 +24,8 @@
* AudioUriBuilder
* BackendApiForwarder
* ClipboardMonitor
+ * Database
+ * DictionaryImporter
* JsonSchema
* Mecab
* Translator
@@ -32,9 +34,7 @@
* dictEnabledSet
* dictTermsSort
* handlebarsRenderDynamic
- * jpConvertReading
- * jpDistributeFuriganaInflected
- * jpKatakanaToHiragana
+ * jp
* optionsLoad
* optionsSave
* profileConditionsDescriptor
@@ -45,16 +45,22 @@
class Backend {
constructor() {
- this.translator = new Translator();
+ this.database = new Database();
+ this.dictionaryImporter = new DictionaryImporter();
+ this.translator = new Translator(this.database);
this.anki = new AnkiNull();
this.mecab = new Mecab();
this.clipboardMonitor = new ClipboardMonitor({getClipboard: this._onApiClipboardGet.bind(this)});
- this.ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: this._renderTemplate.bind(this)});
this.options = null;
this.optionsSchema = null;
this.defaultAnkiFieldTemplates = null;
this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)});
this.audioUriBuilder = new AudioUriBuilder();
+ this.ankiNoteBuilder = new AnkiNoteBuilder({
+ audioSystem: this.audioSystem,
+ renderTemplate: this._renderTemplate.bind(this)
+ });
+
this.optionsContext = {
depth: 0,
url: window.location.href
@@ -109,6 +115,7 @@ class Backend {
}
async prepare() {
+ await this.database.prepare();
await this.translator.prepare();
this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET');
@@ -298,6 +305,10 @@ class Backend {
return true;
}
+ async importDictionary(archiveSource, onProgress, details) {
+ return await this.dictionaryImporter.import(this.database, archiveSource, onProgress, details);
+ }
+
// Message handlers
_onApiYomichanCoreReady(_params, sender) {
@@ -402,13 +413,13 @@ class Backend {
dictTermsSort(definitions);
const {expression, reading} = definitions[0];
const source = text.substring(0, sourceLength);
- for (const {text: text2, furigana} of jpDistributeFuriganaInflected(expression, reading, source)) {
- const reading2 = jpConvertReading(text2, furigana, options.parsing.readingMode);
+ for (const {text: text2, furigana} of jp.distributeFuriganaInflected(expression, reading, source)) {
+ const reading2 = jp.convertReading(text2, furigana, options.parsing.readingMode);
term.push({text: text2, reading: reading2});
}
text = text.substring(source.length);
} else {
- const reading = jpConvertReading(text[0], null, options.parsing.readingMode);
+ const reading = jp.convertReading(text[0], null, options.parsing.readingMode);
term.push({text: text[0], reading});
text = text.substring(1);
}
@@ -427,16 +438,16 @@ class Backend {
for (const {expression, reading, source} of parsedLine) {
const term = [];
if (expression !== null && reading !== null) {
- for (const {text: text2, furigana} of jpDistributeFuriganaInflected(
+ for (const {text: text2, furigana} of jp.distributeFuriganaInflected(
expression,
- jpKatakanaToHiragana(reading),
+ jp.convertKatakanaToHiragana(reading),
source
)) {
- const reading2 = jpConvertReading(text2, furigana, options.parsing.readingMode);
+ const reading2 = jp.convertReading(text2, furigana, options.parsing.readingMode);
term.push({text: text2, reading: reading2});
}
} else {
- const reading2 = jpConvertReading(source, null, options.parsing.readingMode);
+ const reading2 = jp.convertReading(source, null, options.parsing.readingMode);
term.push({text: source, reading: reading2});
}
result.push(term);
@@ -448,12 +459,12 @@ class Backend {
return results;
}
- async _onApiDefinitionAdd({definition, mode, context, optionsContext}) {
+ async _onApiDefinitionAdd({definition, mode, context, details, optionsContext}) {
const options = this.getOptions(optionsContext);
const templates = this.defaultAnkiFieldTemplates;
if (mode !== 'kanji') {
- await this._audioInject(
+ await this.ankiNoteBuilder.injectAudio(
definition,
options.anki.terms.fields,
options.audio.sources,
@@ -461,19 +472,20 @@ class Backend {
);
}
- if (context && context.screenshot) {
- await this._injectScreenshot(
+ if (details && details.screenshot) {
+ await this.ankiNoteBuilder.injectScreenshot(
definition,
options.anki.terms.fields,
- context.screenshot
+ details.screenshot,
+ this.anki
);
}
- const note = await this.ankiNoteBuilder.createNote(definition, mode, options, templates);
+ const note = await this.ankiNoteBuilder.createNote(definition, mode, context, options, templates);
return this.anki.addNote(note);
}
- async _onApiDefinitionsAddable({definitions, modes, optionsContext}) {
+ async _onApiDefinitionsAddable({definitions, modes, context, optionsContext}) {
const options = this.getOptions(optionsContext);
const templates = this.defaultAnkiFieldTemplates;
const states = [];
@@ -482,7 +494,7 @@ class Backend {
const notes = [];
for (const definition of definitions) {
for (const mode of modes) {
- const note = await this.ankiNoteBuilder.createNote(definition, mode, options, templates);
+ const note = await this.ankiNoteBuilder.createNote(definition, mode, context, options, templates);
notes.push(note);
}
}
@@ -793,86 +805,10 @@ class Backend {
return await this.audioUriBuilder.getUri(definition, source, options);
}
- async _audioInject(definition, fields, sources, optionsContext) {
- let usesAudio = false;
- for (const fieldValue of Object.values(fields)) {
- if (fieldValue.includes('{audio}')) {
- usesAudio = true;
- break;
- }
- }
-
- if (!usesAudio) {
- return true;
- }
-
- try {
- const expressions = definition.expressions;
- const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition;
-
- const {uri} = await this.audioSystem.getDefinitionAudio(audioSourceDefinition, sources, {tts: false, optionsContext});
- const filename = this._createInjectedAudioFileName(audioSourceDefinition);
- if (filename !== null) {
- definition.audio = {url: uri, filename};
- }
-
- return true;
- } catch (e) {
- return false;
- }
- }
-
- async _injectScreenshot(definition, fields, screenshot) {
- let usesScreenshot = false;
- for (const fieldValue of Object.values(fields)) {
- if (fieldValue.includes('{screenshot}')) {
- usesScreenshot = true;
- break;
- }
- }
-
- if (!usesScreenshot) {
- return;
- }
-
- const dateToString = (date) => {
- const year = date.getUTCFullYear();
- const month = date.getUTCMonth().toString().padStart(2, '0');
- const day = date.getUTCDate().toString().padStart(2, '0');
- const hours = date.getUTCHours().toString().padStart(2, '0');
- const minutes = date.getUTCMinutes().toString().padStart(2, '0');
- const seconds = date.getUTCSeconds().toString().padStart(2, '0');
- return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`;
- };
-
- const now = new Date(Date.now());
- const filename = `yomichan_browser_screenshot_${definition.reading}_${dateToString(now)}.${screenshot.format}`;
- const data = screenshot.dataUrl.replace(/^data:[\w\W]*?,/, '');
-
- try {
- await this.anki.storeMediaFile(filename, data);
- } catch (e) {
- return;
- }
-
- definition.screenshotFileName = filename;
- }
-
async _renderTemplate(template, data) {
return handlebarsRenderDynamic(template, data);
}
- _createInjectedAudioFileName(definition) {
- const {reading, expression} = definition;
- if (!reading && !expression) { return null; }
-
- let filename = 'yomichan';
- if (reading) { filename += `_${reading}`; }
- if (expression) { filename += `_${expression}`; }
- filename += '.mp3';
- return filename;
- }
-
static _getTabUrl(tab) {
return new Promise((resolve) => {
chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => {
diff --git a/ext/bg/js/clipboard-monitor.js b/ext/bg/js/clipboard-monitor.js
index 9a881f57..c67525fc 100644
--- a/ext/bg/js/clipboard-monitor.js
+++ b/ext/bg/js/clipboard-monitor.js
@@ -17,7 +17,7 @@
*/
/* global
- * jpIsStringPartiallyJapanese
+ * jp
*/
class ClipboardMonitor extends EventDispatcher {
@@ -54,7 +54,7 @@ class ClipboardMonitor extends EventDispatcher {
text !== this._previousText
) {
this._previousText = text;
- if (jpIsStringPartiallyJapanese(text)) {
+ if (jp.isStringPartiallyJapanese(text)) {
this.trigger('change', {text});
}
}
diff --git a/ext/bg/js/database.js b/ext/bg/js/database.js
index 08a2a39f..4a677fea 100644
--- a/ext/bg/js/database.js
+++ b/ext/bg/js/database.js
@@ -99,7 +99,7 @@ class Database {
});
return true;
} catch (e) {
- console.error(e);
+ logError(e);
return false;
}
}
@@ -110,6 +110,10 @@ class Database {
this.db = null;
}
+ isPrepared() {
+ return this.db !== null;
+ }
+
async purge() {
this._validate();
@@ -322,177 +326,44 @@ class Database {
return result;
}
- async importDictionary(archiveSource, onProgress, details) {
+ async dictionaryExists(title) {
this._validate();
- const db = this.db;
- const hasOnProgress = (typeof onProgress === 'function');
-
- // Read archive
- const archive = await JSZip.loadAsync(archiveSource);
-
- // Read and validate index
- const indexFileName = 'index.json';
- const indexFile = archive.files[indexFileName];
- if (!indexFile) {
- throw new Error('No dictionary index found in archive');
- }
-
- const index = JSON.parse(await indexFile.async('string'));
-
- const indexSchema = await this._getSchema('/bg/data/dictionary-index-schema.json');
- Database._validateJsonSchema(index, indexSchema, indexFileName);
-
- const dictionaryTitle = index.title;
- const version = index.format || index.version;
-
- if (!dictionaryTitle || !index.revision) {
- throw new Error('Unrecognized dictionary format');
- }
-
- // Verify database is not already imported
- if (await this._dictionaryExists(dictionaryTitle)) {
- throw new Error('Dictionary is already imported');
- }
-
- // 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 {
- const [expression, reading, definitionTags, rules, score, glossary, sequence, termTags] = entry;
- return {expression, reading, definitionTags, rules, score, glossary, sequence, termTags};
- }
- };
-
- const convertTermMetaBankEntry = (entry) => {
- const [expression, mode, data] = entry;
- return {expression, mode, data};
- };
-
- 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};
- }
- };
-
- const convertKanjiMetaBankEntry = (entry) => {
- const [character, mode, data] = entry;
- return {character, mode, data};
- };
-
- const convertTagBankEntry = (entry) => {
- const [name, category, order, notes, score] = entry;
- return {name, category, order, notes, score};
- };
+ const transaction = this.db.transaction(['dictionaries'], 'readonly');
+ const index = transaction.objectStore('dictionaries').index('title');
+ const query = IDBKeyRange.only(title);
+ const count = await Database._getCount(index, query);
+ return count > 0;
+ }
- // 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);
- }
- }
- return results;
- };
+ bulkAdd(objectStoreName, items, start, count) {
+ return new Promise((resolve, reject) => {
+ const transaction = this.db.transaction([objectStoreName], 'readwrite');
+ const objectStore = transaction.objectStore(objectStoreName);
- // 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});
+ if (start + count > items.length) {
+ count = items.length - start;
}
- }
- // Prefix wildcard support
- const prefixWildcardsSupported = !!details.prefixWildcardsSupported;
- if (prefixWildcardsSupported) {
- for (const entry of termList) {
- entry.expressionReverse = stringReverse(entry.expression);
- entry.readingReverse = stringReverse(entry.reading);
+ if (count <= 0) {
+ resolve();
+ return;
}
- }
-
- // Add dictionary
- const summary = {
- title: dictionaryTitle,
- revision: index.revision,
- sequenced: index.sequenced,
- version,
- prefixWildcardsSupported
- };
-
- {
- 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);
+ const end = start + count;
+ let completedCount = 0;
+ const onError = (e) => reject(e);
+ const onSuccess = () => {
+ if (++completedCount >= count) {
+ resolve();
}
+ };
- loadedCount += count;
- if (hasOnProgress) {
- onProgress(total, loadedCount);
- }
+ for (let i = start; i < end; ++i) {
+ const request = objectStore.add(items[i]);
+ request.onerror = onError;
+ request.onsuccess = onSuccess;
}
- };
-
- await bulkAdd('terms', termList);
- await bulkAdd('termMeta', termMetaList);
- await bulkAdd('kanji', kanjiList);
- await bulkAdd('kanjiMeta', kanjiMetaList);
- await bulkAdd('tagMeta', tagList);
-
- return {result: summary, errors};
+ });
}
// Private
@@ -503,80 +374,6 @@ class Database {
}
}
- async _getSchema(fileName) {
- let schemaPromise = this._schemas.get(fileName);
- if (typeof schemaPromise !== 'undefined') {
- return schemaPromise;
- }
-
- 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);
- }
- }
-
- static _formatSchemaError(e, fileName) {
- const valuePathString = Database._getSchemaErrorPathString(e.info.valuePath, 'dictionary');
- const schemaPathString = Database._getSchemaErrorPathString(e.info.schemaPath, 'schema');
-
- const e2 = new Error(`Dictionary has invalid data in '${fileName}' for value '${valuePathString}', validated against '${schemaPathString}': ${e.message}`);
- e2.data = e;
-
- return e2;
- }
-
- 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;
- }
-
- 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';
-
- return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank];
- }
-
- 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;
- }
-
async _findGenericBulk(tableName, indexName, indexValueList, dictionaries, createResult) {
this._validate();
@@ -760,34 +557,6 @@ class Database {
});
}
- static _bulkAdd(objectStore, items, start, count) {
- return new Promise((resolve, reject) => {
- if (start + count > items.length) {
- count = items.length - start;
- }
-
- if (count <= 0) {
- resolve();
- return;
- }
-
- const end = start + count;
- let completedCount = 0;
- const onError = (e) => reject(e);
- const onSuccess = () => {
- if (++completedCount >= count) {
- resolve();
- }
- };
-
- for (let i = start; i < end; ++i) {
- const request = objectStore.add(items[i]);
- request.onerror = onError;
- request.onsuccess = onSuccess;
- }
- });
- }
-
static _open(name, version, onUpgradeNeeded) {
return new Promise((resolve, reject) => {
const request = window.indexedDB.open(name, version * 10);
diff --git a/ext/bg/js/dictionary-importer.js b/ext/bg/js/dictionary-importer.js
new file mode 100644
index 00000000..254fde4f
--- /dev/null
+++ b/ext/bg/js/dictionary-importer.js
@@ -0,0 +1,279 @@
+/*
+ * 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
+ * JSZip
+ * JsonSchema
+ * requestJson
+ */
+
+class DictionaryImporter {
+ constructor() {
+ this._schemas = new Map();
+ }
+
+ async import(database, archiveSource, onProgress, details) {
+ if (!database) {
+ throw new Error('Invalid database');
+ }
+ if (!database.isPrepared()) {
+ throw new Error('Database is not ready');
+ }
+
+ const hasOnProgress = (typeof onProgress === 'function');
+
+ // Read archive
+ const archive = await JSZip.loadAsync(archiveSource);
+
+ // Read and validate index
+ const indexFileName = 'index.json';
+ const indexFile = archive.files[indexFileName];
+ if (!indexFile) {
+ throw new Error('No dictionary index found in archive');
+ }
+
+ const index = JSON.parse(await indexFile.async('string'));
+
+ const indexSchema = await this._getSchema('/bg/data/dictionary-index-schema.json');
+ this._validateJsonSchema(index, indexSchema, indexFileName);
+
+ const dictionaryTitle = index.title;
+ const version = index.format || index.version;
+
+ if (!dictionaryTitle || !index.revision) {
+ throw new Error('Unrecognized dictionary format');
+ }
+
+ // Verify database is not already imported
+ if (await database.dictionaryExists(dictionaryTitle)) {
+ throw new Error('Dictionary is already imported');
+ }
+
+ // 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 {
+ const [expression, reading, definitionTags, rules, score, glossary, sequence, termTags] = entry;
+ return {expression, reading, definitionTags, rules, score, glossary, sequence, termTags};
+ }
+ };
+
+ const convertTermMetaBankEntry = (entry) => {
+ const [expression, mode, data] = entry;
+ return {expression, mode, data};
+ };
+
+ 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};
+ }
+ };
+
+ const convertKanjiMetaBankEntry = (entry) => {
+ const [character, mode, data] = entry;
+ return {character, mode, data};
+ };
+
+ 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'));
+ this._validateJsonSchema(entries, schema, fileName);
+
+ for (let entry of entries) {
+ entry = convertEntry(entry);
+ entry.dictionary = dictionaryTitle;
+ results.push(entry);
+ }
+ }
+ return results;
+ };
+
+ // Load schemas
+ const dataBankSchemaPaths = this._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});
+ }
+ }
+
+ // Prefix wildcard support
+ const prefixWildcardsSupported = !!details.prefixWildcardsSupported;
+ if (prefixWildcardsSupported) {
+ for (const entry of termList) {
+ entry.expressionReverse = stringReverse(entry.expression);
+ entry.readingReverse = stringReverse(entry.reading);
+ }
+ }
+
+ // Add dictionary
+ const summary = this._createSummary(dictionaryTitle, version, index, {prefixWildcardsSupported});
+
+ database.bulkAdd('dictionaries', [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 {
+ await database.bulkAdd(objectStoreName, entries, i, count);
+ } catch (e) {
+ errors.push(errorToJson(e));
+ }
+
+ 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};
+ }
+
+ _createSummary(dictionaryTitle, version, index, details) {
+ const summary = {
+ title: dictionaryTitle,
+ revision: index.revision,
+ sequenced: index.sequenced,
+ version
+ };
+
+ const {author, url, description, attribution} = index;
+ if (typeof author === 'string') { summary.author = author; }
+ if (typeof url === 'string') { summary.url = url; }
+ if (typeof description === 'string') { summary.description = description; }
+ if (typeof attribution === 'string') { summary.attribution = attribution; }
+
+ Object.assign(summary, details);
+
+ return summary;
+ }
+
+ async _getSchema(fileName) {
+ let schemaPromise = this._schemas.get(fileName);
+ if (typeof schemaPromise !== 'undefined') {
+ return schemaPromise;
+ }
+
+ schemaPromise = requestJson(chrome.runtime.getURL(fileName), 'GET');
+ this._schemas.set(fileName, schemaPromise);
+ return schemaPromise;
+ }
+
+ _validateJsonSchema(value, schema, fileName) {
+ try {
+ JsonSchema.validate(value, schema);
+ } catch (e) {
+ throw this._formatSchemaError(e, fileName);
+ }
+ }
+
+ _formatSchemaError(e, fileName) {
+ const valuePathString = this._getSchemaErrorPathString(e.info.valuePath, 'dictionary');
+ const schemaPathString = this._getSchemaErrorPathString(e.info.schemaPath, 'schema');
+
+ const e2 = new Error(`Dictionary has invalid data in '${fileName}' for value '${valuePathString}', validated against '${schemaPathString}': ${e.message}`);
+ e2.data = e;
+
+ return e2;
+ }
+
+ _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;
+ }
+
+ _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';
+
+ return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank];
+ }
+}
diff --git a/ext/bg/js/dictionary.js b/ext/bg/js/dictionary.js
index 3dd1d0c1..74bd5a64 100644
--- a/ext/bg/js/dictionary.js
+++ b/ext/bg/js/dictionary.js
@@ -137,30 +137,6 @@ 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 sequencedDefinitions = new Map();
const nonSequencedDefinitions = [];
@@ -281,11 +257,11 @@ function dictTermsMergeByGloss(result, definitions, appendTo=null, mergedIndices
const only = [];
const expressionSet = definition.expression;
const readingSet = definition.reading;
- if (!dictAreSetsEqual(expressionSet, resultExpressionSet)) {
- only.push(...dictGetSetIntersection(expressionSet, resultExpressionSet));
+ if (!areSetsEqual(expressionSet, resultExpressionSet)) {
+ only.push(...getSetIntersection(expressionSet, resultExpressionSet));
}
- if (!dictAreSetsEqual(readingSet, resultReadingSet)) {
- only.push(...dictGetSetIntersection(readingSet, resultReadingSet));
+ if (!areSetsEqual(readingSet, resultReadingSet)) {
+ only.push(...getSetIntersection(readingSet, resultReadingSet));
}
definition.only = only;
}
diff --git a/ext/bg/js/handlebars.js b/ext/bg/js/handlebars.js
index e3ce6bd0..5fda5baa 100644
--- a/ext/bg/js/handlebars.js
+++ b/ext/bg/js/handlebars.js
@@ -18,8 +18,7 @@
/* global
* Handlebars
- * jpDistributeFurigana
- * jpIsCodePointKanji
+ * jp
*/
function handlebarsEscape(text) {
@@ -33,7 +32,7 @@ function handlebarsDumpObject(options) {
function handlebarsFurigana(options) {
const definition = options.fn(this);
- const segs = jpDistributeFurigana(definition.expression, definition.reading);
+ const segs = jp.distributeFurigana(definition.expression, definition.reading);
let result = '';
for (const seg of segs) {
@@ -49,7 +48,7 @@ function handlebarsFurigana(options) {
function handlebarsFuriganaPlain(options) {
const definition = options.fn(this);
- const segs = jpDistributeFurigana(definition.expression, definition.reading);
+ const segs = jp.distributeFurigana(definition.expression, definition.reading);
let result = '';
for (const seg of segs) {
@@ -66,7 +65,7 @@ function handlebarsFuriganaPlain(options) {
function handlebarsKanjiLinks(options) {
let result = '';
for (const c of options.fn(this)) {
- if (jpIsCodePointKanji(c.codePointAt(0))) {
+ if (jp.isCodePointKanji(c.codePointAt(0))) {
result += `<a href="#" class="kanji-link">${c}</a>`;
} else {
result += c;
diff --git a/ext/bg/js/japanese.js b/ext/bg/js/japanese.js
index 3b37754d..2a2b39fd 100644
--- a/ext/bg/js/japanese.js
+++ b/ext/bg/js/japanese.js
@@ -17,442 +17,373 @@
*/
/* global
+ * jp
* 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 _jpIsCodePointInRanges(codePoint, ranges) {
- for (const [min, max] of ranges) {
- if (codePoint >= min && codePoint <= max) {
- return true;
+(() => {
+ const HALFWIDTH_KATAKANA_MAPPING = new Map([
+ ['ヲ', 'ヲヺ-'],
+ ['ァ', 'ァ--'],
+ ['ィ', 'ィ--'],
+ ['ゥ', 'ゥ--'],
+ ['ェ', 'ェ--'],
+ ['ォ', 'ォ--'],
+ ['ャ', 'ャ--'],
+ ['ュ', 'ュ--'],
+ ['ョ', 'ョ--'],
+ ['ッ', 'ッ--'],
+ ['ー', 'ー--'],
+ ['ア', 'ア--'],
+ ['イ', 'イ--'],
+ ['ウ', 'ウヴ-'],
+ ['エ', 'エ--'],
+ ['オ', 'オ--'],
+ ['カ', 'カガ-'],
+ ['キ', 'キギ-'],
+ ['ク', 'クグ-'],
+ ['ケ', 'ケゲ-'],
+ ['コ', 'コゴ-'],
+ ['サ', 'サザ-'],
+ ['シ', 'シジ-'],
+ ['ス', 'スズ-'],
+ ['セ', 'セゼ-'],
+ ['ソ', 'ソゾ-'],
+ ['タ', 'タダ-'],
+ ['チ', 'チヂ-'],
+ ['ツ', 'ツヅ-'],
+ ['テ', 'テデ-'],
+ ['ト', 'トド-'],
+ ['ナ', 'ナ--'],
+ ['ニ', 'ニ--'],
+ ['ヌ', 'ヌ--'],
+ ['ネ', 'ネ--'],
+ ['ノ', 'ノ--'],
+ ['ハ', 'ハバパ'],
+ ['ヒ', 'ヒビピ'],
+ ['フ', 'フブプ'],
+ ['ヘ', 'ヘベペ'],
+ ['ホ', 'ホボポ'],
+ ['マ', 'マ--'],
+ ['ミ', 'ミ--'],
+ ['ム', 'ム--'],
+ ['メ', 'メ--'],
+ ['モ', 'モ--'],
+ ['ヤ', 'ヤ--'],
+ ['ユ', 'ユ--'],
+ ['ヨ', 'ヨ--'],
+ ['ラ', 'ラ--'],
+ ['リ', 'リ--'],
+ ['ル', 'ル--'],
+ ['レ', 'レ--'],
+ ['ロ', 'ロ--'],
+ ['ワ', 'ワ--'],
+ ['ン', 'ン--']
+ ]);
+
+ const ITERATION_MARK_CODE_POINT = 0x3005;
+
+
+ // Existing functions
+
+ const isCodePointKanji = jp.isCodePointKanji;
+ const isStringEntirelyKana = jp.isStringEntirelyKana;
+
+
+ // Conversion functions
+
+ function convertKatakanaToHiragana(text) {
+ let result = '';
+ for (const c of text) {
+ if (wanakana.isKatakana(c)) {
+ result += wanakana.toHiragana(c);
+ } else {
+ result += c;
+ }
}
- }
- return false;
-}
-
-
-// Character code testing functions
-function jpIsCodePointKanji(codePoint) {
- return _jpIsCodePointInRanges(codePoint, JP_CJK_RANGES);
-}
-
-function jpIsCodePointKana(codePoint) {
- return _jpIsCodePointInRanges(codePoint, JP_KANA_RANGES);
-}
+ return result;
+ }
-function jpIsCodePointJapanese(codePoint) {
- return _jpIsCodePointInRanges(codePoint, JP_JAPANESE_RANGES);
-}
+ function convertHiraganaToKatakana(text) {
+ let result = '';
+ for (const c of text) {
+ if (wanakana.isHiragana(c)) {
+ result += wanakana.toKatakana(c);
+ } else {
+ result += c;
+ }
+ }
+ return result;
+ }
-// String testing functions
+ function convertToRomaji(text) {
+ return wanakana.toRomaji(text);
+ }
-function jpIsStringEntirelyKana(str) {
- if (str.length === 0) { return false; }
- for (const c of str) {
- if (!jpIsCodePointKana(c.codePointAt(0))) {
- return false;
+ function convertReading(expressionFragment, readingFragment, readingMode) {
+ switch (readingMode) {
+ case 'hiragana':
+ return convertKatakanaToHiragana(readingFragment || '');
+ case 'katakana':
+ return convertHiraganaToKatakana(readingFragment || '');
+ case 'romaji':
+ if (readingFragment) {
+ return convertToRomaji(readingFragment);
+ } else {
+ if (isStringEntirelyKana(expressionFragment)) {
+ return convertToRomaji(expressionFragment);
+ }
+ }
+ return readingFragment;
+ case 'none':
+ return null;
+ default:
+ return readingFragment;
}
}
- return true;
-}
-
-function jpIsStringPartiallyJapanese(str) {
- if (str.length === 0) { return false; }
- for (const c of str) {
- if (jpIsCodePointJapanese(c.codePointAt(0))) {
- return true;
+
+ function convertNumericToFullWidth(text) {
+ let result = '';
+ for (const char of text) {
+ let c = char.codePointAt(0);
+ if (c >= 0x30 && c <= 0x39) { // ['0', '9']
+ c += 0xff10 - 0x30; // 0xff10 = '0' full width
+ result += String.fromCodePoint(c);
+ } else {
+ result += char;
+ }
}
+ return result;
}
- return false;
-}
+ function convertHalfWidthKanaToFullWidth(text, sourceMap=null) {
+ let result = '';
+
+ // This function is safe to use charCodeAt instead of codePointAt, since all
+ // the relevant characters are represented with a single UTF-16 character code.
+ for (let i = 0, ii = text.length; i < ii; ++i) {
+ const c = text[i];
+ const mapping = HALFWIDTH_KATAKANA_MAPPING.get(c);
+ if (typeof mapping !== 'string') {
+ result += c;
+ continue;
+ }
-// Conversion functions
-
-function jpKatakanaToHiragana(text) {
- let result = '';
- for (const c of text) {
- if (wanakana.isKatakana(c)) {
- result += wanakana.toHiragana(c);
- } else {
- result += c;
- }
- }
+ let index = 0;
+ switch (text.charCodeAt(i + 1)) {
+ case 0xff9e: // dakuten
+ index = 1;
+ break;
+ case 0xff9f: // handakuten
+ index = 2;
+ break;
+ }
- return result;
-}
+ let c2 = mapping[index];
+ if (index > 0) {
+ if (c2 === '-') { // invalid
+ index = 0;
+ c2 = mapping[0];
+ } else {
+ ++i;
+ }
+ }
-function jpHiraganaToKatakana(text) {
- let result = '';
- for (const c of text) {
- if (wanakana.isHiragana(c)) {
- result += wanakana.toKatakana(c);
- } else {
- result += c;
+ if (sourceMap !== null && index > 0) {
+ sourceMap.combine(result.length, 1);
+ }
+ result += c2;
}
+
+ return result;
}
- 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);
+ function convertAlphabeticToKana(text, sourceMap=null) {
+ let part = '';
+ let result = '';
+
+ for (const char of text) {
+ // Note: 0x61 is the character code for 'a'
+ let c = char.codePointAt(0);
+ if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z']
+ c += (0x61 - 0x41);
+ } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z']
+ // NOP; c += (0x61 - 0x61);
+ } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth
+ c += (0x61 - 0xff21);
+ } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth
+ c += (0x61 - 0xff41);
+ } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash
+ c = 0x2d; // '-'
} else {
- if (jpIsStringEntirelyKana(expressionFragment)) {
- return jpToRomaji(expressionFragment);
+ if (part.length > 0) {
+ result += convertAlphabeticPartToKana(part, sourceMap, result.length);
+ part = '';
}
+ result += char;
+ continue;
}
- return readingFragment;
- case 'none':
- return null;
- default:
- return readingFragment;
- }
-}
-
-function jpDistributeFurigana(expression, reading) {
- const fallback = [{furigana: reading, text: expression}];
- if (!reading) {
- return fallback;
- }
+ part += String.fromCodePoint(c);
+ }
- let isAmbiguous = false;
- const segmentize = (reading2, groups) => {
- if (groups.length === 0 || isAmbiguous) {
- return [];
+ if (part.length > 0) {
+ result += convertAlphabeticPartToKana(part, sourceMap, result.length);
}
+ return result;
+ }
- 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;
+ function convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) {
+ const result = wanakana.toHiragana(text);
+
+ // Generate source mapping
+ if (sourceMap !== null) {
+ let i = 0;
+ let resultPos = 0;
+ const ii = text.length;
+ while (i < ii) {
+ // Find smallest matching substring
+ let iNext = i + 1;
+ let resultPosNext = result.length;
+ while (iNext < ii) {
+ const t = wanakana.toHiragana(text.substring(0, iNext));
+ if (t === result.substring(0, t.length)) {
+ resultPosNext = t.length;
+ break;
}
- foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs);
+ ++iNext;
}
- // there is only one way to segmentize the last non-kana group
- if (groups.length === 1) {
- break;
+
+ // Merge characters
+ const removals = iNext - i - 1;
+ if (removals > 0) {
+ sourceMap.combine(sourceMapStart, removals);
+ }
+ ++sourceMapStart;
+
+ // Empty elements
+ const additions = resultPosNext - resultPos - 1;
+ for (let j = 0; j < additions; ++j) {
+ sourceMap.insert(sourceMapStart, 0);
+ ++sourceMapStart;
}
+
+ i = iNext;
+ resultPos = resultPosNext;
}
- return foundSegments;
- }
- };
-
- const groups = [];
- let modePrev = null;
- for (const c of expression) {
- const codePoint = c.codePointAt(0);
- const modeCurr = jpIsCodePointKanji(codePoint) || codePoint === 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);
+ return result;
}
- if (stemLength !== source.length) {
- output.push({text: source.substring(stemLength)});
- }
- return output;
-}
-
-function jpConvertHalfWidthKanaToFullWidth(text, sourceMapping) {
- let result = '';
- const hasSourceMapping = Array.isArray(sourceMapping);
-
- // This function is safe to use charCodeAt instead of codePointAt, since all
- // the relevant characters are represented with a single UTF-16 character code.
- for (let i = 0, ii = text.length; i < ii; ++i) {
- const c = text[i];
- const mapping = JP_HALFWIDTH_KATAKANA_MAPPING.get(c);
- if (typeof mapping !== 'string') {
- result += c;
- continue;
- }
+ // Furigana distribution
- let index = 0;
- switch (text.charCodeAt(i + 1)) {
- case 0xff9e: // dakuten
- index = 1;
- break;
- case 0xff9f: // handakuten
- index = 2;
- break;
+ function distributeFurigana(expression, reading) {
+ const fallback = [{furigana: reading, text: expression}];
+ if (!reading) {
+ return fallback;
}
- let c2 = mapping[index];
- if (index > 0) {
- if (c2 === '-') { // invalid
- index = 0;
- c2 = mapping[0];
- } else {
- ++i;
+ let isAmbiguous = false;
+ const segmentize = (reading2, groups) => {
+ if (groups.length === 0 || isAmbiguous) {
+ return [];
}
- }
- if (hasSourceMapping && index > 0) {
- index = result.length;
- const v = sourceMapping.splice(index + 1, 1)[0];
- sourceMapping[index] += v;
+ const group = groups[0];
+ if (group.mode === 'kana') {
+ if (convertKatakanaToHiragana(reading2).startsWith(convertKatakanaToHiragana(group.text))) {
+ const readingLeft = reading2.substring(group.text.length);
+ const segs = segmentize(readingLeft, groups.splice(1));
+ if (segs) {
+ return [{text: group.text}].concat(segs);
+ }
+ }
+ } else {
+ let foundSegments = null;
+ for (let i = reading2.length; i >= group.text.length; --i) {
+ const readingUsed = reading2.substring(0, i);
+ const readingLeft = reading2.substring(i);
+ const segs = segmentize(readingLeft, groups.slice(1));
+ if (segs) {
+ if (foundSegments !== null) {
+ // more than one way to segmentize the tail, mark as ambiguous
+ isAmbiguous = true;
+ return null;
+ }
+ foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs);
+ }
+ // there is only one way to segmentize the last non-kana group
+ if (groups.length === 1) {
+ break;
+ }
+ }
+ return foundSegments;
+ }
+ };
+
+ const groups = [];
+ let modePrev = null;
+ for (const c of expression) {
+ const codePoint = c.codePointAt(0);
+ const modeCurr = isCodePointKanji(codePoint) || codePoint === ITERATION_MARK_CODE_POINT ? 'kanji' : 'kana';
+ if (modeCurr === modePrev) {
+ groups[groups.length - 1].text += c;
+ } else {
+ groups.push({mode: modeCurr, text: c});
+ modePrev = modeCurr;
+ }
}
- result += c2;
- }
- return result;
-}
-
-function jpConvertNumericTofullWidth(text) {
- let result = '';
- for (const char of text) {
- let c = char.codePointAt(0);
- if (c >= 0x30 && c <= 0x39) { // ['0', '9']
- c += 0xff10 - 0x30; // 0xff10 = '0' full width
- result += String.fromCodePoint(c);
- } else {
- result += char;
+ const segments = segmentize(reading, groups);
+ if (segments && !isAmbiguous) {
+ return segments;
}
+ return fallback;
}
- return result;
-}
-function jpConvertAlphabeticToKana(text, sourceMapping) {
- let part = '';
- let result = '';
- const ii = text.length;
+ function distributeFuriganaInflected(expression, reading, source) {
+ const output = [];
- if (sourceMapping.length === ii) {
- sourceMapping.length = ii;
- sourceMapping.fill(1);
- }
+ let stemLength = 0;
+ const shortest = Math.min(source.length, expression.length);
+ const sourceHiragana = convertKatakanaToHiragana(source);
+ const expressionHiragana = convertKatakanaToHiragana(expression);
+ while (stemLength < shortest && sourceHiragana[stemLength] === expressionHiragana[stemLength]) {
+ ++stemLength;
+ }
+ const offset = source.length - stemLength;
+
+ const stemExpression = source.substring(0, source.length - offset);
+ const stemReading = reading.substring(
+ 0,
+ offset === 0 ? reading.length : reading.length - expression.length + stemLength
+ );
+ for (const segment of distributeFurigana(stemExpression, stemReading)) {
+ output.push(segment);
+ }
- for (const char of text) {
- // Note: 0x61 is the character code for 'a'
- let c = char.codePointAt(0);
- if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z']
- c += (0x61 - 0x41);
- } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z']
- // NOP; c += (0x61 - 0x61);
- } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth
- c += (0x61 - 0xff21);
- } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth
- c += (0x61 - 0xff41);
- } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash
- c = 0x2d; // '-'
- } else {
- if (part.length > 0) {
- result += jpToHiragana(part, sourceMapping, result.length);
- part = '';
- }
- result += char;
- continue;
+ if (stemLength !== source.length) {
+ output.push({text: source.substring(stemLength)});
}
- part += String.fromCodePoint(c);
- }
- if (part.length > 0) {
- result += jpToHiragana(part, sourceMapping, result.length);
+ return output;
}
- 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;
-}
+ // Exports
+
+ Object.assign(jp, {
+ convertKatakanaToHiragana,
+ convertHiraganaToKatakana,
+ convertToRomaji,
+ convertReading,
+ convertNumericToFullWidth,
+ convertHalfWidthKanaToFullWidth,
+ convertAlphabeticToKana,
+ distributeFurigana,
+ distributeFuriganaInflected
+ });
+})();
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index bd0bbe0e..abb054d4 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -91,6 +91,15 @@ const profileOptionsVersionUpdates = [
if (utilStringHashCode(options.anki.fieldTemplates) === 1444379824) {
options.anki.fieldTemplates = null;
}
+ },
+ (options) => {
+ // Version 13 changes:
+ // Default anki field tempaltes updated to include {document-title}.
+ let fieldTemplates = options.anki.fieldTemplates;
+ if (typeof fieldTemplates === 'string') {
+ fieldTemplates += '\n\n{{#*inline "document-title"}}\n {{~context.document.title~}}\n{{/inline}}';
+ options.anki.fieldTemplates = fieldTemplates;
+ }
}
];
@@ -124,7 +133,11 @@ function profileOptionsCreateDefaults() {
customPopupCss: '',
customPopupOuterCss: '',
enableWanakana: true,
- enableClipboardMonitor: false
+ enableClipboardMonitor: false,
+ showPitchAccentDownstepNotation: true,
+ showPitchAccentPositionNotation: true,
+ showPitchAccentGraph: false,
+ showIframePopupsInRootFrame: false
},
audio: {
diff --git a/ext/bg/js/search-frontend.js b/ext/bg/js/search-frontend.js
index a470e873..f130a6fa 100644
--- a/ext/bg/js/search-frontend.js
+++ b/ext/bg/js/search-frontend.js
@@ -30,16 +30,12 @@ async function searchFrontendSetup() {
const options = await apiOptionsGet(optionsContext);
if (!options.scanning.enableOnSearchPage) { return; }
- const ignoreNodes = ['.scan-disable', '.scan-disable *'];
- if (!options.scanning.enableOnPopupExpressions) {
- ignoreNodes.push('.source-text', '.source-text *');
- }
-
- window.frontendInitializationData = {depth: 1, ignoreNodes, proxy: false};
+ window.frontendInitializationData = {depth: 1, proxy: false};
const scriptSrcs = [
'/mixed/js/text-scanner.js',
'/fg/js/frontend-api-receiver.js',
+ '/fg/js/frame-offset-forwarder.js',
'/fg/js/popup.js',
'/fg/js/popup-proxy-host.js',
'/fg/js/frontend.js',
diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js
index 06316ce2..9f59f2e5 100644
--- a/ext/bg/js/search-query-parser.js
+++ b/ext/bg/js/search-query-parser.js
@@ -27,12 +27,14 @@
*/
class QueryParser extends TextScanner {
- constructor(search) {
- super(document.querySelector('#query-parser-content'), [], [], []);
- this.search = search;
+ constructor({getOptionsContext, setContent, setSpinnerVisible}) {
+ super(document.querySelector('#query-parser-content'), [], []);
+
+ this.getOptionsContext = getOptionsContext;
+ this.setContent = setContent;
+ this.setSpinnerVisible = setSpinnerVisible;
this.parseResults = [];
- this.selectedParser = null;
this.queryParser = document.querySelector('#query-parser-content');
this.queryParserSelect = document.querySelector('#query-parser-select-container');
@@ -56,18 +58,18 @@ class QueryParser extends TextScanner {
async onSearchSource(textSource, cause) {
if (textSource === null) { return null; }
- this.setTextSourceScanLength(textSource, this.search.options.scanning.length);
+ this.setTextSourceScanLength(textSource, this.options.scanning.length);
const searchText = textSource.text();
if (searchText.length === 0) { return; }
- const {definitions, length} = await apiTermsFind(searchText, {}, this.search.getOptionsContext());
+ const {definitions, length} = await apiTermsFind(searchText, {}, this.getOptionsContext());
if (definitions.length === 0) { return null; }
- const sentence = docSentenceExtract(textSource, this.search.options.anki.sentenceExt);
+ const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt);
textSource.setEndOffset(length);
- this.search.setContent('terms', {definitions, context: {
+ this.setContent('terms', {definitions, context: {
focus: false,
disableHistory: cause === 'mouse',
sentence,
@@ -79,9 +81,7 @@ class QueryParser extends TextScanner {
onParserChange(e) {
const selectedParser = e.target.value;
- this.selectedParser = selectedParser;
- apiOptionsSet({parsing: {selectedParser}}, this.search.getOptionsContext());
- this.renderParseResult();
+ apiOptionsSet({parsing: {selectedParser}}, this.getOptionsContext());
}
getMouseEventListeners() {
@@ -112,23 +112,20 @@ class QueryParser extends TextScanner {
refreshSelectedParser() {
if (this.parseResults.length > 0) {
- if (this.selectedParser === null) {
- this.selectedParser = this.search.options.parsing.selectedParser;
- }
- if (this.selectedParser === null || !this.getParseResult()) {
+ if (!this.getParseResult()) {
const selectedParser = this.parseResults[0].id;
- this.selectedParser = selectedParser;
- apiOptionsSet({parsing: {selectedParser}}, this.search.getOptionsContext());
+ apiOptionsSet({parsing: {selectedParser}}, this.getOptionsContext());
}
}
}
getParseResult() {
- return this.parseResults.find((r) => r.id === this.selectedParser);
+ const {selectedParser} = this.options.parsing;
+ return this.parseResults.find((r) => r.id === selectedParser);
}
async setText(text) {
- this.search.setSpinnerVisible(true);
+ this.setSpinnerVisible(true);
this.setPreview(text);
@@ -138,20 +135,20 @@ class QueryParser extends TextScanner {
this.renderParserSelect();
this.renderParseResult();
- this.search.setSpinnerVisible(false);
+ this.setSpinnerVisible(false);
}
async parseText(text) {
const results = [];
- if (this.search.options.parsing.enableScanningParser) {
+ if (this.options.parsing.enableScanningParser) {
results.push({
name: 'Scanning parser',
id: 'scan',
- parsedText: await apiTextParse(text, this.search.getOptionsContext())
+ parsedText: await apiTextParse(text, this.getOptionsContext())
});
}
- if (this.search.options.parsing.enableMecabParser) {
- const mecabResults = await apiTextParseMecab(text, this.search.getOptionsContext());
+ if (this.options.parsing.enableMecabParser) {
+ const mecabResults = await apiTextParseMecab(text, this.getOptionsContext());
for (const [mecabDictName, mecabDictResults] of mecabResults) {
results.push({
name: `MeCab: ${mecabDictName}`,
@@ -176,7 +173,8 @@ class QueryParser extends TextScanner {
renderParserSelect() {
this.queryParserSelect.textContent = '';
if (this.parseResults.length > 1) {
- const select = this.queryParserGenerator.createParserSelect(this.parseResults, this.selectedParser);
+ const {selectedParser} = this.options.parsing;
+ const select = this.queryParserGenerator.createParserSelect(this.parseResults, selectedParser);
select.addEventListener('change', this.onParserChange.bind(this));
this.queryParserSelect.appendChild(select);
}
diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js
index e2bdff73..9250fdde 100644
--- a/ext/bg/js/search.js
+++ b/ext/bg/js/search.js
@@ -29,12 +29,18 @@ class DisplaySearch extends Display {
constructor() {
super(document.querySelector('#spinner'), document.querySelector('#content'));
+ this._isPrepared = false;
+
this.optionsContext = {
depth: 0,
url: window.location.href
};
- this.queryParser = new QueryParser(this);
+ this.queryParser = new QueryParser({
+ getOptionsContext: this.getOptionsContext.bind(this),
+ setContent: this.setContent.bind(this),
+ setSpinnerVisible: this.setSpinnerVisible.bind(this)
+ });
this.search = document.querySelector('#search');
this.query = document.querySelector('#query');
@@ -112,6 +118,8 @@ class DisplaySearch extends Display {
this.clipboardMonitor.on('change', this.onExternalSearchUpdate.bind(this));
this.updateSearchButton();
+
+ this._isPrepared = true;
} catch (e) {
this.onError(e);
}
@@ -247,15 +255,12 @@ class DisplaySearch extends Display {
}
onWanakanaEnableChange(e) {
- const {queryParams: {query=''}} = parseUrl(window.location.href);
const enableWanakana = e.target.checked;
if (enableWanakana) {
window.wanakana.bind(this.query);
} else {
window.wanakana.unbind(this.query);
}
- this.setQuery(query);
- this.onSearchQueryUpdated(this.query.value, false);
apiOptionsSet({general: {enableWanakana}}, this.getOptionsContext());
}
@@ -278,19 +283,21 @@ class DisplaySearch extends Display {
}
}
- async updateOptions(options) {
- await super.updateOptions(options);
+ async updateOptions() {
+ await super.updateOptions();
this.queryParser.setOptions(this.options);
+ if (!this._isPrepared) { return; }
+ const query = this.query.value;
+ if (query) {
+ this.setQuery(query);
+ this.onSearchQueryUpdated(query, false);
+ }
}
isWanakanaEnabled() {
return this.wanakanaEnable !== null && this.wanakanaEnable.checked;
}
- getOptionsContext() {
- return this.optionsContext;
- }
-
setQuery(query) {
const interpretedQuery = this.isWanakanaEnabled() ? window.wanakana.toKana(query) : query;
this.query.value = interpretedQuery;
diff --git a/ext/bg/js/settings/anki-templates.js b/ext/bg/js/settings/anki-templates.js
index c5222d30..e3852eb4 100644
--- a/ext/bg/js/settings/anki-templates.js
+++ b/ext/bg/js/settings/anki-templates.js
@@ -99,10 +99,15 @@ async function ankiTemplatesValidate(infoNode, field, mode, showSuccessResult, i
const definition = await ankiTemplatesValidateGetDefinition(text, optionsContext);
if (definition !== null) {
const options = await apiOptionsGet(optionsContext);
+ const context = {
+ document: {
+ title: document.title
+ }
+ };
let templates = options.anki.fieldTemplates;
if (typeof templates !== 'string') { templates = await apiGetDefaultAnkiFieldTemplates(); }
const ankiNoteBuilder = new AnkiNoteBuilder({renderTemplate: apiTemplateRender});
- result = await ankiNoteBuilder.formatField(field, definition, mode, options, templates, exceptions);
+ result = await ankiNoteBuilder.formatField(field, definition, mode, context, options, templates, exceptions);
}
} catch (e) {
exceptions.push(e);
diff --git a/ext/bg/js/settings/anki.js b/ext/bg/js/settings/anki.js
index b706cd1b..f2e1ca76 100644
--- a/ext/bg/js/settings/anki.js
+++ b/ext/bg/js/settings/anki.js
@@ -243,6 +243,7 @@ function ankiGetFieldMarkers(type) {
'cloze-prefix',
'cloze-suffix',
'dictionary',
+ 'document-title',
'expression',
'furigana',
'furigana-plain',
@@ -258,6 +259,7 @@ function ankiGetFieldMarkers(type) {
return [
'character',
'dictionary',
+ 'document-title',
'glossary',
'kunyomi',
'onyomi',
diff --git a/ext/bg/js/settings/dictionaries.js b/ext/bg/js/settings/dictionaries.js
index 5e59cc3d..33ced3b9 100644
--- a/ext/bg/js/settings/dictionaries.js
+++ b/ext/bg/js/settings/dictionaries.js
@@ -199,11 +199,16 @@ class SettingsDictionaryEntryUI {
this.allowSecondarySearchesCheckbox = this.content.querySelector('.dict-allow-secondary-searches');
this.priorityInput = this.content.querySelector('.dict-priority');
this.deleteButton = this.content.querySelector('.dict-delete-button');
+ this.detailsToggleLink = this.content.querySelector('.dict-details-toggle-link');
+ this.detailsContainer = this.content.querySelector('.dict-details');
+ this.detailsTable = this.content.querySelector('.dict-details-table');
if (this.dictionaryInfo.version < 3) {
this.content.querySelector('.dict-outdated').hidden = false;
}
+ this.setupDetails(dictionaryInfo);
+
this.content.querySelector('.dict-title').textContent = this.dictionaryInfo.title;
this.content.querySelector('.dict-revision').textContent = `rev.${this.dictionaryInfo.revision}`;
this.content.querySelector('.dict-prefix-wildcard-searches-supported').checked = !!this.dictionaryInfo.prefixWildcardsSupported;
@@ -214,6 +219,45 @@ class SettingsDictionaryEntryUI {
this.eventListeners.addEventListener(this.allowSecondarySearchesCheckbox, 'change', this.onAllowSecondarySearchesChanged.bind(this), false);
this.eventListeners.addEventListener(this.priorityInput, 'change', this.onPriorityChanged.bind(this), false);
this.eventListeners.addEventListener(this.deleteButton, 'click', this.onDeleteButtonClicked.bind(this), false);
+ this.eventListeners.addEventListener(this.detailsToggleLink, 'click', this.onDetailsToggleLinkClicked.bind(this), false);
+ }
+
+ setupDetails(dictionaryInfo) {
+ const targets = [
+ ['Author', 'author'],
+ ['URL', 'url'],
+ ['Description', 'description'],
+ ['Attribution', 'attribution']
+ ];
+
+ let count = 0;
+ for (const [label, key] of targets) {
+ const info = dictionaryInfo[key];
+ if (typeof info !== 'string') { continue; }
+
+ const n1 = document.createElement('div');
+ n1.className = 'dict-details-entry';
+ n1.dataset.type = key;
+
+ const n2 = document.createElement('span');
+ n2.className = 'dict-details-entry-label';
+ n2.textContent = `${label}:`;
+ n1.appendChild(n2);
+
+ const n3 = document.createElement('span');
+ n3.className = 'dict-details-entry-info';
+ n3.textContent = info;
+ n1.appendChild(n3);
+
+ this.detailsTable.appendChild(n1);
+
+ ++count;
+ }
+
+ if (count === 0) {
+ this.detailsContainer.hidden = true;
+ this.detailsToggleLink.hidden = true;
+ }
}
cleanup() {
@@ -318,6 +362,12 @@ class SettingsDictionaryEntryUI {
document.querySelector('#dict-remove-modal-dict-name').textContent = title;
$(n).modal('show');
}
+
+ onDetailsToggleLinkClicked(e) {
+ e.preventDefault();
+
+ this.detailsContainer.hidden = !this.detailsContainer.hidden;
+ }
}
class SettingsDictionaryExtraUI {
@@ -505,7 +555,7 @@ function dictionaryErrorsShow(errors) {
if (errors !== null && errors.length > 0) {
const uniqueErrors = new Map();
for (let e of errors) {
- console.error(e);
+ logError(e);
e = dictionaryErrorToString(e);
let count = uniqueErrors.get(e);
if (typeof count === 'undefined') {
@@ -643,9 +693,9 @@ async function onDictionaryImport(e) {
await settingsSaveOptions();
if (errors.length > 0) {
- errors.push(...errors);
- errors.push(`Dictionary may not have been imported properly: ${errors.length} error${errors.length === 1 ? '' : 's'} reported.`);
- dictionaryErrorsShow(errors);
+ const errors2 = errors.map((error) => jsonToError(error));
+ errors2.push(`Dictionary may not have been imported properly: ${errors2.length} error${errors2.length === 1 ? '' : 's'} reported.`);
+ dictionaryErrorsShow(errors2);
}
onDatabaseUpdated();
diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js
index ebc443df..1653ee35 100644
--- a/ext/bg/js/settings/main.js
+++ b/ext/bg/js/settings/main.js
@@ -84,6 +84,10 @@ async function formRead(options) {
options.general.popupScalingFactor = parseFloat($('#popup-scaling-factor').val());
options.general.popupScaleRelativeToPageZoom = $('#popup-scale-relative-to-page-zoom').prop('checked');
options.general.popupScaleRelativeToVisualViewport = $('#popup-scale-relative-to-visual-viewport').prop('checked');
+ options.general.showPitchAccentDownstepNotation = $('#show-pitch-accent-downstep-notation').prop('checked');
+ options.general.showPitchAccentPositionNotation = $('#show-pitch-accent-position-notation').prop('checked');
+ options.general.showPitchAccentGraph = $('#show-pitch-accent-graph').prop('checked');
+ options.general.showIframePopupsInRootFrame = $('#show-iframe-popups-in-root-frame').prop('checked');
options.general.popupTheme = $('#popup-theme').val();
options.general.popupOuterTheme = $('#popup-outer-theme').val();
options.general.customPopupCss = $('#custom-popup-css').val();
@@ -161,6 +165,10 @@ async function formWrite(options) {
$('#popup-scaling-factor').val(options.general.popupScalingFactor);
$('#popup-scale-relative-to-page-zoom').prop('checked', options.general.popupScaleRelativeToPageZoom);
$('#popup-scale-relative-to-visual-viewport').prop('checked', options.general.popupScaleRelativeToVisualViewport);
+ $('#show-pitch-accent-downstep-notation').prop('checked', options.general.showPitchAccentDownstepNotation);
+ $('#show-pitch-accent-position-notation').prop('checked', options.general.showPitchAccentPositionNotation);
+ $('#show-pitch-accent-graph').prop('checked', options.general.showPitchAccentGraph);
+ $('#show-iframe-popups-in-root-frame').prop('checked', options.general.showIframePopupsInRootFrame);
$('#popup-theme').val(options.general.popupTheme);
$('#popup-outer-theme').val(options.general.popupOuterTheme);
$('#custom-popup-css').val(options.general.customPopupCss);
diff --git a/ext/bg/js/text-source-map.js b/ext/bg/js/text-source-map.js
new file mode 100644
index 00000000..24970978
--- /dev/null
+++ b/ext/bg/js/text-source-map.js
@@ -0,0 +1,115 @@
+/*
+ * 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/>.
+ */
+
+class TextSourceMap {
+ constructor(source, mapping=null) {
+ this._source = source;
+ this._mapping = (Array.isArray(mapping) ? TextSourceMap._normalizeMapping(mapping) : null);
+ }
+
+ get source() {
+ return this._source;
+ }
+
+ equals(other) {
+ if (this === other) {
+ return true;
+ }
+
+ const source = this._source;
+ if (!(other instanceof TextSourceMap && source === other._source)) {
+ return false;
+ }
+
+ let mapping = this._mapping;
+ let otherMapping = other._mapping;
+ if (mapping === null) {
+ if (otherMapping === null) {
+ return true;
+ }
+ mapping = TextSourceMap._createMapping(source);
+ } else if (otherMapping === null) {
+ otherMapping = TextSourceMap._createMapping(source);
+ }
+
+ const mappingLength = mapping.length;
+ if (mappingLength !== otherMapping.length) {
+ return false;
+ }
+
+ for (let i = 0; i < mappingLength; ++i) {
+ if (mapping[i] !== otherMapping[i]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ getSourceLength(finalLength) {
+ const mapping = this._mapping;
+ if (mapping === null) {
+ return finalLength;
+ }
+
+ let sourceLength = 0;
+ for (let i = 0; i < finalLength; ++i) {
+ sourceLength += mapping[i];
+ }
+ return sourceLength;
+ }
+
+ combine(index, count) {
+ if (count <= 0) { return; }
+
+ if (this._mapping === null) {
+ this._mapping = TextSourceMap._createMapping(this._source);
+ }
+
+ let sum = this._mapping[index];
+ const parts = this._mapping.splice(index + 1, count);
+ for (const part of parts) {
+ sum += part;
+ }
+ this._mapping[index] = sum;
+ }
+
+ insert(index, ...items) {
+ if (this._mapping === null) {
+ this._mapping = TextSourceMap._createMapping(this._source);
+ }
+
+ this._mapping.splice(index, 0, ...items);
+ }
+
+ static _createMapping(text) {
+ return new Array(text.length).fill(1);
+ }
+
+ static _normalizeMapping(mapping) {
+ const result = [];
+ for (const value of mapping) {
+ result.push(
+ (typeof value === 'number' && Number.isFinite(value)) ?
+ Math.floor(value) :
+ 0
+ );
+ }
+ return result;
+ }
+}
diff --git a/ext/bg/js/translator.js b/ext/bg/js/translator.js
index 25da9bf0..27f91c05 100644
--- a/ext/bg/js/translator.js
+++ b/ext/bg/js/translator.js
@@ -17,8 +17,8 @@
*/
/* global
- * Database
* Deinflector
+ * TextSourceMap
* dictEnabledSet
* dictTagBuildSource
* dictTagSanitize
@@ -29,34 +29,21 @@
* dictTermsMergeBySequence
* dictTermsSort
* dictTermsUndupe
- * jpConvertAlphabeticToKana
- * jpConvertHalfWidthKanaToFullWidth
- * jpConvertNumericTofullWidth
- * jpDistributeFurigana
- * jpHiraganaToKatakana
- * jpIsCodePointJapanese
- * jpKatakanaToHiragana
+ * jp
* requestJson
*/
class Translator {
- constructor() {
- this.database = null;
+ constructor(database) {
+ this.database = database;
this.deinflector = null;
this.tagCache = new Map();
}
async prepare() {
- if (!this.database) {
- this.database = new Database();
- await this.database.prepare();
- }
-
- if (!this.deinflector) {
- const url = chrome.runtime.getURL('/bg/lang/deinflect.json');
- const reasons = await requestJson(url, 'GET');
- this.deinflector = new Deinflector(reasons);
- }
+ const url = chrome.runtime.getURL('/bg/lang/deinflect.json');
+ const reasons = await requestJson(url, 'GET');
+ this.deinflector = new Deinflector(reasons);
}
async purgeDatabase() {
@@ -275,7 +262,7 @@ class Translator {
const termTags = await this.expandTags(definition.termTags, definition.dictionary);
const {expression, reading} = definition;
- const furiganaSegments = jpDistributeFurigana(expression, reading);
+ const furiganaSegments = jp.distributeFurigana(expression, reading);
definitions.push({
source: deinflection.source,
@@ -373,23 +360,21 @@ class Translator {
const used = new Set();
for (const [halfWidth, numeric, alphabetic, katakana, hiragana] of Translator.getArrayVariants(textOptionVariantArray)) {
let text2 = text;
- let sourceMapping = null;
+ const sourceMap = new TextSourceMap(text2);
if (halfWidth) {
- if (sourceMapping === null) { sourceMapping = Translator.createTextSourceMapping(text2); }
- text2 = jpConvertHalfWidthKanaToFullWidth(text2, sourceMapping);
+ text2 = jp.convertHalfWidthKanaToFullWidth(text2, sourceMap);
}
if (numeric) {
- text2 = jpConvertNumericTofullWidth(text2);
+ text2 = jp.convertNumericToFullWidth(text2);
}
if (alphabetic) {
- if (sourceMapping === null) { sourceMapping = Translator.createTextSourceMapping(text2); }
- text2 = jpConvertAlphabeticToKana(text2, sourceMapping);
+ text2 = jp.convertAlphabeticToKana(text2, sourceMap);
}
if (katakana) {
- text2 = jpHiraganaToKatakana(text2);
+ text2 = jp.convertHiraganaToKatakana(text2);
}
if (hiragana) {
- text2 = jpKatakanaToHiragana(text2);
+ text2 = jp.convertKatakanaToHiragana(text2);
}
for (let i = text2.length; i > 0; --i) {
@@ -397,7 +382,7 @@ class Translator {
if (used.has(text2Substring)) { break; }
used.add(text2Substring);
for (const deinflection of this.deinflector.deinflect(text2Substring)) {
- deinflection.rawSource = Translator.getDeinflectionRawSource(text, i, sourceMapping);
+ deinflection.rawSource = sourceMap.source.substring(0, sourceMap.getSourceLength(i));
deinflections.push(deinflection);
}
}
@@ -413,25 +398,6 @@ class Translator {
}
}
- static getDeinflectionRawSource(source, length, sourceMapping) {
- if (sourceMapping === null) {
- return source.substring(0, length);
- }
-
- let result = '';
- let index = 0;
- for (let i = 0; i < length; ++i) {
- const c = sourceMapping[i];
- result += source.substring(index, index + c);
- index += c;
- }
- return result;
- }
-
- static createTextSourceMapping(text) {
- return new Array(text.length).fill(1);
- }
-
async findKanji(text, options) {
const dictionaries = dictEnabledSet(options);
const kanjiUnique = new Set();
@@ -496,6 +462,7 @@ class Translator {
// New data
term.frequencies = [];
+ term.pitches = [];
}
const metas = await this.database.findTermMetaBulk(expressionsUnique, dictionaries);
@@ -506,6 +473,13 @@ class Translator {
term.frequencies.push({expression, frequency: data, dictionary});
}
break;
+ case 'pitch':
+ for (const term of termsUnique[index]) {
+ const pitchData = await this.getPitchData(expression, data, dictionary, term);
+ if (pitchData === null) { continue; }
+ term.pitches.push(pitchData);
+ }
+ break;
}
}
}
@@ -589,8 +563,22 @@ class Translator {
return tagMetaList;
}
+ async getPitchData(expression, data, dictionary, term) {
+ const reading = data.reading;
+ const termReading = term.reading || expression;
+ if (reading !== termReading) { return null; }
+
+ const pitches = [];
+ for (let {position, tags} of data.pitches) {
+ tags = Array.isArray(tags) ? await this.getTagMetaList(tags, dictionary) : [];
+ pitches.push({position, tags});
+ }
+
+ return {reading, pitches, dictionary};
+ }
+
static createExpression(expression, reading, termTags=null, termFrequency=null) {
- const furiganaSegments = jpDistributeFurigana(expression, reading);
+ const furiganaSegments = jp.distributeFurigana(expression, reading);
return {
expression,
reading,
@@ -639,7 +627,7 @@ class Translator {
if (!options.scanning.alphanumeric) {
let newText = '';
for (const c of text) {
- if (!jpIsCodePointJapanese(c.codePointAt(0))) {
+ if (!jp.isCodePointJapanese(c.codePointAt(0))) {
break;
}
newText += c;
diff --git a/ext/bg/js/util.js b/ext/bg/js/util.js
index 79c6af06..a7ed4a34 100644
--- a/ext/bg/js/util.js
+++ b/ext/bg/js/util.js
@@ -118,7 +118,7 @@ async function utilDatabaseDeleteDictionary(dictionaryName, onProgress) {
async function utilDatabaseImport(data, onProgress, details) {
data = await utilReadFile(data);
- return utilIsolate(await utilBackend().translator.database.importDictionary(
+ return utilIsolate(await utilBackend().importDictionary(
utilBackgroundIsolate(data),
utilBackgroundFunctionIsolate(onProgress),
utilBackgroundIsolate(details)
diff --git a/ext/bg/search.html b/ext/bg/search.html
index f4c1a737..eacc1893 100644
--- a/ext/bg/search.html
+++ b/ext/bg/search.html
@@ -74,6 +74,7 @@
<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/dictionary.js"></script>
<script src="/bg/js/handlebars.js"></script>
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index 0db76d71..1297a9cc 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -163,6 +163,22 @@
</div>
<div class="checkbox options-advanced">
+ <label><input type="checkbox" id="show-pitch-accent-downstep-notation"> Show downstep notation for pitch accents</label>
+ </div>
+
+ <div class="checkbox options-position">
+ <label><input type="checkbox" id="show-pitch-accent-position-notation"> Show position notation for pitch accents</label>
+ </div>
+
+ <div class="checkbox options-advanced">
+ <label><input type="checkbox" id="show-pitch-accent-graph"> Show graph for pitch accents</label>
+ </div>
+
+ <div class="checkbox options-advanced">
+ <label><input type="checkbox" id="show-iframe-popups-in-root-frame"> Show iframe popups in root frame</label>
+ </div>
+
+ <div class="checkbox options-advanced">
<label><input type="checkbox" id="show-debug-info"> Show debug information</label>
</div>
@@ -658,6 +674,10 @@
<label class="dict-result-priority-label">Result priority</label>
<input type="number" class="form-control dict-priority">
</div>
+ <div class="dict-details-container">
+ <a class="dict-details-toggle-link">Details...</a>
+ <div class="dict-details" hidden><div class="dict-details-table"></div></div>
+ </div>
<div class="dict-delete-table">
<div>
<button class="btn btn-default dict-delete-button">Delete Dictionary</button>
@@ -1088,6 +1108,7 @@
<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/anki-note-builder.js"></script>
diff --git a/ext/fg/float.html b/ext/fg/float.html
index 7bbed565..3ccf68eb 100644
--- a/ext/fg/float.html
+++ b/ext/fg/float.html
@@ -43,6 +43,7 @@
<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="/fg/js/document.js"></script>
<script src="/fg/js/source.js"></script>
diff --git a/ext/fg/js/float.js b/ext/fg/js/float.js
index 393c2719..01055ca6 100644
--- a/ext/fg/js/float.js
+++ b/ext/fg/js/float.js
@@ -28,6 +28,8 @@ class DisplayFloat extends Display {
super(document.querySelector('#spinner'), document.querySelector('#definitions'));
this.autoPlayAudioTimer = null;
+ this._popupId = null;
+
this.optionsContext = {
depth: 0,
url: window.location.href
@@ -53,7 +55,7 @@ class DisplayFloat extends Display {
['setContent', ({type, details}) => this.setContent(type, details)],
['clearAutoPlayTimer', () => this.clearAutoPlayTimer()],
['setCustomCss', ({css}) => this.setCustomCss(css)],
- ['prepare', ({options, popupInfo, url, childrenSupported, scale, uniqueId}) => this.prepare(options, popupInfo, url, childrenSupported, scale, uniqueId)],
+ ['prepare', ({popupInfo, url, childrenSupported, scale}) => this.prepare(popupInfo, url, childrenSupported, scale)],
['setContentScale', ({scale}) => this.setContentScale(scale)]
]);
@@ -61,23 +63,24 @@ class DisplayFloat extends Display {
window.addEventListener('message', this.onMessage.bind(this), false);
}
- async prepare(options, popupInfo, url, childrenSupported, scale, uniqueId) {
+ async prepare(popupInfo, url, childrenSupported, scale) {
if (this._prepareInvoked) { return; }
this._prepareInvoked = true;
- await super.prepare(options);
-
const {id, depth, parentFrameId} = popupInfo;
+ this._popupId = id;
this.optionsContext.depth = depth;
this.optionsContext.url = url;
+ await super.prepare();
+
if (childrenSupported) {
popupNestedInitialize(id, depth, parentFrameId, url);
}
this.setContentScale(scale);
- apiForward('popupPrepareCompleted', {uniqueId});
+ apiForward('popupPrepareCompleted', {targetPopupId: this._popupId});
}
onError(error) {
@@ -144,10 +147,6 @@ class DisplayFloat extends Display {
handler(params);
}
- getOptionsContext() {
- return this.optionsContext;
- }
-
autoPlayAudio() {
this.clearAutoPlayTimer();
this.autoPlayAudioTimer = window.setTimeout(() => super.autoPlayAudio(), 400);
@@ -163,6 +162,33 @@ class DisplayFloat extends Display {
setContentScale(scale) {
document.body.style.fontSize = `${scale}em`;
}
+
+ async getDocumentTitle() {
+ try {
+ const uniqueId = yomichan.generateId(16);
+
+ const promise = yomichan.getTemporaryListenerResult(
+ chrome.runtime.onMessage,
+ ({action, params}, {resolve}) => {
+ if (
+ action === 'documentInformationBroadcast' &&
+ isObject(params) &&
+ params.uniqueId === uniqueId &&
+ params.frameId === 0
+ ) {
+ resolve(params);
+ }
+ },
+ 2000
+ );
+ apiForward('requestDocumentInformationBroadcast', {uniqueId});
+
+ const {title} = await promise;
+ return title;
+ } catch (e) {
+ return '';
+ }
+ }
}
DisplayFloat.instance = new DisplayFloat();
diff --git a/ext/fg/js/frame-offset-forwarder.js b/ext/fg/js/frame-offset-forwarder.js
new file mode 100644
index 00000000..7b417b6e
--- /dev/null
+++ b/ext/fg/js/frame-offset-forwarder.js
@@ -0,0 +1,102 @@
+/*
+ * 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
+ * apiForward
+ */
+
+class FrameOffsetForwarder {
+ constructor() {
+ this._started = false;
+
+ this._forwardFrameOffset = (
+ window !== window.parent ?
+ this._forwardFrameOffsetParent.bind(this) :
+ this._forwardFrameOffsetOrigin.bind(this)
+ );
+
+ this._windowMessageHandlers = new Map([
+ ['getFrameOffset', ({offset, uniqueId}, e) => this._onGetFrameOffset(offset, uniqueId, e)]
+ ]);
+ }
+
+ start() {
+ if (this._started) { return; }
+ window.addEventListener('message', this.onMessage.bind(this), false);
+ this._started = true;
+ }
+
+ async getOffset() {
+ const uniqueId = yomichan.generateId(16);
+
+ const frameOffsetPromise = yomichan.getTemporaryListenerResult(
+ chrome.runtime.onMessage,
+ ({action, params}, {resolve}) => {
+ if (action === 'frameOffset' && isObject(params) && params.uniqueId === uniqueId) {
+ resolve(params);
+ }
+ },
+ 5000
+ );
+
+ window.parent.postMessage({
+ action: 'getFrameOffset',
+ params: {
+ uniqueId,
+ offset: [0, 0]
+ }
+ }, '*');
+
+ const {offset} = await frameOffsetPromise;
+ return offset;
+ }
+
+ onMessage(e) {
+ const {action, params} = e.data;
+ const handler = this._windowMessageHandlers.get(action);
+ if (typeof handler !== 'function') { return; }
+ handler(params, e);
+ }
+
+ _onGetFrameOffset(offset, uniqueId, e) {
+ let sourceFrame = null;
+ for (const frame of document.querySelectorAll('frame, iframe:not(.yomichan-float)')) {
+ if (frame.contentWindow !== e.source) { continue; }
+ sourceFrame = frame;
+ break;
+ }
+ if (sourceFrame === null) {
+ this._forwardFrameOffsetOrigin(null, uniqueId);
+ return;
+ }
+
+ const [forwardedX, forwardedY] = offset;
+ const {x, y} = sourceFrame.getBoundingClientRect();
+ offset = [forwardedX + x, forwardedY + y];
+
+ this._forwardFrameOffset(offset, uniqueId);
+ }
+
+ _forwardFrameOffsetParent(offset, uniqueId) {
+ window.parent.postMessage({action: 'getFrameOffset', params: {offset, uniqueId}}, '*');
+ }
+
+ _forwardFrameOffsetOrigin(offset, uniqueId) {
+ apiForward('frameOffset', {offset, uniqueId});
+ }
+}
diff --git a/ext/fg/js/frontend-initialize.js b/ext/fg/js/frontend-initialize.js
index 8424b21d..4a1409db 100644
--- a/ext/fg/js/frontend-initialize.js
+++ b/ext/fg/js/frontend-initialize.js
@@ -17,28 +17,56 @@
*/
/* global
+ * FrameOffsetForwarder
* Frontend
* PopupProxy
* PopupProxyHost
+ * apiForward
+ * apiOptionsGet
*/
async function main() {
await yomichan.prepare();
const data = window.frontendInitializationData || {};
- const {id, depth=0, parentFrameId, ignoreNodes, url, proxy=false} = data;
+ const {id, depth=0, parentFrameId, url, proxy=false} = data;
+
+ const optionsContext = {depth, url};
+ const options = await apiOptionsGet(optionsContext);
let popup;
- if (proxy) {
+ if (!proxy && (window !== window.parent) && options.general.showIframePopupsInRootFrame) {
+ const rootPopupInformationPromise = yomichan.getTemporaryListenerResult(
+ chrome.runtime.onMessage,
+ ({action, params}, {resolve}) => {
+ if (action === 'rootPopupInformation') {
+ resolve(params);
+ }
+ }
+ );
+ apiForward('rootPopupRequestInformationBroadcast');
+ const {popupId, frameId} = await rootPopupInformationPromise;
+
+ const frameOffsetForwarder = new FrameOffsetForwarder();
+ frameOffsetForwarder.start();
+ const getFrameOffset = frameOffsetForwarder.getOffset.bind(frameOffsetForwarder);
+
+ popup = new PopupProxy(popupId, 0, null, frameId, url, getFrameOffset);
+ await popup.prepare();
+ } else if (proxy) {
popup = new PopupProxy(null, depth + 1, id, parentFrameId, url);
+ await popup.prepare();
} else {
+ const frameOffsetForwarder = new FrameOffsetForwarder();
+ frameOffsetForwarder.start();
+
const popupHost = new PopupProxyHost();
await popupHost.prepare();
popup = popupHost.getOrCreatePopup(null, null, depth);
}
- const frontend = new Frontend(popup, ignoreNodes);
+ const frontend = new Frontend(popup);
await frontend.prepare();
}
diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js
index 768b9326..31843212 100644
--- a/ext/fg/js/frontend.js
+++ b/ext/fg/js/frontend.js
@@ -18,6 +18,7 @@
/* global
* TextScanner
+ * apiForward
* apiGetZoom
* apiKanjiFind
* apiOptionsGet
@@ -26,10 +27,9 @@
*/
class Frontend extends TextScanner {
- constructor(popup, ignoreNodes) {
+ constructor(popup) {
super(
window,
- ignoreNodes,
popup.isProxy() ? [] : [popup.getContainer()],
[(x, y) => this.popup.containsPoint(x, y)]
);
@@ -53,7 +53,9 @@ class Frontend extends TextScanner {
]);
this._runtimeMessageHandlers = new Map([
- ['popupSetVisibleOverride', ({visible}) => { this.popup.setVisibleOverride(visible); }]
+ ['popupSetVisibleOverride', ({visible}) => { this.popup.setVisibleOverride(visible); }],
+ ['rootPopupRequestInformationBroadcast', () => { this._broadcastRootPopupInformation(); }],
+ ['requestDocumentInformationBroadcast', ({uniqueId}) => { this._broadcastDocumentInformation(uniqueId); }]
]);
}
@@ -77,6 +79,7 @@ class Frontend extends TextScanner {
chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));
this._updateContentScale();
+ this._broadcastRootPopupInformation();
} catch (e) {
this.onError(e);
}
@@ -95,6 +98,9 @@ class Frontend extends TextScanner {
}
onRuntimeMessage({action, params}, sender, callback) {
+ const {targetPopupId} = params || {};
+ if (typeof targetPopupId !== 'undefined' && targetPopupId !== this.popup.id) { return; }
+
const handler = this._runtimeMessageHandlers.get(action);
if (typeof handler !== 'function') { return false; }
@@ -129,8 +135,20 @@ class Frontend extends TextScanner {
async updateOptions() {
this.setOptions(await apiOptionsGet(this.getOptionsContext()));
+
+ const ignoreNodes = ['.scan-disable', '.scan-disable *'];
+ if (!this.options.scanning.enableOnPopupExpressions) {
+ ignoreNodes.push('.source-text', '.source-text *');
+ }
+ this.ignoreNodes = ignoreNodes.join(',');
+
await this.popup.setOptions(this.options);
+
this._updateContentScale();
+
+ if (this.textSourceCurrent !== null && this.causeCurrent !== null) {
+ await this.onSearchSource(this.textSourceCurrent, this.causeCurrent);
+ }
}
async onSearchSource(textSource, cause) {
@@ -241,6 +259,20 @@ class Frontend extends TextScanner {
this._updatePopupPosition();
}
+ _broadcastRootPopupInformation() {
+ if (!this.popup.isProxy() && this.popup.depth === 0) {
+ apiForward('rootPopupInformation', {popupId: this.popup.id, frameId: this.popup.frameId});
+ }
+ }
+
+ _broadcastDocumentInformation(uniqueId) {
+ apiForward('documentInformationBroadcast', {
+ uniqueId,
+ frameId: this.popup.frameId,
+ title: document.title
+ });
+ }
+
async _updatePopupPosition() {
const textSource = this.getCurrentTextSource();
if (textSource !== null && await this.popup.isVisible()) {
diff --git a/ext/fg/js/popup-nested.js b/ext/fg/js/popup-nested.js
index 06f8fc4b..39d91fd8 100644
--- a/ext/fg/js/popup-nested.js
+++ b/ext/fg/js/popup-nested.js
@@ -36,12 +36,7 @@ async function popupNestedInitialize(id, depth, parentFrameId, url) {
return;
}
- const ignoreNodes = ['.scan-disable', '.scan-disable *'];
- if (!options.scanning.enableOnPopupExpressions) {
- ignoreNodes.push('.source-text', '.source-text *');
- }
-
- window.frontendInitializationData = {id, depth, parentFrameId, ignoreNodes, url, proxy: true};
+ window.frontendInitializationData = {id, depth, parentFrameId, url, proxy: true};
const scriptSrcs = [
'/mixed/js/text-scanner.js',
diff --git a/ext/fg/js/popup-proxy-host.js b/ext/fg/js/popup-proxy-host.js
index 793d3949..4b136e41 100644
--- a/ext/fg/js/popup-proxy-host.js
+++ b/ext/fg/js/popup-proxy-host.js
@@ -25,19 +25,18 @@
class PopupProxyHost {
constructor() {
this._popups = new Map();
- this._nextId = 0;
this._apiReceiver = null;
- this._frameIdPromise = null;
+ this._frameId = null;
}
// Public functions
async prepare() {
- this._frameIdPromise = apiFrameInformationGet();
- const {frameId} = await this._frameIdPromise;
+ const {frameId} = await apiFrameInformationGet();
if (typeof frameId !== 'number') { return; }
+ this._frameId = frameId;
- this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${frameId}`, new Map([
+ this._apiReceiver = new FrontendApiReceiver(`popup-proxy-host#${this._frameId}`, new Map([
['getOrCreatePopup', this._onApiGetOrCreatePopup.bind(this)],
['setOptions', this._onApiSetOptions.bind(this)],
['hide', this._onApiHide.bind(this)],
@@ -76,7 +75,7 @@ class PopupProxyHost {
// New unique id
if (id === null) {
- id = this._nextId++;
+ id = yomichan.generateId(16);
}
// Create new popup
@@ -88,7 +87,7 @@ class PopupProxyHost {
} else if (depth === null) {
depth = 0;
}
- const popup = new Popup(id, depth, this._frameIdPromise);
+ const popup = new Popup(id, depth, this._frameId);
if (parent !== null) {
popup.setParent(parent);
}
@@ -96,7 +95,7 @@ class PopupProxyHost {
return popup;
}
- // Message handlers
+ // API message handlers
async _onApiGetOrCreatePopup({id, parentId}) {
const popup = this.getOrCreatePopup(id, parentId);
diff --git a/ext/fg/js/popup-proxy.js b/ext/fg/js/popup-proxy.js
index f7cef214..966198a9 100644
--- a/ext/fg/js/popup-proxy.js
+++ b/ext/fg/js/popup-proxy.js
@@ -21,18 +21,26 @@
*/
class PopupProxy {
- constructor(id, depth, parentId, parentFrameId, url) {
+ constructor(id, depth, parentId, parentFrameId, url, getFrameOffset=null) {
this._parentId = parentId;
this._parentFrameId = parentFrameId;
this._id = id;
- this._idPromise = null;
this._depth = depth;
this._url = url;
this._apiSender = new FrontendApiSender();
+ this._getFrameOffset = getFrameOffset;
+
+ this._frameOffset = null;
+ this._frameOffsetPromise = null;
+ this._frameOffsetUpdatedAt = null;
}
// Public properties
+ get id() {
+ return this._id;
+ }
+
get parent() {
return null;
}
@@ -47,92 +55,106 @@ class PopupProxy {
// Public functions
+ async prepare() {
+ const {id} = await this._invokeHostApi('getOrCreatePopup', {id: this._id, parentId: this._parentId});
+ this._id = id;
+ }
+
isProxy() {
return true;
}
async setOptions(options) {
- const id = await this._getPopupId();
- return await this._invokeHostApi('setOptions', {id, options});
+ return await this._invokeHostApi('setOptions', {id: this._id, options});
}
hide(changeFocus) {
- if (this._id === null) {
- return;
- }
this._invokeHostApi('hide', {id: this._id, changeFocus});
}
async isVisible() {
- const id = await this._getPopupId();
- return await this._invokeHostApi('isVisible', {id});
+ return await this._invokeHostApi('isVisible', {id: this._id});
}
setVisibleOverride(visible) {
- if (this._id === null) {
- return;
- }
this._invokeHostApi('setVisibleOverride', {id: this._id, visible});
}
async containsPoint(x, y) {
- if (this._id === null) {
- return false;
+ if (this._getFrameOffset !== null) {
+ await this._updateFrameOffset();
+ [x, y] = this._applyFrameOffset(x, y);
}
return await this._invokeHostApi('containsPoint', {id: this._id, x, y});
}
async showContent(elementRect, writingMode, type=null, details=null) {
- const id = await this._getPopupId();
- elementRect = PopupProxy._convertDOMRectToJson(elementRect);
- return await this._invokeHostApi('showContent', {id, elementRect, writingMode, type, details});
+ let {x, y, width, height} = elementRect;
+ if (this._getFrameOffset !== null) {
+ await this._updateFrameOffset();
+ [x, y] = this._applyFrameOffset(x, y);
+ }
+ elementRect = {x, y, width, height};
+ return await this._invokeHostApi('showContent', {id: this._id, elementRect, writingMode, type, details});
}
async setCustomCss(css) {
- const id = await this._getPopupId();
- return await this._invokeHostApi('setCustomCss', {id, css});
+ return await this._invokeHostApi('setCustomCss', {id: this._id, css});
}
clearAutoPlayTimer() {
- if (this._id === null) {
- return;
- }
this._invokeHostApi('clearAutoPlayTimer', {id: this._id});
}
async setContentScale(scale) {
- const id = await this._getPopupId();
- this._invokeHostApi('setContentScale', {id, scale});
+ this._invokeHostApi('setContentScale', {id: this._id, scale});
}
// Private
- _getPopupId() {
- if (this._idPromise === null) {
- this._idPromise = this._getPopupIdAsync();
+ _invokeHostApi(action, params={}) {
+ if (typeof this._parentFrameId !== 'number') {
+ return Promise.reject(new Error('Invalid frame'));
}
- return this._idPromise;
+ return this._apiSender.invoke(action, params, `popup-proxy-host#${this._parentFrameId}`);
}
- async _getPopupIdAsync() {
- const {id} = await this._invokeHostApi('getOrCreatePopup', {id: this._id, parentId: this._parentId});
- this._id = id;
- return id;
+ async _updateFrameOffset() {
+ const now = Date.now();
+ const firstRun = this._frameOffsetUpdatedAt === null;
+ const expired = firstRun || this._frameOffsetUpdatedAt < now - PopupProxy._frameOffsetExpireTimeout;
+ if (this._frameOffsetPromise === null && !expired) { return; }
+
+ if (this._frameOffsetPromise !== null) {
+ if (firstRun) {
+ await this._frameOffsetPromise;
+ }
+ return;
+ }
+
+ const promise = this._updateFrameOffsetInner(now);
+ if (firstRun) {
+ await promise;
+ }
}
- _invokeHostApi(action, params={}) {
- if (typeof this._parentFrameId !== 'number') {
- return Promise.reject(new Error('Invalid frame'));
+ async _updateFrameOffsetInner(now) {
+ this._frameOffsetPromise = this._getFrameOffset();
+ try {
+ const offset = await this._frameOffsetPromise;
+ this._frameOffset = offset !== null ? offset : [0, 0];
+ this._frameOffsetUpdatedAt = now;
+ } catch (e) {
+ logError(e);
+ } finally {
+ this._frameOffsetPromise = null;
}
- return this._apiSender.invoke(action, params, `popup-proxy-host#${this._parentFrameId}`);
}
- static _convertDOMRectToJson(domRect) {
- return {
- x: domRect.x,
- y: domRect.y,
- width: domRect.width,
- height: domRect.height
- };
+ _applyFrameOffset(x, y) {
+ const [offsetX, offsetY] = this._frameOffset;
+ return [x + offsetX, y + offsetY];
}
}
+
+PopupProxy._frameOffsetExpireTimeout = 1000;
diff --git a/ext/fg/js/popup.js b/ext/fg/js/popup.js
index d752812e..60dc16dd 100644
--- a/ext/fg/js/popup.js
+++ b/ext/fg/js/popup.js
@@ -22,11 +22,10 @@
*/
class Popup {
- constructor(id, depth, frameIdPromise) {
+ constructor(id, depth, frameId) {
this._id = id;
this._depth = depth;
- this._frameIdPromise = frameIdPromise;
- this._frameId = null;
+ this._frameId = frameId;
this._parent = null;
this._child = null;
this._childrenSupported = true;
@@ -69,6 +68,10 @@ class Popup {
return this._depth;
}
+ get frameId() {
+ return this._frameId;
+ }
+
get url() {
return window.location.href;
}
@@ -193,43 +196,42 @@ class Popup {
}
async _createInjectPromise() {
- try {
- const {frameId} = await this._frameIdPromise;
- if (typeof frameId === 'number') {
- this._frameId = frameId;
- }
- } catch (e) {
- // NOP
- }
-
if (this._messageToken === null) {
this._messageToken = await apiGetMessageToken();
}
- return new Promise((resolve) => {
- const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null);
- this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html'));
- this._container.addEventListener('load', () => {
- const uniqueId = yomichan.generateId(32);
- Popup._listenForDisplayPrepareCompleted(uniqueId, resolve);
-
- this._invokeApi('prepare', {
- options: this._options,
- popupInfo: {
- id: this._id,
- depth: this._depth,
- parentFrameId
- },
- url: this.url,
- childrenSupported: this._childrenSupported,
- scale: this._contentScale,
- uniqueId
- });
+ const popupPreparedPromise = yomichan.getTemporaryListenerResult(
+ chrome.runtime.onMessage,
+ ({action, params}, {resolve}) => {
+ if (
+ action === 'popupPrepareCompleted' &&
+ isObject(params) &&
+ params.targetPopupId === this._id
+ ) {
+ resolve();
+ }
+ }
+ );
+
+ const parentFrameId = (typeof this._frameId === 'number' ? this._frameId : null);
+ this._container.setAttribute('src', chrome.runtime.getURL('/fg/float.html'));
+ this._container.addEventListener('load', () => {
+ this._invokeApi('prepare', {
+ popupInfo: {
+ id: this._id,
+ depth: this._depth,
+ parentFrameId
+ },
+ url: this.url,
+ childrenSupported: this._childrenSupported,
+ scale: this._contentScale
});
- this._observeFullscreen(true);
- this._onFullscreenChanged();
- this._injectStyles();
});
+ this._observeFullscreen(true);
+ this._onFullscreenChanged();
+ this._injectStyles();
+
+ return popupPreparedPromise;
}
async _injectStyles() {
@@ -374,23 +376,6 @@ class Popup {
);
}
- static _listenForDisplayPrepareCompleted(uniqueId, resolve) {
- const runtimeMessageCallback = ({action, params}, sender, callback) => {
- if (
- action === 'popupPrepareCompleted' &&
- typeof params === 'object' &&
- params !== null &&
- params.uniqueId === uniqueId
- ) {
- chrome.runtime.onMessage.removeListener(runtimeMessageCallback);
- callback();
- resolve();
- return false;
- }
- };
- chrome.runtime.onMessage.addListener(runtimeMessageCallback);
- }
-
static _getPositionForHorizontalText(elementRect, width, height, viewport, offsetScale, optionsGeneral) {
const preferBelow = (optionsGeneral.popupHorizontalTextPosition === 'below');
const horizontalOffset = optionsGeneral.popupHorizontalOffset * offsetScale;
diff --git a/ext/manifest.json b/ext/manifest.json
index 2a602e3b..3a75beb4 100644
--- a/ext/manifest.json
+++ b/ext/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "Yomichan (testing)",
- "version": "20.3.14.0",
+ "version": "20.4.10.0",
"description": "Japanese dictionary with Anki integration (testing)",
"icons": {"16": "mixed/img/icon16.png", "48": "mixed/img/icon48.png", "128": "mixed/img/icon128.png"},
@@ -23,9 +23,12 @@
"mixed/js/api.js",
"mixed/js/text-scanner.js",
"fg/js/document.js",
+ "fg/js/frontend-api-sender.js",
"fg/js/frontend-api-receiver.js",
"fg/js/popup.js",
"fg/js/source.js",
+ "fg/js/frame-offset-forwarder.js",
+ "fg/js/popup-proxy.js",
"fg/js/popup-proxy-host.js",
"fg/js/frontend.js",
"fg/js/frontend-initialize.js"
diff --git a/ext/mixed/css/display-dark.css b/ext/mixed/css/display-dark.css
index c9cd9f90..550dff3e 100644
--- a/ext/mixed/css/display-dark.css
+++ b/ext/mixed/css/display-dark.css
@@ -19,6 +19,8 @@
body { background-color: #1e1e1e; color: #d4d4d4; }
+h2 { border-bottom-color: #2f2f2f; }
+
.navigation-header {
background-color: #1e1e1e;
border-bottom-color: #2f2f2f;
@@ -39,6 +41,7 @@ body { background-color: #1e1e1e; color: #d4d4d4; }
.tag[data-category=frequency] { background-color: #489148; }
.tag[data-category=partOfSpeech] { background-color: #565656; }
.tag[data-category=search] { background-color: #69696e; }
+.tag[data-category=pitch-accent-dictionary] { background-color: #6640be; }
.term-reasons { color: #888888; }
@@ -57,12 +60,15 @@ body { background-color: #1e1e1e; color: #d4d4d4; }
color: #666666;
}
-.term-definition-container,
-.kanji-glossary-container {
+.term-definition-list,
+.term-pitch-accent-group-list,
+.term-pitch-accent-disambiguation-list,
+.kanji-glossary-list {
color: #888888;
}
.term-glossary,
+.term-pitch-accent,
.kanji-glossary {
color: #d4d4d4;
}
@@ -72,3 +78,20 @@ body { background-color: #1e1e1e; color: #d4d4d4; }
background-color: #d4d4d4;
color: #1e1e1e;
}
+
+.term-pitch-accent-container { border-bottom-color: #2f2f2f; }
+
+.term-pitch-accent-character:before { border-color: #ffffff; }
+
+.term-pitch-accent-graph-line,
+.term-pitch-accent-graph-line-tail,
+#term-pitch-accent-graph-dot,
+#term-pitch-accent-graph-dot-downstep,
+#term-pitch-accent-graph-triangle {
+ stroke: #ffffff;
+}
+
+#term-pitch-accent-graph-dot,
+#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
+ fill: #ffffff;
+}
diff --git a/ext/mixed/css/display-default.css b/ext/mixed/css/display-default.css
index 6eee43c4..487b8cb8 100644
--- a/ext/mixed/css/display-default.css
+++ b/ext/mixed/css/display-default.css
@@ -19,6 +19,8 @@
body { background-color: #ffffff; color: #333333; }
+h2 { border-bottom-color: #eeeeee; }
+
.navigation-header {
background-color: #ffffff;
border-bottom-color: #eeeeee;
@@ -39,6 +41,7 @@ body { background-color: #ffffff; color: #333333; }
.tag[data-category=frequency] { background-color: #5cb85c; }
.tag[data-category=partOfSpeech] { background-color: #565656; }
.tag[data-category=search] { background-color: #8a8a91; }
+.tag[data-category=pitch-accent-dictionary] { background-color: #6640be; }
.term-reasons { color: #777777; }
@@ -57,12 +60,15 @@ body { background-color: #ffffff; color: #333333; }
color: #999999;
}
-.term-definition-container,
-.kanji-glossary-container {
+.term-definition-list,
+.term-pitch-accent-group-list,
+.term-pitch-accent-disambiguation-list,
+.kanji-glossary-list {
color: #777777;
}
.term-glossary,
+.term-pitch-accent,
.kanji-glossary {
color: #000000;
}
@@ -72,3 +78,20 @@ body { background-color: #ffffff; color: #333333; }
background-color: #333333;
color: #ffffff;
}
+
+.term-pitch-accent-container { border-bottom-color: #eeeeee; }
+
+.term-pitch-accent-character:before { border-color: #000000; }
+
+.term-pitch-accent-graph-line,
+.term-pitch-accent-graph-line-tail,
+#term-pitch-accent-graph-dot,
+#term-pitch-accent-graph-dot-downstep,
+#term-pitch-accent-graph-triangle {
+ stroke: #000000;
+}
+
+#term-pitch-accent-graph-dot,
+#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
+ fill: #000000;
+}
diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css
index 688a357c..a4432016 100644
--- a/ext/mixed/css/display.css
+++ b/ext/mixed/css/display.css
@@ -65,6 +65,14 @@ ol, ul {
height: 2.28571428em; /* 14px => 32px */
}
+h2 {
+ font-size: 1.25em;
+ font-weight: normal;
+ margin: 0.25em 0 0;
+ border-bottom-width: 0.05714285714285714em; /* 14px * 1.25em => 1px */
+ border-bottom-style: solid;
+}
+
/*
* Navigation
*/
@@ -302,6 +310,7 @@ button.action-button {
width: 0;
height: 0;
visibility: hidden;
+ z-index: 1;
}
.term-expression-list[data-multi=true] .term-expression:hover .term-expression-details {
@@ -422,6 +431,187 @@ button.action-button {
display: inline;
}
+.term-entry-body[data-section-count="0"] .term-entry-body-section-header,
+.term-entry-body[data-section-count="1"] .term-entry-body-section-header {
+ display: none;
+}
+
+
+/*
+ * Pitch accent styles
+ */
+
+.entry[data-pitch-accent-count='0'] .term-pitch-accent-container {
+ display: none;
+}
+
+.term-pitch-accent-container {
+ border-bottom-width: 0.05714285714285714em; /* 14px * 1.25em => 1px */
+ border-bottom-style: solid;
+ padding-bottom: 0.25em;
+ margin-bottom: 0.25em;
+}
+
+.term-pitch-accent-group-list {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+}
+
+.term-pitch-accent-group-list:not([data-count="0"]):not([data-count="1"]) {
+ padding-left: 1.4em;
+ list-style-type: decimal;
+}
+
+.term-pitch-accent-list {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+ display: inline;
+}
+
+.term-pitch-accent-list:not([data-count="0"]):not([data-count="1"]) {
+ padding-left: 1.4em;
+ list-style-type: circle;
+ display: block;
+}
+
+.term-pitch-accent {
+ display: inline;
+ line-height: 1.5em;
+}
+
+.term-pitch-accent-list:not([data-count="0"]):not([data-count="1"])>.term-pitch-accent {
+ display: list-item;
+}
+
+.term-pitch-accent-group-tag-list {
+ margin-right: 0.375em;
+}
+
+.term-pitch-accent-disambiguation-list {
+ padding-right: 0.25em;
+}
+
+.term-pitch-accent-disambiguation-list:before {
+ content: "(";
+}
+
+.term-pitch-accent-disambiguation-list:after {
+ content: " only)";
+}
+
+.term-pitch-accent-disambiguation+.term-pitch-accent-disambiguation:before {
+ content: ", ";
+}
+
+.term-pitch-accent-disambiguation-list[data-count="0"],
+:root[data-show-pitch-accent-downstep-notation=true] .term-pitch-accent-disambiguation-list[data-expression-count="0"],
+:root[data-show-pitch-accent-downstep-notation=true] .term-pitch-accent-disambiguation[data-type=reading] {
+ display: none;
+}
+
+.term-pitch-accent-tag-list:not([data-count="0"]) {
+ margin-right: 0.375em;
+}
+
+.term-special-tags>.pitches {
+ display: inline;
+}
+
+.term-pitch-accent-character {
+ display: inline-block;
+ position: relative;
+}
+.term-pitch-accent-character[data-pitch='high']:before {
+ content: "";
+ display: block;
+ user-select: none;
+ pointer-events: none;
+ position: absolute;
+ top: 0.1em;
+ left: 0;
+ right: 0;
+ height: 0;
+ border-top-width: 0.1em;
+ border-top-style: solid;
+}
+.term-pitch-accent-character[data-pitch='high'][data-pitch-next='low']:before {
+ right: -0.1em;
+ height: 0.4em;
+ border-right-width: 0.1em;
+ border-right-style: solid;
+}
+.term-pitch-accent-character[data-pitch='high'][data-pitch-next='low'] {
+ padding-right: 0.1em;
+ margin-right: 0.1em;
+}
+
+.term-pitch-accent-position:before {
+ content: " [";
+}
+.term-pitch-accent-position:after {
+ content: "]";
+}
+
+.term-pitch-accent-details {
+ display: inline-block;
+ height: 0;
+ padding: 0 0.25em;
+ vertical-align: middle;
+}
+
+
+:root[data-show-pitch-accent-downstep-notation=false] .term-pitch-accent-characters {
+ display: none;
+}
+
+:root[data-show-pitch-accent-position-notation=false] .term-pitch-accent-position {
+ display: none;
+}
+
+:root[data-show-pitch-accent-graph=false] .term-pitch-accent-details {
+ display: none;
+}
+
+
+/*
+ * Pitch accent graph styles
+ */
+
+.term-pitch-accent-graph {
+ display: block;
+ height: 1.5em;
+ transform: translateY(-0.875em);
+}
+.term-pitch-accent-graph-line,
+.term-pitch-accent-graph-line-tail {
+ fill: none;
+ stroke: #000000;
+ stroke-width: 5;
+}
+.term-pitch-accent-graph-line-tail {
+ stroke-dasharray: 5 5;
+}
+#term-pitch-accent-graph-dot {
+ fill: #000000;
+ stroke: #000000;
+ stroke-width: 5;
+}
+#term-pitch-accent-graph-dot-downstep {
+ fill: none;
+ stroke: #000000;
+ stroke-width: 5;
+}
+#term-pitch-accent-graph-dot-downstep>circle:last-of-type {
+ fill: #000000;
+}
+#term-pitch-accent-graph-triangle {
+ fill: none;
+ stroke: #000000;
+ stroke-width: 5;
+}
+
/*
* Kanji
diff --git a/ext/mixed/display-templates.html b/ext/mixed/display-templates.html
index 7ae51a62..b8d52d15 100644
--- a/ext/mixed/display-templates.html
+++ b/ext/mixed/display-templates.html
@@ -17,7 +17,10 @@
</div>
<div class="term-special-tags"><div class="frequencies tag-list"></div></div>
</div>
- <div class="term-definition-container"><ol class="term-definition-list"></ol></div>
+ <div class="term-entry-body">
+ <div class="term-entry-body-section term-pitch-accent-container"><ol class="term-entry-body-section-content term-pitch-accent-group-list"></ol></div>
+ <div class="term-entry-body-section term-definition-container"><ol class="term-entry-body-section-content term-definition-list"></ol></div>
+ </div>
<pre class="debug-info"></pre>
</div></template>
<template id="term-expression-template"><div class="term-expression"><span class="term-expression-text source-text"></span><div class="term-expression-details">
@@ -34,6 +37,18 @@
<template id="term-glossary-item-template"><li class="term-glossary-item"><span class="term-glossary-separator"> </span><span class="term-glossary"></span></li></template>
<template id="term-reason-template"><span class="term-reason"></span><span class="term-reason-separator"> </span></template>
+<template id="term-pitch-accent-static-template"><svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
+ <defs>
+ <g id="term-pitch-accent-graph-dot"><circle cx="0" cy="0" r="15" /></g>
+ <g id="term-pitch-accent-graph-dot-downstep"><circle cx="0" cy="0" r="15" /><circle cx="0" cy="0" r="5" /></g>
+ <g id="term-pitch-accent-graph-triangle"><path d="M0 13 L15 -13 L-15 -13 Z" /></g>
+ </defs>
+</svg></template>
+<template id="term-pitch-accent-group-template"><li class="term-pitch-accent-group"><span class="term-pitch-accent-group-tag-list tag-list"></span><ul class="term-pitch-accent-list"></ul></li></template>
+<template id="term-pitch-accent-disambiguation-template"><span class="term-pitch-accent-disambiguation"></span></template>
+<template id="term-pitch-accent-template"><li class="term-pitch-accent"><span class="term-pitch-accent-tag-list tag-list"></span><span class="term-pitch-accent-disambiguation-list"></span><span class="term-pitch-accent-characters"></span><span class="term-pitch-accent-position"></span><span class="term-pitch-accent-details"><svg class="term-pitch-accent-graph" xmlns="http://www.w3.org/2000/svg"><path class="term-pitch-accent-graph-line" /><path class="term-pitch-accent-graph-line-tail" /></svg></span></li></template>
+<template id="term-pitch-accent-character-template"><span class="term-pitch-accent-character"><span class="term-pitch-accent-character-inner"></span></span></template>
+
<template id="kanji-entry-template"><div class="entry" data-type="kanji">
<div class="entry-header1">
<div class="entry-header2">
diff --git a/ext/mixed/js/api.js b/ext/mixed/js/api.js
index 0ab07039..feec94df 100644
--- a/ext/mixed/js/api.js
+++ b/ext/mixed/js/api.js
@@ -53,12 +53,12 @@ function apiKanjiFind(text, optionsContext) {
return _apiInvoke('kanjiFind', {text, optionsContext});
}
-function apiDefinitionAdd(definition, mode, context, optionsContext) {
- return _apiInvoke('definitionAdd', {definition, mode, context, optionsContext});
+function apiDefinitionAdd(definition, mode, context, details, optionsContext) {
+ return _apiInvoke('definitionAdd', {definition, mode, context, details, optionsContext});
}
-function apiDefinitionsAddable(definitions, modes, optionsContext) {
- return _apiInvoke('definitionsAddable', {definitions, modes, optionsContext});
+function apiDefinitionsAddable(definitions, modes, context, optionsContext) {
+ return _apiInvoke('definitionsAddable', {definitions, modes, context, optionsContext});
}
function apiNoteView(noteId) {
diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js
index 0d50e915..db7fc69b 100644
--- a/ext/mixed/js/core.js
+++ b/ext/mixed/js/core.js
@@ -132,6 +132,30 @@ function parseUrl(url) {
return {baseUrl, queryParams};
}
+function areSetsEqual(set1, set2) {
+ if (set1.size !== set2.size) {
+ return false;
+ }
+
+ for (const value of set1) {
+ if (!set2.has(value)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function getSetIntersection(set1, set2) {
+ const result = [];
+ for (const value of set1) {
+ if (set2.has(value)) {
+ result.push(value);
+ }
+ }
+ return result;
+}
+
/*
* Async utilities
@@ -254,11 +278,16 @@ const yomichan = (() => {
constructor() {
super();
- this._isBackendPreparedResolve = null;
- this._isBackendPreparedPromise = new Promise((resolve) => (this._isBackendPreparedResolve = resolve));
+ this._isBackendPreparedPromise = this.getTemporaryListenerResult(
+ chrome.runtime.onMessage,
+ ({action}, {resolve}) => {
+ if (action === 'backendPrepared') {
+ resolve();
+ }
+ }
+ );
this._messageHandlers = new Map([
- ['backendPrepared', this._onBackendPrepared.bind(this)],
['getUrl', this._onMessageGetUrl.bind(this)],
['optionsUpdated', this._onMessageOptionsUpdated.bind(this)],
['zoomChanged', this._onMessageZoomChanged.bind(this)]
@@ -288,6 +317,42 @@ const yomichan = (() => {
this.trigger('orphaned', {error});
}
+ getTemporaryListenerResult(eventHandler, userCallback, timeout=null) {
+ if (!(
+ typeof eventHandler.addListener === 'function' &&
+ typeof eventHandler.removeListener === 'function'
+ )) {
+ throw new Error('Event handler type not supported');
+ }
+
+ return new Promise((resolve, reject) => {
+ const runtimeMessageCallback = ({action, params}, sender, sendResponse) => {
+ let timeoutId = null;
+ if (timeout !== null) {
+ timeoutId = window.setTimeout(() => {
+ timeoutId = null;
+ eventHandler.removeListener(runtimeMessageCallback);
+ reject(new Error(`Listener timed out in ${timeout} ms`));
+ }, timeout);
+ }
+
+ const cleanupResolve = (value) => {
+ if (timeoutId !== null) {
+ window.clearTimeout(timeoutId);
+ timeoutId = null;
+ }
+ eventHandler.removeListener(runtimeMessageCallback);
+ sendResponse();
+ resolve(value);
+ };
+
+ userCallback({action, params}, {resolve: cleanupResolve, sender});
+ };
+
+ eventHandler.addListener(runtimeMessageCallback);
+ });
+ }
+
// Private
_onMessage({action, params}, sender, callback) {
@@ -299,10 +364,6 @@ const yomichan = (() => {
return false;
}
- _onBackendPrepared() {
- this._isBackendPreparedResolve();
- }
-
_onMessageGetUrl() {
return {url: window.location.href};
}
diff --git a/ext/mixed/js/display-generator.js b/ext/mixed/js/display-generator.js
index 49afc44b..f1122e3d 100644
--- a/ext/mixed/js/display-generator.js
+++ b/ext/mixed/js/display-generator.js
@@ -19,11 +19,13 @@
/* global
* TemplateHandler
* apiGetDisplayTemplatesHtml
+ * jp
*/
class DisplayGenerator {
constructor() {
this._templateHandler = null;
+ this._termPitchAccentStaticTemplateIsSetup = false;
}
async prepare() {
@@ -36,17 +38,33 @@ class DisplayGenerator {
const expressionsContainer = node.querySelector('.term-expression-list');
const reasonsContainer = node.querySelector('.term-reasons');
+ const pitchesContainer = node.querySelector('.term-pitch-accent-group-list');
const frequenciesContainer = node.querySelector('.frequencies');
const definitionsContainer = node.querySelector('.term-definition-list');
const debugInfoContainer = node.querySelector('.debug-info');
+ const bodyContainer = node.querySelector('.term-entry-body');
+
+ const pitches = DisplayGenerator._getPitchInfos(details);
+ const pitchCount = pitches.reduce((i, v) => i + v[1].length, 0);
const expressionMulti = Array.isArray(details.expressions);
const definitionMulti = Array.isArray(details.definitions);
+ const expressionCount = expressionMulti ? details.expressions.length : 1;
+ const definitionCount = definitionMulti ? details.definitions.length : 1;
+ const uniqueExpressionCount = Array.isArray(details.expression) ? new Set(details.expression).size : 1;
node.dataset.expressionMulti = `${expressionMulti}`;
node.dataset.definitionMulti = `${definitionMulti}`;
- node.dataset.expressionCount = `${expressionMulti ? details.expressions.length : 1}`;
- node.dataset.definitionCount = `${definitionMulti ? details.definitions.length : 1}`;
+ node.dataset.expressionCount = `${expressionCount}`;
+ node.dataset.definitionCount = `${definitionCount}`;
+ node.dataset.uniqueExpressionCount = `${uniqueExpressionCount}`;
+ node.dataset.pitchAccentDictionaryCount = `${pitches.length}`;
+ node.dataset.pitchAccentCount = `${pitchCount}`;
+
+ bodyContainer.dataset.sectionCount = `${
+ (definitionCount > 0 ? 1 : 0) +
+ (pitches.length > 0 ? 1 : 0)
+ }`;
const termTags = details.termTags;
let expressions = details.expressions;
@@ -55,6 +73,7 @@ class DisplayGenerator {
DisplayGenerator._appendMultiple(expressionsContainer, this.createTermExpression.bind(this), expressions, [[details, termTags]]);
DisplayGenerator._appendMultiple(reasonsContainer, this.createTermReason.bind(this), details.reasons);
DisplayGenerator._appendMultiple(frequenciesContainer, this.createFrequencyTag.bind(this), details.frequencies);
+ DisplayGenerator._appendMultiple(pitchesContainer, this.createPitches.bind(this), pitches);
DisplayGenerator._appendMultiple(definitionsContainer, this.createTermDefinitionItem.bind(this), details.definitions, [details]);
if (debugInfoContainer !== null) {
@@ -261,6 +280,133 @@ class DisplayGenerator {
return node;
}
+ createPitches(details) {
+ if (!this._termPitchAccentStaticTemplateIsSetup) {
+ this._termPitchAccentStaticTemplateIsSetup = true;
+ const t = this._templateHandler.instantiate('term-pitch-accent-static');
+ document.head.appendChild(t);
+ }
+
+ const [dictionary, dictionaryPitches] = details;
+
+ const node = this._templateHandler.instantiate('term-pitch-accent-group');
+ node.dataset.dictionary = dictionary;
+ node.dataset.pitchesMulti = 'true';
+ node.dataset.pitchesCount = `${dictionaryPitches.length}`;
+
+ const tag = this.createTag({notes: '', name: dictionary, category: 'pitch-accent-dictionary'});
+ node.querySelector('.term-pitch-accent-group-tag-list').appendChild(tag);
+
+ const n = node.querySelector('.term-pitch-accent-list');
+ DisplayGenerator._appendMultiple(n, this.createPitch.bind(this), dictionaryPitches);
+
+ return node;
+ }
+
+ createPitch(details) {
+ const {reading, position, tags, exclusiveExpressions, exclusiveReadings} = details;
+ const morae = jp.getKanaMorae(reading);
+
+ const node = this._templateHandler.instantiate('term-pitch-accent');
+
+ node.dataset.pitchAccentPosition = `${position}`;
+ node.dataset.tagCount = `${tags.length}`;
+
+ let n = node.querySelector('.term-pitch-accent-position');
+ n.textContent = `${position}`;
+
+ n = node.querySelector('.term-pitch-accent-tag-list');
+ DisplayGenerator._appendMultiple(n, this.createTag.bind(this), tags);
+
+ n = node.querySelector('.term-pitch-accent-disambiguation-list');
+ this.createPitchAccentDisambiguations(n, exclusiveExpressions, exclusiveReadings);
+
+ n = node.querySelector('.term-pitch-accent-characters');
+ for (let i = 0, ii = morae.length; i < ii; ++i) {
+ const mora = morae[i];
+ const highPitch = jp.isMoraPitchHigh(i, position);
+ const highPitchNext = jp.isMoraPitchHigh(i + 1, position);
+
+ const n1 = this._templateHandler.instantiate('term-pitch-accent-character');
+ const n2 = n1.querySelector('.term-pitch-accent-character-inner');
+
+ n1.dataset.position = `${i}`;
+ n1.dataset.pitch = highPitch ? 'high' : 'low';
+ n1.dataset.pitchNext = highPitchNext ? 'high' : 'low';
+ n2.textContent = mora;
+
+ n.appendChild(n1);
+ }
+
+ if (morae.length > 0) {
+ this.populatePitchGraph(node.querySelector('.term-pitch-accent-graph'), position, morae);
+ }
+
+ return node;
+ }
+
+ createPitchAccentDisambiguations(container, exclusiveExpressions, exclusiveReadings) {
+ const templateName = 'term-pitch-accent-disambiguation';
+ for (const exclusiveExpression of exclusiveExpressions) {
+ const node = this._templateHandler.instantiate(templateName);
+ node.dataset.type = 'expression';
+ node.textContent = exclusiveExpression;
+ container.appendChild(node);
+ }
+
+ for (const exclusiveReading of exclusiveReadings) {
+ const node = this._templateHandler.instantiate(templateName);
+ node.dataset.type = 'reading';
+ node.textContent = exclusiveReading;
+ container.appendChild(node);
+ }
+
+ container.dataset.multi = 'true';
+ container.dataset.count = `${exclusiveExpressions.length + exclusiveReadings.length}`;
+ container.dataset.expressionCount = `${exclusiveExpressions.length}`;
+ container.dataset.readingCount = `${exclusiveReadings.length}`;
+ }
+
+ populatePitchGraph(svg, position, morae) {
+ const svgns = svg.getAttribute('xmlns');
+ const ii = morae.length;
+ svg.setAttribute('viewBox', `0 0 ${50 * (ii + 1)} 100`);
+
+ const pathPoints = [];
+ for (let i = 0; i < ii; ++i) {
+ const highPitch = jp.isMoraPitchHigh(i, position);
+ const highPitchNext = jp.isMoraPitchHigh(i + 1, position);
+ const graphic = (highPitch && !highPitchNext ? '#term-pitch-accent-graph-dot-downstep' : '#term-pitch-accent-graph-dot');
+ const x = `${i * 50 + 25}`;
+ const y = highPitch ? '25' : '75';
+ const use = document.createElementNS(svgns, 'use');
+ use.setAttribute('href', graphic);
+ use.setAttribute('x', x);
+ use.setAttribute('y', y);
+ svg.appendChild(use);
+ pathPoints.push(`${x} ${y}`);
+ }
+
+ let path = svg.querySelector('.term-pitch-accent-graph-line');
+ path.setAttribute('d', `M${pathPoints.join(' L')}`);
+
+ pathPoints.splice(0, ii - 1);
+ {
+ const highPitch = jp.isMoraPitchHigh(ii, position);
+ const x = `${ii * 50 + 25}`;
+ const y = highPitch ? '25' : '75';
+ const use = document.createElementNS(svgns, 'use');
+ use.setAttribute('href', '#term-pitch-accent-graph-triangle');
+ use.setAttribute('x', x);
+ use.setAttribute('y', y);
+ svg.appendChild(use);
+ pathPoints.push(`${x} ${y}`);
+ }
+
+ path = svg.querySelector('.term-pitch-accent-graph-line-tail');
+ path.setAttribute('d', `M${pathPoints.join(' L')}`);
+ }
+
createFrequencyTag(details) {
const node = this._templateHandler.instantiate('tag-frequency');
@@ -283,7 +429,7 @@ class DisplayGenerator {
_appendKanjiLinks(container, text) {
let part = '';
for (const c of text) {
- if (DisplayGenerator._isCharacterKanji(c)) {
+ if (jp.isCodePointKanji(c.codePointAt(0))) {
if (part.length > 0) {
container.appendChild(document.createTextNode(part));
part = '';
@@ -300,30 +446,28 @@ class DisplayGenerator {
}
}
- static _isCharacterKanji(c) {
- const code = c.codePointAt(0);
- return (
- code >= 0x4e00 && code < 0x9fb0 ||
- code >= 0x3400 && code < 0x4dc0
- );
- }
-
- static _appendMultiple(container, createItem, detailsArray, fallback=[]) {
+ static _appendMultiple(container, createItem, detailsIterable, fallback=[]) {
if (container === null) { return 0; }
- const isArray = Array.isArray(detailsArray);
- if (!isArray) { detailsArray = fallback; }
-
- container.dataset.multi = `${isArray}`;
- container.dataset.count = `${detailsArray.length}`;
+ const multi = (
+ detailsIterable !== null &&
+ typeof detailsIterable === 'object' &&
+ typeof detailsIterable[Symbol.iterator] !== 'undefined'
+ );
+ if (!multi) { detailsIterable = fallback; }
- for (const details of detailsArray) {
+ let count = 0;
+ for (const details of detailsIterable) {
const item = createItem(details);
if (item === null) { continue; }
container.appendChild(item);
+ ++count;
}
- return detailsArray.length;
+ container.dataset.multi = `${multi}`;
+ container.dataset.count = `${count}`;
+
+ return count;
}
static _appendFurigana(container, segments, addText) {
@@ -349,4 +493,79 @@ class DisplayGenerator {
container.appendChild(document.createTextNode(parts[i]));
}
}
+
+ static _getPitchInfos(definition) {
+ const results = new Map();
+
+ const allExpressions = new Set();
+ const allReadings = new Set();
+ const expressions = definition.expressions;
+ const sources = Array.isArray(expressions) ? expressions : [definition];
+ for (const {pitches: expressionPitches, expression} of sources) {
+ allExpressions.add(expression);
+ for (const {reading, pitches, dictionary} of expressionPitches) {
+ allReadings.add(reading);
+ let dictionaryResults = results.get(dictionary);
+ if (typeof dictionaryResults === 'undefined') {
+ dictionaryResults = [];
+ results.set(dictionary, dictionaryResults);
+ }
+
+ for (const {position, tags} of pitches) {
+ let pitchInfo = DisplayGenerator._findExistingPitchInfo(reading, position, tags, dictionaryResults);
+ if (pitchInfo === null) {
+ pitchInfo = {expressions: new Set(), reading, position, tags};
+ dictionaryResults.push(pitchInfo);
+ }
+ pitchInfo.expressions.add(expression);
+ }
+ }
+ }
+
+ for (const dictionaryResults of results.values()) {
+ for (const result of dictionaryResults) {
+ const exclusiveExpressions = [];
+ const exclusiveReadings = [];
+ const resultExpressions = result.expressions;
+ if (!areSetsEqual(resultExpressions, allExpressions)) {
+ exclusiveExpressions.push(...getSetIntersection(resultExpressions, allExpressions));
+ }
+ if (allReadings.size > 1) {
+ exclusiveReadings.push(result.reading);
+ }
+ result.exclusiveExpressions = exclusiveExpressions;
+ result.exclusiveReadings = exclusiveReadings;
+ }
+ }
+
+ return [...results.entries()];
+ }
+
+ static _findExistingPitchInfo(reading, position, tags, pitchInfoList) {
+ for (const pitchInfo of pitchInfoList) {
+ if (
+ pitchInfo.reading === reading &&
+ pitchInfo.position === position &&
+ DisplayGenerator._areTagListsEqual(pitchInfo.tags, tags)
+ ) {
+ return pitchInfo;
+ }
+ }
+ return null;
+ }
+
+ static _areTagListsEqual(tagList1, tagList2) {
+ const ii = tagList1.length;
+ if (tagList2.length !== ii) { return false; }
+
+ for (let i = 0; i < ii; ++i) {
+ const tag1 = tagList1[i];
+ const tag2 = tagList2[i];
+ if (tag1.name !== tag2.name || tag1.dictionary !== tag2.dictionary) {
+ return false;
+ }
+ }
+
+ return true;
+ }
}
diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js
index 515e28a7..2f456c3e 100644
--- a/ext/mixed/js/display.js
+++ b/ext/mixed/js/display.js
@@ -40,6 +40,7 @@ class Display {
this.spinner = spinner;
this.container = container;
this.definitions = [];
+ this.optionsContext = null;
this.options = null;
this.context = null;
this.index = 0;
@@ -165,12 +166,11 @@ class Display {
this.setInteractive(true);
}
- async prepare(options=null) {
+ async prepare() {
await yomichan.prepare();
- const displayGeneratorPromise = this.displayGenerator.prepare();
- const updateOptionsPromise = this.updateOptions(options);
- await Promise.all([displayGeneratorPromise, updateOptionsPromise]);
- yomichan.on('optionsUpdated', () => this.updateOptions(null));
+ await this.displayGenerator.prepare();
+ await this.updateOptions();
+ yomichan.on('optionsUpdated', () => this.updateOptions());
}
onError(_error) {
@@ -369,11 +369,11 @@ class Display {
}
getOptionsContext() {
- throw new Error('Override me');
+ return this.optionsContext;
}
- async updateOptions(options) {
- this.options = options ? options : await apiOptionsGet(this.getOptionsContext());
+ async updateOptions() {
+ this.options = await apiOptionsGet(this.getOptionsContext());
this.updateDocumentOptions(this.options);
this.updateTheme(this.options.general.popupTheme);
this.setCustomCss(this.options.general.customPopupCss);
@@ -385,6 +385,9 @@ class Display {
data.audioEnabled = `${options.audio.enabled}`;
data.compactGlossaries = `${options.general.compactGlossaries}`;
data.enableSearchTags = `${options.scanning.enableSearchTags}`;
+ data.showPitchAccentDownstepNotation = `${options.general.showPitchAccentDownstepNotation}`;
+ data.showPitchAccentPositionNotation = `${options.general.showPitchAccentPositionNotation}`;
+ data.showPitchAccentGraph = `${options.general.showPitchAccentGraph}`;
data.debug = `${options.general.debugInfo}`;
}
@@ -749,15 +752,16 @@ class Display {
try {
this.setSpinnerVisible(true);
- const context = {};
+ const details = {};
if (this.noteUsesScreenshot(mode)) {
const screenshot = await this.getScreenshot();
if (screenshot) {
- context.screenshot = screenshot;
+ details.screenshot = screenshot;
}
}
- const noteId = await apiDefinitionAdd(definition, mode, context, this.getOptionsContext());
+ const context = await this._getNoteContext();
+ const noteId = await apiDefinitionAdd(definition, mode, context, details, this.getOptionsContext());
if (noteId) {
const index = this.definitions.indexOf(definition);
const adderButton = this.adderButtonFind(index, mode);
@@ -905,12 +909,17 @@ class Display {
async getDefinitionsAddable(definitions, modes) {
try {
- return await apiDefinitionsAddable(definitions, modes, this.getOptionsContext());
+ const context = await this._getNoteContext();
+ return await apiDefinitionsAddable(definitions, modes, context, this.getOptionsContext());
} catch (e) {
return [];
}
}
+ async getDocumentTitle() {
+ return document.title;
+ }
+
static indexOf(nodeList, node) {
for (let i = 0, ii = nodeList.length; i < ii; ++i) {
if (nodeList[i] === node) {
@@ -931,6 +940,15 @@ class Display {
return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : '');
}
+ async _getNoteContext() {
+ const documentTitle = await this.getDocumentTitle();
+ return {
+ document: {
+ title: documentTitle
+ }
+ };
+ }
+
async _getAudioUri(definition, source) {
const optionsContext = this.getOptionsContext();
return await apiAudioGetUri(definition, source, optionsContext);
diff --git a/ext/mixed/js/japanese.js b/ext/mixed/js/japanese.js
new file mode 100644
index 00000000..e6b9a8a0
--- /dev/null
+++ b/ext/mixed/js/japanese.js
@@ -0,0 +1,148 @@
+/*
+ * 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/>.
+ */
+
+const jp = (() => {
+ const HIRAGANA_RANGE = [0x3040, 0x309f];
+ const KATAKANA_RANGE = [0x30a0, 0x30ff];
+ const KANA_RANGES = [HIRAGANA_RANGE, KATAKANA_RANGE];
+
+ const CJK_UNIFIED_IDEOGRAPHS_RANGE = [0x4e00, 0x9fff];
+ const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE = [0x3400, 0x4dbf];
+ const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE = [0x20000, 0x2a6df];
+ const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE = [0x2a700, 0x2b73f];
+ const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE = [0x2b740, 0x2b81f];
+ const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE = [0x2b820, 0x2ceaf];
+ const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE = [0x2ceb0, 0x2ebef];
+ const CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE = [0x2f800, 0x2fa1f];
+ const CJK_UNIFIED_IDEOGRAPHS_RANGES = [
+ CJK_UNIFIED_IDEOGRAPHS_RANGE,
+ CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE,
+ CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE,
+ CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE,
+ CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE,
+ CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE,
+ CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE,
+ CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE
+ ];
+
+ // Japanese character ranges, roughly ordered in order of expected frequency
+ const JAPANESE_RANGES = [
+ HIRAGANA_RANGE,
+ KATAKANA_RANGE,
+
+ ...CJK_UNIFIED_IDEOGRAPHS_RANGES,
+
+ [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
+ ];
+
+ const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ'));
+
+
+ // Character code testing functions
+
+ function isCodePointKanji(codePoint) {
+ return isCodePointInRanges(codePoint, CJK_UNIFIED_IDEOGRAPHS_RANGES);
+ }
+
+ function isCodePointKana(codePoint) {
+ return isCodePointInRanges(codePoint, KANA_RANGES);
+ }
+
+ function isCodePointJapanese(codePoint) {
+ return isCodePointInRanges(codePoint, JAPANESE_RANGES);
+ }
+
+ function isCodePointInRanges(codePoint, ranges) {
+ for (const [min, max] of ranges) {
+ if (codePoint >= min && codePoint <= max) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+
+ // String testing functions
+
+ function isStringEntirelyKana(str) {
+ if (str.length === 0) { return false; }
+ for (const c of str) {
+ if (!isCodePointKana(c.codePointAt(0))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ function isStringPartiallyJapanese(str) {
+ if (str.length === 0) { return false; }
+ for (const c of str) {
+ if (isCodePointJapanese(c.codePointAt(0))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+
+ // Mora functions
+
+ function isMoraPitchHigh(moraIndex, pitchAccentPosition) {
+ return pitchAccentPosition === 0 ? (moraIndex > 0) : (moraIndex < pitchAccentPosition);
+ }
+
+ function getKanaMorae(text) {
+ const morae = [];
+ let i;
+ for (const c of text) {
+ if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) {
+ morae[i - 1] += c;
+ } else {
+ morae.push(c);
+ }
+ }
+ return morae;
+ }
+
+
+ // Exports
+
+ return {
+ isCodePointKanji,
+ isCodePointKana,
+ isCodePointJapanese,
+ isStringEntirelyKana,
+ isStringPartiallyJapanese,
+ isMoraPitchHigh,
+ getKanaMorae
+ };
+})();
diff --git a/ext/mixed/js/object-property-accessor.js b/ext/mixed/js/object-property-accessor.js
new file mode 100644
index 00000000..108afc0d
--- /dev/null
+++ b/ext/mixed/js/object-property-accessor.js
@@ -0,0 +1,244 @@
+/*
+ * 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/>.
+ */
+
+/**
+ * Class used to get and set generic properties of an object by using path strings.
+ */
+class ObjectPropertyAccessor {
+ constructor(target, setter=null) {
+ this._target = target;
+ this._setter = (typeof setter === 'function' ? setter : null);
+ }
+
+ getProperty(pathArray, pathLength) {
+ let target = this._target;
+ const ii = typeof pathLength === 'number' ? Math.min(pathArray.length, pathLength) : pathArray.length;
+ for (let i = 0; i < ii; ++i) {
+ const key = pathArray[i];
+ if (!ObjectPropertyAccessor.hasProperty(target, key)) {
+ throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray.slice(0, i + 1))}`);
+ }
+ target = target[key];
+ }
+ return target;
+ }
+
+ setProperty(pathArray, value) {
+ if (pathArray.length === 0) {
+ throw new Error('Invalid path');
+ }
+
+ const target = this.getProperty(pathArray, pathArray.length - 1);
+ const key = pathArray[pathArray.length - 1];
+ if (!ObjectPropertyAccessor.isValidPropertyType(target, key)) {
+ throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`);
+ }
+
+ if (this._setter !== null) {
+ this._setter(target, key, value, pathArray);
+ } else {
+ target[key] = value;
+ }
+ }
+
+ static getPathString(pathArray) {
+ const regexShort = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+ let pathString = '';
+ let first = true;
+ for (let part of pathArray) {
+ switch (typeof part) {
+ case 'number':
+ if (Math.floor(part) !== part || part < 0) {
+ throw new Error('Invalid index');
+ }
+ part = `[${part}]`;
+ break;
+ case 'string':
+ if (!regexShort.test(part)) {
+ const escapedPart = part.replace(/["\\]/g, '\\$&');
+ part = `["${escapedPart}"]`;
+ } else {
+ if (!first) {
+ part = `.${part}`;
+ }
+ }
+ break;
+ default:
+ throw new Error(`Invalid type: ${typeof part}`);
+ }
+ pathString += part;
+ first = false;
+ }
+ return pathString;
+ }
+
+ static getPathArray(pathString) {
+ const pathArray = [];
+ let state = 'empty';
+ let quote = 0;
+ let value = '';
+ let escaped = false;
+ for (const c of pathString) {
+ const v = c.codePointAt(0);
+ switch (state) {
+ case 'empty': // Empty
+ case 'id-start': // Expecting identifier start
+ if (v === 0x5b) { // '['
+ if (state === 'id-start') {
+ throw new Error(`Unexpected character: ${c}`);
+ }
+ state = 'open-bracket';
+ } else if (
+ (v >= 0x41 && v <= 0x5a) || // ['A', 'Z']
+ (v >= 0x61 && v <= 0x7a) || // ['a', 'z']
+ v === 0x5f // '_'
+ ) {
+ state = 'id';
+ value += c;
+ } else {
+ throw new Error(`Unexpected character: ${c}`);
+ }
+ break;
+ case 'id': // Identifier
+ if (
+ (v >= 0x41 && v <= 0x5a) || // ['A', 'Z']
+ (v >= 0x61 && v <= 0x7a) || // ['a', 'z']
+ (v >= 0x30 && v <= 0x39) || // ['0', '9']
+ v === 0x5f // '_'
+ ) {
+ value += c;
+ } else if (v === 0x5b) { // '['
+ pathArray.push(value);
+ value = '';
+ state = 'open-bracket';
+ } else if (v === 0x2e) { // '.'
+ pathArray.push(value);
+ value = '';
+ state = 'id-start';
+ } else {
+ throw new Error(`Unexpected character: ${c}`);
+ }
+ break;
+ case 'open-bracket': // Open bracket
+ if (v === 0x22 || v === 0x27) { // '"' or '\''
+ quote = v;
+ state = 'string';
+ } else if (v >= 0x30 && v <= 0x39) { // ['0', '9']
+ state = 'number';
+ value += c;
+ } else {
+ throw new Error(`Unexpected character: ${c}`);
+ }
+ break;
+ case 'string': // Quoted string
+ if (escaped) {
+ value += c;
+ escaped = false;
+ } else if (v === 0x5c) { // '\\'
+ escaped = true;
+ } else if (v !== quote) {
+ value += c;
+ } else {
+ state = 'close-bracket';
+ }
+ break;
+ case 'number': // Number
+ if (v >= 0x30 && v <= 0x39) { // ['0', '9']
+ value += c;
+ } else if (v === 0x5d) { // ']'
+ pathArray.push(Number.parseInt(value, 10));
+ value = '';
+ state = 'next';
+ } else {
+ throw new Error(`Unexpected character: ${c}`);
+ }
+ break;
+ case 'close-bracket': // Expecting closing bracket after quoted string
+ if (v === 0x5d) { // ']'
+ pathArray.push(value);
+ value = '';
+ state = 'next';
+ } else {
+ throw new Error(`Unexpected character: ${c}`);
+ }
+ break;
+ case 'next': // Expecting . or [
+ if (v === 0x5b) { // '['
+ state = 'open-bracket';
+ } else if (v === 0x2e) { // '.'
+ state = 'id-start';
+ } else {
+ throw new Error(`Unexpected character: ${c}`);
+ }
+ break;
+ }
+ }
+ switch (state) {
+ case 'empty':
+ case 'next':
+ break;
+ case 'id':
+ pathArray.push(value);
+ value = '';
+ break;
+ default:
+ throw new Error('Path not terminated correctly');
+ }
+ return pathArray;
+ }
+
+ static hasProperty(object, property) {
+ switch (typeof property) {
+ case 'string':
+ return (
+ typeof object === 'object' &&
+ object !== null &&
+ !Array.isArray(object) &&
+ Object.prototype.hasOwnProperty.call(object, property)
+ );
+ case 'number':
+ return (
+ Array.isArray(object) &&
+ property >= 0 &&
+ property < object.length &&
+ property === Math.floor(property)
+ );
+ default:
+ return false;
+ }
+ }
+
+ static isValidPropertyType(object, property) {
+ switch (typeof property) {
+ case 'string':
+ return (
+ typeof object === 'object' &&
+ object !== null &&
+ !Array.isArray(object)
+ );
+ case 'number':
+ return (
+ Array.isArray(object) &&
+ property >= 0 &&
+ property === Math.floor(property)
+ );
+ default:
+ return false;
+ }
+ }
+}
diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js
index a08e09fb..b8156c01 100644
--- a/ext/mixed/js/text-scanner.js
+++ b/ext/mixed/js/text-scanner.js
@@ -23,13 +23,15 @@
*/
class TextScanner {
- constructor(node, ignoreNodes, ignoreElements, ignorePoints) {
+ constructor(node, ignoreElements, ignorePoints) {
this.node = node;
- this.ignoreNodes = (Array.isArray(ignoreNodes) && ignoreNodes.length > 0 ? ignoreNodes.join(',') : null);
this.ignoreElements = ignoreElements;
this.ignorePoints = ignorePoints;
+ this.ignoreNodes = null;
+
this.scanTimerPromise = null;
+ this.causeCurrent = null;
this.textSourceCurrent = null;
this.pendingLookup = false;
this.options = null;
@@ -298,6 +300,7 @@ class TextScanner {
this.pendingLookup = true;
const result = await this.onSearchSource(textSource, cause);
if (result !== null) {
+ this.causeCurrent = cause;
this.textSourceCurrent = textSource;
if (this.options.scanning.selectText) {
textSource.select();
diff --git a/package-lock.json b/package-lock.json
index 88ba43f6..920263d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,27 +31,19 @@
"dev": true
},
"acorn": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz",
- "integrity": "sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz",
+ "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==",
"dev": true
},
"acorn-globals": {
- "version": "4.3.4",
- "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.4.tgz",
- "integrity": "sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==",
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz",
+ "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==",
"dev": true,
"requires": {
- "acorn": "^6.0.1",
- "acorn-walk": "^6.0.1"
- },
- "dependencies": {
- "acorn": {
- "version": "6.4.0",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.0.tgz",
- "integrity": "sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw==",
- "dev": true
- }
+ "acorn": "^7.1.1",
+ "acorn-walk": "^7.1.1"
}
},
"acorn-jsx": {
@@ -61,9 +53,9 @@
"dev": true
},
"acorn-walk": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz",
- "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.1.1.tgz",
+ "integrity": "sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ==",
"dev": true
},
"ajv": {
@@ -182,9 +174,9 @@
}
},
"browser-process-hrtime": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz",
- "integrity": "sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw==",
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
+ "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==",
"dev": true
},
"callsites": {
@@ -747,9 +739,9 @@
"dev": true
},
"html-encoding-sniffer": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.0.tgz",
- "integrity": "sha512-Y9prnPKkM7FXxQevZ5UH8Z6aVTY0ede1tHquck5UxGmKWDshxXh95gSa2xXYjS8AsGO5iOvrCI5+GttRKnLdNA==",
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
+ "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==",
"dev": true,
"requires": {
"whatwg-encoding": "^1.0.5"
@@ -914,30 +906,30 @@
"dev": true
},
"jsdom": {
- "version": "16.2.0",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.2.0.tgz",
- "integrity": "sha512-6VaW3UWyKbm9DFVIAgTfhuwnvqiqlRYNg5Rk6dINTVoZT0eKz+N86vQZr+nqt1ny1lSB1TWZJWSEWQAfu8oTpA==",
+ "version": "16.2.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.2.1.tgz",
+ "integrity": "sha512-3p0gHs5EfT7PxW9v8Phz3mrq//4Dy8MQenU/PoKxhdT+c45S7NjIjKbGT3Ph0nkICweE1r36+yaknXA5WfVNAg==",
"dev": true,
"requires": {
"abab": "^2.0.3",
- "acorn": "^7.1.0",
- "acorn-globals": "^4.3.4",
+ "acorn": "^7.1.1",
+ "acorn-globals": "^6.0.0",
"cssom": "^0.4.4",
"cssstyle": "^2.2.0",
"data-urls": "^2.0.0",
"decimal.js": "^10.2.0",
"domexception": "^2.0.1",
- "escodegen": "^1.13.0",
- "html-encoding-sniffer": "^2.0.0",
+ "escodegen": "^1.14.1",
+ "html-encoding-sniffer": "^2.0.1",
"is-potential-custom-element-name": "^1.0.0",
"nwsapi": "^2.2.0",
"parse5": "5.1.1",
- "request": "^2.88.0",
+ "request": "^2.88.2",
"request-promise-native": "^1.0.8",
- "saxes": "^4.0.2",
+ "saxes": "^5.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^3.0.1",
- "w3c-hr-time": "^1.0.1",
+ "w3c-hr-time": "^1.0.2",
"w3c-xmlserializer": "^2.0.0",
"webidl-conversions": "^5.0.0",
"whatwg-encoding": "^1.0.5",
@@ -1370,9 +1362,9 @@
"dev": true
},
"saxes": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/saxes/-/saxes-4.0.2.tgz",
- "integrity": "sha512-EZOTeQ4bgkOaGCDaTKux+LaRNcLNbdbvMH7R3/yjEEULPEmqvkFbFub6DJhJTub2iGMT93CfpZ5LTdKZmAbVeQ==",
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.0.tgz",
+ "integrity": "sha512-LXTZygxhf8lfwKaTP/8N9CsVdjTlea3teze4lL6u37ivbgGbV0GGMuNtS/I9rnD/HC2/txUM7Df4S2LVl1qhiA==",
"dev": true,
"requires": {
"xmlchars": "^2.2.0"
@@ -1690,12 +1682,12 @@
}
},
"w3c-hr-time": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz",
- "integrity": "sha1-gqwr/2PZUOqeMYmlimViX+3xkEU=",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
+ "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==",
"dev": true,
"requires": {
- "browser-process-hrtime": "^0.1.2"
+ "browser-process-hrtime": "^1.0.0"
}
},
"w3c-xmlserializer": {
@@ -1770,9 +1762,9 @@
}
},
"ws": {
- "version": "7.2.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.1.tgz",
- "integrity": "sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==",
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.3.tgz",
+ "integrity": "sha512-HTDl9G9hbkNDk98naoR/cHDws7+EyYMOdL1BmjsZXRUjf7d+MficC4B7HLUPlSiho0vg+CWKrGIt/VJBd1xunQ==",
"dev": true
},
"xml-name-validator": {
diff --git a/package.json b/package.json
index eb449ea9..b02ec179 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"scripts": {
"test": "npm run test-lint && npm run test-code",
"test-lint": "eslint . && node ./test/lint/global-declarations.js",
- "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document.js"
+ "test-code": "node ./test/test-schema.js && node ./test/test-dictionary.js && node ./test/test-database.js && node ./test/test-document.js && node ./test/test-object-property-accessor.js && node ./test/test-japanese.js && node ./test/test-text-source-map.js"
},
"repository": {
"type": "git",
@@ -30,6 +30,6 @@
"eslint": "^6.8.0",
"eslint-plugin-no-unsanitized": "^3.0.2",
"fake-indexeddb": "^3.0.0",
- "jsdom": "^16.2.0"
+ "jsdom": "^16.2.1"
}
}
diff --git a/test/data/dictionaries/valid-dictionary1/tag_bank_3.json b/test/data/dictionaries/valid-dictionary1/tag_bank_3.json
new file mode 100644
index 00000000..572221fe
--- /dev/null
+++ b/test/data/dictionaries/valid-dictionary1/tag_bank_3.json
@@ -0,0 +1,4 @@
+[
+ ["ptag1", "pcategory1", 0, "ptag1 notes", 0],
+ ["ptag2", "pcategory2", 0, "ptag2 notes", 0]
+] \ No newline at end of file
diff --git a/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json b/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json
index 78096502..26922394 100644
--- a/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json
+++ b/test/data/dictionaries/valid-dictionary1/term_meta_bank_1.json
@@ -1,5 +1,39 @@
[
["打", "freq", 1],
["打つ", "freq", 2],
- ["打ち込む", "freq", 3]
+ ["打ち込む", "freq", 3],
+ [
+ "打ち込む",
+ "pitch",
+ {
+ "reading": "うちこむ",
+ "pitches": [
+ {"position": 0},
+ {"position": 3}
+ ]
+ }
+ ],
+ [
+ "打ち込む",
+ "pitch",
+ {
+ "reading": "ぶちこむ",
+ "pitches": [
+ {"position": 0},
+ {"position": 3}
+ ]
+ }
+ ],
+ [
+ "お手前",
+ "pitch",
+ {
+ "reading": "おてまえ",
+ "pitches": [
+ {"position": 2, "tags": ["ptag1"]},
+ {"position": 2, "tags": ["ptag2"]},
+ {"position": 0, "tags": ["ptag2"]}
+ ]
+ }
+ ]
] \ No newline at end of file
diff --git a/test/data/html/test-document2-frame1.html b/test/data/html/test-document2-frame1.html
new file mode 100644
index 00000000..3b85cdc5
--- /dev/null
+++ b/test/data/html/test-document2-frame1.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <title>Yomichan Tests</title>
+ <script src="test-document2-script.js"></script>
+ <style>
+body {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 14px;
+ padding: 0;
+ margin: 0;
+ background-color: #f8f8f8;
+}
+a, a:visited {
+ color: #1080c0;
+ text-decoration: underline;
+}
+.content {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ padding: 0.5em;
+ background-color: #f8f8f8;
+}
+ </style>
+ </head>
+<body><div class="content">
+<div>
+ ありがとう
+</div>
+<div>
+ <a href="#" id="fullscreen-link">Toggle fullscreen</a>
+ <script>
+document.querySelector('#fullscreen-link').addEventListener('click', () => toggleFullscreen(document.body), false);
+ </script>
+</div>
+</div></body>
+</html> \ No newline at end of file
diff --git a/test/data/html/test-document2-script.js b/test/data/html/test-document2-script.js
new file mode 100644
index 00000000..bd5a570d
--- /dev/null
+++ b/test/data/html/test-document2-script.js
@@ -0,0 +1,41 @@
+function requestFullscreen(element) {
+ if (element.requestFullscreen) {
+ element.requestFullscreen();
+ } else if (element.mozRequestFullScreen) {
+ element.mozRequestFullScreen();
+ } else if (element.webkitRequestFullscreen) {
+ element.webkitRequestFullscreen();
+ } else if (element.msRequestFullscreen) {
+ element.msRequestFullscreen();
+ }
+}
+
+function exitFullscreen() {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ } else if (document.mozCancelFullScreen) {
+ document.mozCancelFullScreen();
+ } else if (document.webkitExitFullscreen) {
+ document.webkitExitFullscreen();
+ } else if (document.msExitFullscreen) {
+ document.msExitFullscreen();
+ }
+}
+
+function getFullscreenElement() {
+ return (
+ document.fullscreenElement ||
+ document.msFullscreenElement ||
+ document.mozFullScreenElement ||
+ document.webkitFullscreenElement ||
+ null
+ );
+}
+
+function toggleFullscreen(element) {
+ if (getFullscreenElement()) {
+ exitFullscreen();
+ } else {
+ requestFullscreen(element);
+ }
+}
diff --git a/test/data/html/test-document2.html b/test/data/html/test-document2.html
new file mode 100644
index 00000000..3a22a5bf
--- /dev/null
+++ b/test/data/html/test-document2.html
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <title>Yomichan Manual Tests</title>
+ <link rel="icon" type="image/gif" href="data:image/gif;base64,R0lGODlhEAAQAKEBAAAAAP///////////yH5BAEKAAIALAAAAAAQABAAAAImFI6Zpt0B4YkS0TCpq07xbmEgcGVRUpLaI46ZG7ppalY0jDCwUAAAOw==" />
+ <link rel="stylesheet" href="test-stylesheet.css" />
+ <script src="test-document2-script.js"></script>
+ </head>
+<body>
+
+ <h1>Yomichan Manual Tests</h1>
+ <p class="description">Manual tests involving fullscreen elements, &lt;iframe&gt;s, and shadow DOMs.</p>
+
+ <div class="test">
+ <div class="description">Standard content.</div>
+ <div id="fullscreen-element1" style="width: 100%; height: 200px; border: 1px solid #d8d8d8; position: relative;"><div style="background-color: #f8f8f8; padding: 0.5em; position: absolute; left: 0; top: 0; bottom: 0; right: 0;">
+ <div>
+ ありがとう
+ </div>
+ <div>
+ <a href="#" id="fullscreen-link1">Toggle fullscreen</a>
+ </div>
+ </div></div>
+ <script>
+document.querySelector('#fullscreen-link1').addEventListener('click', () => toggleFullscreen(document.querySelector('#fullscreen-element1')), false);
+ </script>
+ </div>
+
+ <div class="test">
+ <div class="description">Content inside of a shadow DOM.</div>
+ <div id="shadow-content-container"></div>
+ <template id="shadow-content-container-content-template">
+ <link rel="stylesheet" href="test-stylesheet.css" />
+ <div id="fullscreen-element2" style="width: 100%; height: 200px; border: 1px solid #d8d8d8; position: relative;"><div style="background-color: #f8f8f8; padding: 0.5em; position: absolute; left: 0; top: 0; bottom: 0; right: 0;">
+ <div>
+ ありがとう
+ </div>
+ <div>
+ <a href="#" id="fullscreen-link2">Toggle fullscreen</a>
+ </div>
+ </div></div>
+ </template>
+ <script>
+(() => {
+ const shadowIframeContainer = document.querySelector('#shadow-content-container');
+ const shadow = shadowIframeContainer.attachShadow({mode: 'closed'});
+ const template = document.querySelector('#shadow-content-container-content-template').content;
+ const content = document.importNode(template, true);
+ const fullscreenElement = content.querySelector('#fullscreen-element2');
+ content.querySelector('#fullscreen-link2').addEventListener('click', () => toggleFullscreen(fullscreenElement), false);
+ shadow.appendChild(content);
+})();
+ </script>
+ </div>
+
+ <div class="test">
+ <div class="description">&lt;iframe&gt; element.</div>
+ <iframe src="test-document2-frame1.html" allowfullscreen="true" style="width: 100%; height: 200px; border: 1px solid #d8d8d8;"></iframe>
+ </div>
+
+ <div class="test">
+ <div class="description">&lt;iframe&gt; element inside of a shadow DOM.</div>
+ <div id="shadow-iframe-container"></div>
+ <template id="shadow-iframe-container-content-template">
+ <iframe src="test-document2-frame1.html" allowfullscreen="true" style="width: 100%; height: 200px; border: 1px solid #d8d8d8;"></iframe>
+ </template>
+ <script>
+(() => {
+ const shadowIframeContainer = document.querySelector('#shadow-iframe-container');
+ const shadow = shadowIframeContainer.attachShadow({mode: 'closed'});
+ const template = document.querySelector('#shadow-iframe-container-content-template').content;
+ const content = document.importNode(template, true);
+ shadow.appendChild(content);
+})();
+ </script>
+ </div>
+
+</body>
+</html> \ No newline at end of file
diff --git a/test/data/html/test-stylesheet.css b/test/data/html/test-stylesheet.css
index ab25732e..f63d2481 100644
--- a/test/data/html/test-stylesheet.css
+++ b/test/data/html/test-stylesheet.css
@@ -7,6 +7,7 @@ body {
margin: 0 auto;
background-color: #f8f8f8;
counter-reset: test-id;
+ overflow-y: scroll;
}
h1 {
@@ -14,6 +15,19 @@ h1 {
margin: 0.67em 0;
}
+p {
+ margin: 0.33em 0;
+}
+
+h1+p {
+ margin-top: -0.67em;
+}
+
+a, a:visited {
+ color: #1080c0;
+ text-decoration: underline;
+}
+
.test {
background-color: #ffffff;
margin: 1em 0;
@@ -30,3 +44,8 @@ h1 {
border-bottom: 1px solid #d8d8d8;
font-weight: bold;
}
+
+.description {
+ color: #444444;
+ font-style: italic;
+}
diff --git a/test/test-database.js b/test/test-database.js
index 833aa75d..bab15aa4 100644
--- a/test/test-database.js
+++ b/test/test-database.js
@@ -27,7 +27,8 @@ require('fake-indexeddb/auto');
const chrome = {
runtime: {
onMessage: {
- addListener() { /* NOP */ }
+ addListener() { /* NOP */ },
+ removeListener() { /* NOP */ }
},
getURL(path2) {
return url.pathToFileURL(path.join(__dirname, '..', 'ext', path2.replace(/^\//, '')));
@@ -107,8 +108,10 @@ vm.execute([
'bg/js/dictionary.js',
'mixed/js/core.js',
'bg/js/request.js',
+ 'bg/js/dictionary-importer.js',
'bg/js/database.js'
]);
+const DictionaryImporter = vm.get('DictionaryImporter');
const Database = vm.get('Database');
@@ -196,6 +199,7 @@ async function testDatabase1() {
];
// Setup database
+ const dictionaryImporter = new DictionaryImporter();
const database = new Database();
await database.prepare();
@@ -210,7 +214,8 @@ async function testDatabase1() {
// Import data
let progressEvent = false;
- const {result, errors} = await database.importDictionary(
+ const {result, errors} = await dictionaryImporter.import(
+ database,
testDictionarySource,
() => {
progressEvent = true;
@@ -231,8 +236,8 @@ async function testDatabase1() {
true
);
vm.assert.deepStrictEqual(counts, {
- counts: [{kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 3, tagMeta: 12}],
- total: {kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 3, tagMeta: 12}
+ counts: [{kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 6, tagMeta: 14}],
+ total: {kanji: 2, kanjiMeta: 2, terms: 32, termMeta: 6, tagMeta: 14}
});
// Test find* functions
@@ -648,9 +653,10 @@ async function testFindTermMetaBulk1(database, titles) {
}
],
expectedResults: {
- total: 1,
+ total: 3,
modes: [
- ['freq', 1]
+ ['freq', 1],
+ ['pitch', 2]
]
}
},
@@ -847,6 +853,7 @@ async function testDatabase2() {
]);
// Setup database
+ const dictionaryImporter = new DictionaryImporter();
const database = new Database();
// Error: not prepared
@@ -862,17 +869,17 @@ async function testDatabase2() {
await assert.rejects(async () => await database.findTagForTitle('tag', title));
await assert.rejects(async () => await database.getDictionaryInfo());
await assert.rejects(async () => await database.getDictionaryCounts(titles, true));
- await assert.rejects(async () => await database.importDictionary(testDictionarySource, () => {}, {}));
+ await assert.rejects(async () => await dictionaryImporter.import(database, testDictionarySource, () => {}, {}));
await database.prepare();
// Error: already prepared
await assert.rejects(async () => await database.prepare());
- await database.importDictionary(testDictionarySource, () => {}, {});
+ await dictionaryImporter.import(database, testDictionarySource, () => {}, {});
// Error: dictionary already imported
- await assert.rejects(async () => await database.importDictionary(testDictionarySource, () => {}, {}));
+ await assert.rejects(async () => await dictionaryImporter.import(database, testDictionarySource, () => {}, {}));
await database.close();
}
@@ -889,6 +896,7 @@ async function testDatabase3() {
];
// Setup database
+ const dictionaryImporter = new DictionaryImporter();
const database = new Database();
await database.prepare();
@@ -898,7 +906,7 @@ async function testDatabase3() {
let error = null;
try {
- await database.importDictionary(testDictionarySource, () => {}, {});
+ await dictionaryImporter.import(database, testDictionarySource, () => {}, {});
} catch (e) {
error = e;
}
diff --git a/test/test-japanese.js b/test/test-japanese.js
new file mode 100644
index 00000000..ca65dde2
--- /dev/null
+++ b/test/test-japanese.js
@@ -0,0 +1,471 @@
+/*
+ * 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/>.
+ */
+
+const assert = require('assert');
+const {VM} = require('./yomichan-vm');
+
+const vm = new VM();
+vm.execute([
+ 'mixed/lib/wanakana.min.js',
+ 'mixed/js/japanese.js',
+ 'bg/js/text-source-map.js',
+ 'bg/js/japanese.js'
+]);
+const jp = vm.get('jp');
+const TextSourceMap = vm.get('TextSourceMap');
+
+
+function testIsCodePointKanji() {
+ const data = [
+ ['力方', true],
+ ['\u53f1\u{20b9f}', true],
+ ['かたカタ々kata、。?,.?', false]
+ ];
+
+ for (const [characters, expected] of data) {
+ for (const character of characters) {
+ const codePoint = character.codePointAt(0);
+ const actual = jp.isCodePointKanji(codePoint);
+ assert.strictEqual(actual, expected, `isCodePointKanji failed for ${character} (\\u{${codePoint.toString(16)}})`);
+ }
+ }
+}
+
+function testIsCodePointKana() {
+ const data = [
+ ['かたカタ', true],
+ ['力方々kata、。?,.?', false],
+ ['\u53f1\u{20b9f}', false]
+ ];
+
+ for (const [characters, expected] of data) {
+ for (const character of characters) {
+ const codePoint = character.codePointAt(0);
+ const actual = jp.isCodePointKana(codePoint);
+ assert.strictEqual(actual, expected, `isCodePointKana failed for ${character} (\\u{${codePoint.toString(16)}})`);
+ }
+ }
+}
+
+function testIsCodePointJapanese() {
+ const data = [
+ ['かたカタ力方々、。?', true],
+ ['\u53f1\u{20b9f}', true],
+ ['kata,.?', false]
+ ];
+
+ for (const [characters, expected] of data) {
+ for (const character of characters) {
+ const codePoint = character.codePointAt(0);
+ const actual = jp.isCodePointJapanese(codePoint);
+ assert.strictEqual(actual, expected, `isCodePointJapanese failed for ${character} (\\u{${codePoint.toString(16)}})`);
+ }
+ }
+}
+
+function testIsStringEntirelyKana() {
+ const data = [
+ ['かたかな', true],
+ ['カタカナ', true],
+ ['ひらがな', true],
+ ['ヒラガナ', true],
+ ['カタカナひらがな', true],
+ ['かたカタ力方々、。?', false],
+ ['\u53f1\u{20b9f}', false],
+ ['kata,.?', false],
+ ['かたカタ力方々、。?invalid', false],
+ ['\u53f1\u{20b9f}invalid', false],
+ ['kata,.?かた', false]
+ ];
+
+ for (const [string, expected] of data) {
+ assert.strictEqual(jp.isStringEntirelyKana(string), expected);
+ }
+}
+
+function testIsStringPartiallyJapanese() {
+ const data = [
+ ['かたかな', true],
+ ['カタカナ', true],
+ ['ひらがな', true],
+ ['ヒラガナ', true],
+ ['カタカナひらがな', true],
+ ['かたカタ力方々、。?', true],
+ ['\u53f1\u{20b9f}', true],
+ ['kata,.?', false],
+ ['かたカタ力方々、。?invalid', true],
+ ['\u53f1\u{20b9f}invalid', true],
+ ['kata,.?かた', true]
+ ];
+
+ for (const [string, expected] of data) {
+ assert.strictEqual(jp.isStringPartiallyJapanese(string), expected);
+ }
+}
+
+function testConvertKatakanaToHiragana() {
+ const data = [
+ ['かたかな', 'かたかな'],
+ ['ひらがな', 'ひらがな'],
+ ['カタカナ', 'かたかな'],
+ ['ヒラガナ', 'ひらがな'],
+ ['カタカナかたかな', 'かたかなかたかな'],
+ ['ヒラガナひらがな', 'ひらがなひらがな'],
+ ['chikaraちからチカラ力', 'chikaraちからちから力'],
+ ['katakana', 'katakana'],
+ ['hiragana', 'hiragana']
+ ];
+
+ for (const [string, expected] of data) {
+ assert.strictEqual(jp.convertKatakanaToHiragana(string), expected);
+ }
+}
+
+function testConvertHiraganaToKatakana() {
+ const data = [
+ ['かたかな', 'カタカナ'],
+ ['ひらがな', 'ヒラガナ'],
+ ['カタカナ', 'カタカナ'],
+ ['ヒラガナ', 'ヒラガナ'],
+ ['カタカナかたかな', 'カタカナカタカナ'],
+ ['ヒラガナひらがな', 'ヒラガナヒラガナ'],
+ ['chikaraちからチカラ力', 'chikaraチカラチカラ力'],
+ ['katakana', 'katakana'],
+ ['hiragana', 'hiragana']
+ ];
+
+ for (const [string, expected] of data) {
+ assert.strictEqual(jp.convertHiraganaToKatakana(string), expected);
+ }
+}
+
+function testConvertToRomaji() {
+ const data = [
+ ['かたかな', 'katakana'],
+ ['ひらがな', 'hiragana'],
+ ['カタカナ', 'katakana'],
+ ['ヒラガナ', 'hiragana'],
+ ['カタカナかたかな', 'katakanakatakana'],
+ ['ヒラガナひらがな', 'hiraganahiragana'],
+ ['chikaraちからチカラ力', 'chikarachikarachikara力'],
+ ['katakana', 'katakana'],
+ ['hiragana', 'hiragana']
+ ];
+
+ for (const [string, expected] of data) {
+ assert.strictEqual(jp.convertToRomaji(string), expected);
+ }
+}
+
+function testConvertReading() {
+ const data = [
+ [['アリガトウ', 'アリガトウ', 'hiragana'], 'ありがとう'],
+ [['アリガトウ', 'アリガトウ', 'katakana'], 'アリガトウ'],
+ [['アリガトウ', 'アリガトウ', 'romaji'], 'arigatou'],
+ [['アリガトウ', 'アリガトウ', 'none'], null],
+ [['アリガトウ', 'アリガトウ', 'default'], 'アリガトウ'],
+
+ [['ありがとう', 'ありがとう', 'hiragana'], 'ありがとう'],
+ [['ありがとう', 'ありがとう', 'katakana'], 'アリガトウ'],
+ [['ありがとう', 'ありがとう', 'romaji'], 'arigatou'],
+ [['ありがとう', 'ありがとう', 'none'], null],
+ [['ありがとう', 'ありがとう', 'default'], 'ありがとう'],
+
+ [['有り難う', 'ありがとう', 'hiragana'], 'ありがとう'],
+ [['有り難う', 'ありがとう', 'katakana'], 'アリガトウ'],
+ [['有り難う', 'ありがとう', 'romaji'], 'arigatou'],
+ [['有り難う', 'ありがとう', 'none'], null],
+ [['有り難う', 'ありがとう', 'default'], 'ありがとう'],
+
+ // Cases with falsy readings
+
+ [['ありがとう', '', 'hiragana'], ''],
+ [['ありがとう', '', 'katakana'], ''],
+ [['ありがとう', '', 'romaji'], 'arigatou'],
+ [['ありがとう', '', 'none'], null],
+ [['ありがとう', '', 'default'], ''],
+
+ [['ありがとう', null, 'hiragana'], ''],
+ [['ありがとう', null, 'katakana'], ''],
+ [['ありがとう', null, 'romaji'], 'arigatou'],
+ [['ありがとう', null, 'none'], null],
+ [['ありがとう', null, 'default'], null],
+
+ [['ありがとう', void 0, 'hiragana'], ''],
+ [['ありがとう', void 0, 'katakana'], ''],
+ [['ありがとう', void 0, 'romaji'], 'arigatou'],
+ [['ありがとう', void 0, 'none'], null],
+ [['ありがとう', void 0, 'default'], void 0],
+
+ // Cases with falsy readings and kanji expressions
+
+ [['有り難う', '', 'hiragana'], ''],
+ [['有り難う', '', 'katakana'], ''],
+ [['有り難う', '', 'romaji'], ''],
+ [['有り難う', '', 'none'], null],
+ [['有り難う', '', 'default'], ''],
+
+ [['有り難う', null, 'hiragana'], ''],
+ [['有り難う', null, 'katakana'], ''],
+ [['有り難う', null, 'romaji'], null],
+ [['有り難う', null, 'none'], null],
+ [['有り難う', null, 'default'], null],
+
+ [['有り難う', void 0, 'hiragana'], ''],
+ [['有り難う', void 0, 'katakana'], ''],
+ [['有り難う', void 0, 'romaji'], void 0],
+ [['有り難う', void 0, 'none'], null],
+ [['有り難う', void 0, 'default'], void 0]
+ ];
+
+ for (const [[expressionFragment, readingFragment, readingMode], expected] of data) {
+ assert.strictEqual(jp.convertReading(expressionFragment, readingFragment, readingMode), expected);
+ }
+}
+
+function testConvertNumericToFullWidth() {
+ const data = [
+ ['0123456789', '0123456789'],
+ ['abcdefghij', 'abcdefghij'],
+ ['カタカナ', 'カタカナ'],
+ ['ひらがな', 'ひらがな']
+ ];
+
+ for (const [string, expected] of data) {
+ assert.strictEqual(jp.convertNumericToFullWidth(string), expected);
+ }
+}
+
+function testConvertHalfWidthKanaToFullWidth() {
+ const data = [
+ ['0123456789', '0123456789'],
+ ['abcdefghij', 'abcdefghij'],
+ ['カタカナ', 'カタカナ'],
+ ['ひらがな', 'ひらがな'],
+ ['カキ', 'カキ', [1, 1]],
+ ['ガキ', 'ガキ', [2, 1]],
+ ['ニホン', 'ニホン', [1, 1, 1]],
+ ['ニッポン', 'ニッポン', [1, 1, 2, 1]]
+ ];
+
+ for (const [string, expected, expectedSourceMapping] of data) {
+ const sourceMap = new TextSourceMap(string);
+ const actual1 = jp.convertHalfWidthKanaToFullWidth(string, null);
+ const actual2 = jp.convertHalfWidthKanaToFullWidth(string, sourceMap);
+ assert.strictEqual(actual1, expected);
+ assert.strictEqual(actual2, expected);
+ if (typeof expectedSourceMapping !== 'undefined') {
+ assert.ok(sourceMap.equals(new TextSourceMap(string, expectedSourceMapping)));
+ }
+ }
+}
+
+function testConvertAlphabeticToKana() {
+ const data = [
+ ['0123456789', '0123456789'],
+ ['abcdefghij', 'あbcでfgひj', [1, 1, 1, 2, 1, 1, 2, 1]],
+ ['ABCDEFGHIJ', 'あbcでfgひj', [1, 1, 1, 2, 1, 1, 2, 1]], // wanakana.toHiragana converts text to lower case
+ ['カタカナ', 'カタカナ'],
+ ['ひらがな', 'ひらがな'],
+ ['chikara', 'ちから', [3, 2, 2]],
+ ['CHIKARA', 'ちから', [3, 2, 2]]
+ ];
+
+ for (const [string, expected, expectedSourceMapping] of data) {
+ const sourceMap = new TextSourceMap(string);
+ const actual1 = jp.convertAlphabeticToKana(string, null);
+ const actual2 = jp.convertAlphabeticToKana(string, sourceMap);
+ assert.strictEqual(actual1, expected);
+ assert.strictEqual(actual2, expected);
+ if (typeof expectedSourceMapping !== 'undefined') {
+ assert.ok(sourceMap.equals(new TextSourceMap(string, expectedSourceMapping)));
+ }
+ }
+}
+
+function testDistributeFurigana() {
+ const data = [
+ [
+ ['有り難う', 'ありがとう'],
+ [
+ {text: '有', furigana: 'あ'},
+ {text: 'り'},
+ {text: '難', furigana: 'がと'},
+ {text: 'う'}
+ ]
+ ],
+ [
+ ['方々', 'かたがた'],
+ [
+ {text: '方々', furigana: 'かたがた'}
+ ]
+ ],
+ [
+ ['お祝い', 'おいわい'],
+ [
+ {text: 'お'},
+ {text: '祝', furigana: 'いわ'},
+ {text: 'い'}
+ ]
+ ],
+ [
+ ['美味しい', 'おいしい'],
+ [
+ {text: '美味', furigana: 'おい'},
+ {text: 'しい'}
+ ]
+ ],
+ [
+ ['食べ物', 'たべもの'],
+ [
+ {text: '食', furigana: 'た'},
+ {text: 'べ'},
+ {text: '物', furigana: 'もの'}
+ ]
+ ],
+ [
+ ['試し切り', 'ためしぎり'],
+ [
+ {text: '試', furigana: 'ため'},
+ {text: 'し'},
+ {text: '切', furigana: 'ぎ'},
+ {text: 'り'}
+ ]
+ ],
+ // Ambiguous
+ [
+ ['飼い犬', 'かいいぬ'],
+ [
+ {text: '飼い犬', furigana: 'かいいぬ'}
+ ]
+ ],
+ [
+ ['長い間', 'ながいあいだ'],
+ [
+ {text: '長い間', furigana: 'ながいあいだ'}
+ ]
+ ]
+ ];
+
+ for (const [[expression, reading], expected] of data) {
+ const actual = jp.distributeFurigana(expression, reading);
+ vm.assert.deepStrictEqual(actual, expected);
+ }
+}
+
+function testDistributeFuriganaInflected() {
+ const data = [
+ [
+ ['美味しい', 'おいしい', '美味しかた'],
+ [
+ {text: '美味', furigana: 'おい'},
+ {text: 'し'},
+ {text: 'かた'}
+ ]
+ ],
+ [
+ ['食べる', 'たべる', '食べた'],
+ [
+ {text: '食', furigana: 'た'},
+ {text: 'べ'},
+ {text: 'た'}
+ ]
+ ]
+ ];
+
+ for (const [[expression, reading, source], expected] of data) {
+ const actual = jp.distributeFuriganaInflected(expression, reading, source);
+ vm.assert.deepStrictEqual(actual, expected);
+ }
+}
+
+function testIsMoraPitchHigh() {
+ const data = [
+ [[0, 0], false],
+ [[1, 0], true],
+ [[2, 0], true],
+ [[3, 0], true],
+
+ [[0, 1], true],
+ [[1, 1], false],
+ [[2, 1], false],
+ [[3, 1], false],
+
+ [[0, 2], true],
+ [[1, 2], true],
+ [[2, 2], false],
+ [[3, 2], false],
+
+ [[0, 3], true],
+ [[1, 3], true],
+ [[2, 3], true],
+ [[3, 3], false],
+
+ [[0, 4], true],
+ [[1, 4], true],
+ [[2, 4], true],
+ [[3, 4], true]
+ ];
+
+ for (const [[moraIndex, pitchAccentPosition], expected] of data) {
+ const actual = jp.isMoraPitchHigh(moraIndex, pitchAccentPosition);
+ assert.strictEqual(actual, expected);
+ }
+}
+
+function testGetKanaMorae() {
+ const data = [
+ ['かこ', ['か', 'こ']],
+ ['かっこ', ['か', 'っ', 'こ']],
+ ['カコ', ['カ', 'コ']],
+ ['カッコ', ['カ', 'ッ', 'コ']],
+ ['コート', ['コ', 'ー', 'ト']],
+ ['ちゃんと', ['ちゃ', 'ん', 'と']],
+ ['とうきょう', ['と', 'う', 'きょ', 'う']],
+ ['ぎゅう', ['ぎゅ', 'う']],
+ ['ディスコ', ['ディ', 'ス', 'コ']]
+ ];
+
+ for (const [text, expected] of data) {
+ const actual = jp.getKanaMorae(text);
+ vm.assert.deepStrictEqual(actual, expected);
+ }
+}
+
+
+function main() {
+ testIsCodePointKanji();
+ testIsCodePointKana();
+ testIsCodePointJapanese();
+ testIsStringEntirelyKana();
+ testIsStringPartiallyJapanese();
+ testConvertKatakanaToHiragana();
+ testConvertHiraganaToKatakana();
+ testConvertToRomaji();
+ testConvertReading();
+ testConvertNumericToFullWidth();
+ testConvertHalfWidthKanaToFullWidth();
+ testConvertAlphabeticToKana();
+ testDistributeFurigana();
+ testDistributeFuriganaInflected();
+ testIsMoraPitchHigh();
+ testGetKanaMorae();
+}
+
+
+if (require.main === module) { main(); }
diff --git a/test/test-object-property-accessor.js b/test/test-object-property-accessor.js
new file mode 100644
index 00000000..47d2e451
--- /dev/null
+++ b/test/test-object-property-accessor.js
@@ -0,0 +1,289 @@
+/*
+ * 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/>.
+ */
+
+const assert = require('assert');
+const {VM} = require('./yomichan-vm');
+
+const vm = new VM({});
+vm.execute('mixed/js/object-property-accessor.js');
+const ObjectPropertyAccessor = vm.get('ObjectPropertyAccessor');
+
+
+function createTestObject() {
+ return {
+ 0: null,
+ value1: {
+ value2: {},
+ value3: [],
+ value4: null
+ },
+ value5: [
+ {},
+ [],
+ null
+ ]
+ };
+}
+
+
+function testGetProperty1() {
+ const object = createTestObject();
+ const accessor = new ObjectPropertyAccessor(object);
+
+ const data = [
+ [[], object],
+ [['0'], object['0']],
+ [['value1'], object.value1],
+ [['value1', 'value2'], object.value1.value2],
+ [['value1', 'value3'], object.value1.value3],
+ [['value1', 'value4'], object.value1.value4],
+ [['value5'], object.value5],
+ [['value5', 0], object.value5[0]],
+ [['value5', 1], object.value5[1]],
+ [['value5', 2], object.value5[2]]
+ ];
+
+ for (const [pathArray, expected] of data) {
+ assert.strictEqual(accessor.getProperty(pathArray), expected);
+ }
+}
+
+function testGetProperty2() {
+ const object = createTestObject();
+ const accessor = new ObjectPropertyAccessor(object);
+
+ const data = [
+ [[0], 'Invalid path: [0]'],
+ [['0', 'invalid'], 'Invalid path: ["0"].invalid'],
+ [['invalid'], 'Invalid path: invalid'],
+ [['value1', 'invalid'], 'Invalid path: value1.invalid'],
+ [['value1', 'value2', 'invalid'], 'Invalid path: value1.value2.invalid'],
+ [['value1', 'value2', 0], 'Invalid path: value1.value2[0]'],
+ [['value1', 'value3', 'invalid'], 'Invalid path: value1.value3.invalid'],
+ [['value1', 'value3', 0], 'Invalid path: value1.value3[0]'],
+ [['value1', 'value4', 'invalid'], 'Invalid path: value1.value4.invalid'],
+ [['value1', 'value4', 0], 'Invalid path: value1.value4[0]'],
+ [['value5', 'length'], 'Invalid path: value5.length'],
+ [['value5', 0, 'invalid'], 'Invalid path: value5[0].invalid'],
+ [['value5', 0, 0], 'Invalid path: value5[0][0]'],
+ [['value5', 1, 'invalid'], 'Invalid path: value5[1].invalid'],
+ [['value5', 1, 0], 'Invalid path: value5[1][0]'],
+ [['value5', 2, 'invalid'], 'Invalid path: value5[2].invalid'],
+ [['value5', 2, 0], 'Invalid path: value5[2][0]'],
+ [['value5', 2, 0, 'invalid'], 'Invalid path: value5[2][0]'],
+ [['value5', 2.5], 'Invalid index']
+ ];
+
+ for (const [pathArray, message] of data) {
+ assert.throws(() => accessor.getProperty(pathArray), {message});
+ }
+}
+
+
+function testSetProperty1() {
+ const object = createTestObject();
+ const accessor = new ObjectPropertyAccessor(object);
+
+ const testValue = {};
+ const data = [
+ ['0'],
+ ['value1', 'value2'],
+ ['value1', 'value3'],
+ ['value1', 'value4'],
+ ['value1'],
+ ['value5', 0],
+ ['value5', 1],
+ ['value5', 2],
+ ['value5']
+ ];
+
+ for (const pathArray of data) {
+ accessor.setProperty(pathArray, testValue);
+ assert.strictEqual(accessor.getProperty(pathArray), testValue);
+ }
+}
+
+function testSetProperty2() {
+ const object = createTestObject();
+ const accessor = new ObjectPropertyAccessor(object);
+
+ const testValue = {};
+ const data = [
+ [[0], 'Invalid path: [0]'],
+ [['0', 'invalid'], 'Invalid path: ["0"].invalid'],
+ [['value1', 'value2', 0], 'Invalid path: value1.value2[0]'],
+ [['value1', 'value3', 'invalid'], 'Invalid path: value1.value3.invalid'],
+ [['value1', 'value4', 'invalid'], 'Invalid path: value1.value4.invalid'],
+ [['value1', 'value4', 0], 'Invalid path: value1.value4[0]'],
+ [['value5', 1, 'invalid'], 'Invalid path: value5[1].invalid'],
+ [['value5', 2, 'invalid'], 'Invalid path: value5[2].invalid'],
+ [['value5', 2, 0], 'Invalid path: value5[2][0]'],
+ [['value5', 2, 0, 'invalid'], 'Invalid path: value5[2][0]'],
+ [['value5', 2.5], 'Invalid index']
+ ];
+
+ for (const [pathArray, message] of data) {
+ assert.throws(() => accessor.setProperty(pathArray, testValue), {message});
+ }
+}
+
+
+function testGetPathString1() {
+ const data = [
+ [[], ''],
+ [[0], '[0]'],
+ [['escape\\'], '["escape\\\\"]'],
+ [['\'quote\''], '["\'quote\'"]'],
+ [['"quote"'], '["\\"quote\\""]'],
+ [['part1', 'part2'], 'part1.part2'],
+ [['part1', 'part2', 3], 'part1.part2[3]'],
+ [['part1', 'part2', '3'], 'part1.part2["3"]'],
+ [['part1', 'part2', '3part'], 'part1.part2["3part"]'],
+ [['part1', 'part2', '3part', 'part4'], 'part1.part2["3part"].part4'],
+ [['part1', 'part2', '3part', '4part'], 'part1.part2["3part"]["4part"]']
+ ];
+
+ for (const [pathArray, expected] of data) {
+ assert.strictEqual(ObjectPropertyAccessor.getPathString(pathArray), expected);
+ }
+}
+
+function testGetPathString2() {
+ const data = [
+ [[1.5], 'Invalid index'],
+ [[null], 'Invalid type: object']
+ ];
+
+ for (const [pathArray, message] of data) {
+ assert.throws(() => ObjectPropertyAccessor.getPathString(pathArray), {message});
+ }
+}
+
+
+function testGetPathArray1() {
+ const data = [
+ ['', []],
+ ['[0]', [0]],
+ ['["escape\\\\"]', ['escape\\']],
+ ['["\'quote\'"]', ['\'quote\'']],
+ ['["\\"quote\\""]', ['"quote"']],
+ ['part1.part2', ['part1', 'part2']],
+ ['part1.part2[3]', ['part1', 'part2', 3]],
+ ['part1.part2["3"]', ['part1', 'part2', '3']],
+ ['part1.part2[\'3\']', ['part1', 'part2', '3']],
+ ['part1.part2["3part"]', ['part1', 'part2', '3part']],
+ ['part1.part2[\'3part\']', ['part1', 'part2', '3part']],
+ ['part1.part2["3part"].part4', ['part1', 'part2', '3part', 'part4']],
+ ['part1.part2[\'3part\'].part4', ['part1', 'part2', '3part', 'part4']],
+ ['part1.part2["3part"]["4part"]', ['part1', 'part2', '3part', '4part']],
+ ['part1.part2[\'3part\'][\'4part\']', ['part1', 'part2', '3part', '4part']]
+ ];
+
+ for (const [pathString, expected] of data) {
+ vm.assert.deepStrictEqual(ObjectPropertyAccessor.getPathArray(pathString), expected);
+ }
+}
+
+function testGetPathArray2() {
+ const data = [
+ ['?', 'Unexpected character: ?'],
+ ['.', 'Unexpected character: .'],
+ ['0', 'Unexpected character: 0'],
+ ['part1.[0]', 'Unexpected character: ['],
+ ['part1?', 'Unexpected character: ?'],
+ ['[part1]', 'Unexpected character: p'],
+ ['[0a]', 'Unexpected character: a'],
+ ['["part1"x]', 'Unexpected character: x'],
+ ['[\'part1\'x]', 'Unexpected character: x'],
+ ['["part1"]x', 'Unexpected character: x'],
+ ['[\'part1\']x', 'Unexpected character: x'],
+ ['part1..part2', 'Unexpected character: .'],
+
+ ['[', 'Path not terminated correctly'],
+ ['part1.', 'Path not terminated correctly'],
+ ['part1[', 'Path not terminated correctly'],
+ ['part1["', 'Path not terminated correctly'],
+ ['part1[\'', 'Path not terminated correctly'],
+ ['part1[""', 'Path not terminated correctly'],
+ ['part1[\'\'', 'Path not terminated correctly'],
+ ['part1[0', 'Path not terminated correctly'],
+ ['part1[0].', 'Path not terminated correctly']
+ ];
+
+ for (const [pathString, message] of data) {
+ assert.throws(() => ObjectPropertyAccessor.getPathArray(pathString), {message});
+ }
+}
+
+
+function testHasProperty() {
+ const data = [
+ [{}, 'invalid', false],
+ [{}, 0, false],
+ [{valid: 0}, 'valid', true],
+ [{null: 0}, null, false],
+ [[], 'invalid', false],
+ [[], 0, false],
+ [[0], 0, true],
+ [[0], null, false],
+ ['string', 0, false],
+ ['string', 'length', false],
+ ['string', null, false]
+ ];
+
+ for (const [object, property, expected] of data) {
+ assert.strictEqual(ObjectPropertyAccessor.hasProperty(object, property), expected);
+ }
+}
+
+function testIsValidPropertyType() {
+ const data = [
+ [{}, 'invalid', true],
+ [{}, 0, false],
+ [{valid: 0}, 'valid', true],
+ [{null: 0}, null, false],
+ [[], 'invalid', false],
+ [[], 0, true],
+ [[0], 0, true],
+ [[0], null, false],
+ ['string', 0, false],
+ ['string', 'length', false],
+ ['string', null, false]
+ ];
+
+ for (const [object, property, expected] of data) {
+ assert.strictEqual(ObjectPropertyAccessor.isValidPropertyType(object, property), expected);
+ }
+}
+
+
+function main() {
+ testGetProperty1();
+ testGetProperty2();
+ testSetProperty1();
+ testSetProperty2();
+ testGetPathString1();
+ testGetPathString2();
+ testGetPathArray1();
+ testGetPathArray2();
+ testHasProperty();
+ testIsValidPropertyType();
+}
+
+
+if (require.main === module) { main(); }
diff --git a/test/test-text-source-map.js b/test/test-text-source-map.js
new file mode 100644
index 00000000..25bd8fc2
--- /dev/null
+++ b/test/test-text-source-map.js
@@ -0,0 +1,234 @@
+/*
+ * 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/>.
+ */
+
+const assert = require('assert');
+const {VM} = require('./yomichan-vm');
+
+const vm = new VM();
+vm.execute(['bg/js/text-source-map.js']);
+const TextSourceMap = vm.get('TextSourceMap');
+
+
+function testSource() {
+ const data = [
+ ['source1'],
+ ['source2'],
+ ['source3']
+ ];
+
+ for (const [source] of data) {
+ const sourceMap = new TextSourceMap(source);
+ assert.strictEqual(source, sourceMap.source);
+ }
+}
+
+function testEquals() {
+ const data = [
+ [['source1', null], ['source1', null], true],
+ [['source2', null], ['source2', null], true],
+ [['source3', null], ['source3', null], true],
+
+ [['source1', [1, 1, 1, 1, 1, 1, 1]], ['source1', null], true],
+ [['source2', [1, 1, 1, 1, 1, 1, 1]], ['source2', null], true],
+ [['source3', [1, 1, 1, 1, 1, 1, 1]], ['source3', null], true],
+
+ [['source1', null], ['source1', [1, 1, 1, 1, 1, 1, 1]], true],
+ [['source2', null], ['source2', [1, 1, 1, 1, 1, 1, 1]], true],
+ [['source3', null], ['source3', [1, 1, 1, 1, 1, 1, 1]], true],
+
+ [['source1', [1, 1, 1, 1, 1, 1, 1]], ['source1', [1, 1, 1, 1, 1, 1, 1]], true],
+ [['source2', [1, 1, 1, 1, 1, 1, 1]], ['source2', [1, 1, 1, 1, 1, 1, 1]], true],
+ [['source3', [1, 1, 1, 1, 1, 1, 1]], ['source3', [1, 1, 1, 1, 1, 1, 1]], true],
+
+ [['source1', [1, 2, 1, 3]], ['source1', [1, 2, 1, 3]], true],
+ [['source2', [1, 2, 1, 3]], ['source2', [1, 2, 1, 3]], true],
+ [['source3', [1, 2, 1, 3]], ['source3', [1, 2, 1, 3]], true],
+
+ [['source1', [1, 3, 1, 2]], ['source1', [1, 2, 1, 3]], false],
+ [['source2', [1, 3, 1, 2]], ['source2', [1, 2, 1, 3]], false],
+ [['source3', [1, 3, 1, 2]], ['source3', [1, 2, 1, 3]], false],
+
+ [['source1', [1, 1, 1, 1, 1, 1, 1]], ['source4', [1, 1, 1, 1, 1, 1, 1]], false],
+ [['source2', [1, 1, 1, 1, 1, 1, 1]], ['source5', [1, 1, 1, 1, 1, 1, 1]], false],
+ [['source3', [1, 1, 1, 1, 1, 1, 1]], ['source6', [1, 1, 1, 1, 1, 1, 1]], false]
+ ];
+
+ for (const [[source1, mapping1], [source2, mapping2], expectedEquals] of data) {
+ const sourceMap1 = new TextSourceMap(source1, mapping1);
+ const sourceMap2 = new TextSourceMap(source2, mapping2);
+ assert.ok(sourceMap1.equals(sourceMap1));
+ assert.ok(sourceMap2.equals(sourceMap2));
+ assert.strictEqual(sourceMap1.equals(sourceMap2), expectedEquals);
+ }
+}
+
+function testGetSourceLength() {
+ const data = [
+ [['source', [1, 1, 1, 1, 1, 1]], 1, 1],
+ [['source', [1, 1, 1, 1, 1, 1]], 2, 2],
+ [['source', [1, 1, 1, 1, 1, 1]], 3, 3],
+ [['source', [1, 1, 1, 1, 1, 1]], 4, 4],
+ [['source', [1, 1, 1, 1, 1, 1]], 5, 5],
+ [['source', [1, 1, 1, 1, 1, 1]], 6, 6],
+
+ [['source', [2, 2, 2]], 1, 2],
+ [['source', [2, 2, 2]], 2, 4],
+ [['source', [2, 2, 2]], 3, 6],
+
+ [['source', [3, 3]], 1, 3],
+ [['source', [3, 3]], 2, 6],
+
+ [['source', [6, 6]], 1, 6]
+ ];
+
+ for (const [[source, mapping], finalLength, expectedValue] of data) {
+ const sourceMap = new TextSourceMap(source, mapping);
+ assert.strictEqual(sourceMap.getSourceLength(finalLength), expectedValue);
+ }
+}
+
+function testCombineInsert() {
+ const data = [
+ // No operations
+ [
+ ['source', null],
+ ['source', [1, 1, 1, 1, 1, 1]],
+ []
+ ],
+
+ // Combine
+ [
+ ['source', null],
+ ['source', [3, 1, 1, 1]],
+ [
+ ['combine', 0, 2]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [1, 1, 1, 3]],
+ [
+ ['combine', 3, 2]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [3, 3]],
+ [
+ ['combine', 0, 2],
+ ['combine', 1, 2]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [3, 3]],
+ [
+ ['combine', 3, 2],
+ ['combine', 0, 2]
+ ]
+ ],
+
+ // Insert
+ [
+ ['source', null],
+ ['source', [0, 1, 1, 1, 1, 1, 1]],
+ [
+ ['insert', 0, 0]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [1, 1, 1, 1, 1, 1, 0]],
+ [
+ ['insert', 6, 0]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [0, 1, 1, 1, 1, 1, 1, 0]],
+ [
+ ['insert', 0, 0],
+ ['insert', 7, 0]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [0, 1, 1, 1, 1, 1, 1, 0]],
+ [
+ ['insert', 6, 0],
+ ['insert', 0, 0]
+ ]
+ ],
+
+ // Mixed
+ [
+ ['source', null],
+ ['source', [3, 0, 3]],
+ [
+ ['combine', 0, 2],
+ ['insert', 1, 0],
+ ['combine', 2, 2]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [3, 0, 3]],
+ [
+ ['combine', 0, 2],
+ ['combine', 1, 2],
+ ['insert', 1, 0]
+ ]
+ ],
+ [
+ ['source', null],
+ ['source', [3, 0, 3]],
+ [
+ ['insert', 3, 0],
+ ['combine', 0, 2],
+ ['combine', 2, 2]
+ ]
+ ]
+ ];
+
+ for (const [[source, mapping], [expectedSource, expectedMapping], operations] of data) {
+ const sourceMap = new TextSourceMap(source, mapping);
+ const expectedSourceMap = new TextSourceMap(expectedSource, expectedMapping);
+ for (const [operation, ...args] of operations) {
+ switch (operation) {
+ case 'combine':
+ sourceMap.combine(...args);
+ break;
+ case 'insert':
+ sourceMap.insert(...args);
+ break;
+ }
+ }
+ assert.ok(sourceMap.equals(expectedSourceMap));
+ }
+}
+
+
+function main() {
+ testSource();
+ testEquals();
+ testGetSourceLength();
+ testCombineInsert();
+}
+
+
+if (require.main === module) { main(); }