Files
SubMiner/vendor/yomitan/js/display/display-anki.js

1455 lines
56 KiB
JavaScript

/*
* Copyright (C) 2023-2025 Yomitan Authors
* Copyright (C) 2021-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/>.
*/
import {EventListenerCollection} from '../core/event-listener-collection.js';
import {ExtensionError} from '../core/extension-error.js';
import {log} from '../core/log.js';
import {toError} from '../core/to-error.js';
import {deferPromise} from '../core/utilities.js';
import {AnkiNoteBuilder} from '../data/anki-note-builder.js';
import {getDynamicTemplates} from '../data/anki-template-util.js';
import {INVALID_NOTE_ID, isNoteDataValid} from '../data/anki-util.js';
import {PopupMenu} from '../dom/popup-menu.js';
import {querySelectorNotNull} from '../dom/query-selector.js';
import {TemplateRendererProxy} from '../templates/template-renderer-proxy.js';
export class DisplayAnki {
/**
* @param {import('./display.js').Display} display
* @param {import('./display-audio.js').DisplayAudio} displayAudio
*/
constructor(display, displayAudio) {
/** @type {import('./display.js').Display} */
this._display = display;
/** @type {import('./display-audio.js').DisplayAudio} */
this._displayAudio = displayAudio;
/** @type {?string} */
this._ankiFieldTemplates = null;
/** @type {?string} */
this._ankiFieldTemplatesDefault = null;
/** @type {AnkiNoteBuilder} */
this._ankiNoteBuilder = new AnkiNoteBuilder(display.application.api, new TemplateRendererProxy());
/** @type {?import('./display-notification.js').DisplayNotification} */
this._errorNotification = null;
/** @type {?EventListenerCollection} */
this._errorNotificationEventListeners = null;
/** @type {?import('./display-notification.js').DisplayNotification} */
this._tagsNotification = null;
/** @type {?import('./display-notification.js').DisplayNotification} */
this._flagsNotification = null;
/** @type {?Promise<void>} */
this._updateSaveButtonsPromise = null;
/** @type {?import('core').TokenObject} */
this._updateDictionaryEntryDetailsToken = null;
/** @type {EventListenerCollection} */
this._eventListeners = new EventListenerCollection();
/** @type {?import('display-anki').DictionaryEntryDetails[]} */
this._dictionaryEntryDetails = null;
/** @type {?import('anki-templates-internal').Context} */
this._noteContext = null;
/** @type {boolean} */
this._checkForDuplicates = false;
/** @type {boolean} */
this._suspendNewCards = false;
/** @type {boolean} */
this._compactTags = false;
/** @type {import('settings').ResultOutputMode} */
this._resultOutputMode = 'split';
/** @type {import('settings').GlossaryLayoutMode} */
this._glossaryLayoutMode = 'default';
/** @type {import('settings').AnkiDisplayTagsAndFlags} */
this._displayTagsAndFlags = 'never';
/** @type {import('settings').AnkiDuplicateScope} */
this._duplicateScope = 'collection';
/** @type {boolean} */
this._duplicateScopeCheckAllModels = false;
/** @type {import('settings').AnkiDuplicateBehavior} */
this._duplicateBehavior = 'new';
/** @type {import('settings').AnkiScreenshotFormat} */
this._screenshotFormat = 'png';
/** @type {number} */
this._screenshotQuality = 100;
/** @type {number} */
this._scanLength = 10;
/** @type {import('settings').AnkiNoteGuiMode} */
this._noteGuiMode = 'browse';
/** @type {?number} */
this._audioDownloadIdleTimeout = null;
/** @type {string[]} */
this._noteTags = [];
/** @type {string[]} */
this._targetTags = [];
/** @type {import('settings').AnkiCardFormat[]} */
this._cardFormats = [];
/** @type {import('settings').DictionariesOptions} */
this._dictionaries = [];
/** @type {HTMLElement} */
this._menuContainer = querySelectorNotNull(document, '#popup-menus');
/** @type {(event: MouseEvent) => void} */
this._onShowTagsBind = this._onShowTags.bind(this);
/** @type {(event: MouseEvent) => void} */
this._onShowFlagsBind = this._onShowFlags.bind(this);
/** @type {(event: MouseEvent) => void} */
this._onNoteSaveBind = this._onNoteSave.bind(this);
/** @type {(event: MouseEvent) => void} */
this._onViewNotesButtonClickBind = this._onViewNotesButtonClick.bind(this);
/** @type {(event: MouseEvent) => void} */
this._onViewNotesButtonContextMenuBind = this._onViewNotesButtonContextMenu.bind(this);
/** @type {(event: import('popup-menu').MenuCloseEvent) => void} */
this._onViewNotesButtonMenuCloseBind = this._onViewNotesButtonMenuClose.bind(this);
/** @type {boolean} */
this._forceSync = false;
}
/** */
prepare() {
this._noteContext = this._getNoteContext();
/* eslint-disable @stylistic/no-multi-spaces */
this._display.hotkeyHandler.registerActions([
['addNote', this._hotkeySaveAnkiNoteForSelectedEntry.bind(this)],
['viewNotes', this._hotkeyViewNotesForSelectedEntry.bind(this)],
]);
/* eslint-enable @stylistic/no-multi-spaces */
this._display.on('optionsUpdated', this._onOptionsUpdated.bind(this));
this._display.on('contentClear', this._onContentClear.bind(this));
this._display.on('contentUpdateStart', this._onContentUpdateStart.bind(this));
this._display.on('contentUpdateComplete', this._onContentUpdateComplete.bind(this));
this._display.on('logDictionaryEntryData', this._onLogDictionaryEntryData.bind(this));
}
/**
* @param {import('dictionary').DictionaryEntry} dictionaryEntry
* @returns {Promise<import('display-anki').LogData>}
*/
async getLogData(dictionaryEntry) {
// Anki note data
let ankiNoteData;
let ankiNoteDataException;
try {
if (this._noteContext === null) { throw new Error('Note context not initialized'); }
ankiNoteData = await this._ankiNoteBuilder.getRenderingData({
dictionaryEntry,
cardFormat: this._cardFormats[0],
context: this._noteContext,
resultOutputMode: this._resultOutputMode,
glossaryLayoutMode: this._glossaryLayoutMode,
compactTags: this._compactTags,
marker: 'test',
dictionaryStylesMap: this._ankiNoteBuilder.getDictionaryStylesMap(this._dictionaries),
});
} catch (e) {
ankiNoteDataException = e;
}
// Anki notes
/** @type {import('display-anki').AnkiNoteLogData[]} */
const ankiNotes = [];
for (const [cardFormatIndex] of this._cardFormats.entries()) {
let note;
let errors;
let requirements;
try {
({note: note, errors, requirements} = await this._createNote(dictionaryEntry, cardFormatIndex, []));
} catch (e) {
errors = [toError(e)];
}
/** @type {import('display-anki').AnkiNoteLogData} */
const entry = {cardFormatIndex, note};
if (Array.isArray(errors) && errors.length > 0) {
entry.errors = errors;
}
if (Array.isArray(requirements) && requirements.length > 0) {
entry.requirements = requirements;
}
ankiNotes.push(entry);
}
return {
ankiNoteData,
ankiNoteDataException: toError(ankiNoteDataException),
ankiNotes,
};
}
// Private
/**
* @param {import('display').EventArgument<'optionsUpdated'>} details
*/
_onOptionsUpdated({options}) {
const {
general: {
resultOutputMode,
glossaryLayoutMode,
compactTags,
},
dictionaries,
anki: {
tags,
targetTags,
duplicateScope,
duplicateScopeCheckAllModels,
duplicateBehavior,
suspendNewCards,
checkForDuplicates,
displayTagsAndFlags,
cardFormats,
noteGuiMode,
screenshot: {format, quality},
downloadTimeout,
forceSync,
},
scanning: {length: scanLength},
} = options;
this._checkForDuplicates = checkForDuplicates;
this._suspendNewCards = suspendNewCards;
this._compactTags = compactTags;
this._resultOutputMode = resultOutputMode;
this._glossaryLayoutMode = glossaryLayoutMode;
this._displayTagsAndFlags = displayTagsAndFlags;
this._duplicateScope = duplicateScope;
this._duplicateScopeCheckAllModels = duplicateScopeCheckAllModels;
this._duplicateBehavior = duplicateBehavior;
this._screenshotFormat = format;
this._screenshotQuality = quality;
this._scanLength = scanLength;
this._noteGuiMode = noteGuiMode;
this._noteTags = [...tags];
this._targetTags = [...targetTags];
this._audioDownloadIdleTimeout = (Number.isFinite(downloadTimeout) && downloadTimeout > 0 ? downloadTimeout : null);
this._cardFormats = cardFormats;
this._dictionaries = dictionaries;
this._forceSync = forceSync;
void this._updateAnkiFieldTemplates(options);
}
/** */
_onContentClear() {
this._updateDictionaryEntryDetailsToken = null;
this._dictionaryEntryDetails = null;
this._hideErrorNotification(false);
this._eventListeners.removeAllEventListeners();
}
/** */
_onContentUpdateStart() {
this._noteContext = this._getNoteContext();
}
/** */
_onContentUpdateComplete() {
void this._updateDictionaryEntryDetails();
}
/**
* @param {import('display').EventArgument<'logDictionaryEntryData'>} details
*/
_onLogDictionaryEntryData({dictionaryEntry, promises}) {
promises.push(this.getLogData(dictionaryEntry));
}
/**
* @param {MouseEvent} e
* @throws {Error}
*/
_onNoteSave(e) {
e.preventDefault();
const element = /** @type {HTMLElement} */ (e.currentTarget);
const cardFormatIndex = element.dataset.cardFormatIndex;
if (!cardFormatIndex || !Number.isInteger(Number.parseInt(cardFormatIndex, 10))) {
throw new Error(`Invalid note options index: ${cardFormatIndex}`);
}
const index = this._display.getElementDictionaryEntryIndex(element);
void this._saveAnkiNote(index, Number.parseInt(cardFormatIndex, 10));
}
/**
* @param {MouseEvent} e
*/
_onShowTags(e) {
e.preventDefault();
const element = /** @type {HTMLElement} */ (e.currentTarget);
const tags = element.title;
this._showTagsNotification(tags);
}
/**
* @param {MouseEvent} e
*/
_onShowFlags(e) {
e.preventDefault();
const element = /** @type {HTMLElement} */ (e.currentTarget);
const flags = element.title;
this._showFlagsNotification(flags);
}
/**
* @param {number} index
* @param {number} cardFormatIndex
* @returns {?HTMLButtonElement}
*/
_createSaveButtons(index, cardFormatIndex) {
const entry = this._getEntry(index);
if (entry === null) { return null; }
const container = entry.querySelector('.note-actions-container');
if (container === null) { return null; }
// Create button from template
const singleNoteActionButtons = /** @type {HTMLElement} */ (this._display.displayGenerator.instantiateTemplate('action-button-container'));
/** @type {HTMLButtonElement} */
const saveButton = querySelectorNotNull(singleNoteActionButtons, '.action-button');
/** @type {HTMLElement} */
const iconSpan = querySelectorNotNull(saveButton, '.action-icon');
// Set button properties
const cardFormat = this._cardFormats[cardFormatIndex];
singleNoteActionButtons.dataset.cardFormatIndex = cardFormatIndex.toString();
saveButton.title = `Add ${cardFormat.name} note`;
saveButton.dataset.cardFormatIndex = cardFormatIndex.toString();
iconSpan.dataset.icon = cardFormat.icon;
const saveButtonIndex = container.children.length;
if ([0, 1].includes(saveButtonIndex)) {
saveButton.dataset.hotkey = `["addNote${saveButtonIndex + 1}","title","Add ${cardFormat.name} note"]`;
// eslint-disable-next-line no-underscore-dangle
this._display._hotkeyHelpController.setHotkeyLabel(saveButton, `Add ${cardFormat.name} note ({0})`);
} else {
delete saveButton.dataset.hotkey;
}
// Add event listeners
this._eventListeners.addEventListener(saveButton, 'click', this._onNoteSaveBind);
// Add button to container
container.appendChild(singleNoteActionButtons);
return saveButton;
}
/**
* @param {number} index
* @returns {?HTMLElement}
*/
_getEntry(index) {
const entries = this._display.dictionaryEntryNodes;
return index >= 0 && index < entries.length ? entries[index] : null;
}
/**
* @returns {?import('anki-templates-internal').Context}
*/
_getNoteContext() {
const {state} = this._display.history;
let documentTitle, url, sentence;
if (typeof state === 'object' && state !== null) {
({documentTitle, url, sentence} = state);
}
if (typeof documentTitle !== 'string') {
documentTitle = document.title;
}
if (typeof url !== 'string') {
url = window.location.href;
}
const {query, fullQuery, queryOffset} = this._display;
sentence = this._getValidSentenceData(sentence, fullQuery, queryOffset);
return {
url,
sentence,
documentTitle,
query,
fullQuery,
};
}
/** */
async _updateDictionaryEntryDetails() {
if (!this._display.getOptions()?.anki.enable) { return; }
const {dictionaryEntries} = this._display;
/** @type {?import('core').TokenObject} */
const token = {};
this._updateDictionaryEntryDetailsToken = token;
if (this._updateSaveButtonsPromise !== null) {
await this._updateSaveButtonsPromise;
}
if (this._updateDictionaryEntryDetailsToken !== token) { return; }
const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise());
try {
this._updateSaveButtonsPromise = promise;
const dictionaryEntryDetails = await this._getDictionaryEntryDetails(dictionaryEntries);
if (this._updateDictionaryEntryDetailsToken !== token) { return; }
this._dictionaryEntryDetails = dictionaryEntryDetails;
this._updateSaveButtons(dictionaryEntryDetails);
// eslint-disable-next-line no-underscore-dangle
this._display._hotkeyHelpController.setupNode(document.documentElement);
} finally {
resolve();
if (this._updateSaveButtonsPromise === promise) {
this._updateSaveButtonsPromise = null;
}
}
}
/**
* @param {HTMLButtonElement} button
* @param {number[]} noteIds
* @throws {Error}
*/
_updateSaveButtonForDuplicateBehavior(button, noteIds) {
const behavior = this._duplicateBehavior;
if (behavior === 'prevent') {
button.disabled = true;
button.title = 'Duplicate notes are disabled';
return;
}
const cardFormatIndex = button.dataset.cardFormatIndex;
if (typeof cardFormatIndex === 'undefined') { throw new Error('Invalid note options index'); }
const cardFormatIndexNumber = Number.parseInt(cardFormatIndex, 10);
if (Number.isNaN(cardFormatIndexNumber)) { throw new Error('Invalid note options index'); }
const cardFormat = this._cardFormats[cardFormatIndexNumber];
const verb = behavior === 'overwrite' ? 'Overwrite' : 'Add duplicate';
const iconPrefix = behavior === 'overwrite' ? 'overwrite' : 'add-duplicate';
const target = `${cardFormat.name} note`;
if (behavior === 'overwrite') {
button.dataset.overwrite = 'true';
if (!noteIds.some((id) => id !== INVALID_NOTE_ID)) {
button.disabled = true;
}
} else {
delete button.dataset.overwrite;
}
const title = `${verb} ${target}`;
button.setAttribute('title', title);
// eslint-disable-next-line no-underscore-dangle
const hotkeyLabel = this._display._hotkeyHelpController.getHotkeyLabel(button);
if (hotkeyLabel) {
// eslint-disable-next-line no-underscore-dangle
this._display._hotkeyHelpController.setHotkeyLabel(button, `${title} ({0})`); // {0} is a placeholder that gets replaced with the actual hotkey combination. For example, "Add expression (Ctrl+1)" or "Overwrite reading (Ctrl+2)"
}
const actionIcon = button.querySelector('.action-icon');
if (actionIcon instanceof HTMLElement) {
actionIcon.dataset.icon = `${iconPrefix}-${cardFormat.icon}`;
}
}
/**
* @param {import('display-anki').DictionaryEntryDetails[]} dictionaryEntryDetails
*/
_updateSaveButtons(dictionaryEntryDetails) {
const displayTagsAndFlags = this._displayTagsAndFlags;
for (let entryIndex = 0, entryCount = dictionaryEntryDetails.length; entryIndex < entryCount; ++entryIndex) {
for (const [cardFormatIndex, {canAdd, noteIds, noteInfos, ankiError}] of dictionaryEntryDetails[entryIndex].noteMap.entries()) {
const button = this._createSaveButtons(entryIndex, cardFormatIndex);
if (button !== null) {
button.disabled = !canAdd;
button.hidden = (ankiError !== null);
if (ankiError && ankiError.message !== 'Anki not connected') {
log.error(ankiError);
}
// If entry has noteIds, show the "add duplicate" button.
if (Array.isArray(noteIds) && noteIds.length > 0) {
this._updateSaveButtonForDuplicateBehavior(button, noteIds);
}
}
const validNoteIds = noteIds?.filter((id) => id !== INVALID_NOTE_ID) ?? [];
this._createViewNoteButton(entryIndex, cardFormatIndex, validNoteIds, Array.isArray(noteInfos) ? noteInfos : []);
if (displayTagsAndFlags !== 'never' && Array.isArray(noteInfos)) {
this._setupTagsIndicator(entryIndex, cardFormatIndex, noteInfos);
this._setupFlagsIndicator(entryIndex, cardFormatIndex, noteInfos);
}
}
}
}
/**
* @param {number} i
* @param {number} cardFormatIndex
* @param {(?import('anki').NoteInfo)[]} noteInfos
*/
_setupTagsIndicator(i, cardFormatIndex, noteInfos) {
const entry = this._getEntry(i);
if (entry === null) { return; }
const container = entry.querySelector(`[data-card-format-index="${cardFormatIndex}"]`);
if (container === null) { return; }
const tagsIndicator = /** @type {HTMLButtonElement} */ (this._display.displayGenerator.instantiateTemplate('note-action-button-view-tags'));
if (tagsIndicator === null) { return; }
const displayTags = new Set();
for (const item of noteInfos) {
if (item === null) { continue; }
for (const tag of item.tags) {
displayTags.add(tag);
}
}
if (this._displayTagsAndFlags === 'non-standard') {
for (const tag of this._noteTags) {
displayTags.delete(tag);
}
} else if (this._displayTagsAndFlags === 'custom') {
const tagsToRemove = [];
for (const tag of displayTags) {
if (typeof tag === 'string' && !this._targetTags.includes(tag)) {
tagsToRemove.push(tag);
}
}
for (const tag of tagsToRemove) {
displayTags.delete(tag);
}
}
if (displayTags.size > 0) {
tagsIndicator.disabled = false;
tagsIndicator.hidden = false;
tagsIndicator.title = `Card tags: ${[...displayTags].join(', ')}`;
tagsIndicator.addEventListener('click', this._onShowTagsBind);
container.appendChild(tagsIndicator);
}
}
/**
* @param {number} i
* @param {number} cardFormatIndex
* @param {(?import('anki').NoteInfo)[]} noteInfos
*/
_setupFlagsIndicator(i, cardFormatIndex, noteInfos) {
const entry = this._getEntry(i);
if (entry === null) { return; }
const container = entry.querySelector(`[data-card-format-index="${cardFormatIndex}"]`);
if (container === null) { return; }
const flagsIndicator = /** @type {HTMLButtonElement} */ (this._display.displayGenerator.instantiateTemplate('note-action-button-view-flags'));
if (flagsIndicator === null) { return; }
/** @type {Set<string>} */
const displayFlags = new Set();
for (const item of noteInfos) {
if (item === null) { continue; }
for (const cardInfo of item.cardsInfo) {
if (cardInfo.flags !== 0) {
displayFlags.add(this._getFlagName(cardInfo.flags));
}
}
}
if (displayFlags.size > 0) {
flagsIndicator.disabled = false;
flagsIndicator.hidden = false;
flagsIndicator.title = `Card flags: ${[...displayFlags].join(', ')}`;
/** @type {HTMLElement | null} */
const flagsIndicatorIcon = flagsIndicator.querySelector('.action-icon');
if (flagsIndicatorIcon !== null && flagsIndicator instanceof HTMLElement) {
flagsIndicatorIcon.style.background = this._getFlagColor(displayFlags);
}
flagsIndicator.addEventListener('click', this._onShowFlagsBind);
container.appendChild(flagsIndicator);
}
}
/**
* @param {string} message
*/
_showTagsNotification(message) {
if (this._tagsNotification === null) {
this._tagsNotification = this._display.createNotification(true);
}
this._tagsNotification.setContent(message);
this._tagsNotification.open();
}
/**
* @param {string} message
*/
_showFlagsNotification(message) {
if (this._flagsNotification === null) {
this._flagsNotification = this._display.createNotification(true);
}
this._flagsNotification.setContent(message);
this._flagsNotification.open();
}
/**
* @param {unknown} cardFormatStringIndex
*/
_hotkeySaveAnkiNoteForSelectedEntry(cardFormatStringIndex) {
if (typeof cardFormatStringIndex !== 'string') { return; }
const cardFormatIndex = Number.parseInt(cardFormatStringIndex, 10);
if (Number.isNaN(cardFormatIndex)) { return; }
const index = this._display.selectedIndex;
const entry = this._getEntry(index);
if (entry === null) { return; }
const container = entry.querySelector('.note-actions-container');
if (container === null) { return; }
/** @type {HTMLButtonElement | null} */
const nthButton = container.querySelector(`.action-button[data-action=save-note][data-card-format-index="${cardFormatIndex}"]`);
if (nthButton === null) { return; }
void this._saveAnkiNote(index, cardFormatIndex);
}
/**
* @param {number} dictionaryEntryIndex
* @param {number} cardFormatIndex
*/
async _saveAnkiNote(dictionaryEntryIndex, cardFormatIndex) {
const dictionaryEntries = this._display.dictionaryEntries;
const dictionaryEntryDetails = this._dictionaryEntryDetails;
if (!(
dictionaryEntryDetails !== null &&
dictionaryEntryIndex >= 0 &&
dictionaryEntryIndex < dictionaryEntries.length &&
dictionaryEntryIndex < dictionaryEntryDetails.length
)) {
return;
}
const dictionaryEntry = dictionaryEntries[dictionaryEntryIndex];
const details = dictionaryEntryDetails[dictionaryEntryIndex].noteMap.get(cardFormatIndex);
if (typeof details === 'undefined') { return; }
const {requirements} = details;
const button = this._saveButtonFind(dictionaryEntryIndex, cardFormatIndex);
if (button === null || button.disabled) { return; }
this._hideErrorNotification(true);
/** @type {Error[]} */
const allErrors = [];
const progressIndicatorVisible = this._display.progressIndicatorVisible;
const overrideToken = progressIndicatorVisible.setOverride(true);
try {
const {note, errors, requirements: outputRequirements} = await this._createNote(dictionaryEntry, cardFormatIndex, requirements);
allErrors.push(...errors);
const error = this._getAddNoteRequirementsError(requirements, outputRequirements);
if (error !== null) { allErrors.push(error); }
if (button.dataset.overwrite) {
const overwrittenNote = await this._getOverwrittenNote(note, dictionaryEntryIndex, cardFormatIndex);
await this._updateAnkiNote(overwrittenNote, allErrors);
} else {
await this._addNewAnkiNote(note, allErrors, button, dictionaryEntryIndex);
}
} catch (e) {
allErrors.push(toError(e));
} finally {
progressIndicatorVisible.clearOverride(overrideToken);
}
if (allErrors.length > 0) {
this._showErrorNotification(allErrors);
} else {
this._hideErrorNotification(true);
}
}
/**
* @param {number} dictionaryEntryIndex
* @param {number} cardFormatIndex
* @returns {?HTMLButtonElement}
*/
_saveButtonFind(dictionaryEntryIndex, cardFormatIndex) {
const entry = this._getEntry(dictionaryEntryIndex);
if (entry === null) { return null; }
const container = entry.querySelector('.note-actions-container');
if (container === null) { return null; }
const singleNoteActionButtonContainer = container.querySelector(`[data-card-format-index="${cardFormatIndex}"]`);
if (singleNoteActionButtonContainer === null) { return null; }
return singleNoteActionButtonContainer.querySelector('.action-button[data-action=save-note]');
}
/**
* @param {import('anki').Note} note
* @param {number} dictionaryEntryIndex
* @param {number} cardFormatIndex
* @returns {Promise<import('anki').NoteWithId | null>}
*/
async _getOverwrittenNote(note, dictionaryEntryIndex, cardFormatIndex) {
const dictionaryEntries = this._display.dictionaryEntries;
const allEntryDetails = await this._getDictionaryEntryDetails(dictionaryEntries);
const relevantEntryDetails = allEntryDetails[dictionaryEntryIndex];
const relevantNoteDetails = relevantEntryDetails.noteMap.get(cardFormatIndex);
if (typeof relevantNoteDetails === 'undefined') { return null; }
const {noteIds, noteInfos} = relevantNoteDetails;
if (noteIds === null || typeof noteInfos === 'undefined') { return null; }
const overwriteId = noteIds.find((id) => id !== INVALID_NOTE_ID);
if (typeof overwriteId === 'undefined') { return null; }
const overwriteInfo = noteInfos.find((info) => info !== null && info.noteId === overwriteId);
if (!overwriteInfo) { return null; }
const existingFields = overwriteInfo.fields;
const fieldOptions = this._cardFormats[cardFormatIndex].fields;
if (!fieldOptions) { return null; }
const newValues = note.fields;
/** @type {import('anki').NoteFields} */
const noteFields = {};
for (const [field, newValue] of Object.entries(newValues)) {
const overwriteMode = fieldOptions[field].overwriteMode;
const existingValue = existingFields[field].value;
noteFields[field] = this._getOverwrittenField(existingValue, newValue, overwriteMode);
}
return {
...note,
fields: noteFields,
id: overwriteId,
};
}
/**
* @param {string} existingValue
* @param {string} newValue
* @param {import('settings').AnkiNoteFieldOverwriteMode} overwriteMode
* @returns {string}
*/
_getOverwrittenField(existingValue, newValue, overwriteMode) {
switch (overwriteMode) {
case 'overwrite':
return newValue;
case 'skip':
return existingValue;
case 'append':
return existingValue + newValue;
case 'prepend':
return newValue + existingValue;
case 'coalesce':
return existingValue || newValue;
case 'coalesce-new':
return newValue || existingValue;
}
}
/**
* @param {import('anki').Note} note
* @param {Error[]} allErrors
* @param {HTMLButtonElement} button
* @param {number} dictionaryEntryIndex
*/
async _addNewAnkiNote(note, allErrors, button, dictionaryEntryIndex) {
let noteId = null;
let addNoteOkay = false;
try {
noteId = await this._display.application.api.addAnkiNote(note);
addNoteOkay = true;
} catch (e) {
allErrors.length = 0;
allErrors.push(toError(e));
}
if (addNoteOkay) {
if (noteId === null) {
allErrors.push(new Error('Note could not be added'));
} else {
if (this._suspendNewCards) {
try {
await this._display.application.api.suspendAnkiCardsForNote(noteId);
} catch (e) {
allErrors.push(toError(e));
}
}
const cardFormatIndex = this._getCardFormatIndex(button);
this._updateSaveButtonForDuplicateBehavior(button, [noteId]);
this._updateViewNoteButton(dictionaryEntryIndex, cardFormatIndex, [noteId]);
if (this._forceSync) {
try {
await this._display.application.api.forceSync();
} catch (e) {
allErrors.push(toError(e));
}
}
}
}
}
/**
* @param {HTMLButtonElement} button
* @returns {number}
* @throws {Error}
*/
_getCardFormatIndex(button) {
const cardFormatIndex = button.dataset.cardFormatIndex;
if (typeof cardFormatIndex === 'undefined') { throw new Error('Invalid card format index'); }
const cardFormatIndexNumber = Number.parseInt(cardFormatIndex, 10);
if (Number.isNaN(cardFormatIndexNumber)) { throw new Error('Invalid card format index'); }
return cardFormatIndexNumber;
}
/**
* @param {number} dictionaryEntryIndex
* @param {number} cardFormatIndex
* @param {number[]} noteIds
*/
_updateViewNoteButton(dictionaryEntryIndex, cardFormatIndex, noteIds) {
const entry = this._getEntry(dictionaryEntryIndex);
if (entry === null) { return; }
const singleNoteActions = entry.querySelector(`[data-card-format-index="${cardFormatIndex}"]`);
if (singleNoteActions === null) { return; }
/** @type {HTMLButtonElement | null} */
let viewNoteButton = singleNoteActions.querySelector('.action-button[data-action=view-note]');
if (viewNoteButton === null) {
viewNoteButton = this._createViewNoteButton(dictionaryEntryIndex, cardFormatIndex, noteIds, []);
}
if (viewNoteButton === null) { return; }
const newNoteIds = new Set([...this._getNodeNoteIds(viewNoteButton), ...noteIds]);
viewNoteButton.dataset.noteIds = [...newNoteIds].join(' ');
this._setViewButtonBadge(viewNoteButton);
viewNoteButton.hidden = false;
}
/**
* @param {import('anki').NoteWithId | null} noteWithId
* @param {Error[]} allErrors
*/
async _updateAnkiNote(noteWithId, allErrors) {
if (noteWithId === null) { return; }
try {
await this._display.application.api.updateAnkiNote(noteWithId);
} catch (e) {
allErrors.length = 0;
allErrors.push(toError(e));
}
}
/**
* @param {import('anki-note-builder').Requirement[]} requirements
* @param {import('anki-note-builder').Requirement[]} outputRequirements
* @returns {?DisplayAnkiError}
*/
_getAddNoteRequirementsError(requirements, outputRequirements) {
if (outputRequirements.length === 0) { return null; }
let count = 0;
for (const requirement of outputRequirements) {
const {type} = requirement;
switch (type) {
case 'audio':
case 'clipboardImage':
break;
default:
++count;
break;
}
}
if (count === 0) { return null; }
const error = new DisplayAnkiError('The created card may not have some content');
error.requirements = requirements;
error.outputRequirements = outputRequirements;
return error;
}
/**
* @param {Error[]} errors
* @param {(DocumentFragment|Node|Error)[]} [displayErrors]
*/
_showErrorNotification(errors, displayErrors) {
if (typeof displayErrors === 'undefined') { displayErrors = errors; }
if (this._errorNotificationEventListeners !== null) {
this._errorNotificationEventListeners.removeAllEventListeners();
}
if (this._errorNotification === null) {
this._errorNotification = this._display.createNotification(false);
this._errorNotificationEventListeners = new EventListenerCollection();
}
const content = this._display.displayGenerator.createAnkiNoteErrorsNotificationContent(displayErrors);
for (const node of content.querySelectorAll('.anki-note-error-log-link')) {
/** @type {EventListenerCollection} */ (this._errorNotificationEventListeners).addEventListener(node, 'click', () => {
log.log({ankiNoteErrors: errors});
}, false);
}
this._errorNotification.setContent(content);
this._errorNotification.open();
}
/**
* @param {boolean} animate
*/
_hideErrorNotification(animate) {
if (this._errorNotification === null) { return; }
this._errorNotification.close(animate);
/** @type {EventListenerCollection} */ (this._errorNotificationEventListeners).removeAllEventListeners();
}
/**
* @param {import('settings').ProfileOptions} options
*/
async _updateAnkiFieldTemplates(options) {
this._ankiFieldTemplates = await this._getAnkiFieldTemplates(options);
}
/**
* @param {import('settings').ProfileOptions} options
* @returns {Promise<string>}
*/
async _getAnkiFieldTemplates(options) {
const dictionaryInfo = await this._display.application.api.getDictionaryInfo();
const staticTemplates = await this._getStaticAnkiFieldTemplates(options);
const dynamicTemplates = getDynamicTemplates(options, dictionaryInfo);
return staticTemplates + dynamicTemplates;
}
/**
* @param {import('settings').ProfileOptions} options
* @returns {Promise<string>}
*/
async _getStaticAnkiFieldTemplates(options) {
let templates = options.anki.fieldTemplates;
if (typeof templates === 'string') { return templates; }
templates = this._ankiFieldTemplatesDefault;
if (typeof templates === 'string') { return templates; }
templates = await this._display.application.api.getDefaultAnkiFieldTemplates();
this._ankiFieldTemplatesDefault = templates;
return templates;
}
/**
* Checks whether fetching additional information (e.g. tags and flags, or overwrite) is enabled
* based on the current instance's display settings and duplicate handling behavior.
* @returns {boolean} - True if additional info fetching is enabled, false otherwise.
*/
_isAdditionalInfoEnabled() {
return this._displayTagsAndFlags !== 'never' || this._duplicateBehavior === 'overwrite';
}
/**
* @param {import('dictionary').DictionaryEntry[]} dictionaryEntries
* @returns {Promise<import('display-anki').DictionaryEntryDetails[]>}
*/
async _getDictionaryEntryDetails(dictionaryEntries) {
const notePromises = [];
const noteTargets = [];
for (let i = 0, ii = dictionaryEntries.length; i < ii; ++i) {
const dictionaryEntry = dictionaryEntries[i];
const {type} = dictionaryEntry;
for (const [cardFormatIndex, cardFormat] of this._cardFormats.entries()) {
if (cardFormat.type !== type) { continue; }
const notePromise = this._createNote(dictionaryEntry, cardFormatIndex, []);
notePromises.push(notePromise);
noteTargets.push({index: i, cardFormatIndex, cardFormat});
}
}
const noteInfoList = (await Promise.all(notePromises));
const validNotes = [];
/** @type {(import('anki').NoteInfoWrapper?)[]} */
const invalidAndPlaceholderNotes = [];
for (const noteInfo of noteInfoList) {
const note = noteInfo.note;
if (note.deckName.length > 0 && note.modelName.length > 0) {
validNotes.push(note);
invalidAndPlaceholderNotes.push(null);
} else {
invalidAndPlaceholderNotes.push({
canAdd: false,
valid: false,
noteIds: null,
});
}
}
let infos;
let ankiError = null;
try {
if (this._checkForDuplicates) {
infos = await this._display.application.api.getAnkiNoteInfo(validNotes, this._isAdditionalInfoEnabled());
} else {
const isAnkiConnected = await this._display.application.api.isAnkiConnected();
infos = this._getAnkiNoteInfoForceValueIfValid(validNotes, isAnkiConnected);
ankiError = isAnkiConnected ? null : new Error('Anki not connected');
}
} catch (e) {
infos = this._getAnkiNoteInfoForceValueIfValid(validNotes, false);
ankiError = (e instanceof ExtensionError && e.message.includes('Anki connection failure')) ?
new Error('Anki not connected') :
toError(e);
}
/** @type {(import('anki').NoteInfoWrapper)[]} */
const notesDupechecked = [];
for (const invalidAndPlaceholderNote of invalidAndPlaceholderNotes) {
if (invalidAndPlaceholderNote !== null) {
notesDupechecked.push(invalidAndPlaceholderNote);
} else {
const info = infos.shift();
if (typeof info !== 'undefined') {
notesDupechecked.push(info);
}
}
}
/** @type {import('display-anki').DictionaryEntryDetails[]} */
const results = new Array(dictionaryEntries.length).fill(null).map(() => ({noteMap: new Map()}));
for (let i = 0, ii = noteInfoList.length; i < ii; ++i) {
const {note, errors, requirements} = noteInfoList[i];
const {canAdd, valid, noteIds, noteInfos} = notesDupechecked[i];
const {cardFormatIndex, cardFormat, index} = noteTargets[i];
results[index].noteMap.set(cardFormatIndex, {cardFormat, note, errors, requirements, canAdd, valid, noteIds, noteInfos, ankiError});
}
return results;
}
/**
* @param {import('anki').Note[]} notes
* @param {boolean} canAdd
* @returns {import('anki').NoteInfoWrapper[]}
*/
_getAnkiNoteInfoForceValueIfValid(notes, canAdd) {
const results = [];
for (const note of notes) {
const valid = isNoteDataValid(note);
results.push({canAdd: (valid ? canAdd : valid), valid, noteIds: null});
}
return results;
}
/**
* @param {import('dictionary').DictionaryEntry} dictionaryEntry
* @param {number} cardFormatIndex
* @param {import('anki-note-builder').Requirement[]} requirements
* @returns {Promise<import('display-anki').CreateNoteResult>}
*/
async _createNote(dictionaryEntry, cardFormatIndex, requirements) {
const context = this._noteContext;
if (context === null) { throw new Error('Note context not initialized'); }
const cardFormat = this._cardFormats?.[cardFormatIndex];
if (typeof cardFormat === 'undefined') { throw new Error('Unsupported note type}'); }
if (!this._ankiFieldTemplates) {
const options = this._display.getOptions();
if (options) {
await this._updateAnkiFieldTemplates(options);
}
}
const template = this._ankiFieldTemplates;
if (typeof template !== 'string') { throw new Error('Invalid template'); }
const contentOrigin = this._display.getContentOrigin();
const details = this._ankiNoteBuilder.getDictionaryEntryDetailsForNote(dictionaryEntry);
const audioDetails = this._getAnkiNoteMediaAudioDetails(details);
const optionsContext = this._display.getOptionsContext();
const dictionaryStylesMap = this._ankiNoteBuilder.getDictionaryStylesMap(this._dictionaries);
const {note, errors, requirements: outputRequirements} = await this._ankiNoteBuilder.createNote({
dictionaryEntry,
cardFormat,
context,
template,
tags: this._noteTags,
duplicateScope: this._duplicateScope,
duplicateScopeCheckAllModels: this._duplicateScopeCheckAllModels,
resultOutputMode: this._resultOutputMode,
glossaryLayoutMode: this._glossaryLayoutMode,
compactTags: this._compactTags,
mediaOptions: {
audio: audioDetails,
screenshot: {
format: this._screenshotFormat,
quality: this._screenshotQuality,
contentOrigin,
},
textParsing: {
optionsContext,
scanLength: this._scanLength,
},
},
requirements,
dictionaryStylesMap,
});
return {note, errors, requirements: outputRequirements};
}
/**
* @param {unknown} sentence
* @param {string} fallback
* @param {number} fallbackOffset
* @returns {import('anki-templates-internal').ContextSentence}
*/
_getValidSentenceData(sentence, fallback, fallbackOffset) {
let text;
let offset;
if (typeof sentence === 'object' && sentence !== null) {
({text, offset} = /** @type {import('core').UnknownObject} */ (sentence));
}
if (typeof text !== 'string') {
text = fallback;
offset = fallbackOffset;
} else {
if (typeof offset !== 'number') { offset = 0; }
}
return {text, offset};
}
/**
* @param {import('api').InjectAnkiNoteMediaDefinitionDetails} details
* @returns {?import('anki-note-builder').AudioMediaOptions}
*/
_getAnkiNoteMediaAudioDetails(details) {
if (details.type !== 'term') { return null; }
const {sources, preferredAudioIndex, enableDefaultAudioSources} = this._displayAudio.getAnkiNoteMediaAudioDetails(details.term, details.reading);
const languageSummary = this._display.getLanguageSummary();
return {
sources,
preferredAudioIndex,
idleTimeout: this._audioDownloadIdleTimeout,
languageSummary,
enableDefaultAudioSources,
};
}
// View note functions
/**
* @param {MouseEvent} e
*/
_onViewNotesButtonClick(e) {
const element = /** @type {HTMLElement} */ (e.currentTarget);
e.preventDefault();
if (e.shiftKey) {
this._showViewNotesMenu(element);
} else {
void this._viewNotes(element);
}
}
/**
* @param {MouseEvent} e
*/
_onViewNotesButtonContextMenu(e) {
const element = /** @type {HTMLElement} */ (e.currentTarget);
e.preventDefault();
this._showViewNotesMenu(element);
}
/**
* @param {import('popup-menu').MenuCloseEvent} e
*/
_onViewNotesButtonMenuClose(e) {
const {detail: {action, item}} = e;
switch (action) {
case 'viewNotes':
if (item !== null) {
void this._viewNotes(item);
}
break;
}
}
/**
* @param {number} index
* @param {number} cardFormatIndex
* @param {number[]} noteIds
* @param {(?import('anki').NoteInfo)[]} noteInfos
* @returns {?HTMLButtonElement}
*/
_createViewNoteButton(index, cardFormatIndex, noteIds, noteInfos) {
if (noteIds.length === 0) { return null; }
let viewNoteButton = /** @type {HTMLButtonElement} */ (this._display.displayGenerator.instantiateTemplate('note-action-button-view-note'));
if (viewNoteButton === null) { return null; }
const disabled = (noteIds.length === 0);
viewNoteButton.disabled = disabled;
viewNoteButton.hidden = disabled;
viewNoteButton.dataset.noteIds = noteIds.join(' ');
viewNoteButton = this._setViewNoteButtonCardState(noteInfos, viewNoteButton);
this._setViewButtonBadge(viewNoteButton);
const entry = this._getEntry(index);
if (entry === null) { return null; }
const container = entry.querySelector('.note-actions-container');
if (container === null) { return null; }
const singleNoteActionButtonContainer = container.querySelector(`[data-card-format-index="${cardFormatIndex}"]`);
if (singleNoteActionButtonContainer === null) { return null; }
singleNoteActionButtonContainer.appendChild(viewNoteButton);
this._eventListeners.addEventListener(viewNoteButton, 'click', this._onViewNotesButtonClickBind);
this._eventListeners.addEventListener(viewNoteButton, 'contextmenu', this._onViewNotesButtonContextMenuBind);
this._eventListeners.addEventListener(viewNoteButton, 'menuClose', this._onViewNotesButtonMenuCloseBind);
return viewNoteButton;
}
/**
* @param {(?import('anki').NoteInfo)[]} noteInfos
* @param {HTMLButtonElement} viewNoteButton
* @returns {HTMLButtonElement}
*/
_setViewNoteButtonCardState(noteInfos, viewNoteButton) {
if (this._isAdditionalInfoEnabled() === false || noteInfos.length === 0) { return viewNoteButton; }
const cardStates = [];
for (const item of noteInfos) {
if (item === null) { continue; }
for (const cardInfo of item.cardsInfo) {
cardStates.push(cardInfo.cardState);
}
}
const highestState = this._getHighestPriorityCardState(cardStates);
const dataIcon = /** @type {HTMLElement} */ (viewNoteButton.querySelector('.icon[data-icon^="view-note"]'));
dataIcon.dataset.icon = highestState !== 'new' ? `view-note-${highestState}` : 'view-note';
const label = `View added note (${highestState})`;
viewNoteButton.title = label;
viewNoteButton.dataset.hotkey = JSON.stringify(['viewNotes', 'title', `${label} ({0})`]);
return viewNoteButton;
}
/**
* @param {HTMLButtonElement} viewNoteButton
*/
_setViewButtonBadge(viewNoteButton) {
/** @type {?HTMLElement} */
const badge = viewNoteButton.querySelector('.action-button-badge');
const noteIds = this._getNodeNoteIds(viewNoteButton);
if (badge !== null) {
const badgeData = badge.dataset;
if (noteIds.length > 1) {
badgeData.icon = 'plus-thick';
badge.hidden = false;
} else {
delete badgeData.icon;
badge.hidden = true;
}
}
}
/**
* @param {HTMLElement} node
*/
async _viewNotes(node) {
const noteIds = this._getNodeNoteIds(node);
if (noteIds.length === 0) { return; }
try {
await this._display.application.api.viewNotes(noteIds, this._noteGuiMode, false);
} catch (e) {
const displayErrors = (
toError(e).message === 'Mode not supported' ?
[this._display.displayGenerator.instantiateTemplateFragment('footer-notification-anki-view-note-error')] :
void 0
);
this._showErrorNotification([toError(e)], displayErrors);
return;
}
}
/**
* @param {HTMLElement} node
*/
_showViewNotesMenu(node) {
const noteIds = this._getNodeNoteIds(node);
if (noteIds.length === 0) { return; }
/** @type {HTMLElement} */
const menuContainerNode = this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu');
/** @type {HTMLElement} */
const menuBodyNode = querySelectorNotNull(menuContainerNode, '.popup-menu-body');
for (let i = 0, ii = noteIds.length; i < ii; ++i) {
const noteId = noteIds[i];
/** @type {HTMLElement} */
const item = this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu-item');
/** @type {Element} */
const label = querySelectorNotNull(item, '.popup-menu-item-label');
label.textContent = `Note ${i + 1}: ${noteId}`;
item.dataset.menuAction = 'viewNotes';
item.dataset.noteIds = `${noteId}`;
menuBodyNode.appendChild(item);
}
this._menuContainer.appendChild(menuContainerNode);
const popupMenu = new PopupMenu(node, menuContainerNode);
popupMenu.prepare();
}
/**
* @param {HTMLElement} node
* @returns {number[]}
*/
_getNodeNoteIds(node) {
const {noteIds} = node.dataset;
const results = [];
if (typeof noteIds === 'string' && noteIds.length > 0) {
for (const noteId of noteIds.split(' ')) {
const noteIdInt = Number.parseInt(noteId, 10);
if (Number.isFinite(noteIdInt)) {
results.push(noteIdInt);
}
}
}
return results;
}
/**
* @param {number} index
* @returns {?HTMLButtonElement}
*/
_getViewNoteButton(index) {
const entry = this._getEntry(index);
return entry !== null ? entry.querySelector('.action-button[data-action=view-note]') : null;
}
/**
* @param {unknown} cardFormatStringIndex
*/
_hotkeyViewNotesForSelectedEntry(cardFormatStringIndex) {
if (typeof cardFormatStringIndex !== 'string') { return; }
const cardFormatIndex = Number.parseInt(cardFormatStringIndex, 10);
if (Number.isNaN(cardFormatIndex)) { return; }
const index = this._display.selectedIndex;
const entry = this._getEntry(index);
if (entry === null) { return; }
const container = entry.querySelector('.note-actions-container');
if (container === null) { return; }
/** @type {HTMLButtonElement | null} */
const nthButton = container.querySelector(`.action-button-container[data-card-format-index="${cardFormatIndex}"] .action-button[data-action=view-note]`);
if (nthButton === null) { return; }
void this._viewNotes(nthButton);
}
/**
* @param {number} flag
* @returns {string}
*/
_getFlagName(flag) {
/** @type {Record<number, string>} */
const flagNamesDict = {
1: 'Red',
2: 'Orange',
3: 'Green',
4: 'Blue',
5: 'Pink',
6: 'Turquoise',
7: 'Purple',
};
if (flag in flagNamesDict) {
return flagNamesDict[flag];
}
return '';
}
/**
* @param {Set<string>} flags
* @returns {string}
*/
_getFlagColor(flags) {
/** @type {Record<string, import('display-anki').RGB>} */
const flagColorsDict = {
Red: {red: 248, green: 113, blue: 113},
Orange: {red: 253, green: 186, blue: 116},
Green: {red: 134, green: 239, blue: 172},
Blue: {red: 96, green: 165, blue: 250},
Pink: {red: 240, green: 171, blue: 252},
Turquoise: {red: 94, green: 234, blue: 212},
Purple: {red: 192, green: 132, blue: 252},
};
const gradientSliceSize = 100 / flags.size;
let currentGradientPercent = 0;
const gradientSlices = [];
for (const flag of flags) {
const flagColor = flagColorsDict[flag];
gradientSlices.push(
'rgb(' + flagColor.red + ',' + flagColor.green + ',' + flagColor.blue + ') ' + currentGradientPercent + '%',
'rgb(' + flagColor.red + ',' + flagColor.green + ',' + flagColor.blue + ') ' + (currentGradientPercent + gradientSliceSize) + '%',
);
currentGradientPercent += gradientSliceSize;
}
return 'linear-gradient(to right,' + gradientSlices.join(',') + ')';
}
/**
* Get the highest priority state from a list of Anki queue states.
* Source: https://github.com/ankidroid/Anki-Android/wiki/Database-Structure#cards
*
* Priority order:
* - -3, -2 → "buried"
* - -1 → "suspended"
* - 2 → "review"
* - 1, 3 → "learning"
* - 0 → "new" (default fallback)
* @param {number[]} cardStates Array of queue state integers.
* @returns {"buried" | "suspended" | "review" | "learning" | "new" } - The highest priority state found.
*/
_getHighestPriorityCardState(cardStates) {
if (cardStates.includes(-3) || cardStates.includes(-2)) {
return 'buried';
}
if (cardStates.includes(-1)) {
return 'suspended';
}
if (cardStates.includes(2)) {
return 'review';
}
if (cardStates.includes(1) || cardStates.includes(3)) {
return 'learning';
}
return 'new';
}
}
class DisplayAnkiError extends Error {
/**
* @param {string} message
*/
constructor(message) {
super(message);
/** @type {string} */
this.name = 'DisplayAnkiError';
/** @type {?import('anki-note-builder').Requirement[]} */
this._requirements = null;
/** @type {?import('anki-note-builder').Requirement[]} */
this._outputRequirements = null;
}
/** @type {?import('anki-note-builder').Requirement[]} */
get requirements() { return this._requirements; }
set requirements(value) { this._requirements = value; }
/** @type {?import('anki-note-builder').Requirement[]} */
get outputRequirements() { return this._outputRequirements; }
set outputRequirements(value) { this._outputRequirements = value; }
}