summaryrefslogtreecommitdiff
path: root/ext
diff options
context:
space:
mode:
Diffstat (limited to 'ext')
-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
6 files changed, 759 insertions, 0 deletions
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">