aboutsummaryrefslogtreecommitdiff
path: root/ext/bg/js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/bg/js')
-rw-r--r--ext/bg/js/api.js8
-rw-r--r--ext/bg/js/backend.js127
-rw-r--r--ext/bg/js/clipboard-monitor.js80
-rw-r--r--ext/bg/js/context.js4
-rw-r--r--ext/bg/js/japanese.js454
-rw-r--r--ext/bg/js/options.js1
-rw-r--r--ext/bg/js/search-query-parser-generator.js77
-rw-r--r--ext/bg/js/search-query-parser.js64
-rw-r--r--ext/bg/js/search.js178
-rw-r--r--ext/bg/js/settings/main.js17
-rw-r--r--ext/bg/js/templates.js55
11 files changed, 822 insertions, 243 deletions
diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js
index 285b8016..cd6a9d18 100644
--- a/ext/bg/js/api.js
+++ b/ext/bg/js/api.js
@@ -29,6 +29,14 @@ function apiGetDisplayTemplatesHtml() {
return _apiInvoke('getDisplayTemplatesHtml');
}
+function apiClipboardGet() {
+ return _apiInvoke('clipboardGet');
+}
+
+function apiGetQueryParserTemplatesHtml() {
+ return _apiInvoke('getQueryParserTemplatesHtml');
+}
+
function _apiInvoke(action, params={}) {
const data = {action, params};
return new Promise((resolve, reject) => {
diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js
index eeab68a5..529055d2 100644
--- a/ext/bg/js/backend.js
+++ b/ext/bg/js/backend.js
@@ -22,6 +22,7 @@ class Backend {
this.translator = new Translator();
this.anki = new AnkiNull();
this.mecab = new Mecab();
+ this.clipboardMonitor = new ClipboardMonitor();
this.options = null;
this.optionsSchema = null;
this.optionsContext = {
@@ -34,6 +35,8 @@ class Backend {
this.clipboardPasteTarget = document.querySelector('#clipboard-paste-target');
+ this.popupWindow = null;
+
this.apiForwarder = new BackendApiForwarder();
}
@@ -67,6 +70,8 @@ class Backend {
this.isPreparedResolve();
this.isPreparedResolve = null;
this.isPreparedPromise = null;
+
+ this.clipboardMonitor.onClipboardText = (text) => this._onClipboardText(text);
}
onOptionsUpdated(source) {
@@ -97,6 +102,10 @@ class Backend {
}
}
+ _onClipboardText(text) {
+ this._onCommandSearch({mode: 'popup', query: text});
+ }
+
_onZoomChange({tabId, oldZoomFactor, newZoomFactor}) {
const callback = () => this.checkLastError(chrome.runtime.lastError);
chrome.tabs.sendMessage(tabId, {action: 'zoomChanged', params: {oldZoomFactor, newZoomFactor}}, callback);
@@ -121,6 +130,12 @@ class Backend {
} else {
this.mecab.stopListener();
}
+
+ if (options.general.enableClipboardPopups) {
+ this.clipboardMonitor.start();
+ } else {
+ this.clipboardMonitor.stop();
+ }
}
async getOptionsSchema() {
@@ -521,13 +536,30 @@ class Backend {
}
async _onApiClipboardGet() {
- const clipboardPasteTarget = this.clipboardPasteTarget;
- clipboardPasteTarget.value = '';
- clipboardPasteTarget.focus();
- document.execCommand('paste');
- const result = clipboardPasteTarget.value;
- clipboardPasteTarget.value = '';
- return result;
+ /*
+ Notes:
+ document.execCommand('paste') doesn't work on Firefox.
+ This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985
+ Therefore, navigator.clipboard.readText() is used on Firefox.
+
+ navigator.clipboard.readText() can't be used in Chrome for two reasons:
+ * Requires page to be focused, else it rejects with an exception.
+ * When the page is focused, Chrome will request clipboard permission, despite already
+ being an extension with clipboard permissions. It effectively asks for the
+ non-extension permission for clipboard access.
+ */
+ const browser = await Backend._getBrowser();
+ if (browser === 'firefox' || browser === 'firefox-mobile') {
+ return await navigator.clipboard.readText();
+ } else {
+ const clipboardPasteTarget = this.clipboardPasteTarget;
+ clipboardPasteTarget.value = '';
+ clipboardPasteTarget.focus();
+ document.execCommand('paste');
+ const result = clipboardPasteTarget.value;
+ clipboardPasteTarget.value = '';
+ return result;
+ }
}
async _onApiGetDisplayTemplatesHtml() {
@@ -535,6 +567,11 @@ class Backend {
return await requestText(url, 'GET');
}
+ async _onApiGetQueryParserTemplatesHtml() {
+ const url = chrome.runtime.getURL('/bg/query-parser-templates.html');
+ return await requestText(url, 'GET');
+ }
+
_onApiGetZoom(params, sender) {
if (!sender || !sender.tab) {
return Promise.reject(new Error('Invalid tab'));
@@ -565,23 +602,68 @@ class Backend {
// Command handlers
async _onCommandSearch(params) {
- const url = chrome.runtime.getURL('/bg/search.html');
- if (!(params && params.newTab)) {
- try {
- const tab = await Backend._findTab(1000, (url2) => (
- url2 !== null &&
- url2.startsWith(url) &&
- (url2.length === url.length || url2[url.length] === '?' || url2[url.length] === '#')
- ));
- if (tab !== null) {
- await Backend._focusTab(tab);
- return;
+ const {mode='existingOrNewTab', query} = params || {};
+
+ const options = await this.getOptions(this.optionsContext);
+ const {popupWidth, popupHeight} = options.general;
+
+ const baseUrl = chrome.runtime.getURL('/bg/search.html');
+ const queryParams = {mode};
+ if (query && query.length > 0) { queryParams.query = query; }
+ const queryString = new URLSearchParams(queryParams).toString();
+ const url = `${baseUrl}?${queryString}`;
+
+ const isTabMatch = (url2) => {
+ if (url2 === null || !url2.startsWith(baseUrl)) { return false; }
+ const {baseUrl: baseUrl2, queryParams: queryParams2} = parseUrl(url2);
+ return baseUrl2 === baseUrl && (queryParams2.mode === mode || (!queryParams2.mode && mode === 'existingOrNewTab'));
+ };
+
+ const openInTab = async () => {
+ const tab = await Backend._findTab(1000, isTabMatch);
+ if (tab !== null) {
+ await Backend._focusTab(tab);
+ if (queryParams.query) {
+ await new Promise((resolve) => chrome.tabs.sendMessage(
+ tab.id, {action: 'searchQueryUpdate', params: {query: queryParams.query}}, resolve
+ ));
}
- } catch (e) {
- // NOP
+ return true;
}
+ };
+
+ switch (mode) {
+ case 'existingOrNewTab':
+ try {
+ if (await openInTab()) { return; }
+ } catch (e) {
+ // NOP
+ }
+ chrome.tabs.create({url});
+ return;
+ case 'newTab':
+ chrome.tabs.create({url});
+ return;
+ case 'popup':
+ try {
+ // chrome.windows not supported (e.g. on Firefox mobile)
+ if (!isObject(chrome.windows)) { return; }
+ if (await openInTab()) { return; }
+ // if the previous popup is open in an invalid state, close it
+ if (this.popupWindow !== null) {
+ const callback = () => this.checkLastError(chrome.runtime.lastError);
+ chrome.windows.remove(this.popupWindow.id, callback);
+ }
+ // open new popup
+ this.popupWindow = await new Promise((resolve) => chrome.windows.create(
+ {url, width: popupWidth, height: popupHeight, type: 'popup'},
+ resolve
+ ));
+ } catch (e) {
+ // NOP
+ }
+ return;
}
- chrome.tabs.create({url});
}
_onCommandHelp() {
@@ -708,7 +790,7 @@ class Backend {
}
try {
- const tabWindow = await new Promise((resolve) => {
+ const tabWindow = await new Promise((resolve, reject) => {
chrome.windows.get(tab.windowId, {}, (tabWindow) => {
const e = chrome.runtime.lastError;
if (e) { reject(e); }
@@ -777,6 +859,7 @@ Backend._messageHandlers = new Map([
['getEnvironmentInfo', (self, ...args) => self._onApiGetEnvironmentInfo(...args)],
['clipboardGet', (self, ...args) => self._onApiClipboardGet(...args)],
['getDisplayTemplatesHtml', (self, ...args) => self._onApiGetDisplayTemplatesHtml(...args)],
+ ['getQueryParserTemplatesHtml', (self, ...args) => self._onApiGetQueryParserTemplatesHtml(...args)],
['getZoom', (self, ...args) => self._onApiGetZoom(...args)]
]);
diff --git a/ext/bg/js/clipboard-monitor.js b/ext/bg/js/clipboard-monitor.js
new file mode 100644
index 00000000..b4a27fa2
--- /dev/null
+++ b/ext/bg/js/clipboard-monitor.js
@@ -0,0 +1,80 @@
+/*
+ * 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 ClipboardMonitor {
+ constructor() {
+ this.timerId = null;
+ this.timerToken = null;
+ this.interval = 250;
+ this.previousText = null;
+ }
+
+ onClipboardText(_text) {
+ throw new Error('Override me');
+ }
+
+ start() {
+ this.stop();
+
+ // The token below is used as a unique identifier to ensure that a new clipboard monitor
+ // hasn't been started during the await call. The check below the await apiClipboardGet()
+ // call will exit early if the reference has changed.
+ const token = {};
+ const intervalCallback = async () => {
+ this.timerId = null;
+
+ let text = null;
+ try {
+ text = await apiClipboardGet();
+ } catch (e) {
+ // NOP
+ }
+ if (this.timerToken !== token) { return; }
+
+ if (
+ typeof text === 'string' &&
+ (text = text.trim()).length > 0 &&
+ text !== this.previousText
+ ) {
+ this.previousText = text;
+ if (jpIsStringPartiallyJapanese(text)) {
+ this.onClipboardText(text);
+ }
+ }
+
+ this.timerId = setTimeout(intervalCallback, this.interval);
+ };
+
+ this.timerToken = token;
+
+ intervalCallback();
+ }
+
+ stop() {
+ this.timerToken = null;
+ if (this.timerId !== null) {
+ clearTimeout(this.timerId);
+ this.timerId = null;
+ }
+ }
+
+ setPreviousText(text) {
+ this.previousText = text;
+ }
+}
diff --git a/ext/bg/js/context.js b/ext/bg/js/context.js
index 834174bf..37adb6b7 100644
--- a/ext/bg/js/context.js
+++ b/ext/bg/js/context.js
@@ -30,12 +30,12 @@ function setupButtonEvents(selector, command, url) {
for (const node of nodes) {
node.addEventListener('click', (e) => {
if (e.button !== 0) { return; }
- apiCommandExec(command, {newTab: e.ctrlKey});
+ apiCommandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'});
e.preventDefault();
}, false);
node.addEventListener('auxclick', (e) => {
if (e.button !== 1) { return; }
- apiCommandExec(command, {newTab: true});
+ apiCommandExec(command, {mode: 'newTab'});
e.preventDefault();
}, false);
diff --git a/ext/bg/js/japanese.js b/ext/bg/js/japanese.js
new file mode 100644
index 00000000..c45c0958
--- /dev/null
+++ b/ext/bg/js/japanese.js
@@ -0,0 +1,454 @@
+/*
+ * 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/>.
+ */
+
+
+const JP_HALFWIDTH_KATAKANA_MAPPING = new Map([
+ ['ヲ', 'ヲヺ-'],
+ ['ァ', 'ァ--'],
+ ['ィ', 'ィ--'],
+ ['ゥ', 'ゥ--'],
+ ['ェ', 'ェ--'],
+ ['ォ', 'ォ--'],
+ ['ャ', 'ャ--'],
+ ['ュ', 'ュ--'],
+ ['ョ', 'ョ--'],
+ ['ッ', 'ッ--'],
+ ['ー', 'ー--'],
+ ['ア', 'ア--'],
+ ['イ', 'イ--'],
+ ['ウ', 'ウヴ-'],
+ ['エ', 'エ--'],
+ ['オ', 'オ--'],
+ ['カ', 'カガ-'],
+ ['キ', 'キギ-'],
+ ['ク', 'クグ-'],
+ ['ケ', 'ケゲ-'],
+ ['コ', 'コゴ-'],
+ ['サ', 'サザ-'],
+ ['シ', 'シジ-'],
+ ['ス', 'スズ-'],
+ ['セ', 'セゼ-'],
+ ['ソ', 'ソゾ-'],
+ ['タ', 'タダ-'],
+ ['チ', 'チヂ-'],
+ ['ツ', 'ツヅ-'],
+ ['テ', 'テデ-'],
+ ['ト', 'トド-'],
+ ['ナ', 'ナ--'],
+ ['ニ', 'ニ--'],
+ ['ヌ', 'ヌ--'],
+ ['ネ', 'ネ--'],
+ ['ノ', 'ノ--'],
+ ['ハ', 'ハバパ'],
+ ['ヒ', 'ヒビピ'],
+ ['フ', 'フブプ'],
+ ['ヘ', 'ヘベペ'],
+ ['ホ', 'ホボポ'],
+ ['マ', 'マ--'],
+ ['ミ', 'ミ--'],
+ ['ム', 'ム--'],
+ ['メ', 'メ--'],
+ ['モ', 'モ--'],
+ ['ヤ', 'ヤ--'],
+ ['ユ', 'ユ--'],
+ ['ヨ', 'ヨ--'],
+ ['ラ', 'ラ--'],
+ ['リ', 'リ--'],
+ ['ル', 'ル--'],
+ ['レ', 'レ--'],
+ ['ロ', 'ロ--'],
+ ['ワ', 'ワ--'],
+ ['ン', 'ン--']
+]);
+
+const JP_HIRAGANA_RANGE = [0x3040, 0x309f];
+const JP_KATAKANA_RANGE = [0x30a0, 0x30ff];
+const JP_KANA_RANGES = [JP_HIRAGANA_RANGE, JP_KATAKANA_RANGE];
+
+const JP_CJK_COMMON_RANGE = [0x4e00, 0x9fff];
+const JP_CJK_RARE_RANGE = [0x3400, 0x4dbf];
+const JP_CJK_RANGES = [JP_CJK_COMMON_RANGE, JP_CJK_RARE_RANGE];
+
+const JP_ITERATION_MARK_CHAR_CODE = 0x3005;
+
+// Japanese character ranges, roughly ordered in order of expected frequency
+const JP_JAPANESE_RANGES = [
+ JP_HIRAGANA_RANGE,
+ JP_KATAKANA_RANGE,
+
+ JP_CJK_COMMON_RANGE,
+ JP_CJK_RARE_RANGE,
+
+ [0xff66, 0xff9f], // Halfwidth katakana
+
+ [0x30fb, 0x30fc], // Katakana punctuation
+ [0xff61, 0xff65], // Kana punctuation
+ [0x3000, 0x303f], // CJK punctuation
+
+ [0xff10, 0xff19], // Fullwidth numbers
+ [0xff21, 0xff3a], // Fullwidth upper case Latin letters
+ [0xff41, 0xff5a], // Fullwidth lower case Latin letters
+
+ [0xff01, 0xff0f], // Fullwidth punctuation 1
+ [0xff1a, 0xff1f], // Fullwidth punctuation 2
+ [0xff3b, 0xff3f], // Fullwidth punctuation 3
+ [0xff5b, 0xff60], // Fullwidth punctuation 4
+ [0xffe0, 0xffee] // Currency markers
+];
+
+
+// Helper functions
+
+function _jpIsCharCodeInRanges(charCode, ranges) {
+ for (const [min, max] of ranges) {
+ if (charCode >= min && charCode <= max) {
+ return true;
+ }
+ }
+ return false;
+}
+
+
+// Character code testing functions
+
+function jpIsCharCodeKanji(charCode) {
+ return _jpIsCharCodeInRanges(charCode, JP_CJK_RANGES);
+}
+
+function jpIsCharCodeKana(charCode) {
+ return _jpIsCharCodeInRanges(charCode, JP_KANA_RANGES);
+}
+
+function jpIsCharCodeJapanese(charCode) {
+ return _jpIsCharCodeInRanges(charCode, JP_JAPANESE_RANGES);
+}
+
+
+// String testing functions
+
+function jpIsStringEntirelyKana(str) {
+ if (str.length === 0) { return false; }
+ for (let i = 0, ii = str.length; i < ii; ++i) {
+ if (!jpIsCharCodeKana(str.charCodeAt(i))) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function jpIsStringPartiallyJapanese(str) {
+ if (str.length === 0) { return false; }
+ for (let i = 0, ii = str.length; i < ii; ++i) {
+ if (jpIsCharCodeJapanese(str.charCodeAt(i))) {
+ return true;
+ }
+ }
+ return false;
+}
+
+
+// Conversion functions
+
+function jpKatakanaToHiragana(text) {
+ let result = '';
+ for (const c of text) {
+ if (wanakana.isKatakana(c)) {
+ result += wanakana.toHiragana(c);
+ } else {
+ result += c;
+ }
+ }
+
+ return result;
+}
+
+function jpHiraganaToKatakana(text) {
+ let result = '';
+ for (const c of text) {
+ if (wanakana.isHiragana(c)) {
+ result += wanakana.toKatakana(c);
+ } else {
+ result += c;
+ }
+ }
+
+ return result;
+}
+
+function jpToRomaji(text) {
+ return wanakana.toRomaji(text);
+}
+
+function jpConvertReading(expressionFragment, readingFragment, readingMode) {
+ switch (readingMode) {
+ case 'hiragana':
+ return jpKatakanaToHiragana(readingFragment || '');
+ case 'katakana':
+ return jpHiraganaToKatakana(readingFragment || '');
+ case 'romaji':
+ if (readingFragment) {
+ return jpToRomaji(readingFragment);
+ } else {
+ if (jpIsStringEntirelyKana(expressionFragment)) {
+ return jpToRomaji(expressionFragment);
+ }
+ }
+ return readingFragment;
+ case 'none':
+ return null;
+ default:
+ return readingFragment;
+ }
+}
+
+function jpDistributeFurigana(expression, reading) {
+ const fallback = [{furigana: reading, text: expression}];
+ if (!reading) {
+ return fallback;
+ }
+
+ let isAmbiguous = false;
+ const segmentize = (reading, groups) => {
+ if (groups.length === 0 || isAmbiguous) {
+ return [];
+ }
+
+ const group = groups[0];
+ if (group.mode === 'kana') {
+ if (jpKatakanaToHiragana(reading).startsWith(jpKatakanaToHiragana(group.text))) {
+ const readingLeft = reading.substring(group.text.length);
+ const segs = segmentize(readingLeft, groups.splice(1));
+ if (segs) {
+ return [{text: group.text}].concat(segs);
+ }
+ }
+ } else {
+ let foundSegments = null;
+ for (let i = reading.length; i >= group.text.length; --i) {
+ const readingUsed = reading.substring(0, i);
+ const readingLeft = reading.substring(i);
+ const segs = segmentize(readingLeft, groups.slice(1));
+ if (segs) {
+ if (foundSegments !== null) {
+ // more than one way to segmentize the tail, mark as ambiguous
+ isAmbiguous = true;
+ return null;
+ }
+ foundSegments = [{text: group.text, furigana: readingUsed}].concat(segs);
+ }
+ // there is only one way to segmentize the last non-kana group
+ if (groups.length === 1) {
+ break;
+ }
+ }
+ return foundSegments;
+ }
+ };
+
+ const groups = [];
+ let modePrev = null;
+ for (const c of expression) {
+ const charCode = c.charCodeAt(0);
+ const modeCurr = jpIsCharCodeKanji(charCode) || charCode === JP_ITERATION_MARK_CHAR_CODE ? 'kanji' : 'kana';
+ if (modeCurr === modePrev) {
+ groups[groups.length - 1].text += c;
+ } else {
+ groups.push({mode: modeCurr, text: c});
+ modePrev = modeCurr;
+ }
+ }
+
+ const segments = segmentize(reading, groups);
+ if (segments && !isAmbiguous) {
+ return segments;
+ }
+ return fallback;
+}
+
+function jpDistributeFuriganaInflected(expression, reading, source) {
+ const output = [];
+
+ let stemLength = 0;
+ const shortest = Math.min(source.length, expression.length);
+ const sourceHiragana = jpKatakanaToHiragana(source);
+ const expressionHiragana = jpKatakanaToHiragana(expression);
+ while (stemLength < shortest && sourceHiragana[stemLength] === expressionHiragana[stemLength]) {
+ ++stemLength;
+ }
+ const offset = source.length - stemLength;
+
+ const stemExpression = source.substring(0, source.length - offset);
+ const stemReading = reading.substring(
+ 0,
+ offset === 0 ? reading.length : reading.length - expression.length + stemLength
+ );
+ for (const segment of jpDistributeFurigana(stemExpression, stemReading)) {
+ output.push(segment);
+ }
+
+ if (stemLength !== source.length) {
+ output.push({text: source.substring(stemLength)});
+ }
+
+ return output;
+}
+
+function jpConvertHalfWidthKanaToFullWidth(text, sourceMapping) {
+ let result = '';
+ const ii = text.length;
+ const hasSourceMapping = Array.isArray(sourceMapping);
+
+ for (let i = 0; i < ii; ++i) {
+ const c = text[i];
+ const mapping = JP_HALFWIDTH_KATAKANA_MAPPING.get(c);
+ if (typeof mapping !== 'string') {
+ result += c;
+ continue;
+ }
+
+ let index = 0;
+ switch (text.charCodeAt(i + 1)) {
+ case 0xff9e: // dakuten
+ index = 1;
+ break;
+ case 0xff9f: // handakuten
+ index = 2;
+ break;
+ }
+
+ let c2 = mapping[index];
+ if (index > 0) {
+ if (c2 === '-') { // invalid
+ index = 0;
+ c2 = mapping[0];
+ } else {
+ ++i;
+ }
+ }
+
+ if (hasSourceMapping && index > 0) {
+ index = result.length;
+ const v = sourceMapping.splice(index + 1, 1)[0];
+ sourceMapping[index] += v;
+ }
+ result += c2;
+ }
+
+ return result;
+}
+
+function jpConvertNumericTofullWidth(text) {
+ let result = '';
+ for (let i = 0, ii = text.length; i < ii; ++i) {
+ let c = text.charCodeAt(i);
+ if (c >= 0x30 && c <= 0x39) { // ['0', '9']
+ c += 0xff10 - 0x30; // 0xff10 = '0' full width
+ result += String.fromCharCode(c);
+ } else {
+ result += text[i];
+ }
+ }
+ return result;
+}
+
+function jpConvertAlphabeticToKana(text, sourceMapping) {
+ let part = '';
+ let result = '';
+ const ii = text.length;
+
+ if (sourceMapping.length === ii) {
+ sourceMapping.length = ii;
+ sourceMapping.fill(1);
+ }
+
+ for (let i = 0; i < ii; ++i) {
+ // Note: 0x61 is the character code for 'a'
+ let c = text.charCodeAt(i);
+ if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z']
+ c += (0x61 - 0x41);
+ } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z']
+ // NOP; c += (0x61 - 0x61);
+ } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth
+ c += (0x61 - 0xff21);
+ } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth
+ c += (0x61 - 0xff41);
+ } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash
+ c = 0x2d; // '-'
+ } else {
+ if (part.length > 0) {
+ result += jpToHiragana(part, sourceMapping, result.length);
+ part = '';
+ }
+ result += text[i];
+ continue;
+ }
+ part += String.fromCharCode(c);
+ }
+
+ if (part.length > 0) {
+ result += jpToHiragana(part, sourceMapping, result.length);
+ }
+ return result;
+}
+
+function jpToHiragana(text, sourceMapping, sourceMappingStart) {
+ const result = wanakana.toHiragana(text);
+
+ // Generate source mapping
+ if (Array.isArray(sourceMapping)) {
+ if (typeof sourceMappingStart !== 'number') { sourceMappingStart = 0; }
+ let i = 0;
+ let resultPos = 0;
+ const ii = text.length;
+ while (i < ii) {
+ // Find smallest matching substring
+ let iNext = i + 1;
+ let resultPosNext = result.length;
+ while (iNext < ii) {
+ const t = wanakana.toHiragana(text.substring(0, iNext));
+ if (t === result.substring(0, t.length)) {
+ resultPosNext = t.length;
+ break;
+ }
+ ++iNext;
+ }
+
+ // Merge characters
+ const removals = iNext - i - 1;
+ if (removals > 0) {
+ let sum = 0;
+ const vs = sourceMapping.splice(sourceMappingStart + 1, removals);
+ for (const v of vs) { sum += v; }
+ sourceMapping[sourceMappingStart] += sum;
+ }
+ ++sourceMappingStart;
+
+ // Empty elements
+ const additions = resultPosNext - resultPos - 1;
+ for (let j = 0; j < additions; ++j) {
+ sourceMapping.splice(sourceMappingStart, 0, 0);
+ ++sourceMappingStart;
+ }
+
+ i = iNext;
+ resultPos = resultPosNext;
+ }
+ }
+
+ return result;
+}
diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js
index 78508059..97032660 100644
--- a/ext/bg/js/options.js
+++ b/ext/bg/js/options.js
@@ -266,6 +266,7 @@ function profileOptionsCreateDefaults() {
return {
general: {
enable: true,
+ enableClipboardPopups: false,
resultOutputMode: 'group',
debugInfo: false,
maxResults: 32,
diff --git a/ext/bg/js/search-query-parser-generator.js b/ext/bg/js/search-query-parser-generator.js
new file mode 100644
index 00000000..8d71890b
--- /dev/null
+++ b/ext/bg/js/search-query-parser-generator.js
@@ -0,0 +1,77 @@
+/*
+ * 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 QueryParserGenerator {
+ constructor() {
+ this._templateHandler = null;
+ }
+
+ async prepare() {
+ const html = await apiGetQueryParserTemplatesHtml();
+ this._templateHandler = new TemplateHandler(html);
+ }
+
+ createParseResult(terms, preview=false) {
+ const fragment = document.createDocumentFragment();
+ for (const term of terms) {
+ const termContainer = this._templateHandler.instantiate(preview ? 'term-preview' : 'term');
+ for (const segment of term) {
+ if (!segment.text.trim()) { continue; }
+ if (!segment.reading || !segment.reading.trim()) {
+ termContainer.appendChild(this.createSegmentText(segment.text));
+ } else {
+ termContainer.appendChild(this.createSegment(segment));
+ }
+ }
+ fragment.appendChild(termContainer);
+ }
+ return fragment;
+ }
+
+ createSegment(segment) {
+ const segmentContainer = this._templateHandler.instantiate('segment');
+ const segmentTextContainer = segmentContainer.querySelector('.query-parser-segment-text');
+ const segmentReadingContainer = segmentContainer.querySelector('.query-parser-segment-reading');
+ segmentTextContainer.appendChild(this.createSegmentText(segment.text));
+ segmentReadingContainer.innerText = segment.reading;
+ return segmentContainer;
+ }
+
+ createSegmentText(text) {
+ const fragment = document.createDocumentFragment();
+ for (const chr of text) {
+ const charContainer = this._templateHandler.instantiate('char');
+ charContainer.innerText = chr;
+ fragment.appendChild(charContainer);
+ }
+ return fragment;
+ }
+
+ createParserSelect(parseResults, selectedParser) {
+ const selectContainer = this._templateHandler.instantiate('select');
+ for (const parseResult of parseResults) {
+ const optionContainer = this._templateHandler.instantiate('select-option');
+ optionContainer.value = parseResult.id;
+ optionContainer.innerText = parseResult.name;
+ optionContainer.defaultSelected = selectedParser === parseResult.id;
+ selectContainer.appendChild(optionContainer);
+ }
+ return selectContainer;
+ }
+}
diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js
index e8e6d11f..f648fdd4 100644
--- a/ext/bg/js/search-query-parser.js
+++ b/ext/bg/js/search-query-parser.js
@@ -19,14 +19,20 @@
class QueryParser extends TextScanner {
constructor(search) {
- super(document.querySelector('#query-parser'), [], [], []);
+ super(document.querySelector('#query-parser-content'), [], [], []);
this.search = search;
this.parseResults = [];
this.selectedParser = null;
- this.queryParser = document.querySelector('#query-parser');
- this.queryParserSelect = document.querySelector('#query-parser-select');
+ this.queryParser = document.querySelector('#query-parser-content');
+ this.queryParserSelect = document.querySelector('#query-parser-select-container');
+
+ this.queryParserGenerator = new QueryParserGenerator();
+ }
+
+ async prepare() {
+ await this.queryParserGenerator.prepare();
}
onError(error) {
@@ -64,7 +70,7 @@ class QueryParser extends TextScanner {
const selectedParser = e.target.value;
this.selectedParser = selectedParser;
apiOptionsSet({parsing: {selectedParser}}, this.search.getOptionsContext());
- this.renderParseResult(this.getParseResult());
+ this.renderParseResult();
}
getMouseEventListeners() {
@@ -113,13 +119,13 @@ class QueryParser extends TextScanner {
async setText(text) {
this.search.setSpinnerVisible(true);
- await this.setPreview(text);
+ this.setPreview(text);
this.parseResults = await this.parseText(text);
this.refreshSelectedParser();
this.renderParserSelect();
- await this.renderParseResult();
+ this.renderParseResult();
this.search.setSpinnerVisible(false);
}
@@ -146,57 +152,29 @@ class QueryParser extends TextScanner {
return results;
}
- async setPreview(text) {
+ setPreview(text) {
const previewTerms = [];
for (let i = 0, ii = text.length; i < ii; i += 2) {
const tempText = text.substring(i, i + 2);
- previewTerms.push([{text: tempText.split('')}]);
+ previewTerms.push([{text: tempText}]);
}
- this.queryParser.innerHTML = await apiTemplateRender('query-parser.html', {
- terms: previewTerms,
- preview: true
- });
+ this.queryParser.textContent = '';
+ this.queryParser.appendChild(this.queryParserGenerator.createParseResult(previewTerms, true));
}
renderParserSelect() {
this.queryParserSelect.innerHTML = '';
if (this.parseResults.length > 1) {
- const select = document.createElement('select');
- select.classList.add('form-control');
- for (const parseResult of this.parseResults) {
- const option = document.createElement('option');
- option.value = parseResult.id;
- option.innerText = parseResult.name;
- option.defaultSelected = this.selectedParser === parseResult.id;
- select.appendChild(option);
- }
+ const select = this.queryParserGenerator.createParserSelect(this.parseResults, this.selectedParser);
select.addEventListener('change', this.onParserChange.bind(this));
this.queryParserSelect.appendChild(select);
}
}
- async renderParseResult() {
+ renderParseResult() {
const parseResult = this.getParseResult();
- if (!parseResult) {
- this.queryParser.innerHTML = '';
- return;
- }
-
- this.queryParser.innerHTML = await apiTemplateRender(
- 'query-parser.html',
- {terms: QueryParser.processParseResultForDisplay(parseResult.parsedText)}
- );
- }
-
- static processParseResultForDisplay(result) {
- return result.map((term) => {
- return term.filter((part) => part.text.trim()).map((part) => {
- return {
- text: part.text.split(''),
- reading: part.reading,
- raw: !part.reading || !part.reading.trim()
- };
- });
- });
+ this.queryParser.textContent = '';
+ if (!parseResult) { return; }
+ this.queryParser.appendChild(this.queryParserGenerator.createParseResult(parseResult.parsedText));
}
}
diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js
index f5c641a8..6641255f 100644
--- a/ext/bg/js/search.js
+++ b/ext/bg/js/search.js
@@ -36,12 +36,7 @@ class DisplaySearch extends Display {
this.introVisible = true;
this.introAnimationTimer = null;
- this.isFirefox = false;
-
- this.clipboardMonitorTimerId = null;
- this.clipboardMonitorTimerToken = null;
- this.clipboardInterval = 250;
- this.clipboardPreviousText = null;
+ this.clipboardMonitor = new ClipboardMonitor();
}
static create() {
@@ -53,12 +48,16 @@ class DisplaySearch extends Display {
async prepare() {
try {
await this.initialize();
- this.isFirefox = await DisplaySearch._isFirefox();
+
+ await this.queryParser.prepare();
+
+ const {queryParams: {query='', mode=''}} = parseUrl(window.location.href);
if (this.search !== null) {
this.search.addEventListener('click', (e) => this.onSearch(e), false);
}
if (this.query !== null) {
+ document.documentElement.dataset.searchMode = mode;
this.query.addEventListener('input', () => this.onSearchInput(), false);
if (this.wanakanaEnable !== null) {
@@ -69,34 +68,26 @@ class DisplaySearch extends Display {
this.wanakanaEnable.checked = false;
}
this.wanakanaEnable.addEventListener('change', (e) => {
- const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || '';
+ const {queryParams: {query=''}} = parseUrl(window.location.href);
if (e.target.checked) {
window.wanakana.bind(this.query);
- this.setQuery(window.wanakana.toKana(query));
apiOptionsSet({general: {enableWanakana: true}}, this.getOptionsContext());
} else {
window.wanakana.unbind(this.query);
- this.setQuery(query);
apiOptionsSet({general: {enableWanakana: false}}, this.getOptionsContext());
}
+ this.setQuery(query);
this.onSearchQueryUpdated(this.query.value, false);
});
}
- const query = DisplaySearch.getSearchQueryFromLocation(window.location.href);
- if (query !== null) {
- if (this.isWanakanaEnabled()) {
- this.setQuery(window.wanakana.toKana(query));
- } else {
- this.setQuery(query);
- }
- this.onSearchQueryUpdated(this.query.value, false);
- }
+ this.setQuery(query);
+ this.onSearchQueryUpdated(this.query.value, false);
}
- if (this.clipboardMonitorEnable !== null) {
+ if (this.clipboardMonitorEnable !== null && mode !== 'popup') {
if (this.options.general.enableClipboardMonitor === true) {
this.clipboardMonitorEnable.checked = true;
- this.startClipboardMonitor();
+ this.clipboardMonitor.start();
} else {
this.clipboardMonitorEnable.checked = false;
}
@@ -106,7 +97,7 @@ class DisplaySearch extends Display {
{permissions: ['clipboardRead']},
(granted) => {
if (granted) {
- this.startClipboardMonitor();
+ this.clipboardMonitor.start();
apiOptionsSet({general: {enableClipboardMonitor: true}}, this.getOptionsContext());
} else {
e.target.checked = false;
@@ -114,16 +105,20 @@ class DisplaySearch extends Display {
}
);
} else {
- this.stopClipboardMonitor();
+ this.clipboardMonitor.stop();
apiOptionsSet({general: {enableClipboardMonitor: false}}, this.getOptionsContext());
}
});
}
+ chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));
+
window.addEventListener('popstate', (e) => this.onPopState(e));
+ window.addEventListener('copy', (e) => this.onCopy(e));
+
+ this.clipboardMonitor.onClipboardText = (text) => this.onExternalSearchUpdate(text);
this.updateSearchButton();
- this.initClipboardMonitor();
} catch (e) {
this.onError(e);
}
@@ -159,25 +154,32 @@ class DisplaySearch extends Display {
e.preventDefault();
const query = this.query.value;
+
this.queryParser.setText(query);
- const queryString = query.length > 0 ? `?query=${encodeURIComponent(query)}` : '';
- window.history.pushState(null, '', `${window.location.pathname}${queryString}`);
+
+ const url = new URL(window.location.href);
+ url.searchParams.set('query', query);
+ window.history.pushState(null, '', url.toString());
+
this.onSearchQueryUpdated(query, true);
}
onPopState() {
- const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || '';
- if (this.query !== null) {
- if (this.isWanakanaEnabled()) {
- this.setQuery(window.wanakana.toKana(query));
- } else {
- this.setQuery(query);
- }
- }
-
+ const {queryParams: {query='', mode=''}} = parseUrl(window.location.href);
+ document.documentElement.dataset.searchMode = mode;
+ this.setQuery(query);
this.onSearchQueryUpdated(this.query.value, false);
}
+ onRuntimeMessage({action, params}, sender, callback) {
+ const handler = DisplaySearch._runtimeMessageHandlers.get(action);
+ if (typeof handler !== 'function') { return false; }
+
+ const result = handler(this, params, sender);
+ callback(result);
+ return false;
+ }
+
onKeyDown(e) {
const key = Display.getKeyFromEvent(e);
const ignoreKeys = DisplaySearch.onKeyDownIgnoreKeys;
@@ -202,6 +204,19 @@ class DisplaySearch extends Display {
}
}
+ onCopy() {
+ // ignore copy from search page
+ this.clipboardMonitor.setPreviousText(document.getSelection().toString().trim());
+ }
+
+ onExternalSearchUpdate(text) {
+ this.setQuery(text);
+ const url = new URL(window.location.href);
+ url.searchParams.set('query', text);
+ window.history.pushState(null, '', url.toString());
+ this.onSearchQueryUpdated(this.query.value, true);
+ }
+
async onSearchQueryUpdated(query, animate) {
try {
const details = {};
@@ -241,74 +256,6 @@ class DisplaySearch extends Display {
this.queryParser.setOptions(this.options);
}
- initClipboardMonitor() {
- // ignore copy from search page
- window.addEventListener('copy', () => {
- this.clipboardPreviousText = document.getSelection().toString().trim();
- });
- }
-
- startClipboardMonitor() {
- // The token below is used as a unique identifier to ensure that a new clipboard monitor
- // hasn't been started during the await call. The check below the await this.getClipboardText()
- // call will exit early if the reference has changed.
- const token = {};
- const intervalCallback = async () => {
- this.clipboardMonitorTimerId = null;
-
- let text = await this.getClipboardText();
- if (this.clipboardMonitorTimerToken !== token) { return; }
-
- if (
- typeof text === 'string' &&
- (text = text.trim()).length > 0 &&
- text !== this.clipboardPreviousText
- ) {
- this.clipboardPreviousText = text;
- if (jpIsStringPartiallyJapanese(text)) {
- this.setQuery(this.isWanakanaEnabled() ? window.wanakana.toKana(text) : text);
- window.history.pushState(null, '', `${window.location.pathname}?query=${encodeURIComponent(text)}`);
- this.onSearchQueryUpdated(this.query.value, true);
- }
- }
-
- this.clipboardMonitorTimerId = setTimeout(intervalCallback, this.clipboardInterval);
- };
-
- this.clipboardMonitorTimerToken = token;
-
- intervalCallback();
- }
-
- stopClipboardMonitor() {
- this.clipboardMonitorTimerToken = null;
- if (this.clipboardMonitorTimerId !== null) {
- clearTimeout(this.clipboardMonitorTimerId);
- this.clipboardMonitorTimerId = null;
- }
- }
-
- async getClipboardText() {
- /*
- Notes:
- apiClipboardGet doesn't work on Firefox because document.execCommand('paste')
- results in an empty string on the web extension background page.
- This may be a bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985
- Therefore, navigator.clipboard.readText() is used on Firefox.
-
- navigator.clipboard.readText() can't be used in Chrome for two reasons:
- * Requires page to be focused, else it rejects with an exception.
- * When the page is focused, Chrome will request clipboard permission, despite already
- being an extension with clipboard permissions. It effectively asks for the
- non-extension permission for clipboard access.
- */
- try {
- return this.isFirefox ? await navigator.clipboard.readText() : await apiClipboardGet();
- } catch (e) {
- return null;
- }
- }
-
isWanakanaEnabled() {
return this.wanakanaEnable !== null && this.wanakanaEnable.checked;
}
@@ -318,8 +265,9 @@ class DisplaySearch extends Display {
}
setQuery(query) {
- this.query.value = query;
- this.queryParser.setText(query);
+ const interpretedQuery = this.isWanakanaEnabled() ? window.wanakana.toKana(query) : query;
+ this.query.value = interpretedQuery;
+ this.queryParser.setText(interpretedQuery);
}
setIntroVisible(visible, animate) {
@@ -394,22 +342,6 @@ class DisplaySearch extends Display {
document.title = `${text} - Yomichan Search`;
}
}
-
- static getSearchQueryFromLocation(url) {
- const match = /^[^?#]*\?(?:[^&#]*&)?query=([^&#]*)/.exec(url);
- return match !== null ? decodeURIComponent(match[1]) : null;
- }
-
- static async _isFirefox() {
- const {browser} = await apiGetEnvironmentInfo();
- switch (browser) {
- case 'firefox':
- case 'firefox-mobile':
- return true;
- default:
- return false;
- }
- }
}
DisplaySearch.onKeyDownIgnoreKeys = {
@@ -427,4 +359,8 @@ DisplaySearch.onKeyDownIgnoreKeys = {
'Shift': []
};
+DisplaySearch._runtimeMessageHandlers = new Map([
+ ['searchQueryUpdate', (self, {query}) => { self.onExternalSearchUpdate(query); }]
+]);
+
DisplaySearch.instance = DisplaySearch.create();
diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js
index 4492cd42..cf0f08db 100644
--- a/ext/bg/js/settings/main.js
+++ b/ext/bg/js/settings/main.js
@@ -28,6 +28,22 @@ function getOptionsFullMutable() {
async function formRead(options) {
options.general.enable = $('#enable').prop('checked');
+ const enableClipboardPopups = $('#enable-clipboard-popups').prop('checked');
+ if (enableClipboardPopups) {
+ options.general.enableClipboardPopups = await new Promise((resolve, _reject) => {
+ chrome.permissions.request(
+ {permissions: ['clipboardRead']},
+ (granted) => {
+ if (!granted) {
+ $('#enable-clipboard-popups').prop('checked', false);
+ }
+ resolve(granted);
+ }
+ );
+ });
+ } else {
+ options.general.enableClipboardPopups = false;
+ }
options.general.showGuide = $('#show-usage-guide').prop('checked');
options.general.compactTags = $('#compact-tags').prop('checked');
options.general.compactGlossaries = $('#compact-glossaries').prop('checked');
@@ -104,6 +120,7 @@ async function formRead(options) {
async function formWrite(options) {
$('#enable').prop('checked', options.general.enable);
+ $('#enable-clipboard-popups').prop('checked', options.general.enableClipboardPopups);
$('#show-usage-guide').prop('checked', options.general.showGuide);
$('#compact-tags').prop('checked', options.general.compactTags);
$('#compact-glossaries').prop('checked', options.general.compactGlossaries);
diff --git a/ext/bg/js/templates.js b/ext/bg/js/templates.js
deleted file mode 100644
index 2f65be31..00000000
--- a/ext/bg/js/templates.js
+++ /dev/null
@@ -1,55 +0,0 @@
-(function() {
- var template = Handlebars.template, templates = Handlebars.templates = Handlebars.templates || {};
-templates['query-parser.html'] = template({"1":function(container,depth0,helpers,partials,data) {
- var stack1, alias1=depth0 != null ? depth0 : (container.nullContext || {});
-
- return ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.preview : depth0),{"name":"if","hash":{},"fn":container.program(2, data, 0),"inverse":container.program(4, data, 0),"data":data})) != null ? stack1 : "")
- + ((stack1 = helpers.each.call(alias1,depth0,{"name":"each","hash":{},"fn":container.program(6, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "")
- + "</span>";
-},"2":function(container,depth0,helpers,partials,data) {
- return "<span class=\"query-parser-term-preview\">";
-},"4":function(container,depth0,helpers,partials,data) {
- return "<span class=\"query-parser-term\">";
-},"6":function(container,depth0,helpers,partials,data) {
- var stack1;
-
- return ((stack1 = container.invokePartial(partials.part,depth0,{"name":"part","data":data,"helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : "");
-},"8":function(container,depth0,helpers,partials,data) {
- var stack1;
-
- return ((stack1 = helpers["if"].call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.raw : depth0),{"name":"if","hash":{},"fn":container.program(9, data, 0),"inverse":container.program(12, data, 0),"data":data})) != null ? stack1 : "");
-},"9":function(container,depth0,helpers,partials,data) {
- var stack1;
-
- return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.text : depth0),{"name":"each","hash":{},"fn":container.program(10, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "");
-},"10":function(container,depth0,helpers,partials,data) {
- return "<span class=\"query-parser-char\">"
- + container.escapeExpression(container.lambda(depth0, depth0))
- + "</span>";
-},"12":function(container,depth0,helpers,partials,data) {
- var stack1, helper, alias1=depth0 != null ? depth0 : (container.nullContext || {});
-
- return "<ruby>"
- + ((stack1 = helpers.each.call(alias1,(depth0 != null ? depth0.text : depth0),{"name":"each","hash":{},"fn":container.program(10, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "")
- + "<rt>"
- + container.escapeExpression(((helper = (helper = helpers.reading || (depth0 != null ? depth0.reading : depth0)) != null ? helper : helpers.helperMissing),(typeof helper === "function" ? helper.call(alias1,{"name":"reading","hash":{},"data":data}) : helper)))
- + "</rt></ruby>";
-},"14":function(container,depth0,helpers,partials,data,blockParams,depths) {
- var stack1;
-
- return ((stack1 = container.invokePartial(partials.term,depth0,{"name":"term","hash":{"preview":(depths[1] != null ? depths[1].preview : depths[1])},"data":data,"helpers":helpers,"partials":partials,"decorators":container.decorators})) != null ? stack1 : "");
-},"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data,blockParams,depths) {
- var stack1;
-
- return ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.terms : depth0),{"name":"each","hash":{},"fn":container.program(14, data, 0, blockParams, depths),"inverse":container.noop,"data":data})) != null ? stack1 : "");
-},"main_d": function(fn, props, container, depth0, data, blockParams, depths) {
-
- var decorators = container.decorators;
-
- fn = decorators.inline(fn,props,container,{"name":"inline","hash":{},"fn":container.program(1, data, 0, blockParams, depths),"inverse":container.noop,"args":["term"],"data":data}) || fn;
- fn = decorators.inline(fn,props,container,{"name":"inline","hash":{},"fn":container.program(8, data, 0, blockParams, depths),"inverse":container.noop,"args":["part"],"data":data}) || fn;
- return fn;
- }
-
-,"useDecorators":true,"usePartial":true,"useData":true,"useDepths":true});
-})(); \ No newline at end of file