diff options
author | Kuuuube <61125188+Kuuuube@users.noreply.github.com> | 2024-05-11 23:03:27 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-05-12 03:03:27 +0000 |
commit | 486eec15b817d6a87ad98245dea3534545286334 (patch) | |
tree | a5376ab50b4770842915c7338401cb1b2a9e4636 /ext/js/pages/settings/anki-deck-generator-controller.js | |
parent | 9b28e8ecf84027c4b52a015b389c6c1730732ce0 (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
Diffstat (limited to 'ext/js/pages/settings/anki-deck-generator-controller.js')
-rw-r--r-- | ext/js/pages/settings/anki-deck-generator-controller.js | 587 |
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', ' ') + '\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); + } +} |