aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKuuuube <61125188+Kuuuube@users.noreply.github.com>2024-05-11 23:03:27 -0400
committerGitHub <noreply@github.com>2024-05-12 03:03:27 +0000
commit486eec15b817d6a87ad98245dea3534545286334 (patch)
treea5376ab50b4770842915c7338401cb1b2a9e4636
parent9b28e8ecf84027c4b52a015b389c6c1730732ce0 (diff)
Add option to bulk generate anki cards (#895)
* Add option to bulk generate anki cards * Fix tab replacement * Set deckname and modelname in note builder * Add addNotes to ankiconnect api implementation * Add option to send word list to anki directly * Add support for audio and media toggle * Add support for dictionary media * Remove unnecessary assignment * Remove unused css * Remove redundant html * Start of progress bar implementation * Remove redundant type annotation * Remove unused import * Rename words to terms * Print progress to console * Add confirmation to Export to file * Improve progress logs * Add unresponsive and console note * Add progress bars * Make cancel button actually cancel operation * Remove unresponsive warnings * Disable send and export buttons after they are clicked * Remove unneeded Yomichan mention * Mark as experimental * Clarify description * Add documentation on Anki Deck Generation * Add experimental note in docs * Add warning text to settings page * Switch example text based on language * Remove silly cancel function and bind directly * Rename to model * Add link to docs * Make test text less confusing * Rename deck to notes * Clarify what is being sent to anki * Fix incorrect modal header text * Clarify wording * Fix ankiconnect addNotes return types * Add error handling to send to anki * Fix wording and naming in docs * Add option to prevent sending duplicates to anki * Update anki deck and model without a page refresh * Cleanup internal html naming * Cleanup type definition styling * Update example text without a page refresh * Prevent closing the send/export confirm modal from messing up the ui and not allowing the user to see the current progress * Fix cancel getting stuck on true * Consolidate state changes * Support idle download timeout * Capitalize Failed to add cards error * Add separate variable for idleTimeout calculation * Remove redundant _cachedDictionaryEntryValue variable * Use tags option to populate tags * Include deck and tags when exporting to file * Use date down to seconds and zero pad * Remove unnecessary ternary * Limit 'path' finding function to only being able to search for 'path' * Rename _findPathsByKey to _findAllPaths
-rw-r--r--docs/anki-integration.md35
-rw-r--r--ext/css/settings.css34
-rw-r--r--ext/js/comm/anki-connect.js13
-rw-r--r--ext/js/pages/settings/anki-controller.js16
-rw-r--r--ext/js/pages/settings/anki-deck-generator-controller.js587
-rw-r--r--ext/js/pages/settings/settings-main.js4
-rw-r--r--ext/settings.html105
7 files changed, 794 insertions, 0 deletions
diff --git a/docs/anki-integration.md b/docs/anki-integration.md
index 547a73c8..35ef6330 100644
--- a/docs/anki-integration.md
+++ b/docs/anki-integration.md
@@ -118,3 +118,38 @@ Below are some troubleshooting tips you can try if you are unable to create new
- If all of the buttons appear grayed out, then you should double-check your deck and model configuration settings.
- If no icons appear at all, make sure that Anki is running in the background and that [AnkiConnect](https://foosoft.net/projects/anki-connect) has been installed.
+
+### Anki Note Generation
+
+Using the `Generate Anki Notes (Experimental)...` feature in the settings page it is possible to easily generate and export large amounts of Anki cards.
+
+> [!WARNING]
+> This feature is experimental!
+
+First, get a newline separated list of terms. For example:
+
+```
+雪
+雨
+竜巻
+```
+
+Enter this list into the large text box in the `Anki Note Generator` popup window.
+
+Next, select either `Send to Anki` or `Export to File`.
+
+**Send to Anki:**
+
+`Send to Anki` will send all the terms to the active Anki deck using the active Anki model specified on the page. To change the active Anki deck or Anki model, edit them in the `Configure Anki card format...` setting.
+
+Make sure to confirm you are exporting to the correct deck and with the correct Anki model. After the notes are sent to Anki there is no way to automatically undo the changes.
+
+To include media in notes sent to Anki, make sure to enable the `Add media to notes` option. Media includes audio, images, and svgs. Exporting with media may take significantly longer than without it.
+
+To prevent duplicate notes being sent to Anki, enable the `Prevent sending duplicate notes` option. This will check for duplicate notes that already exist. The `Check for duplicates across all models` and `Duplicate card scope` settings are used to determine what is considered a duplicate card. **This does not remove duplicates in the term list.**
+
+**Export to File:**
+
+`Export to File` will export all the terms to an Anki deck file using the active Anki card format specified on the page and in Anki's `Notes in plain text (.txt)` format. After exporting completes you will be prompted to save the file. This file can later be imported into Anki.
+
+Media cannot be included when exporting in this format.
diff --git a/ext/css/settings.css b/ext/css/settings.css
index e6e94428..87db558f 100644
--- a/ext/css/settings.css
+++ b/ext/css/settings.css
@@ -1640,6 +1640,40 @@ code.anki-field-marker {
min-height: calc(var(--textarea-line-height) * 5 + var(--textarea-padding) * 2);
}
+.generate-anki-notes-layout {
+ display: flex;
+ flex-flow: column nowrap;
+}
+.generate-anki-notes-info {
+ flex: 0 1 auto;
+}
+.generate-anki-notes-test-container {
+ flex: 0 1 auto;
+}
+.generate-anki-notes-test-table {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ column-gap: 0.85em;
+}
+.generate-anki-notes-test-table-header {
+ font-size: var(--font-size-small);
+}
+#generate-anki-notes-textarea {
+ flex: 1 1 auto;
+ width: 100%;
+ max-width: 100%;
+ box-sizing: border-box;
+ resize: none;
+ min-height: calc(var(--textarea-line-height) * 5 + var(--textarea-padding) * 2);
+}
+#generate-anki-notes-test-text-input {
+ width: 100%;
+}
+
.code {
flex: 0 0 auto;
width: 100%;
diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js
index 23183e79..446b2139 100644
--- a/ext/js/comm/anki-connect.js
+++ b/ext/js/comm/anki-connect.js
@@ -129,6 +129,19 @@ export class AnkiConnect {
return result;
}
+ /**
+ * @param {import('anki').Note[]} notes
+ * @returns {Promise<?((number | null)[] | null)>}
+ */
+ async addNotes(notes) {
+ if (!this._enabled) { return null; }
+ await this._checkVersion();
+ const result = await this._invoke('addNotes', {notes});
+ if (result !== null && !Array.isArray(result)) {
+ throw this._createUnexpectedResultError('(number | null)[] | null', result);
+ }
+ return result;
+ }
/**
* @param {import('anki').Note[]} notes
diff --git a/ext/js/pages/settings/anki-controller.js b/ext/js/pages/settings/anki-controller.js
index 9640ed62..7eb09c85 100644
--- a/ext/js/pages/settings/anki-controller.js
+++ b/ext/js/pages/settings/anki-controller.js
@@ -505,6 +505,22 @@ export class AnkiController {
return null;
}
}
+
+ /**
+ * @param {import('anki').Note[]} notes
+ * @returns {Promise<?((number | null)[] | null)>}
+ */
+ async addNotes(notes) {
+ return await this._ankiConnect.addNotes(notes);
+ }
+
+ /**
+ * @param {import('anki').Note[]} notes
+ * @returns {Promise<boolean[]>}
+ */
+ async canAddNotes(notes) {
+ return await this._ankiConnect.canAddNotes(notes);
+ }
}
class AnkiCardController {
diff --git a/ext/js/pages/settings/anki-deck-generator-controller.js b/ext/js/pages/settings/anki-deck-generator-controller.js
new file mode 100644
index 00000000..c8b17742
--- /dev/null
+++ b/ext/js/pages/settings/anki-deck-generator-controller.js
@@ -0,0 +1,587 @@
+/*
+ * Copyright (C) 2023-2024 Yomitan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+import {ExtensionError} from '../../core/extension-error.js';
+import {log} from '../../core/log.js';
+import {toError} from '../../core/to-error.js';
+import {AnkiNoteBuilder} from '../../data/anki-note-builder.js';
+import {getDynamicTemplates} from '../../data/anki-template-util.js';
+import {querySelectorNotNull} from '../../dom/query-selector.js';
+import {TemplateRendererProxy} from '../../templates/template-renderer-proxy.js';
+
+export class AnkiDeckGeneratorController {
+ /**
+ * @param {import('../../application.js').Application} application
+ * @param {import('./settings-controller.js').SettingsController} settingsController
+ * @param {import('./modal-controller.js').ModalController} modalController
+ * @param {import('./anki-controller.js').AnkiController} ankiController
+ */
+ constructor(application, settingsController, modalController, ankiController) {
+ /** @type {import('../../application.js').Application} */
+ this._application = application;
+ /** @type {import('./settings-controller.js').SettingsController} */
+ this._settingsController = settingsController;
+ /** @type {import('./modal-controller.js').ModalController} */
+ this._modalController = modalController;
+ /** @type {import('./anki-controller.js').AnkiController} */
+ this._ankiController = ankiController;
+ /** @type {?string} */
+ this._defaultFieldTemplates = null;
+ /** @type {HTMLTextAreaElement} */
+ this._mainSettingsEntry = querySelectorNotNull(document, '#generate-anki-notes-main-settings-entry');
+ /** @type {HTMLTextAreaElement} */
+ this._wordInputTextarea = querySelectorNotNull(document, '#generate-anki-notes-textarea');
+ /** @type {HTMLInputElement} */
+ this._renderTextInput = querySelectorNotNull(document, '#generate-anki-notes-test-text-input');
+ /** @type {HTMLElement} */
+ this._renderResult = querySelectorNotNull(document, '#generate-anki-notes-render-result');
+ /** @type {HTMLElement} */
+ this._activeModelText = querySelectorNotNull(document, '#generate-anki-notes-active-model');
+ /** @type {HTMLElement} */
+ this._activeDeckText = querySelectorNotNull(document, '#generate-anki-notes-active-deck');
+ /** @type {HTMLInputElement} */
+ this._addMediaCheckbox = querySelectorNotNull(document, '#generate-anki-notes-add-media');
+ /** @type {HTMLInputElement} */
+ this._disallowDuplicatesCheckbox = querySelectorNotNull(document, '#generate-anki-notes-disallow-duplicates');
+ /** @type {string} */
+ this._activeNoteType = '';
+ /** @type {string} */
+ this._activeAnkiDeck = '';
+ /** @type {HTMLSpanElement} */
+ this._sendWordcount = querySelectorNotNull(document, '#generate-anki-notes-send-wordcount');
+ /** @type {HTMLSpanElement} */
+ this._exportWordcount = querySelectorNotNull(document, '#generate-anki-notes-export-wordcount');
+ /** @type {HTMLButtonElement} */
+ this._sendToAnkiButtonConfirmButton = querySelectorNotNull(document, '#generate-anki-notes-send-button-confirm');
+ /** @type {HTMLButtonElement} */
+ this._exportButtonConfirmButton = querySelectorNotNull(document, '#generate-anki-notes-export-button-confirm');
+ /** @type {NodeListOf<HTMLElement>} */
+ this._progressContainers = (document.querySelectorAll('.generate-anki-notes-progress'));
+ /** @type {?import('./modal.js').Modal} */
+ this._sendToAnkiConfirmModal = null;
+ /** @type {?import('./modal.js').Modal} */
+ this._exportConfirmModal = null;
+ /** @type {boolean} */
+ this._cancel = false;
+ /** @type {boolean} */
+ this._inProgress = false;
+ /** @type {AnkiNoteBuilder} */
+ this._ankiNoteBuilder = new AnkiNoteBuilder(settingsController.application.api, new TemplateRendererProxy());
+ }
+
+ /** */
+ async prepare() {
+ this._defaultFieldTemplates = await this._settingsController.application.api.getDefaultAnkiFieldTemplates();
+
+ /** @type {HTMLButtonElement} */
+ const testRenderButton = querySelectorNotNull(document, '#generate-anki-notes-test-render-button');
+ /** @type {HTMLButtonElement} */
+ const sendToAnkiButton = querySelectorNotNull(document, '#generate-anki-notes-send-to-anki-button');
+ /** @type {HTMLButtonElement} */
+ const sendToAnkiCancelButton = querySelectorNotNull(document, '#generate-anki-notes-send-to-anki-cancel-button');
+ /** @type {HTMLButtonElement} */
+ const exportButton = querySelectorNotNull(document, '#generate-anki-notes-export-button');
+ /** @type {HTMLButtonElement} */
+ const exportCancelButton = querySelectorNotNull(document, '#generate-anki-notes-export-cancel-button');
+ /** @type {HTMLButtonElement} */
+ const generateButton = querySelectorNotNull(document, '#generate-anki-notes-export-button');
+
+ this._sendToAnkiConfirmModal = this._modalController.getModal('generate-anki-notes-send-to-anki');
+ this._exportConfirmModal = this._modalController.getModal('generate-anki-notes-export');
+
+ testRenderButton.addEventListener('click', this._onRender.bind(this), false);
+ sendToAnkiButton.addEventListener('click', this._onSendToAnki.bind(this), false);
+ this._sendToAnkiButtonConfirmButton.addEventListener('click', this._onSendToAnkiConfirm.bind(this), false);
+ sendToAnkiCancelButton.addEventListener('click', (() => { this._cancel = true; }).bind(this), false);
+ exportButton.addEventListener('click', this._onExport.bind(this), false);
+ this._exportButtonConfirmButton.addEventListener('click', this._onExportConfirm.bind(this), false);
+ exportCancelButton.addEventListener('click', (() => { this._cancel = true; }).bind(this), false);
+ generateButton.addEventListener('click', this._onExport.bind(this), false);
+
+ void this._updateExampleText();
+ this._mainSettingsEntry.addEventListener('click', this._updateExampleText.bind(this), false);
+
+ void this._updateActiveModel();
+ this._mainSettingsEntry.addEventListener('click', this._updateActiveModel.bind(this), false);
+ }
+
+ // Private
+
+ /** */
+ async _updateActiveModel() {
+ const activeModelText = /** @type {HTMLElement} */ (this._activeModelText);
+ const activeDeckText = /** @type {HTMLElement} */ (this._activeDeckText);
+ const activeDeckTextConfirm = querySelectorNotNull(document, '#generate-anki-notes-active-deck-confirm');
+ const options = await this._settingsController.getOptions();
+
+ this._activeNoteType = options.anki.terms.model;
+ this._activeAnkiDeck = options.anki.terms.deck;
+ activeModelText.textContent = this._activeNoteType;
+ activeDeckText.textContent = this._activeAnkiDeck;
+ activeDeckTextConfirm.textContent = this._activeAnkiDeck;
+ }
+
+ /** */
+ async _resetState() {
+ this._updateProgressBar(true, '', 0, 1, false);
+ this._cancel = false;
+
+ this._exportButtonConfirmButton.disabled = false;
+ this._exportWordcount.textContent = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n').filter(Boolean).length.toString();
+
+ this._sendToAnkiButtonConfirmButton.disabled = false;
+ this._addMediaCheckbox.disabled = false;
+ this._disallowDuplicatesCheckbox.disabled = false;
+ this._sendWordcount.textContent = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n').filter(Boolean).length.toString();
+ }
+
+ /** */
+ async _startGenerationState() {
+ this._inProgress = true;
+
+ this._exportButtonConfirmButton.disabled = true;
+
+ this._sendToAnkiButtonConfirmButton.disabled = true;
+ this._addMediaCheckbox.disabled = true;
+ this._disallowDuplicatesCheckbox.disabled = true;
+ }
+
+ /** */
+ async _endGenerationState() {
+ this._inProgress = false;
+
+ if (this._exportConfirmModal !== null) {
+ this._exportConfirmModal.setVisible(false);
+ }
+
+ if (this._sendToAnkiConfirmModal !== null) {
+ this._sendToAnkiConfirmModal.setVisible(false);
+ }
+
+ this._updateProgressBar(false, '', 1, 1, false);
+ }
+
+ /** */
+ async _endGenerationStateError() {
+ this._inProgress = false;
+ }
+
+ /**
+ * @param {MouseEvent} e
+ */
+ _onExport(e) {
+ e.preventDefault();
+ if (this._exportConfirmModal !== null) {
+ this._exportConfirmModal.setVisible(true);
+ if (this._inProgress) { return; }
+ void this._resetState();
+ }
+ }
+
+ /**
+ * @param {MouseEvent} e
+ */
+ async _onExportConfirm(e) {
+ e.preventDefault();
+ void this._startGenerationState();
+ const terms = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n');
+ let ankiTSV = '#separator:tab\n#html:true\n#notetype column:1\n#deck column:2\n#tags column:3\n';
+ let index = 0;
+ requestAnimationFrame(() => {
+ this._updateProgressBar(true, 'Exporting to File...', 0, terms.length, true);
+ setTimeout(async () => {
+ for (const value of terms) {
+ if (!value) { continue; }
+ if (this._cancel) {
+ void this._endGenerationState();
+ return;
+ }
+ const noteData = await this._generateNoteData(value, 'term-kanji', false);
+ if (noteData !== null) {
+ const fieldsTSV = this._fieldsToTSV(noteData.fields);
+ if (fieldsTSV) {
+ ankiTSV += this._activeNoteType + '\t';
+ ankiTSV += this._activeAnkiDeck + '\t';
+ ankiTSV += noteData.tags.join(' ') + '\t';
+ ankiTSV += fieldsTSV;
+ ankiTSV += '\n';
+ }
+ }
+ index++;
+ this._updateProgressBar(false, '', index, terms.length, true);
+ }
+ const today = new Date();
+ const fileName = 'anki-deck-' + today.toISOString().split('.')[0].replaceAll(/(T|:)/g, '-') + '.txt';
+ const blob = new Blob([ankiTSV], {type: 'application/octet-stream'});
+ this._saveBlob(blob, fileName);
+
+ void this._endGenerationState();
+ }, 1);
+ });
+ }
+
+ /**
+ * @param {MouseEvent} e
+ */
+ _onSendToAnki(e) {
+ e.preventDefault();
+ if (this._sendToAnkiConfirmModal !== null) {
+ this._sendToAnkiConfirmModal.setVisible(true);
+ if (this._inProgress) { return; }
+ void this._resetState();
+ }
+ }
+
+ /**
+ * @param {MouseEvent} e
+ */
+ async _onSendToAnkiConfirm(e) {
+ e.preventDefault();
+ void this._startGenerationState();
+ const terms = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n');
+ const addMedia = this._addMediaCheckbox.checked;
+ const disallowDuplicates = this._disallowDuplicatesCheckbox.checked;
+ /** @type {import("anki.js").Note[]} */
+ let notes = [];
+ let index = 0;
+ requestAnimationFrame(() => {
+ this._updateProgressBar(true, 'Sending to Anki...', 0, terms.length, true);
+ setTimeout(async () => {
+ for (const value of terms) {
+ if (!value) { continue; }
+ if (this._cancel) {
+ void this._endGenerationState();
+ return;
+ }
+ const noteData = await this._generateNoteData(value, 'term-kanji', addMedia);
+ if (noteData) {
+ notes.push(noteData);
+ }
+ if (notes.length >= 100) {
+ const sendNotesResult = await this._sendNotes(notes, disallowDuplicates);
+ if (sendNotesResult === false) {
+ void this._endGenerationStateError();
+ return;
+ }
+ notes = [];
+ }
+ index++;
+ this._updateProgressBar(false, '', index, terms.length, true);
+ }
+ if (notes.length > 0) {
+ const sendNotesResult = await this._sendNotes(notes, disallowDuplicates);
+ if (sendNotesResult === false) {
+ void this._endGenerationStateError();
+ return;
+ }
+ }
+
+ void this._endGenerationState();
+ }, 1);
+ });
+ }
+
+ /**
+ * @param {import("anki.js").Note[]} notes
+ * @param {boolean} disallowDuplicates
+ * @returns {Promise<boolean>}
+ */
+ async _sendNotes(notes, disallowDuplicates) {
+ try {
+ if (disallowDuplicates) {
+ const duplicateNotes = await this._ankiController.canAddNotes(notes.map((note) => ({...note, options: {...note.options, allowDuplicate: false}})));
+ notes = notes.filter((_, i) => duplicateNotes[i]);
+ }
+ const addNotesResult = await this._ankiController.addNotes(notes);
+ if (addNotesResult === null || addNotesResult.includes(null)) {
+ this._updateProgressBarError('Ankiconnect error: Failed to add cards');
+ return false;
+ }
+ } catch (error) {
+ if (error instanceof Error) {
+ this._updateProgressBarError('Ankiconnect error: ' + error.message + '');
+ log.error(error);
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * @param {boolean} init
+ * @param {string} text
+ * @param {number} current
+ * @param {number} end
+ * @param {boolean} visible
+ */
+ _updateProgressBar(init, text, current, end, visible) {
+ if (!visible) {
+ for (const progress of this._progressContainers) { progress.hidden = true; }
+ return;
+ }
+ if (init) {
+ for (const progress of this._progressContainers) {
+ progress.hidden = false;
+ for (const infoLabel of progress.querySelectorAll('.progress-info')) {
+ infoLabel.textContent = text;
+ infoLabel.classList.remove('danger-text');
+ }
+ }
+ }
+ for (const progress of this._progressContainers) {
+ /** @type {NodeListOf<HTMLElement>} */
+ const statusLabels = progress.querySelectorAll('.progress-status');
+ for (const statusLabel of statusLabels) { statusLabel.textContent = ((current / end) * 100).toFixed(0).toString() + '%'; }
+ /** @type {NodeListOf<HTMLElement>} */
+ const progressBars = progress.querySelectorAll('.progress-bar');
+ for (const progressBar of progressBars) { progressBar.style.width = ((current / end) * 100).toString() + '%'; }
+ }
+ }
+
+ /**
+ * @param {string} text
+ */
+ _updateProgressBarError(text) {
+ for (const progress of this._progressContainers) {
+ progress.hidden = false;
+ for (const infoLabel of progress.querySelectorAll('.progress-info')) {
+ infoLabel.textContent = text;
+ infoLabel.classList.add('danger-text');
+ }
+ }
+ }
+
+ /**
+ * @param {HTMLElement} infoNode
+ * @param {import('anki-templates-internal').CreateModeNoTest} mode
+ * @param {boolean} showSuccessResult
+ */
+ async _testNoteData(infoNode, mode, showSuccessResult) {
+ /** @type {Error[]} */
+ const allErrors = [];
+ const text = /** @type {HTMLInputElement} */ (this._renderTextInput).value;
+ let result;
+ try {
+ const noteData = await this._generateNoteData(text, mode, false);
+ result = noteData ? this._fieldsToTSV(noteData.fields) : `No definition found for ${text}`;
+ } catch (e) {
+ allErrors.push(toError(e));
+ }
+
+ /**
+ * @param {Error} e
+ * @returns {string}
+ */
+ const errorToMessageString = (e) => {
+ if (e instanceof ExtensionError) {
+ const v = e.data;
+ if (typeof v === 'object' && v !== null) {
+ const v2 = /** @type {import('core').UnknownObject} */ (v).error;
+ if (v2 instanceof Error) {
+ return v2.message;
+ }
+ }
+ }
+ return e.message;
+ };
+
+ const hasError = allErrors.length > 0;
+ infoNode.hidden = !(showSuccessResult || hasError);
+ if (hasError || !result) {
+ infoNode.textContent = allErrors.map(errorToMessageString).join('\n');
+ } else {
+ infoNode.textContent = showSuccessResult ? result : '';
+ }
+ infoNode.classList.toggle('text-danger', hasError);
+ }
+
+ /**
+ * @param {string} word
+ * @param {import('anki-templates-internal').CreateModeNoTest} mode
+ * @param {boolean} addMedia
+ * @returns {Promise<?import('anki.js').Note>}
+ */
+ async _generateNoteData(word, mode, addMedia) {
+ const optionsContext = this._settingsController.getOptionsContext();
+ const data = await this._getDictionaryEntry(word, optionsContext);
+ if (data === null) {
+ return null;
+ }
+ const {dictionaryEntry, text: sentenceText} = data;
+ const options = await this._settingsController.getOptions();
+ const context = {
+ url: window.location.href,
+ sentence: {
+ text: sentenceText,
+ offset: 0
+ },
+ documentTitle: document.title,
+ query: sentenceText,
+ fullQuery: sentenceText
+ };
+ const template = this._getAnkiTemplate(options);
+ const deckOptionsFields = options.anki.terms.fields;
+ const {general: {resultOutputMode, glossaryLayoutMode, compactTags}} = options;
+ const fields = [];
+ for (const deckField in deckOptionsFields) {
+ if (Object.prototype.hasOwnProperty.call(deckOptionsFields, deckField)) {
+ fields.push([deckField, deckOptionsFields[deckField]]);
+ }
+ }
+ const idleTimeout = (Number.isFinite(options.anki.downloadTimeout) && options.anki.downloadTimeout > 0 ? options.anki.downloadTimeout : null);
+ const mediaOptions = addMedia ? {audio: {sources: options.audio.sources, preferredAudioIndex: null, idleTimeout: idleTimeout}} : null;
+ const requirements = addMedia ? [...this._getDictionaryEntryMedia(dictionaryEntry), {type: 'audio'}] : [];
+ const {note} = await this._ankiNoteBuilder.createNote(/** @type {import('anki-note-builder').CreateNoteDetails} */ ({
+ dictionaryEntry,
+ mode,
+ context,
+ template,
+ deckName: this._activeAnkiDeck,
+ modelName: this._activeNoteType,
+ fields: fields,
+ resultOutputMode,
+ glossaryLayoutMode,
+ compactTags,
+ tags: options.anki.tags,
+ mediaOptions: mediaOptions,
+ requirements: requirements,
+ duplicateScope: options.anki.duplicateScope,
+ duplicateScopeCheckAllModels: options.anki.duplicateScopeCheckAllModels
+ }));
+ return note;
+ }
+
+ /**
+ * @param {string} text
+ * @param {import('settings').OptionsContext} optionsContext
+ * @returns {Promise<?{dictionaryEntry: import('dictionary').TermDictionaryEntry, text: string}>}
+ */
+ async _getDictionaryEntry(text, optionsContext) {
+ const {dictionaryEntries} = await this._settingsController.application.api.termsFind(text, {}, optionsContext);
+ if (dictionaryEntries.length === 0) { return null; }
+
+ return {
+ dictionaryEntry: /** @type {import('dictionary').TermDictionaryEntry} */ (dictionaryEntries[0]),
+ text: text
+ };
+ }
+
+ /**
+ * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry
+ * @returns {Array<object>}
+ */
+ _getDictionaryEntryMedia(dictionaryEntry) {
+ const media = [];
+ const definitions = dictionaryEntry.definitions;
+ for (const definition of definitions) {
+ const paths = this._findAllPaths(definition);
+ for (const path of paths) {
+ media.push({dictionary: definition.dictionary, path: path, type: 'dictionaryMedia'});
+ }
+ }
+ return media;
+ }
+
+ /**
+ * @param {object} obj
+ * @returns {Array<string>}
+ */
+ _findAllPaths(obj) {
+ // @ts-expect-error - Recursive function to find object keys deeply nested in objects and arrays. Essentially impossible to type correctly.
+ // eslint-disable-next-line unicorn/no-array-reduce, @typescript-eslint/no-unsafe-argument
+ return Object.entries(obj).reduce((acc, [key, value]) => (key === 'path' ? [...acc, value] : (typeof value === 'object' ? [...acc, ...this._findAllPaths(value)] : acc)), []);
+ }
+
+ /**
+ * @param {import('settings').ProfileOptions} options
+ * @returns {string}
+ */
+ _getAnkiTemplate(options) {
+ let staticTemplates = options.anki.fieldTemplates;
+ if (typeof staticTemplates !== 'string') { staticTemplates = this._defaultFieldTemplates; }
+ const dynamicTemplates = getDynamicTemplates(options);
+ return staticTemplates + '\n' + dynamicTemplates;
+ }
+
+ /**
+ * @param {Event} e
+ */
+ _onRender(e) {
+ e.preventDefault();
+
+ const infoNode = /** @type {HTMLElement} */ (this._renderResult);
+ infoNode.hidden = true;
+ void this._testNoteData(infoNode, 'term-kanji', true);
+ }
+
+ /** */
+ async _updateExampleText() {
+ this._languageSummaries = await this._application.api.getLanguageSummaries();
+ const options = await this._settingsController.getOptions();
+ const activeLanguage = /** @type {import('language').LanguageSummary} */ (this._languageSummaries.find(({iso}) => iso === options.general.language));
+ this._renderTextInput.lang = options.general.language;
+ this._renderTextInput.value = activeLanguage.exampleText;
+ }
+
+ /**
+ * @param {import('anki.js').NoteFields} noteFields
+ * @returns {string}
+ */
+ _fieldsToTSV(noteFields) {
+ let tsv = '';
+ for (const key in noteFields) {
+ if (Object.prototype.hasOwnProperty.call(noteFields, key)) {
+ tsv += noteFields[key].replaceAll('\t', '&nbsp;&nbsp;&nbsp;') + '\t';
+ }
+ }
+ return tsv;
+ }
+
+ /**
+ * @param {Blob} blob
+ * @param {string} fileName
+ */
+ _saveBlob(blob, fileName) {
+ if (
+ typeof navigator === 'object' && navigator !== null &&
+ // @ts-expect-error - call for legacy Edge
+ typeof navigator.msSaveBlob === 'function' &&
+ // @ts-expect-error - call for legacy Edge
+ navigator.msSaveBlob(blob)
+ ) {
+ return;
+ }
+
+ const blobUrl = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = blobUrl;
+ a.download = fileName;
+ a.rel = 'noopener';
+ a.target = '_blank';
+
+ const revoke = () => {
+ URL.revokeObjectURL(blobUrl);
+ a.href = '';
+ this._settingsExportRevoke = null;
+ };
+ this._settingsExportRevoke = revoke;
+
+ a.dispatchEvent(new MouseEvent('click'));
+ setTimeout(revoke, 60000);
+ }
+}
diff --git a/ext/js/pages/settings/settings-main.js b/ext/js/pages/settings/settings-main.js
index df43df9b..3d3e2352 100644
--- a/ext/js/pages/settings/settings-main.js
+++ b/ext/js/pages/settings/settings-main.js
@@ -21,6 +21,7 @@ import {DocumentFocusController} from '../../dom/document-focus-controller.js';
import {querySelectorNotNull} from '../../dom/query-selector.js';
import {ExtensionContentController} from '../common/extension-content-controller.js';
import {AnkiController} from './anki-controller.js';
+import {AnkiDeckGeneratorController} from './anki-deck-generator-controller.js';
import {AnkiTemplatesController} from './anki-templates-controller.js';
import {AudioController} from './audio-controller.js';
import {BackupController} from './backup-controller.js';
@@ -117,6 +118,9 @@ await Application.main(true, async (application) => {
const ankiController = new AnkiController(settingsController);
preparePromises.push(ankiController.prepare());
+ const ankiDeckGeneratorController = new AnkiDeckGeneratorController(application, settingsController, modalController, ankiController);
+ preparePromises.push(ankiDeckGeneratorController.prepare());
+
const ankiTemplatesController = new AnkiTemplatesController(settingsController, modalController, ankiController);
preparePromises.push(ankiTemplatesController.prepare());
diff --git a/ext/settings.html b/ext/settings.html
index 3e28436b..f32b8415 100644
--- a/ext/settings.html
+++ b/ext/settings.html
@@ -1842,6 +1842,14 @@
<button type="button" class="icon-button"><span class="icon-button-inner"><span class="icon" data-icon="material-right-arrow"></span></span></button>
</div>
</div></div>
+ <div class="settings-item settings-item-button advanced-only" data-modal-action="show,generate-anki-notes" id="generate-anki-notes-main-settings-entry"><div class="settings-item-inner">
+ <div class="settings-item-left">
+ <div class="settings-item-label">Generate Anki Notes (Experimental)&hellip;</div>
+ </div>
+ <div class="settings-item-right open-panel-button-container">
+ <button type="button" class="icon-button"><span class="icon-button-inner"><span class="icon" data-icon="material-right-arrow"></span></span></button>
+ </div>
+ </div></div>
</div>
<!-- Clipboard -->
@@ -3213,6 +3221,103 @@
</div>
</div></div>
+<!-- Generate anki deck modal -->
+<div id="generate-anki-notes-modal" class="modal" tabindex="-1" role="dialog" hidden><div class="modal-content modal-content-full">
+ <div class="modal-header"><div class="modal-title">Anki Note Generator</div></div>
+ <div class="modal-body generate-anki-notes-layout">
+ <div class="generate-anki-notes-info">
+ <p class="warning-text">
+ WARNING: This feature is experimental!
+ </p>
+ <p>
+ Enter a newline separated list of terms below to send notes directly to an Anki deck or export to an Anki deck file in <code>Notes in plain text (.txt)</code> format.
+ </p>
+ <p>
+ For more information check the <a href="https://github.com/themoeway/yomitan/blob/master/docs/anki-integration.md#anki-note-generation">documentation</a>.
+ </p>
+ </div>
+ <textarea autocomplete="off" spellcheck="false" id="generate-anki-notes-textarea" class="no-wrap margin-above" data-tab-action="indent,4"></textarea>
+ <div class="generate-anki-notes-test-container margin-above">
+ <p>
+ Active Anki deck: <code id="generate-anki-notes-active-deck"></code><br>
+ Active Anki model: <code id="generate-anki-notes-active-model"></code>
+ </p>
+ <div class="generate-anki-notes-test-table margin-above">
+ <div class="generate-anki-notes-test-table-header">Test word</div>
+ <div></div>
+ <input type="text" id="generate-anki-notes-test-text-input" class="form-control" value="読め" placeholder="Preview text" autocomplete="off" lang="ja">
+ <button type="button" id="generate-anki-notes-test-render-button">Preview Card</button>
+ </div>
+ </div>
+ <div class="code margin-above" id="generate-anki-notes-render-result"><em>Card render result</em></div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="low-emphasis" id="generate-anki-notes-send-to-anki-button">Send to Anki</button>
+ <button type="button" class="low-emphasis" id="generate-anki-notes-export-button">Export to File</button>
+ <button type="button" data-modal-action="hide">Close</button>
+ </div>
+</div></div>
+
+<div id="generate-anki-notes-send-to-anki-modal" class="modal" tabindex="-1" role="dialog" hidden><div class="modal-content modal-content-small">
+ <div class="modal-header"><div class="modal-title">Send Notes to Anki</div></div>
+ <div class="modal-body">
+ <p>
+ Are you sure you want to send <strong id="generate-anki-notes-send-wordcount"></strong> terms to <code id="generate-anki-notes-active-deck-confirm"></code>? This action cannot be undone.
+ </p>
+ <div class="settings-item margin-above"><div class="settings-item-inner">
+ <div class="settings-item-left">
+ <div class="settings-item-label">
+ Add media to notes
+ </div>
+ <div class="settings-item-description">
+ Adding media increases processing time.
+ </div>
+ </div>
+ <div class="settings-item-right">
+ <label class="toggle"><input type="checkbox" id="generate-anki-notes-add-media"><span class="toggle-body"><span class="toggle-track"></span><span class="toggle-knob"></span></span></label>
+ </div>
+ </div></div>
+ <div class="settings-item margin-above"><div class="settings-item-inner">
+ <div class="settings-item-left">
+ <div class="settings-item-label">
+ Prevent sending duplicate notes
+ </div>
+ <div class="settings-item-description">
+ Checking for duplicates increases processing time.
+ </div>
+ </div>
+ <div class="settings-item-right">
+ <label class="toggle"><input type="checkbox" id="generate-anki-notes-disallow-duplicates"><span class="toggle-body"><span class="toggle-track"></span><span class="toggle-knob"></span></span></label>
+ </div>
+ </div></div>
+ </div>
+ <div class="modal-body-addon generate-anki-notes-progress" hidden>
+ <div class="progress-labels"><div class="progress-info"></div><div class="progress-status"></div></div>
+ <div class="progress-bar-track"><div class="progress-bar"></div></div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="low-emphasis" data-modal-action="hide" id="generate-anki-notes-send-to-anki-cancel-button">Cancel</button>
+ <button type="button" class="danger" id="generate-anki-notes-send-button-confirm">Send to Anki</button>
+ </div>
+</div></div>
+
+<div id="generate-anki-notes-export-modal" class="modal" tabindex="-1" role="dialog" hidden><div class="modal-content modal-content-small">
+ <div class="modal-header"><div class="modal-title">Export Notes to File</div></div>
+ <div class="modal-body">
+ <p>
+ Are you sure you want to export <strong id="generate-anki-notes-export-wordcount"></strong> terms to <code>Notes in plain text (.txt)</code> format?
+ </p>
+ </div>
+ <div class="modal-body-addon generate-anki-notes-progress" hidden>
+ <div class="progress-labels"><div class="progress-info"></div><div class="progress-status"></div></div>
+ <div class="progress-bar-track"><div class="progress-bar"></div></div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="low-emphasis" data-modal-action="hide" id="generate-anki-notes-export-cancel-button">Cancel</button>
+ <button type="button" class="danger" id="generate-anki-notes-export-button-confirm">Export to File</button>
+ </div>
+</div></div>
+
<!-- Import/export modals -->
<div id="settings-import-error-modal" class="modal" tabindex="-1" role="dialog" hidden><div class="modal-content modal-content-small">