diff options
| -rw-r--r-- | ext/bg/background.html | 1 | ||||
| -rw-r--r-- | ext/bg/js/api.js | 65 | ||||
| -rw-r--r-- | ext/bg/js/backend.js | 9 | ||||
| -rw-r--r-- | ext/bg/js/mecab.js | 92 | ||||
| -rw-r--r-- | ext/bg/js/options.js | 7 | ||||
| -rw-r--r-- | ext/bg/js/search-query-parser.js | 228 | ||||
| -rw-r--r-- | ext/bg/js/search.js | 24 | ||||
| -rw-r--r-- | ext/bg/js/settings.js | 8 | ||||
| -rw-r--r-- | ext/bg/js/templates.js | 52 | ||||
| -rw-r--r-- | ext/bg/search.html | 8 | ||||
| -rw-r--r-- | ext/bg/settings.html | 38 | ||||
| -rw-r--r-- | ext/fg/js/api.js | 8 | ||||
| -rw-r--r-- | ext/manifest.json | 3 | ||||
| -rw-r--r-- | ext/mixed/css/display.css | 13 | ||||
| -rw-r--r-- | ext/mixed/js/display.js | 76 | ||||
| -rw-r--r-- | ext/mixed/js/japanese.js | 69 | ||||
| -rw-r--r-- | tmpl/query-parser.html | 27 | 
17 files changed, 693 insertions, 35 deletions
| diff --git a/ext/bg/background.html b/ext/bg/background.html index bbfbd1e1..6e6e7c26 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -21,6 +21,7 @@          <script src="/mixed/js/extension.js"></script>          <script src="/bg/js/anki.js"></script> +        <script src="/bg/js/mecab.js"></script>          <script src="/bg/js/api.js"></script>          <script src="/bg/js/audio.js"></script>          <script src="/bg/js/backend-api-forwarder.js"></script> diff --git a/ext/bg/js/api.js b/ext/bg/js/api.js index df73aa2a..766fb0ed 100644 --- a/ext/bg/js/api.js +++ b/ext/bg/js/api.js @@ -79,6 +79,71 @@ async function apiTermsFind(text, details, optionsContext) {      return {length, definitions};  } +async function apiTextParse(text, optionsContext) { +    const options = await apiOptionsGet(optionsContext); +    const translator = utilBackend().translator; + +    const results = []; +    while (text.length > 0) { +        const term = []; +        const [definitions, sourceLength] = await translator.findTermsInternal( +            text.slice(0, options.scanning.length), +            dictEnabledSet(options), +            options.scanning.alphanumeric, +            {} +        ); +        if (definitions.length > 0) { +            dictTermsSort(definitions); +            const {expression, reading} = definitions[0]; +            const source = text.slice(0, sourceLength); +            for (const {text, furigana} of jpDistributeFuriganaInflected(expression, reading, source)) { +                const reading = jpConvertReading(text, furigana, options.parsing.readingMode); +                term.push({text, reading}); +            } +            text = text.slice(source.length); +        } else { +            const reading = jpConvertReading(text[0], null, options.parsing.readingMode); +            term.push({text: text[0], reading}); +            text = text.slice(1); +        } +        results.push(term); +    } +    return results; +} + +async function apiTextParseMecab(text, optionsContext) { +    const options = await apiOptionsGet(optionsContext); +    const mecab = utilBackend().mecab; + +    const results = {}; +    const rawResults = await mecab.parseText(text); +    for (const mecabName in rawResults) { +        const result = []; +        for (const parsedLine of rawResults[mecabName]) { +            for (const {expression, reading, source} of parsedLine) { +                const term = []; +                if (expression !== null && reading !== null) { +                    for (const {text, furigana} of jpDistributeFuriganaInflected( +                        expression, +                        jpKatakanaToHiragana(reading), +                        source +                    )) { +                        const reading = jpConvertReading(text, furigana, options.parsing.readingMode); +                        term.push({text, reading}); +                    } +                } else { +                    const reading = jpConvertReading(source, null, options.parsing.readingMode); +                    term.push({text: source, reading}); +                } +                result.push(term); +            } +            result.push([{text: '\n'}]); +        } +        results[mecabName] = result; +    } +    return results; +} +  async function apiKanjiFind(text, optionsContext) {      const options = await apiOptionsGet(optionsContext);      const definitions = await utilBackend().translator.findKanji(text, options); diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index efad153a..45db9660 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -21,6 +21,7 @@ class Backend {      constructor() {          this.translator = new Translator();          this.anki = new AnkiNull(); +        this.mecab = new Mecab();          this.options = null;          this.optionsContext = {              depth: 0, @@ -97,6 +98,12 @@ class Backend {          }          this.anki = options.anki.enable ? new AnkiConnect(options.anki.server) : new AnkiNull(); + +        if (options.parsing.enableMecabParser) { +            this.mecab.startListener(); +        } else { +            this.mecab.stopListener(); +        }      }      async getFullOptions() { @@ -180,6 +187,8 @@ Backend.messageHandlers = {      optionsSet: ({changedOptions, optionsContext, source}) => apiOptionsSet(changedOptions, optionsContext, source),      kanjiFind: ({text, optionsContext}) => apiKanjiFind(text, optionsContext),      termsFind: ({text, details, optionsContext}) => apiTermsFind(text, details, optionsContext), +    textParse: ({text, optionsContext}) => apiTextParse(text, optionsContext), +    textParseMecab: ({text, optionsContext}) => apiTextParseMecab(text, optionsContext),      definitionAdd: ({definition, mode, context, optionsContext}) => apiDefinitionAdd(definition, mode, context, optionsContext),      definitionsAddable: ({definitions, modes, optionsContext}) => apiDefinitionsAddable(definitions, modes, optionsContext),      noteView: ({noteId}) => apiNoteView(noteId), diff --git a/ext/bg/js/mecab.js b/ext/bg/js/mecab.js new file mode 100644 index 00000000..246f8bba --- /dev/null +++ b/ext/bg/js/mecab.js @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2019  Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <http://www.gnu.org/licenses/>. + */ + + +class Mecab { +    constructor() { +        this.port = null; +        this.listeners = {}; +        this.sequence = 0; +    } + +    onError(error) { +        logError(error, false); +    } + +    async checkVersion() { +        try { +            const {version} = await this.invoke('get_version', {}); +            if (version !== Mecab.version) { +                this.stopListener(); +                throw new Error(`Unsupported MeCab native messenger version ${version}. Yomichan supports version ${Mecab.version}.`); +            } +        } catch (error) { +            this.onError(error); +        } +    } + +    async parseText(text) { +        return await this.invoke('parse_text', {text}); +    } + +    startListener() { +        if (this.port !== null) { return; } +        this.port = chrome.runtime.connectNative('yomichan_mecab'); +        this.port.onMessage.addListener(this.onNativeMessage.bind(this)); +        this.checkVersion(); +    } + +    stopListener() { +        if (this.port === null) { return; } +        this.port.disconnect(); +        this.port = null; +        this.listeners = {}; +        this.sequence = 0; +    } + +    onNativeMessage({sequence, data}) { +        if (this.listeners.hasOwnProperty(sequence)) { +            const {callback, timer} = this.listeners[sequence]; +            clearTimeout(timer); +            callback(data); +            delete this.listeners[sequence]; +        } +    } + +    invoke(action, params) { +        if (this.port === null) { +            return Promise.resolve({}); +        } +        return new Promise((resolve, reject) => { +            const sequence = this.sequence++; + +            this.listeners[sequence] = { +                callback: resolve, +                timer: setTimeout(() => { +                    delete this.listeners[sequence]; +                    reject(new Error(`Mecab invoke timed out in ${Mecab.timeout} ms`)); +                }, Mecab.timeout) +            } + +            this.port.postMessage({action, params, sequence}); +        }); +    } +} + +Mecab.timeout = 5000; +Mecab.version = 1; diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index be1ccfbb..b9bf85f3 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -311,6 +311,13 @@ function profileOptionsCreateDefaults() {          dictionaries: {}, +        parsing: { +            enableScanningParser: true, +            enableMecabParser: false, +            selectedParser: null, +            readingMode: 'hiragana' +        }, +          anki: {              enable: false,              server: 'http://127.0.0.1:8765', diff --git a/ext/bg/js/search-query-parser.js b/ext/bg/js/search-query-parser.js new file mode 100644 index 00000000..42e53989 --- /dev/null +++ b/ext/bg/js/search-query-parser.js @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2019  Alex Yatskov <alex@foosoft.net> + * Author: Alex Yatskov <alex@foosoft.net> + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <http://www.gnu.org/licenses/>. + */ + + +class QueryParser { +    constructor(search) { +        this.search = search; +        this.pendingLookup = false; +        this.clickScanPrevent = false; + +        this.parseResults = []; +        this.selectedParser = null; + +        this.queryParser = document.querySelector('#query-parser'); +        this.queryParserSelect = document.querySelector('#query-parser-select'); + +        this.queryParser.addEventListener('mousedown', (e) => this.onMouseDown(e)); +        this.queryParser.addEventListener('mouseup', (e) => this.onMouseUp(e)); +    } + +    onError(error) { +        logError(error, false); +    } + +    onMouseDown(e) { +        if (Frontend.isMouseButton('primary', e)) { +            this.clickScanPrevent = false; +        } +    } + +    onMouseUp(e) { +        if ( +            this.search.options.scanning.clickGlossary && +            !this.clickScanPrevent && +            Frontend.isMouseButton('primary', e) +        ) { +            const selectText = this.search.options.scanning.selectText; +            this.onTermLookup(e, {disableScroll: true, selectText}); +        } +    } + +    onMouseMove(e) { +        if (this.pendingLookup || Frontend.isMouseButton('primary', e)) { +            return; +        } + +        const scanningOptions = this.search.options.scanning; +        const scanningModifier = scanningOptions.modifier; +        if (!( +            Frontend.isScanningModifierPressed(scanningModifier, e) || +            (scanningOptions.middleMouse && Frontend.isMouseButton('auxiliary', e)) +        )) { +            return; +        } + +        const selectText = this.search.options.scanning.selectText; +        this.onTermLookup(e, {disableScroll: true, disableHistory: true, selectText}); +    } + +    onMouseLeave(e) { +        this.clickScanPrevent = true; +        clearTimeout(e.target.dataset.timer); +        delete e.target.dataset.timer; +    } + +    onTermLookup(e, params) { +        this.pendingLookup = true; +        (async () => { +            await this.search.onTermLookup(e, params); +            this.pendingLookup = false; +        })(); +    } + +    onParserChange(e) { +        const selectedParser = e.target.value; +        this.selectedParser = selectedParser; +        apiOptionsSet({parsing: {selectedParser}}, this.search.getOptionsContext()); +        this.renderParseResult(this.getParseResult()); +    } + +    refreshSelectedParser() { +        if (this.parseResults.length > 0) { +            if (this.selectedParser === null) { +                this.selectedParser = this.search.options.parsing.selectedParser; +            } +            if (this.selectedParser === null || !this.getParseResult()) { +                const selectedParser = this.parseResults[0].id; +                this.selectedParser = selectedParser; +                apiOptionsSet({parsing: {selectedParser}}, this.search.getOptionsContext()); +            } +        } +    } + +    getParseResult() { +        return this.parseResults.find(r => r.id === this.selectedParser); +    } + +    async setText(text) { +        this.search.setSpinnerVisible(true); + +        await this.setPreview(text); + +        this.parseResults = await this.parseText(text); +        this.refreshSelectedParser(); + +        this.renderParserSelect(); +        await this.renderParseResult(); + +        this.search.setSpinnerVisible(false); +    } + +    async parseText(text) { +        const results = []; +        if (this.search.options.parsing.enableScanningParser) { +            results.push({ +                name: 'Scanning parser', +                id: 'scan', +                parsedText: await apiTextParse(text, this.search.getOptionsContext()) +            }); +        } +        if (this.search.options.parsing.enableMecabParser) { +            let mecabResults = await apiTextParseMecab(text, this.search.getOptionsContext()); +            for (const mecabDictName in mecabResults) { +                results.push({ +                    name: `MeCab: ${mecabDictName}`, +                    id: `mecab-${mecabDictName}`, +                    parsedText: mecabResults[mecabDictName] +                }); +            } +        } +        return results; +    } + +    async setPreview(text) { +        const previewTerms = []; +        while (text.length > 0) { +            const tempText = text.slice(0, 2); +            previewTerms.push([{text: Array.from(tempText)}]); +            text = text.slice(2); +        } +        this.queryParser.innerHTML = await apiTemplateRender('query-parser.html', { +            terms: previewTerms, +            preview: true +        }); + +        for (const charElement of this.queryParser.querySelectorAll('.query-parser-char')) { +            this.activateScanning(charElement); +        } +    } + +    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); +            } +            select.addEventListener('change', this.onParserChange.bind(this)); +            this.queryParserSelect.appendChild(select); +        } +    } + +    async renderParseResult() { +        const parseResult = this.getParseResult(); +        if (!parseResult) { +            this.queryParser.innerHTML = ''; +            return; +        } + +        this.queryParser.innerHTML = await apiTemplateRender( +            'query-parser.html', +            {terms: QueryParser.processParseResultForDisplay(parseResult.parsedText)} +        ); + +        for (const charElement of this.queryParser.querySelectorAll('.query-parser-char')) { +            this.activateScanning(charElement); +        } +    } + +    activateScanning(element) { +        element.addEventListener('mousemove', (e) => { +            clearTimeout(e.target.dataset.timer); +            if (this.search.options.scanning.modifier === 'none') { +                e.target.dataset.timer = setTimeout(() => { +                    this.onMouseMove(e); +                    delete e.target.dataset.timer; +                }, this.search.options.scanning.delay); +            } else { +                this.onMouseMove(e); +            } +        }); +        element.addEventListener('mouseleave', (e) => { +            this.onMouseLeave(e); +        }); +    } + +    static processParseResultForDisplay(result) { +        return result.map((term) => { +            return term.filter(part => part.text.trim()).map((part) => { +                return { +                    text: Array.from(part.text), +                    reading: part.reading, +                    raw: !part.reading || !part.reading.trim(), +                }; +            }); +        }); +    } +} diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index ec5a5972..b4731e6a 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -32,6 +32,8 @@ class DisplaySearch extends Display {              url: window.location.href          }; +        this.queryParser = new QueryParser(this); +          this.search = document.querySelector('#search');          this.query = document.querySelector('#query');          this.intro = document.querySelector('#intro'); @@ -72,11 +74,11 @@ class DisplaySearch extends Display {                          const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || '';                          if (e.target.checked) {                              window.wanakana.bind(this.query); -                            this.query.value = window.wanakana.toKana(query); +                            this.setQuery(window.wanakana.toKana(query));                              apiOptionsSet({general: {enableWanakana: true}}, this.getOptionsContext());                          } else {                              window.wanakana.unbind(this.query); -                            this.query.value = query; +                            this.setQuery(query);                              apiOptionsSet({general: {enableWanakana: false}}, this.getOptionsContext());                          }                          this.onSearchQueryUpdated(this.query.value, false); @@ -86,9 +88,9 @@ class DisplaySearch extends Display {                  const query = DisplaySearch.getSearchQueryFromLocation(window.location.href);                  if (query !== null) {                      if (this.isWanakanaEnabled()) { -                        this.query.value = window.wanakana.toKana(query); +                        this.setQuery(window.wanakana.toKana(query));                      } else { -                        this.query.value = query; +                        this.setQuery(query);                      }                      this.onSearchQueryUpdated(this.query.value, false);                  } @@ -159,6 +161,7 @@ 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}`);          this.onSearchQueryUpdated(query, true); @@ -168,9 +171,9 @@ class DisplaySearch extends Display {          const query = DisplaySearch.getSearchQueryFromLocation(window.location.href) || '';          if (this.query !== null) {              if (this.isWanakanaEnabled()) { -                this.query.value = window.wanakana.toKana(query); +                this.setQuery(window.wanakana.toKana(query));              } else { -                this.query.value = query; +                this.setQuery(query);              }          } @@ -258,9 +261,9 @@ class DisplaySearch extends Display {              }              if (curText && (curText !== this.clipboardPrevText) && jpIsJapaneseText(curText)) {                  if (this.isWanakanaEnabled()) { -                    this.query.value = window.wanakana.toKana(curText); +                    this.setQuery(window.wanakana.toKana(curText));                  } else { -                    this.query.value = curText; +                    this.setQuery(curText);                  }                  const queryString = curText.length > 0 ? `?query=${encodeURIComponent(curText)}` : ''; @@ -287,6 +290,11 @@ class DisplaySearch extends Display {          return this.optionsContext;      } +    setQuery(query) { +        this.query.value = query; +        this.queryParser.setText(query); +    } +      setIntroVisible(visible, animate) {          if (this.introVisible === visible) {              return; diff --git a/ext/bg/js/settings.js b/ext/bg/js/settings.js index e562c54e..ab267c32 100644 --- a/ext/bg/js/settings.js +++ b/ext/bg/js/settings.js @@ -64,6 +64,10 @@ async function formRead(options) {      options.scanning.modifier = $('#scan-modifier-key').val();      options.scanning.popupNestingMaxDepth = parseInt($('#popup-nesting-max-depth').val(), 10); +    options.parsing.enableScanningParser = $('#parsing-scan-enable').prop('checked'); +    options.parsing.enableMecabParser = $('#parsing-mecab-enable').prop('checked'); +    options.parsing.readingMode = $('#parsing-reading-mode').val(); +      const optionsAnkiEnableOld = options.anki.enable;      options.anki.enable = $('#anki-enable').prop('checked');      options.anki.tags = utilBackgroundIsolate($('#card-tags').val().split(/[,; ]+/)); @@ -126,6 +130,10 @@ async function formWrite(options) {      $('#scan-modifier-key').val(options.scanning.modifier);      $('#popup-nesting-max-depth').val(options.scanning.popupNestingMaxDepth); +    $('#parsing-scan-enable').prop('checked', options.parsing.enableScanningParser); +    $('#parsing-mecab-enable').prop('checked', options.parsing.enableMecabParser); +    $('#parsing-reading-mode').val(options.parsing.readingMode); +      $('#anki-enable').prop('checked', options.anki.enable);      $('#card-tags').val(options.anki.tags.join(' '));      $('#sentence-detection-extent').val(options.anki.sentenceExt); diff --git a/ext/bg/js/templates.js b/ext/bg/js/templates.js index 823b9e6f..6e377957 100644 --- a/ext/bg/js/templates.js +++ b/ext/bg/js/templates.js @@ -163,6 +163,58 @@ templates['kanji.html'] = template({"1":function(container,depth0,helpers,partia    }  ,"useDecorators":true,"usePartial":true,"useData":true,"useDepths":true}); +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});  templates['terms.html'] = template({"1":function(container,depth0,helpers,partials,data) {      var stack1, helper, options, alias1=depth0 != null ? depth0 : (container.nullContext || {}), buffer =     "<div class=\"dict-"; diff --git a/ext/bg/search.html b/ext/bg/search.html index 54c5fb6c..e819ebe6 100644 --- a/ext/bg/search.html +++ b/ext/bg/search.html @@ -47,6 +47,13 @@                  <img src="/mixed/img/spinner.gif">              </div> +            <div class="scan-disable"> +                <div id="query-parser-select" class="input-group"></div> +                <div id="query-parser"></div> +            </div> + +            <hr> +              <div id="content"></div>          </div> @@ -67,6 +74,7 @@          <script src="/mixed/js/japanese.js"></script>          <script src="/mixed/js/scroll.js"></script> +        <script src="/bg/js/search-query-parser.js"></script>          <script src="/bg/js/search.js"></script>          <script src="/bg/js/search-frontend.js"></script>      </body> diff --git a/ext/bg/settings.html b/ext/bg/settings.html index f95e76a1..262386e9 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -410,6 +410,44 @@                  </div>              </div> +            <div id="text-parsing"> +                <h3>Text Parsing Options</h3> + +                <p class="help-block"> +                    Yomichan can attempt to parse entire sentences or longer text blocks on the search page, +                    adding furigana above words and a small space between words. +                </p> + +                <p class="help-block"> +                    Two types of parsers are supported. The first one, enabled by default, works using the built-in +                    scanning functionality by automatically advancing in the sentence after a matching word. +                </p> + +                <p class="help-block"> +                    The second type is an external program called <a href="https://en.wikipedia.org/wiki/MeCab" target="_blank" rel="noopener">MeCab</a> +                    that uses its own dictionaries and a special parsing algorithm. To get it working, you must first +                    install it and <a href="https://github.com/siikamiika/yomichan-mecab-installer" target="_blank" rel="noopener">a native messaging component</a> +                    that acts as a bridge between the program and Yomichan. +                </p> + +                <div class="checkbox"> +                    <label><input type="checkbox" id="parsing-scan-enable"> Enable text parsing using installed dictionaries</label> +                </div> + +                <div class="checkbox"> +                    <label><input type="checkbox" id="parsing-mecab-enable"> Enable text parsing using MeCab</label> +                </div> + +                <div class="form-group"> +                    <label for="parsing-reading-mode">Reading mode</label> +                    <select class="form-control" id="parsing-reading-mode"> +                        <option value="hiragana">ひらがな</option> +                        <option value="katakana">カタカナ</option> +                        <option value="romaji">Romaji</option> +                    </select> +                </div> +            </div> +              <div class="ignore-form-changes">                  <div>                      <img src="/mixed/img/spinner.gif" class="pull-right" id="dict-spinner" alt> diff --git a/ext/fg/js/api.js b/ext/fg/js/api.js index 945ba076..92330d9c 100644 --- a/ext/fg/js/api.js +++ b/ext/fg/js/api.js @@ -29,6 +29,14 @@ function apiTermsFind(text, details, optionsContext) {      return utilInvoke('termsFind', {text, details, optionsContext});  } +function apiTextParse(text, optionsContext) { +    return utilInvoke('textParse', {text, optionsContext}); +} + +function apiTextParseMecab(text, optionsContext) { +    return utilInvoke('textParseMecab', {text, optionsContext}); +} +  function apiKanjiFind(text, optionsContext) {      return utilInvoke('kanjiFind', {text, optionsContext});  } diff --git a/ext/manifest.json b/ext/manifest.json index fabceafd..4d75cd54 100644 --- a/ext/manifest.json +++ b/ext/manifest.json @@ -42,7 +42,8 @@          "<all_urls>",          "storage",          "clipboardWrite", -        "unlimitedStorage" +        "unlimitedStorage", +        "nativeMessaging"      ],      "optional_permissions": [          "clipboardRead" diff --git a/ext/mixed/css/display.css b/ext/mixed/css/display.css index 7ee6f5ac..ba2fadb7 100644 --- a/ext/mixed/css/display.css +++ b/ext/mixed/css/display.css @@ -88,6 +88,19 @@ ol, ul {      user-select: none;  } +#query-parser { +    margin-top: 10px; +    font-size: 24px; +} + +.query-parser-term { +    margin-right: 5px; +} + +html:root[data-yomichan-page=search] body { +    overflow-y: scroll; /* always show scroll bar to avoid scanning problems */ +} +  /*   * Entries diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 8ad3ee1b..4c698ecf 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -98,17 +98,62 @@ class Display {          }      } -    async onTermLookup(e) { +    async onTermLookup(e, {disableScroll, selectText, disableHistory}={}) { +        const termLookupResults = await this.termLookup(e); +        if (!termLookupResults) { +            return false; +        } + +        try { +            const {textSource, definitions} = termLookupResults; + +            const scannedElement = e.target; +            const sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt); + +            if (!disableScroll) { +                this.windowScroll.toY(0); +            } +            let context; +            if (disableHistory) { +                const {url, source} = this.context || {}; +                context = {sentence, url, source, disableScroll}; +            } else { +                context = { +                    disableScroll, +                    source: { +                        definitions: this.definitions, +                        index: this.entryIndexFind(scannedElement), +                        scroll: this.windowScroll.y +                    } +                }; + +                if (this.context) { +                    context.sentence = sentence; +                    context.url = this.context.url; +                    context.source.source = this.context.source; +                } +            } + +            this.setContentTerms(definitions, context); + +            if (selectText) { +                textSource.select(); +            } +        } catch (error) { +            this.onError(error); +        } +    } + +    async termLookup(e) {          try {              e.preventDefault(); -            const clickedElement = e.target;              const textSource = docRangeFromPoint(e.clientX, e.clientY, this.options);              if (textSource === null) {                  return false;              } -            let definitions, length, sentence; +            let definitions, length;              try {                  textSource.setEndOffset(this.options.scanning.length); @@ -118,28 +163,11 @@ class Display {                  }                  textSource.setEndOffset(length); - -                sentence = docSentenceExtract(textSource, this.options.anki.sentenceExt);              } finally {                  textSource.cleanup();              } -            this.windowScroll.toY(0); -            const context = { -                source: { -                    definitions: this.definitions, -                    index: this.entryIndexFind(clickedElement), -                    scroll: this.windowScroll.y -                } -            }; - -            if (this.context) { -                context.sentence = sentence; -                context.url = this.context.url; -                context.source.source = this.context.source; -            } - -            this.setContentTerms(definitions, context); +            return {textSource, definitions};          } catch (error) {              this.onError(error);          } @@ -336,8 +364,10 @@ class Display {              const content = await apiTemplateRender('terms.html', params);              this.container.innerHTML = content; -            const {index, scroll} = context || {}; -            this.entryScrollIntoView(index || 0, scroll); +            const {index, scroll, disableScroll} = context || {}; +            if (!disableScroll) { +                this.entryScrollIntoView(index || 0, scroll); +            }              if (options.audio.enabled && options.audio.autoPlay) {                  this.autoPlayAudio(); diff --git a/ext/mixed/js/japanese.js b/ext/mixed/js/japanese.js index d24f56a6..a7cd0452 100644 --- a/ext/mixed/js/japanese.js +++ b/ext/mixed/js/japanese.js @@ -48,6 +48,43 @@ function jpKatakanaToHiragana(text) {      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 (jpIsKana(expressionFragment)) { +                    return jpToRomaji(expressionFragment); +                } +            } +            return readingFragment; +        default: +            return readingFragment; +    } +} +  function jpDistributeFurigana(expression, reading) {      const fallback = [{furigana: reading, text: expression}];      if (!reading) { @@ -61,12 +98,11 @@ function jpDistributeFurigana(expression, reading) {          const group = groups[0];          if (group.mode === 'kana') { -            if (reading.startsWith(group.text)) { -                const readingUsed = reading.substring(0, group.text.length); +            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: readingUsed}].concat(segs); +                    return [{text: group.text}].concat(segs);                  }              }          } else { @@ -95,3 +131,30 @@ function jpDistributeFurigana(expression, reading) {      return segmentize(reading, groups) || 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.slice(0, source.length - offset); +    const stemReading = reading.slice( +        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.slice(stemLength)}); +    } + +    return output; +} diff --git a/tmpl/query-parser.html b/tmpl/query-parser.html new file mode 100644 index 00000000..db98b5ff --- /dev/null +++ b/tmpl/query-parser.html @@ -0,0 +1,27 @@ +{{~#*inline "term"~}} +{{~#if preview~}} +<span class="query-parser-term-preview"> +{{~else~}} +<span class="query-parser-term"> +{{~/if~}} +{{~#each this~}} +{{> part }} +{{~/each~}} +</span> +{{~/inline~}} + +{{~#*inline "part"~}} +{{~#if raw~}} +{{~#each text~}} +<span class="query-parser-char">{{this}}</span> +{{~/each~}} +{{~else~}} +<ruby>{{~#each text~}} +<span class="query-parser-char">{{this}}</span> +{{~/each~}}<rt>{{reading}}</rt></ruby> +{{~/if~}} +{{~/inline~}} + +{{~#each terms~}} +{{> term preview=../preview }} +{{~/each~}} |