summaryrefslogtreecommitdiff
path: root/ext/js/pages/settings/anki-deck-generator-controller.js
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/pages/settings/anki-deck-generator-controller.js')
-rw-r--r--ext/js/pages/settings/anki-deck-generator-controller.js587
1 files changed, 587 insertions, 0 deletions
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);
+ }
+}