Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
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}