diff --git a/ext/css/search.css b/ext/css/search.css index 1f76682e6f..e0c7a98ced 100644 --- a/ext/css/search.css +++ b/ext/css/search.css @@ -76,6 +76,7 @@ h1 { margin: 0; padding: 0; border: 0; + position: relative; } #search-textbox { color: var(--text-color); @@ -236,6 +237,34 @@ h1 { height: 480px; } +/* for query bar suggestions */ +.suggestions-list { + list-style-type: none; + padding: 0; + margin: 0; + border: 1px solid #666666; + border-top: none; + position: fixed; + z-index: 1000; + width: 100%; + max-height: 200px; + overflow-y: auto; + background-color: var(--background-color); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + top: 0; + left: 0; +} + +.suggestions-list li { + padding: 8px 12px; + cursor: pointer; + transition: background-color var(--animation-duration) ease-in-out; +} + +.suggestions-list li:hover { + background-color: var(--input-background-color-dark); +} + /* Dark mode before themes are applied DO NOT use this for normal theming */ @media (prefers-color-scheme: dark) { diff --git a/ext/data/schemas/options-schema.json b/ext/data/schemas/options-schema.json index 602aea8627..798910a246 100644 --- a/ext/data/schemas/options-schema.json +++ b/ext/data/schemas/options-schema.json @@ -125,6 +125,7 @@ "sortFrequencyDictionary", "sortFrequencyDictionaryOrder", "stickySearchHeader", + "enableSearchSuggestions", "fontFamily", "fontSize", "lineHeight", @@ -339,6 +340,10 @@ "type": "boolean", "default": false }, + "enableSearchSuggestions": { + "type": "boolean", + "default": false + }, "enableYomitanApi": { "type": "boolean", "default": false diff --git a/ext/js/data/trie.js b/ext/js/data/trie.js new file mode 100644 index 0000000000..ccde4c8d51 --- /dev/null +++ b/ext/js/data/trie.js @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2023-2025 Yomitan Authors + * + * 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 . + */ + + +/** + * Trie Node. + */ +export class TrieNode { + constructor() { + /** @type {{[key: string]: TrieNode}} */ + this._children = {}; + /** @type {boolean} */ + this._isEnd = false; + } + + /** @returns {{[key:string]: TrieNode}} */ + get children() { + return this._children; + } + + /** @returns {boolean} */ + get isEnd() { + return this._isEnd; + } + + /** @param {boolean} value */ + set isEnd(value) { + this._isEnd = value; + } +} + +/** + * Trie data structure for storing and searching strings efficiently. + */ +export class Trie { + constructor() { + /** @type {TrieNode} */ + this._root = new TrieNode(); + /** @type {number} */ + this._size = 0; + } + + /** + * @param {string} word + * @returns {void} + * @throws {TypeError} + */ + insert(word) { + if (typeof word !== 'string') { + throw new TypeError('Word must be a string'); + } + if (word.length === 0) { + return; + } + let currNode = this._root; + for (const ch of word) { + if (!currNode.children[ch]) { + currNode.children[ch] = new TrieNode(); + } + currNode = currNode.children[ch]; + } + if (!currNode.isEnd) { + currNode.isEnd = true; + this._size++; + } + } + + /** + * Returns autocomplete suggestions for a given prefix. + * @param {string} prefix The prefix to search for. + * @param {number} [limit=10] The maximum number of suggestions to return. + * @returns {string[]} An array of suggested words. + */ + getSuggestions(prefix, limit = 10) { + if (typeof prefix !== 'string') { + return []; + } + if (typeof limit !== 'number' || limit < 0) { + limit = 10; + } + /** @type {string[]} */ + const results = []; + + /** @type {TrieNode} */ + let currNode = this._root; + for (const ch of prefix) { + if (!currNode.children[ch]) { + return results; + } + currNode = currNode.children[ch]; + } + + /** + * Depth-first search to collect words from currentNode. + * @param {TrieNode} node Current node in the trie. + * @param {string} path Current path (prefix + accumulated characters). + * @returns {void} + */ + const dfs = (node, path) => { + if (results.length >= limit) { return; } + if (node.isEnd) { + results.push(path); + } + for (const [char, child] of Object.entries(node.children)) { + dfs(child, path + char); + } + }; + dfs(currNode, prefix); + return results; + } + + /** + * @param {string} word + * @returns {boolean} + */ + search(word) { + if (typeof word !== 'string') { + return false; + } + + let currNode = this._root; + for (const ch of word) { + if (!currNode.children[ch]) { + return false; + } + currNode = currNode.children[ch]; + } + return currNode.isEnd; + } + + /** + * @param {string} prefix + * @returns {boolean} + */ + startsWith(prefix) { + if (typeof prefix !== 'string') { + return false; + } + + let currNode = this._root; + for (const ch of prefix) { + if (!currNode.children[ch]) { + return false; + } + currNode = currNode.children[ch]; + } + return true; + } + + /** + * Deletes a word from the trie. + * @param {string} word + * @returns {boolean} + */ + delete(word) { + if (typeof word !== 'string' || word.length === 0) { + return false; + } + + return this._deleteHelper(this._root, word, 0); + } + + /** + * Helper method for deleting a word from the trie. + * @param {TrieNode} node Current node. + * @param {string} word Word to delete. + * @param {number} idx Current character index. + * @returns {boolean} True if the word was deleted. + */ + _deleteHelper(node, word, idx) { + if (idx === word.length) { + if (node.isEnd) { + node.isEnd = false; + this._size--; + return true; + } + return false; + } + const char = word[idx]; + const childNode = node.children[char]; + if (!childNode) { + return false; + } + const shouldDeleteChild = this._deleteHelper(childNode, word, idx + 1); + if (shouldDeleteChild && !childNode.isEnd && Object.keys(childNode.children).length === 0) { + delete node.children[char]; + } + return shouldDeleteChild; + } + + /** @returns {number} */ + size() { + return this._size; + } + + /** + * Clears all words from the trie. + * @returns {void} + */ + clear() { + this._root = new TrieNode(); + this._size = 0; + } + + // Testing functions / future use + + /** @returns {string[]} */ + getAllWords() { + /** @type {string[]} */ + const words = []; + /** + * @param {TrieNode} node Current node in the trie. + * @param {string} path Current path (prefix + accumulated characters). + */ + const dfs = (node, path) => { + if (node.isEnd) { + words.push(path); + } + // Sort keys for consistent ordering + const sortedKeys = Object.keys(node.children).sort(); + for (const ch of sortedKeys) { + dfs(node.children[ch], path + ch); + } + }; + dfs(this._root, ''); + return words; + } + + /** @returns {boolean}*/ + isEmpty() { + return this._size === 0; + } + + /** @returns {number} */ + getHeight() { + /** + * @param {TrieNode} node Current node. + * @returns {number} Height of the subtree rooted at node. + */ + const getHeightHelper = (node) => { + if (Object.keys(node.children).length === 0) { + return 0; + } + let maxHeight = 0; + for (const child of Object.values(node.children)) { + maxHeight = Math.max(maxHeight, getHeightHelper(child)); + } + return maxHeight + 1; + }; + return getHeightHelper(this._root); + } + + /** @returns {number} */ + getNodeCount() { + /** + * @param {TrieNode} node + * @returns {number} + */ + const countNodes = (node) => { + let count = 1; + for (const child of Object.values(node.children)) { + count += countNodes(child); + } + return count; + }; + return countNodes(this._root); + } + + /** @returns {string} */ + toString() { + const words = this.getAllWords(); + return `Trie(size=${this._size}, words=[${words.join(', ')}])`; + } + + /** + * Serializes the trie to a JSON string. + * @returns {string} + */ + serialize() { + return JSON.stringify(this._root); + } +} diff --git a/ext/js/display/search-display-controller.js b/ext/js/display/search-display-controller.js index 0382ce2b2c..462d0aefce 100644 --- a/ext/js/display/search-display-controller.js +++ b/ext/js/display/search-display-controller.js @@ -28,14 +28,17 @@ export class SearchDisplayController { * @param {import('./display.js').Display} display * @param {import('./display-audio.js').DisplayAudio} displayAudio * @param {import('./search-persistent-state-controller.js').SearchPersistentStateController} searchPersistentStateController + * @param {import('./search-suggestion-controller.js').SearchSuggestionController} searchSuggestionController */ - constructor(display, displayAudio, searchPersistentStateController) { + constructor(display, displayAudio, searchPersistentStateController, searchSuggestionController) { /** @type {import('./display.js').Display} */ this._display = display; /** @type {import('./display-audio.js').DisplayAudio} */ this._displayAudio = displayAudio; /** @type {import('./search-persistent-state-controller.js').SearchPersistentStateController} */ this._searchPersistentStateController = searchPersistentStateController; + /** @type {import('./search-suggestion-controller.js').SearchSuggestionController} */ + this._searchSuggestionController = searchSuggestionController; /** @type {HTMLButtonElement} */ this._searchButton = querySelectorNotNull(document, '#search-button'); /** @type {HTMLButtonElement} */ @@ -58,6 +61,8 @@ export class SearchDisplayController { this._profileSelect = querySelectorNotNull(document, '#profile-select'); /** @type {HTMLElement} */ this._wanakanaSearchOption = querySelectorNotNull(document, '#search-option-wanakana'); + /** @type {HTMLInputElement} */ + this._searchSuggestionsEnableCheckbox = querySelectorNotNull(document, '#search-suggestions-enable'); /** @type {EventListenerCollection} */ this._queryInputEvents = new EventListenerCollection(); /** @type {boolean} */ @@ -82,6 +87,12 @@ export class SearchDisplayController { ['searchDisplayControllerSetMode', this._onMessageSetMode.bind(this)], ['searchDisplayControllerUpdateSearchQuery', this._onExternalSearchUpdate.bind(this)], ]); + /** @type {HTMLUListElement} */ + this._suggestionListElement = document.createElement('ul'); + this._suggestionListElement.id = 'search-suggestions'; + document.body.appendChild(this._suggestionListElement); + /** @type {boolean} */ + this._searchSuggestionsEnabled = false; } /** */ @@ -90,6 +101,9 @@ export class SearchDisplayController { this._searchPersistentStateController.on('modeChange', this._onModeChange.bind(this)); + // Initialize suggestion controller in background without waiting + this._searchSuggestionController.prepare().catch(() => {}); + chrome.runtime.onMessage.addListener(this._onMessage.bind(this)); this._display.application.on('optionsUpdated', this._onOptionsUpdated.bind(this)); @@ -116,6 +130,7 @@ export class SearchDisplayController { this._clipboardMonitor.on('change', this._onClipboardMonitorChange.bind(this)); this._clipboardMonitorEnableCheckbox.addEventListener('change', this._onClipboardMonitorEnableChange.bind(this)); this._stickyHeaderEnableCheckbox.addEventListener('change', this._onStickyHeaderEnableChange.bind(this)); + this._searchSuggestionsEnableCheckbox.addEventListener('change', this._onSearchSuggestionsEnableChange.bind(this)); this._display.hotkeyHandler.on('keydownNonHotkey', this._onKeyDown.bind(this)); this._profileSelect.addEventListener('change', this._onProfileSelectChange.bind(this), false); @@ -202,11 +217,13 @@ export class SearchDisplayController { * @param {import('settings').ProfileOptions} options */ _updateSearchSettings(options) { - const {language, enableWanakana, stickySearchHeader} = options.general; + const {language, enableWanakana, stickySearchHeader, enableSearchSuggestions} = options.general; const wanakanaEnabled = language === 'ja' && enableWanakana; + const searchSuggestionsEnabled = language === 'ja' && enableSearchSuggestions; this._wanakanaEnableCheckbox.checked = wanakanaEnabled; this._wanakanaSearchOption.style.display = language === 'ja' ? '' : 'none'; this._setWanakanaEnabled(wanakanaEnabled); + this._setSearchSuggestionsEnabled(searchSuggestionsEnabled); this._setStickyHeaderEnabled(stickySearchHeader); } @@ -249,13 +266,20 @@ export class SearchDisplayController { /** * @param {InputEvent} e */ - _onSearchInput(e) { + async _onSearchInput(e) { this._updateSearchHeight(true); const element = /** @type {HTMLTextAreaElement} */ (e.currentTarget); if (this._wanakanaEnabled) { this._searchTextKanaConversion(element, e); } + if (this._searchSuggestionsEnabled) { + const suggestions = await this._searchSuggestionController.getSuggestions(element.value); + + if (suggestions.length > 0) { + await this._searchSuggestionController.renderSuggestions(suggestions); + } + } } /** @@ -420,6 +444,36 @@ export class SearchDisplayController { this._stickyHeaderEnableCheckbox.checked = stickySearchHeaderEnabled; } + /** + * @param {Event} e + */ + _onSearchSuggestionsEnableChange(e) { + const element = /** @type {HTMLInputElement} */ (e.target); + const value = element.checked; + this._setSearchSuggestionsEnabled(value); + /** @type {import('settings-modifications').ScopedModificationSet} */ + const modification = { + action: 'set', + path: 'general.enableSearchSuggestions', + value, + scope: 'profile', + optionsContext: this._display.getOptionsContext(), + }; + void this._display.application.api.modifySettings([modification], 'search'); + } + + /** + * @param {boolean} searchSuggestionsEnabled + */ + _setSearchSuggestionsEnabled(searchSuggestionsEnabled) { + this._searchSuggestionsEnabled = searchSuggestionsEnabled; + this._searchSuggestionsEnableCheckbox.checked = searchSuggestionsEnabled; + if (!searchSuggestionsEnabled && this._searchSuggestionController) { + this._searchSuggestionController.hideSuggestions(); + } + } + + /** */ _onModeChange() { this._updateClipboardMonitorEnabled(); diff --git a/ext/js/display/search-main.js b/ext/js/display/search-main.js index d369cb6a19..9ded7710c6 100644 --- a/ext/js/display/search-main.js +++ b/ext/js/display/search-main.js @@ -28,6 +28,7 @@ import {Display} from './display.js'; import {SearchActionPopupController} from './search-action-popup-controller.js'; import {SearchDisplayController} from './search-display-controller.js'; import {SearchPersistentStateController} from './search-persistent-state-controller.js'; +import {SearchSuggestionController} from './search-suggestion-controller.js'; await Application.main(true, async (application) => { const documentFocusController = new DocumentFocusController('#search-textbox'); @@ -51,7 +52,9 @@ await Application.main(true, async (application) => { const displayAnki = new DisplayAnki(display, displayAudio); displayAnki.prepare(); - const searchDisplayController = new SearchDisplayController(display, displayAudio, searchPersistentStateController); + const searchSuggestionController = new SearchSuggestionController(searchPersistentStateController, display); + + const searchDisplayController = new SearchDisplayController(display, displayAudio, searchPersistentStateController, searchSuggestionController); await searchDisplayController.prepare(); const modalController = new ModalController([]); diff --git a/ext/js/display/search-suggestion-controller.js b/ext/js/display/search-suggestion-controller.js new file mode 100644 index 0000000000..d23eb79e3f --- /dev/null +++ b/ext/js/display/search-suggestion-controller.js @@ -0,0 +1,539 @@ +/* + * Copyright (C) 2023-2025 Yomitan Authors + * + * 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 . + */ + +import {Trie} from '../data/trie.js'; +import {DictionaryDatabase} from '../dictionary/dictionary-database.js'; +import {querySelectorNotNull} from '../dom/query-selector.js'; +import {convertToKana} from '../language/ja/japanese-wanakana.js'; +import {isStringEntirelyKana} from '../language/ja/japanese.js'; + +// Suggestion list size limit for autocomplete UI +const SUGGESTION_LIMIT = 10; + +/** + * Controller for managing search suggestions using a trie. + */ +export class SearchSuggestionController { + /** + * @param {import('./search-persistent-state-controller.js').SearchPersistentStateController} searchPersistentStateController + * @param {import('./display.js').Display} display + */ + constructor(searchPersistentStateController, display) { + /** @type {Trie} */ + this._trie = new Trie(); + /** @type {import('./search-persistent-state-controller.js').SearchPersistentStateController} */ + this._searchPersistentStateController = searchPersistentStateController; + /** @type {import('./display.js').Display} */ + this._display = display; + /** @type {DictionaryDatabase} */ + this._dictionaryDatabase = new DictionaryDatabase(); + /** @type {HTMLUListElement} */ + this._suggestionsList = querySelectorNotNull(document, '#suggestions-list'); + /** @type {boolean} */ + this._isSelectingSuggestion = false; + /** @type {Map} */ + this._termScores = new Map(); + /** @type {Map} */ + this._userFrequency = new Map(); + /** @type {Map>} */ + this._readingToKanji = new Map(); + + // Performance monitoring + /** @type {number} */ + this._trieUsageCount = 0; + /** @type {number} */ + this._databaseUsageCount = 0; + /** @type {number} */ + this._totalRequests = 0; + + this._setupClickOutsideListener(); + } + + /** + * Prepares the suggestion trie with initial terms. + * @returns {Promise} + */ + async prepare() { + await this._dictionaryDatabase.prepare(); + } + + /** + * Renders the list of search suggestions. + * @param {string[] | Promise} suggestions + */ + async renderSuggestions(suggestions) { + if (this._isSelectingSuggestion) { + return; + } + + try { + this._suggestionsList.innerHTML = ''; + const suggestionArray = Array.isArray(suggestions) ? suggestions : await suggestions; + + // Don't hide suggestions if we already have some displayed and the new suggestions are empty + if (suggestionArray.length === 0) { + const currentSuggestions = this._suggestionsList.children.length; + if (currentSuggestions > 0) { + return; + } + this._suggestionsList.style.display = 'none'; + return; + } + this._suggestionsList.style.display = 'block'; + const searchTextbox = document.getElementById('search-textbox'); + if (searchTextbox instanceof HTMLTextAreaElement) { + const rect = searchTextbox.getBoundingClientRect(); + this._suggestionsList.style.top = `${rect.bottom}px`; + this._suggestionsList.style.left = `${rect.left}px`; + this._suggestionsList.style.width = `${rect.width}px`; + } + + const fragment = document.createDocumentFragment(); + for (const suggestion of suggestionArray) { + const li = document.createElement('li'); + li.textContent = suggestion; + li.addEventListener('click', () => { + if (searchTextbox instanceof HTMLTextAreaElement) { + this._isSelectingSuggestion = true; + searchTextbox.value = suggestion; + this.hideSuggestions(); + + // Track user selection frequency + const currentFreq = this._userFrequency.get(suggestion) || 0; + this._userFrequency.set(suggestion, currentFreq + 1); + + searchTextbox.dispatchEvent(new Event('input', {bubbles: true})); + const searchButton = document.getElementById('search-button'); + if (searchButton) { + searchButton.click(); + } + setTimeout(() => { + this._isSelectingSuggestion = false; + }, 100); + } + }); + fragment.appendChild(li); + } + this._suggestionsList.appendChild(fragment); + } catch (e) { + this._suggestionsList.style.display = 'none'; + } + } + + /** + * Returns autocomplete suggestions for the given input. + * @param {string} input + * @returns {Promise} + */ + async getSuggestions(input) { + try { + this._totalRequests++; + const options = this._display.getOptions(); + if (!this._areSuggestionsEnabled(options) || !options) { return []; } + + const searchInput = this._normalizeInput(input, options); + + if (searchInput.length === 0) { + return await this._getPopularSuggestions(); + } + + const suggestions = this._getTrieSuggestions(searchInput); + + this._addKanjiEquivalentsIfNeeded(searchInput, suggestions); + + if (suggestions.length >= SUGGESTION_LIMIT) { + this._trieUsageCount++; + this._logPerformanceStats(); + return this._sortAndLimitSuggestions(suggestions, searchInput, options, 'trie'); + } + + // Fallback to DB + this._databaseUsageCount++; + /** @type {{ entries: { reading: string, term: string }[], suggestions: string[] }} */ + const dbResult = await this._getDatabaseSuggestions(searchInput, options); + /** @type {{ reading: string, term: string }[]} */ + const dbEntries = dbResult.entries; + /** @type {string[]} */ + let dbsuggestions = dbResult.suggestions; + // Add kanji terms for prefix-matching readings + const kanjiSuggestions = []; + if (isStringEntirelyKana(searchInput)) { + for (const entry of dbEntries) { + if ( + entry.reading && + entry.reading.startsWith(/** @type {string} */ (searchInput)) && + entry.term && + entry.term !== entry.reading && + this._containsKanji(entry.term) + ) { + kanjiSuggestions.push(entry.term); + } + } + } + dbsuggestions = [...new Set([...dbsuggestions, ...kanjiSuggestions])]; + + this._logPerformanceStats(); + return this._sortAndLimitSuggestions(dbsuggestions, searchInput, options, 'database'); + } catch (e) { + return []; + } + } + + /** + * Gets popular/most recent suggestions when input is empty. + * @returns {Promise} + */ + async _getPopularSuggestions() { + // Return terms with highest user frequency first + return [...this._userFrequency.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([term]) => term) + .slice(0, SUGGESTION_LIMIT); + } + + /** + * @param {Map} suggestions + * @returns {Promise} + */ + async _sortSuggestionsByScore(suggestions) { + const options = this._display.getOptions(); + const frequencyDictionary = options?.general.sortFrequencyDictionary; + + // Get frequency data from installed frequency dictionaries if enabled + /** @type {Map} */ + const frequencyMap = new Map(); + if (frequencyDictionary) { + try { + const termReadingList = [...suggestions.keys()].map((term) => ({term, reading: null})); + const frequencies = await this._display.application.api.getTermFrequencies(termReadingList, [frequencyDictionary]); + for (const freq of frequencies) { + frequencyMap.set(freq.term, freq.frequency); + } + } catch (e) { + // Ignore frequency data errors + } + } + /** @type {Array<{ term: string, score: number, dictionaryIndex: number }>} */ + return [...suggestions.values()] + .sort((a, b) => { + const freqA = this._userFrequency.get(a.term) || 0; + const freqB = this._userFrequency.get(b.term) || 0; + const yomitanFreqA = frequencyMap.get(a.term) || 0; + const yomitanFreqB = frequencyMap.get(b.term) || 0; + + // Weight: 50% dictionary score + 30% Yomitan frequency + 20% user frequency + const weightedScoreA = (a.score * 0.5) + (yomitanFreqA * 0.3) + (freqA * 100 * 0.2); + const weightedScoreB = (b.score * 0.5) + (yomitanFreqB * 0.3) + (freqB * 100 * 0.2); + + if (weightedScoreA !== weightedScoreB) { + return weightedScoreB - weightedScoreA; + } + return a.dictionaryIndex - b.dictionaryIndex; + }) + .map((item) => item.term) + .slice(0, SUGGESTION_LIMIT); + } + + /** + * Sorts suggestions with frequency data. + * @param {string[]} suggestions + * @param {string} input + * @param {import('settings').ProfileOptions} options + * @param {string} _source + * @returns {Promise} + */ + async _sortSuggestionsWithFrequency(suggestions, input, options, _source) { + const frequencyDictionary = options.general.sortFrequencyDictionary; + if (!frequencyDictionary) { + return suggestions.slice(0, SUGGESTION_LIMIT); + } + + try { + // Use ALL suggestions for frequency lookup, including kanji terms + const termReadingList = suggestions.map((term) => ({term, reading: null})); + const frequencies = await this._display.application.api.getTermFrequencies(termReadingList, [frequencyDictionary]); + + /** @type {Map} */ + const frequencyMap = new Map(); + for (const freq of frequencies) { + frequencyMap.set(freq.term, freq.frequency); + } + + const sorted = suggestions.sort((a, b) => this._compareSuggestions(a, b, input, frequencyMap)); + return sorted.slice(0, SUGGESTION_LIMIT); + } catch (e) { + return suggestions.slice(0, SUGGESTION_LIMIT); + } + } + + /** + * Compares two suggestions for sorting. + * @param {string} a First suggestion. + * @param {string} b Second suggestion. + * @param {string} input The input text. + * @param {Map} frequencyMap The frequency map. + * @returns {number} result. + */ + _compareSuggestions(a, b, input, frequencyMap) { + const freqA = frequencyMap.get(a) || 0; + const freqB = frequencyMap.get(b) || 0; + const userFreqA = this._userFrequency.get(a) || 0; + const userFreqB = this._userFrequency.get(b) || 0; + const scoreA = this._termScores.get(a) || 0; + const scoreB = this._termScores.get(b) || 0; + + // 1. Prioritize exact matches + const isExactMatchA = a === input; + const isExactMatchB = b === input; + if (isExactMatchA && !isExactMatchB) { return -1; } + if (!isExactMatchA && isExactMatchB) { return 1; } + + // 2. Prioritize kanji terms for kana input + if (isStringEntirelyKana(input)) { + const isKanjiA = this._containsKanji(a); + const isKanjiB = this._containsKanji(b); + if (isKanjiA && !isKanjiB) { return -1; } + if (!isKanjiA && isKanjiB) { return 1; } + } + + // 3. Prioritize shorter terms + const lengthDiff = a.length - b.length; + if (lengthDiff !== 0) { return lengthDiff; } + + // 4. Use Yomitans weighting: frequency + score + user frequency + const weightedScoreA = (freqA * 0.6) + (scoreA * 0.25) + (userFreqA * 100 * 0.15); + const weightedScoreB = (freqB * 0.6) + (scoreB * 0.25) + (userFreqB * 100 * 0.15); + + return weightedScoreB - weightedScoreA; + } + + /** + * Sets up event listener to hide suggestions when clicking outside the search area. + */ + _setupClickOutsideListener() { + document.addEventListener('click', (event) => { + const searchTextbox = document.getElementById('search-textbox'); + const suggestionsList = this._suggestionsList; + + if (searchTextbox && suggestionsList) { + const target = /** @type {Element} */ (event.target); + const isClickInSearchArea = searchTextbox.contains(target) || suggestionsList.contains(target); + if (!isClickInSearchArea && suggestionsList.style.display !== 'none') { + this.hideSuggestions(); + } + } + }); + } + + /** */ + hideSuggestions() { + this._suggestionsList.style.display = 'none'; + } + + // --- Helper methods --- + + /** + * Checks if search suggestions are enabled in the options. + * @param {import('settings').ProfileOptions | null} options + * @returns {boolean} + */ + _areSuggestionsEnabled(options) { + return !!(options && options.general && options.general.enableSearchSuggestions); + } + + /** + * Normalizes the input string based on language and settings. + * @param {string} input + * @param {import('settings').ProfileOptions} options + * @returns {string} + */ + _normalizeInput(input, options) { + if (options.general.language === 'ja' && options.general.enableWanakana) { + return convertToKana(input); + } + return input; + } + + /** + * Gets suggestions from the Trie for the given input. + * @param {string} searchInput + * @returns {string[]} + */ + _getTrieSuggestions(searchInput) { + return this._trie.getSuggestions(searchInput); + } + + /** + * Adds kanji equivalents to the suggestions if the input is kana. + * @param {string} searchInput + * @param {string[]} suggestions + * @returns {void} + */ + _addKanjiEquivalentsIfNeeded(searchInput, suggestions) { + if (isStringEntirelyKana(searchInput)) { + for (const [reading, kanjiSet] of this._readingToKanji.entries()) { + if (reading.startsWith(searchInput)) { + for (const kanji of kanjiSet) { + if (!suggestions.includes(kanji)) { + suggestions.push(kanji); + } + } + } + } + } + } + + /** + * Gets suggestions from the database for the given input. + * @param {string} searchInput + * @param {import('settings').ProfileOptions} options + * @returns {Promise<{entries: any[], suggestions: string[]}>} + */ + async _getDatabaseSuggestions(searchInput, options) { + /** @type {Map} */ + const suggestions = new Map(); + if (!options) { return {entries: [], suggestions: []}; } + + /** @type {Map} */ + const dictionaryMap = new Map(); + let index = 0; + for (const {name, enabled} of options.dictionaries) { + if (enabled) { + dictionaryMap.set(name, index++); + } + } + /** @type {{ term: string, score: number, dictionary: string, reading?: string }[]} */ + let entries = []; + try { + entries = await this._dictionaryDatabase.findTermsBulk([searchInput], new Set(dictionaryMap.keys()), 'prefix'); + for (const entry of entries) { + const dictionaryIndex = dictionaryMap.get(entry.dictionary) ?? -1; + const existing = suggestions.get(entry.term); + if (!existing || entry.score > existing.score || (entry.score === existing.score && dictionaryIndex < existing.dictionaryIndex)) { + suggestions.set(entry.term, { + term: entry.term, + score: entry.score, + dictionaryIndex, + }); + } + + this._termScores.set(entry.term, entry.score); + this._trie.insert(entry.term); + + if (entry.reading && entry.reading !== entry.term) { + this._trie.insert(entry.reading); + this._termScores.set(entry.reading, entry.score); + + if (!this._readingToKanji.has(entry.reading)) { + this._readingToKanji.set(entry.reading, new Set()); + } + const kanjiSet = this._readingToKanji.get(entry.reading); + if (kanjiSet) { + kanjiSet.add(entry.term); + } + } + } + + // Sort by score and dictionary index + const sortedEntries = await this._sortSuggestionsByScore(suggestions); + const sortedSuggestions = sortedEntries + .slice(0, SUGGESTION_LIMIT); + + return {entries, suggestions: sortedSuggestions}; + } catch (e) { + return {entries: [], suggestions: /** @type {string[]} */ []}; + } + } + + /** + * Sorts and limits the suggestions array. + * @param {string[] | Map} suggestions + * @param {string} searchInput + * @param {import('settings').ProfileOptions} options + * @param {string} source + * @returns {Promise | string[]} + */ + _sortAndLimitSuggestions(suggestions, searchInput, options, source) { + if (suggestions instanceof Map) { + suggestions = [...suggestions.values()].map((item) => item.term); + } + const uniqueSuggestions = [...new Set(suggestions)]; + + const frequencyDictionary = options.general.sortFrequencyDictionary; + if (frequencyDictionary && uniqueSuggestions.length > 0) { + return this._sortSuggestionsWithFrequency(uniqueSuggestions, searchInput, options, source); + } else { + uniqueSuggestions.sort((a, b) => { + const isExactMatchA = a === searchInput; + const isExactMatchB = b === searchInput; + if (isExactMatchA && !isExactMatchB) { return -1; } + if (!isExactMatchA && isExactMatchB) { return 1; } + + // Prioritize kanji terms that are likely matches for the input + const isKanjiA = this._containsKanji(a); + const isKanjiB = this._containsKanji(b); + if (isKanjiA && !isKanjiB) { return -1; } + if (!isKanjiA && isKanjiB) { return 1; } + + return a.length - b.length; + }); + return uniqueSuggestions.slice(0, SUGGESTION_LIMIT); + } + } + + /** + * Checks if a string contains kanji characters. + * @param {string} text + * @returns {boolean} + */ + _containsKanji(text) { + return /[\u4e00-\u9faf]/.test(text); + } + + // performance monitoring + + /** + * Logs performance statistics periodically. + */ + _logPerformanceStats() { + if (this._totalRequests % 10 === 0) { + this._trieUsageCount = 0; + this._databaseUsageCount = 0; + } + } + + /** + * Gets the current size of the Trie. + * @returns {number} + */ + getTrieSize() { + return this._trie.size(); + } + + /** + * Gets performance statistics. + * @returns {{trieUsage: number, databaseUsage: number, totalRequests: number, trieSize: number}} + */ + getPerformanceStats() { + return { + trieUsage: this._trieUsageCount, + databaseUsage: this._databaseUsageCount, + totalRequests: this._totalRequests, + trieSize: this._trie.size(), + }; + } +} diff --git a/ext/search.html b/ext/search.html index ec59cdc22e..237b33441e 100644 --- a/ext/search.html +++ b/ext/search.html @@ -59,6 +59,9 @@ Yomitan Search + + + @@ -135,6 +138,20 @@ Yomitan Search + + + + + Search suggestions + + + Show autocomplete suggestions as you type. Learns from your search history. + + + + + +