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 | |
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')
-rw-r--r-- | ext/css/settings.css | 34 | ||||
-rw-r--r-- | ext/js/comm/anki-connect.js | 13 | ||||
-rw-r--r-- | ext/js/pages/settings/anki-controller.js | 16 | ||||
-rw-r--r-- | ext/js/pages/settings/anki-deck-generator-controller.js | 587 | ||||
-rw-r--r-- | ext/js/pages/settings/settings-main.js | 4 | ||||
-rw-r--r-- | ext/settings.html | 105 |
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', ' ') + '\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)…</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"> |