/* * Copyright (C) 2020-2022 Yomichan 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 <https://www.gnu.org/licenses/>. */ /* global * AnkiUtil * TemplateRendererProxy */ class AnkiNoteBuilder { constructor({japaneseUtil}) { this._japaneseUtil = japaneseUtil; this._markerPattern = AnkiUtil.cloneFieldMarkerPattern(true); this._templateRenderer = new TemplateRendererProxy(); this._batchedRequests = []; this._batchedRequestsQueued = false; } async createNote({ dictionaryEntry, mode, context, template, deckName, modelName, fields, tags=[], requirements=[], checkForDuplicates=true, duplicateScope='collection', duplicateScopeCheckAllModels=false, resultOutputMode='split', glossaryLayoutMode='default', compactTags=false, mediaOptions=null }) { let duplicateScopeDeckName = null; let duplicateScopeCheckChildren = false; if (duplicateScope === 'deck-root') { duplicateScope = 'deck'; duplicateScopeDeckName = AnkiUtil.getRootDeckName(deckName); duplicateScopeCheckChildren = true; } const allErrors = []; let media; if (requirements.length > 0 && mediaOptions !== null) { let errors; ({media, errors} = await this._injectMedia(dictionaryEntry, requirements, mediaOptions)); for (const error of errors) { allErrors.push(deserializeError(error)); } } else { media = {}; } const commonData = this._createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, media); const formattedFieldValuePromises = []; for (const [, fieldValue] of fields) { const formattedFieldValuePromise = this._formatField(fieldValue, commonData, template); formattedFieldValuePromises.push(formattedFieldValuePromise); } const formattedFieldValues = await Promise.all(formattedFieldValuePromises); const uniqueRequirements = new Map(); const noteFields = {}; for (let i = 0, ii = fields.length; i < ii; ++i) { const fieldName = fields[i][0]; const {value, errors: fieldErrors, requirements: fieldRequirements} = formattedFieldValues[i]; noteFields[fieldName] = value; allErrors.push(...fieldErrors); for (const requirement of fieldRequirements) { const key = JSON.stringify(requirement); if (uniqueRequirements.has(key)) { continue; } uniqueRequirements.set(key, requirement); } } const note = { fields: noteFields, tags, deckName, modelName, options: { allowDuplicate: !checkForDuplicates, duplicateScope, duplicateScopeOptions: { deckName: duplicateScopeDeckName, checkChildren: duplicateScopeCheckChildren, checkAllModels: duplicateScopeCheckAllModels } } }; return {note, errors: allErrors, requirements: [...uniqueRequirements.values()]}; } async getRenderingData({ dictionaryEntry, mode, context, resultOutputMode='split', glossaryLayoutMode='default', compactTags=false, marker=null }) { const commonData = this._createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, {}); return await this._templateRenderer.getModifiedData({marker, commonData}, 'ankiNote'); } getDictionaryEntryDetailsForNote(dictionaryEntry) { const {type} = dictionaryEntry; if (type === 'kanji') { const {character} = dictionaryEntry; return {type, character}; } const {headwords} = dictionaryEntry; let bestIndex = -1; for (let i = 0, ii = headwords.length; i < ii; ++i) { const {term, reading, sources} = headwords[i]; for (const {deinflectedText} of sources) { if (term === deinflectedText) { bestIndex = i; i = ii; break; } else if (reading === deinflectedText && bestIndex < 0) { bestIndex = i; break; } } } const {term, reading} = headwords[Math.max(0, bestIndex)]; return {type, term, reading}; } // Private _createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, media) { return { dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, media }; } async _formatField(field, commonData, template) { const errors = []; const requirements = []; const value = await this._stringReplaceAsync(field, this._markerPattern, async (g0, marker) => { try { const {result, requirements: fieldRequirements} = await this._renderTemplateBatched(template, commonData, marker); requirements.push(...fieldRequirements); return result; } catch (e) { const error = new Error(`Template render error for {${marker}}`); error.data = {error: e}; errors.push(error); return `{${marker}-render-error}`; } }); return {value, errors, requirements}; } async _stringReplaceAsync(str, regex, replacer) { let match; let index = 0; const parts = []; while ((match = regex.exec(str)) !== null) { parts.push(str.substring(index, match.index), replacer(...match, match.index, str)); index = regex.lastIndex; } if (parts.length === 0) { return str; } parts.push(str.substring(index)); return (await Promise.all(parts)).join(''); } _getBatchedTemplateGroup(template) { for (const item of this._batchedRequests) { if (item.template === template) { return item; } } const result = {template, commonDataRequestsMap: new Map()}; this._batchedRequests.push(result); return result; } _renderTemplateBatched(template, commonData, marker) { const {promise, resolve, reject} = deferPromise(); const {commonDataRequestsMap} = this._getBatchedTemplateGroup(template); let requests = commonDataRequestsMap.get(commonData); if (typeof requests === 'undefined') { requests = []; commonDataRequestsMap.set(commonData, requests); } requests.push({resolve, reject, marker}); this._runBatchedRequestsDelayed(); return promise; } _runBatchedRequestsDelayed() { if (this._batchedRequestsQueued) { return; } this._batchedRequestsQueued = true; Promise.resolve().then(() => { this._batchedRequestsQueued = false; this._runBatchedRequests(); }); } _runBatchedRequests() { if (this._batchedRequests.length === 0) { return; } const allRequests = []; const items = []; for (const {template, commonDataRequestsMap} of this._batchedRequests) { const templateItems = []; for (const [commonData, requests] of commonDataRequestsMap.entries()) { const datas = []; for (const {marker} of requests) { datas.push(marker); } allRequests.push(...requests); templateItems.push({type: 'ankiNote', commonData, datas}); } items.push({template, templateItems}); } this._batchedRequests.length = 0; this._resolveBatchedRequests(items, allRequests); } async _resolveBatchedRequests(items, requests) { let responses; try { responses = await this._templateRenderer.renderMulti(items); } catch (e) { for (const {reject} of requests) { reject(e); } return; } for (let i = 0, ii = requests.length; i < ii; ++i) { const request = requests[i]; try { const response = responses[i]; const {error} = response; if (typeof error !== 'undefined') { throw deserializeError(error); } else { request.resolve(response.result); } } catch (e) { request.reject(e); } } } async _injectMedia(dictionaryEntry, requirements, mediaOptions) { const timestamp = Date.now(); // Parse requirements let injectAudio = false; let injectScreenshot = false; let injectClipboardImage = false; let injectClipboardText = false; let injectSelectionText = false; const textFuriganaDetails = []; const dictionaryMediaDetails = []; for (const requirement of requirements) { const {type} = requirement; switch (type) { case 'audio': injectAudio = true; break; case 'screenshot': injectScreenshot = true; break; case 'clipboardImage': injectClipboardImage = true; break; case 'clipboardText': injectClipboardText = true; break; case 'selectionText': injectSelectionText = true; break; case 'textFurigana': { const {text, readingMode} = requirement; textFuriganaDetails.push({text, readingMode}); } break; case 'dictionaryMedia': { const {dictionary, path} = requirement; dictionaryMediaDetails.push({dictionary, path}); } break; } } // Generate request data const dictionaryEntryDetails = this.getDictionaryEntryDetailsForNote(dictionaryEntry); let audioDetails = null; let screenshotDetails = null; const clipboardDetails = {image: injectClipboardImage, text: injectClipboardText}; if (injectAudio && dictionaryEntryDetails.type !== 'kanji') { const audioOptions = mediaOptions.audio; if (typeof audioOptions === 'object' && audioOptions !== null) { const {sources, preferredAudioIndex, idleTimeout} = audioOptions; audioDetails = {sources, preferredAudioIndex, idleTimeout}; } } if (injectScreenshot) { const screenshotOptions = mediaOptions.screenshot; if (typeof screenshotOptions === 'object' && screenshotOptions !== null) { const {format, quality, contentOrigin: {tabId, frameId}} = screenshotOptions; if (typeof tabId === 'number' && typeof frameId === 'number') { screenshotDetails = {tabId, frameId, format, quality}; } } } let textFuriganaPromise = null; if (textFuriganaDetails.length > 0) { const textParsingOptions = mediaOptions.textParsing; if (typeof textParsingOptions === 'object' && textParsingOptions !== null) { const {optionsContext, scanLength} = textParsingOptions; textFuriganaPromise = this._getTextFurigana(textFuriganaDetails, optionsContext, scanLength); } } // Inject media const selectionText = injectSelectionText ? this._getSelectionText() : null; const injectedMedia = await yomichan.api.injectAnkiNoteMedia( timestamp, dictionaryEntryDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails ); const {audioFileName, screenshotFileName, clipboardImageFileName, clipboardText, dictionaryMedia: dictionaryMediaArray, errors} = injectedMedia; const textFurigana = textFuriganaPromise !== null ? await textFuriganaPromise : []; // Format results const dictionaryMedia = {}; for (const {dictionary, path, fileName} of dictionaryMediaArray) { if (fileName === null) { continue; } const dictionaryMedia2 = ( Object.prototype.hasOwnProperty.call(dictionaryMedia, dictionary) ? (dictionaryMedia[dictionary]) : (dictionaryMedia[dictionary] = {}) ); dictionaryMedia2[path] = {value: fileName}; } const media = { audio: (typeof audioFileName === 'string' ? {value: audioFileName} : null), screenshot: (typeof screenshotFileName === 'string' ? {value: screenshotFileName} : null), clipboardImage: (typeof clipboardImageFileName === 'string' ? {value: clipboardImageFileName} : null), clipboardText: (typeof clipboardText === 'string' ? {value: clipboardText} : null), selectionText: (typeof selectionText === 'string' ? {value: selectionText} : null), textFurigana, dictionaryMedia }; return {media, errors}; } _getSelectionText() { return document.getSelection().toString(); } async _getTextFurigana(entries, optionsContext, scanLength) { const results = []; for (const {text, readingMode} of entries) { const parseResults = await yomichan.api.parseText(text, optionsContext, scanLength, true, false); let data = null; for (const {source, content} of parseResults) { if (source !== 'scanning-parser') { continue; } data = content; break; } if (data !== null) { const value = this._createFuriganaHtml(data, readingMode); results.push({text, readingMode, details: {value}}); } } return results; } _createFuriganaHtml(data, readingMode) { let result = ''; for (const term of data) { result += '<span class="term">'; for (const {text, reading} of term) { if (reading.length > 0) { const reading2 = this._convertReading(reading, readingMode); result += `<ruby>${text}<rt>${reading2}</rt></ruby>`; } else { result += text; } } result += '</span>'; } return result; } _convertReading(reading, readingMode) { switch (readingMode) { case 'hiragana': return this._japaneseUtil.convertKatakanaToHiragana(reading); case 'katakana': return this._japaneseUtil.convertHiraganaToKatakana(reading); default: return reading; } } }