Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 605 lines 24 kB view raw
1/* 2 * Copyright (C) 2023-2025 Yomitan Authors 3 * Copyright (C) 2020-2022 Yomichan Authors 4 * 5 * This program is free software: you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation, either version 3 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License 16 * along with this program. If not, see <https://www.gnu.org/licenses/>. 17 */ 18 19import {ExtensionError} from '../core/extension-error.js'; 20import {deferPromise, sanitizeCSS} from '../core/utilities.js'; 21import {convertHiraganaToKatakana, convertKatakanaToHiragana} from '../language/ja/japanese.js'; 22import {cloneFieldMarkerPattern, getRootDeckName} from './anki-util.js'; 23 24export class AnkiNoteBuilder { 25 /** 26 * Initiate an instance of AnkiNoteBuilder. 27 * @param {import('anki-note-builder').MinimalApi} api 28 * @param {import('../templates/template-renderer-proxy.js').TemplateRendererProxy|import('../templates/template-renderer.js').TemplateRenderer} templateRenderer 29 */ 30 constructor(api, templateRenderer) { 31 /** @type {import('anki-note-builder').MinimalApi} */ 32 this._api = api; 33 /** @type {RegExp} */ 34 this._markerPattern = cloneFieldMarkerPattern(true); 35 /** @type {import('../templates/template-renderer-proxy.js').TemplateRendererProxy|import('../templates/template-renderer.js').TemplateRenderer} */ 36 this._templateRenderer = templateRenderer; 37 /** @type {import('anki-note-builder').BatchedRequestGroup[]} */ 38 this._batchedRequests = []; 39 /** @type {boolean} */ 40 this._batchedRequestsQueued = false; 41 } 42 43 /** 44 * @param {import('anki-note-builder').CreateNoteDetails} details 45 * @returns {Promise<import('anki-note-builder').CreateNoteResult>} 46 */ 47 async createNote({ 48 dictionaryEntry, 49 cardFormat, 50 context, 51 template, 52 tags = [], 53 requirements = [], 54 duplicateScope = 'collection', 55 duplicateScopeCheckAllModels = false, 56 resultOutputMode = 'split', 57 glossaryLayoutMode = 'default', 58 compactTags = false, 59 mediaOptions = null, 60 dictionaryStylesMap = new Map(), 61 }) { 62 const {deck: deckName, model: modelName, fields: fieldsSettings} = cardFormat; 63 const fields = Object.entries(fieldsSettings); 64 let duplicateScopeDeckName = null; 65 let duplicateScopeCheckChildren = false; 66 if (duplicateScope === 'deck-root') { 67 duplicateScope = 'deck'; 68 duplicateScopeDeckName = getRootDeckName(deckName); 69 duplicateScopeCheckChildren = true; 70 } 71 72 /** @type {Error[]} */ 73 const allErrors = []; 74 let media; 75 if (requirements.length > 0 && mediaOptions !== null) { 76 let errors; 77 ({media, errors} = await this._injectMedia(dictionaryEntry, requirements, mediaOptions)); 78 for (const error of errors) { 79 allErrors.push(ExtensionError.deserialize(error)); 80 } 81 } 82 83 // Make URL field blank if URL source is Yomitan 84 try { 85 const url = new URL(context.url); 86 if (url.protocol === new URL(import.meta.url).protocol) { 87 context.url = ''; 88 } 89 } catch (e) { 90 // Ignore 91 } 92 93 const commonData = this._createData(dictionaryEntry, cardFormat, context, resultOutputMode, glossaryLayoutMode, compactTags, media, dictionaryStylesMap); 94 const formattedFieldValuePromises = []; 95 for (const [, {value: fieldValue}] of fields) { 96 const formattedFieldValuePromise = this._formatField(fieldValue, commonData, template); 97 formattedFieldValuePromises.push(formattedFieldValuePromise); 98 } 99 100 const formattedFieldValues = await Promise.all(formattedFieldValuePromises); 101 /** @type {Map<string, import('anki-note-builder').Requirement>} */ 102 const uniqueRequirements = new Map(); 103 /** @type {import('anki').NoteFields} */ 104 const noteFields = {}; 105 for (let i = 0, ii = fields.length; i < ii; ++i) { 106 const fieldName = fields[i][0]; 107 const {value, errors: fieldErrors, requirements: fieldRequirements} = formattedFieldValues[i]; 108 noteFields[fieldName] = value; 109 allErrors.push(...fieldErrors); 110 for (const requirement of fieldRequirements) { 111 const key = JSON.stringify(requirement); 112 if (uniqueRequirements.has(key)) { continue; } 113 uniqueRequirements.set(key, requirement); 114 } 115 } 116 117 /** @type {import('anki').Note} */ 118 const note = { 119 fields: noteFields, 120 tags, 121 deckName, 122 modelName, 123 options: { 124 allowDuplicate: true, 125 duplicateScope, 126 duplicateScopeOptions: { 127 deckName: duplicateScopeDeckName, 128 checkChildren: duplicateScopeCheckChildren, 129 checkAllModels: duplicateScopeCheckAllModels, 130 }, 131 }, 132 }; 133 return {note, errors: allErrors, requirements: [...uniqueRequirements.values()]}; 134 } 135 136 /** 137 * @param {import('anki-note-builder').GetRenderingDataDetails} details 138 * @returns {Promise<import('anki-templates').NoteData>} 139 */ 140 async getRenderingData({ 141 dictionaryEntry, 142 cardFormat, 143 context, 144 resultOutputMode = 'split', 145 glossaryLayoutMode = 'default', 146 compactTags = false, 147 marker, 148 dictionaryStylesMap, 149 }) { 150 const commonData = this._createData(dictionaryEntry, cardFormat, context, resultOutputMode, glossaryLayoutMode, compactTags, void 0, dictionaryStylesMap); 151 return await this._templateRenderer.getModifiedData({marker, commonData}, 'ankiNote'); 152 } 153 154 /** 155 * @param {import('dictionary').DictionaryEntry} dictionaryEntry 156 * @returns {import('api').InjectAnkiNoteMediaDefinitionDetails} 157 */ 158 getDictionaryEntryDetailsForNote(dictionaryEntry) { 159 const {type} = dictionaryEntry; 160 if (type === 'kanji') { 161 const {character} = dictionaryEntry; 162 return {type, character}; 163 } 164 165 const {headwords} = dictionaryEntry; 166 let bestIndex = -1; 167 for (let i = 0, ii = headwords.length; i < ii; ++i) { 168 const {term, reading, sources} = headwords[i]; 169 for (const {deinflectedText} of sources) { 170 if (term === deinflectedText) { 171 bestIndex = i; 172 i = ii; 173 break; 174 } else if (reading === deinflectedText && bestIndex < 0) { 175 bestIndex = i; 176 break; 177 } 178 } 179 } 180 181 const {term, reading} = headwords[Math.max(0, bestIndex)]; 182 return {type, term, reading}; 183 } 184 185 /** 186 * @param {import('settings').DictionariesOptions} dictionaries 187 * @returns {Map<string, string>} 188 */ 189 getDictionaryStylesMap(dictionaries) { 190 const styleMap = new Map(); 191 for (const dictionary of dictionaries) { 192 const {name, styles} = dictionary; 193 if (typeof styles === 'string') { 194 styleMap.set(name, sanitizeCSS(styles)); 195 } 196 } 197 return styleMap; 198 } 199 200 // Private 201 202 /** 203 * @param {import('dictionary').DictionaryEntry} dictionaryEntry 204 * @param {import('settings').AnkiCardFormat} cardFormat 205 * @param {import('anki-templates-internal').Context} context 206 * @param {import('settings').ResultOutputMode} resultOutputMode 207 * @param {import('settings').GlossaryLayoutMode} glossaryLayoutMode 208 * @param {boolean} compactTags 209 * @param {import('anki-templates').Media|undefined} media 210 * @param {Map<string, string>} dictionaryStylesMap 211 * @returns {import('anki-note-builder').CommonData} 212 */ 213 _createData(dictionaryEntry, cardFormat, context, resultOutputMode, glossaryLayoutMode, compactTags, media, dictionaryStylesMap) { 214 return { 215 dictionaryEntry, 216 cardFormat, 217 context, 218 resultOutputMode, 219 glossaryLayoutMode, 220 compactTags, 221 media, 222 dictionaryStylesMap, 223 }; 224 } 225 226 /** 227 * @param {string} field 228 * @param {import('anki-note-builder').CommonData} commonData 229 * @param {string} template 230 * @returns {Promise<{value: string, errors: ExtensionError[], requirements: import('anki-note-builder').Requirement[]}>} 231 */ 232 async _formatField(field, commonData, template) { 233 /** @type {ExtensionError[]} */ 234 const errors = []; 235 /** @type {import('anki-note-builder').Requirement[]} */ 236 const requirements = []; 237 const value = await this._stringReplaceAsync(field, this._markerPattern, async (match) => { 238 const marker = match[1]; 239 try { 240 const {result, requirements: fieldRequirements} = await this._renderTemplateBatched(template, commonData, marker); 241 requirements.push(...fieldRequirements); 242 return result; 243 } catch (e) { 244 const error = new ExtensionError(`Template render error for {${marker}}`); 245 error.data = {error: e}; 246 errors.push(error); 247 return `{${marker}-render-error}`; 248 } 249 }); 250 return {value, errors, requirements}; 251 } 252 253 /** 254 * @param {string} str 255 * @param {RegExp} regex 256 * @param {(match: RegExpExecArray, index: number, str: string) => (string|Promise<string>)} replacer 257 * @returns {Promise<string>} 258 */ 259 async _stringReplaceAsync(str, regex, replacer) { 260 let match; 261 let index = 0; 262 /** @type {(Promise<string>|string)[]} */ 263 const parts = []; 264 while ((match = regex.exec(str)) !== null) { 265 parts.push(str.substring(index, match.index), replacer(match, match.index, str)); 266 index = regex.lastIndex; 267 } 268 if (parts.length === 0) { 269 return str; 270 } 271 parts.push(str.substring(index)); 272 return (await Promise.all(parts)).join(''); 273 } 274 275 /** 276 * @param {string} template 277 * @returns {import('anki-note-builder').BatchedRequestGroup} 278 */ 279 _getBatchedTemplateGroup(template) { 280 for (const item of this._batchedRequests) { 281 if (item.template === template) { 282 return item; 283 } 284 } 285 286 const result = {template, commonDataRequestsMap: new Map()}; 287 this._batchedRequests.push(result); 288 return result; 289 } 290 291 /** 292 * @param {string} template 293 * @param {import('anki-note-builder').CommonData} commonData 294 * @param {string} marker 295 * @returns {Promise<import('template-renderer').RenderResult>} 296 */ 297 _renderTemplateBatched(template, commonData, marker) { 298 /** @type {import('core').DeferredPromiseDetails<import('template-renderer').RenderResult>} */ 299 const {promise, resolve, reject} = deferPromise(); 300 const {commonDataRequestsMap} = this._getBatchedTemplateGroup(template); 301 let requests = commonDataRequestsMap.get(commonData); 302 if (typeof requests === 'undefined') { 303 requests = []; 304 commonDataRequestsMap.set(commonData, requests); 305 } 306 requests.push({resolve, reject, marker}); 307 this._runBatchedRequestsDelayed(); 308 return promise; 309 } 310 311 /** 312 * @returns {void} 313 */ 314 _runBatchedRequestsDelayed() { 315 if (this._batchedRequestsQueued) { return; } 316 this._batchedRequestsQueued = true; 317 void Promise.resolve().then(() => { 318 this._batchedRequestsQueued = false; 319 this._runBatchedRequests(); 320 }); 321 } 322 323 /** 324 * @returns {void} 325 */ 326 _runBatchedRequests() { 327 if (this._batchedRequests.length === 0) { return; } 328 329 const allRequests = []; 330 /** @type {import('template-renderer').RenderMultiItem[]} */ 331 const items = []; 332 for (const {template, commonDataRequestsMap} of this._batchedRequests) { 333 /** @type {import('template-renderer').RenderMultiTemplateItem[]} */ 334 const templateItems = []; 335 for (const [commonData, requests] of commonDataRequestsMap.entries()) { 336 /** @type {import('template-renderer').PartialOrCompositeRenderData[]} */ 337 const datas = []; 338 for (const {marker} of requests) { 339 datas.push({marker}); 340 } 341 allRequests.push(...requests); 342 templateItems.push({ 343 type: /** @type {import('anki-templates').RenderMode} */ ('ankiNote'), 344 commonData, 345 datas, 346 }); 347 } 348 items.push({template, templateItems}); 349 } 350 351 this._batchedRequests.length = 0; 352 353 void this._resolveBatchedRequests(items, allRequests); 354 } 355 356 /** 357 * @param {import('template-renderer').RenderMultiItem[]} items 358 * @param {import('anki-note-builder').BatchedRequestData[]} requests 359 */ 360 async _resolveBatchedRequests(items, requests) { 361 let responses; 362 try { 363 responses = await this._templateRenderer.renderMulti(items); 364 } catch (e) { 365 for (const {reject} of requests) { 366 reject(e); 367 } 368 return; 369 } 370 371 for (let i = 0, ii = requests.length; i < ii; ++i) { 372 const request = requests[i]; 373 try { 374 const response = responses[i]; 375 const {error} = response; 376 if (typeof error !== 'undefined') { 377 throw ExtensionError.deserialize(error); 378 } else { 379 request.resolve(response.result); 380 } 381 } catch (e) { 382 request.reject(e); 383 } 384 } 385 } 386 387 /** 388 * @param {import('dictionary').DictionaryEntry} dictionaryEntry 389 * @param {import('anki-note-builder').Requirement[]} requirements 390 * @param {import('anki-note-builder').MediaOptions} mediaOptions 391 * @returns {Promise<{media: import('anki-templates').Media, errors: import('core').SerializedError[]}>} 392 */ 393 async _injectMedia(dictionaryEntry, requirements, mediaOptions) { 394 const timestamp = Date.now(); 395 396 // Parse requirements 397 let injectAudio = false; 398 let injectScreenshot = false; 399 let injectClipboardImage = false; 400 let injectClipboardText = false; 401 let injectPopupSelectionText = false; 402 /** @type {import('anki-note-builder').TextFuriganaDetails[]} */ 403 const textFuriganaDetails = []; 404 /** @type {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} */ 405 const dictionaryMediaDetails = []; 406 for (const requirement of requirements) { 407 const {type} = requirement; 408 switch (type) { 409 case 'audio': injectAudio = true; break; 410 case 'screenshot': injectScreenshot = true; break; 411 case 'clipboardImage': injectClipboardImage = true; break; 412 case 'clipboardText': injectClipboardText = true; break; 413 case 'popupSelectionText': injectPopupSelectionText = true; break; 414 case 'textFurigana': 415 { 416 const {text, readingMode} = requirement; 417 textFuriganaDetails.push({text, readingMode}); 418 } 419 break; 420 case 'dictionaryMedia': 421 { 422 const {dictionary, path} = requirement; 423 dictionaryMediaDetails.push({dictionary, path}); 424 } 425 break; 426 } 427 } 428 429 // Generate request data 430 const dictionaryEntryDetails = this.getDictionaryEntryDetailsForNote(dictionaryEntry); 431 /** @type {?import('api').InjectAnkiNoteMediaAudioDetails} */ 432 let audioDetails = null; 433 /** @type {?import('api').InjectAnkiNoteMediaScreenshotDetails} */ 434 let screenshotDetails = null; 435 /** @type {import('api').InjectAnkiNoteMediaClipboardDetails} */ 436 const clipboardDetails = {image: injectClipboardImage, text: injectClipboardText}; 437 if (injectAudio && dictionaryEntryDetails.type !== 'kanji') { 438 const audioOptions = mediaOptions.audio; 439 if (typeof audioOptions === 'object' && audioOptions !== null) { 440 const {sources, preferredAudioIndex, idleTimeout, languageSummary, enableDefaultAudioSources} = audioOptions; 441 audioDetails = {sources, preferredAudioIndex, idleTimeout, languageSummary, enableDefaultAudioSources}; 442 } 443 } 444 if (injectScreenshot) { 445 const screenshotOptions = mediaOptions.screenshot; 446 if (typeof screenshotOptions === 'object' && screenshotOptions !== null) { 447 const {format, quality, contentOrigin: {tabId, frameId}} = screenshotOptions; 448 if (typeof tabId === 'number' && typeof frameId === 'number') { 449 screenshotDetails = {tabId, frameId, format, quality}; 450 } 451 } 452 } 453 let textFuriganaPromise = null; 454 if (textFuriganaDetails.length > 0) { 455 const textParsingOptions = mediaOptions.textParsing; 456 if (typeof textParsingOptions === 'object' && textParsingOptions !== null) { 457 const {optionsContext, scanLength} = textParsingOptions; 458 textFuriganaPromise = this._getTextFurigana(textFuriganaDetails, optionsContext, scanLength, dictionaryEntryDetails); 459 } 460 } 461 462 // Inject media 463 const popupSelectionText = injectPopupSelectionText ? this._getPopupSelectionText() : null; 464 const injectedMedia = await this._api.injectAnkiNoteMedia( 465 timestamp, 466 dictionaryEntryDetails, 467 audioDetails, 468 screenshotDetails, 469 clipboardDetails, 470 dictionaryMediaDetails, 471 ); 472 const {audioFileName, screenshotFileName, clipboardImageFileName, clipboardText, dictionaryMedia: dictionaryMediaArray, errors} = injectedMedia; 473 const textFurigana = textFuriganaPromise !== null ? await textFuriganaPromise : []; 474 475 // Format results 476 /** @type {import('anki-templates').DictionaryMedia} */ 477 const dictionaryMedia = {}; 478 for (const {dictionary, path, fileName} of dictionaryMediaArray) { 479 if (fileName === null) { continue; } 480 const dictionaryMedia2 = ( 481 Object.prototype.hasOwnProperty.call(dictionaryMedia, dictionary) ? 482 (dictionaryMedia[dictionary]) : 483 (dictionaryMedia[dictionary] = {}) 484 ); 485 dictionaryMedia2[path] = {value: fileName}; 486 } 487 const media = { 488 audio: (typeof audioFileName === 'string' ? {value: audioFileName} : void 0), 489 screenshot: (typeof screenshotFileName === 'string' ? {value: screenshotFileName} : void 0), 490 clipboardImage: (typeof clipboardImageFileName === 'string' ? {value: clipboardImageFileName} : void 0), 491 clipboardText: (typeof clipboardText === 'string' ? {value: clipboardText} : void 0), 492 popupSelectionText: (typeof popupSelectionText === 'string' ? {value: popupSelectionText} : void 0), 493 textFurigana, 494 dictionaryMedia, 495 }; 496 return {media, errors}; 497 } 498 499 /** 500 * @returns {string} 501 */ 502 _getPopupSelectionText() { 503 const selection = document.getSelection(); 504 return selection !== null ? selection.toString() : ''; 505 } 506 507 /** 508 * @param {import('anki-note-builder').TextFuriganaDetails[]} entries 509 * @param {import('settings').OptionsContext} optionsContext 510 * @param {number} scanLength 511 * @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride 512 * @returns {Promise<import('anki-templates').TextFuriganaSegment[]>} 513 */ 514 async _getTextFurigana(entries, optionsContext, scanLength, readingOverride) { 515 const results = []; 516 for (const {text, readingMode} of entries) { 517 const parseResults = await this._api.parseText(text, optionsContext, scanLength, true, false); 518 let data = null; 519 for (const {source, content} of parseResults) { 520 if (source !== 'scanning-parser') { continue; } 521 data = content; 522 break; 523 } 524 if (data !== null) { 525 const valueHtml = createFuriganaHtml(data, readingMode, readingOverride); 526 const valuePlain = createFuriganaPlain(data, readingMode, readingOverride); 527 results.push({text, readingMode, detailsHtml: {value: valueHtml}, detailsPlain: {value: valuePlain}}); 528 } 529 } 530 return results; 531 } 532} 533 534/** 535 * @param {import('api').ParseTextLine[]} data 536 * @param {?import('anki-templates').TextFuriganaReadingMode} readingMode 537 * @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride 538 * @returns {string} 539 */ 540export function createFuriganaHtml(data, readingMode, readingOverride) { 541 let result = ''; 542 for (const term of data) { 543 result += '<span class="term">'; 544 for (const {text, reading} of term) { 545 if (reading.length > 0) { 546 const reading2 = getReading(text, reading, readingMode, readingOverride); 547 result += `<ruby>${text}<rt>${reading2}</rt></ruby>`; 548 } else { 549 result += text; 550 } 551 } 552 result += '</span>'; 553 } 554 return result; 555} 556 557/** 558 * @param {import('api').ParseTextLine[]} data 559 * @param {?import('anki-templates').TextFuriganaReadingMode} readingMode 560 * @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride 561 * @returns {string} 562 */ 563export function createFuriganaPlain(data, readingMode, readingOverride) { 564 let result = ''; 565 for (const term of data) { 566 for (const {text, reading} of term) { 567 if (reading.length > 0) { 568 const reading2 = getReading(text, reading, readingMode, readingOverride); 569 result += ` ${text}[${reading2}]`; 570 } else { 571 result += text; 572 } 573 } 574 } 575 result = result.trimStart(); 576 return result; 577} 578 579/** 580 * @param {string} reading 581 * @param {?import('anki-templates').TextFuriganaReadingMode} readingMode 582 * @returns {string} 583 */ 584function convertReading(reading, readingMode) { 585 switch (readingMode) { 586 case 'hiragana': 587 return convertKatakanaToHiragana(reading); 588 case 'katakana': 589 return convertHiraganaToKatakana(reading); 590 default: 591 return reading; 592 } 593} 594 595/** 596 * @param {string} text 597 * @param {string} reading 598 * @param {?import('anki-templates').TextFuriganaReadingMode} readingMode 599 * @param {?import('api.d.ts').InjectAnkiNoteMediaDefinitionDetails} readingOverride 600 * @returns {string} 601 */ 602function getReading(text, reading, readingMode, readingOverride) { 603 const shouldOverride = readingOverride?.type === 'term' && readingOverride.term === text && readingOverride.reading.length > 0; 604 return convertReading(shouldOverride ? readingOverride.reading : reading, readingMode); 605}