From 92c4f12f059cf7694f9ceb4c0bbc0eb742d4385e Mon Sep 17 00:00:00 2001 From: kuuuube Date: Wed, 8 May 2024 19:37:19 -0400 Subject: [PATCH 01/58] Add option to bulk generate anki cards --- ext/css/settings.css | 37 +++ .../anki-deck-generator-controller.js | 283 ++++++++++++++++++ ext/js/pages/settings/settings-main.js | 4 + ext/settings.html | 37 +++ 4 files changed, 361 insertions(+) create mode 100644 ext/js/pages/settings/anki-deck-generator-controller.js diff --git a/ext/css/settings.css b/ext/css/settings.css index e6e9442821..5cfed64b6a 100644 --- a/ext/css/settings.css +++ b/ext/css/settings.css @@ -1640,6 +1640,43 @@ code.anki-field-marker { min-height: calc(var(--textarea-line-height) * 5 + var(--textarea-padding) * 2); } +.generate-anki-deck-layout { + display: flex; + flex-flow: column nowrap; +} +.generate-anki-deck-info { + flex: 0 1 auto; +} +.generate-anki-deck-test-input-container { + width: 100%; +} +.generate-anki-deck-test-container { + flex: 0 1 auto; +} +.generate-anki-deck-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-deck-test-table-header { + font-size: var(--font-size-small); +} +#generate-anki-deck-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-deck-test-text-input { + width: 100%; +} + .code { flex: 0 0 auto; width: 100%; 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 0000000000..b8d97c22ba --- /dev/null +++ b/ext/js/pages/settings/anki-deck-generator-controller.js @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * Copyright (C) 2019-2022 Yomichan 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 . + */ + +import {ExtensionError} from '../../core/extension-error.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('./settings-controller.js').SettingsController} settingsController + * @param {import('./modal-controller.js').ModalController} modalController + * @param {import('./anki-controller.js').AnkiController} ankiController + */ + constructor(settingsController, modalController, ankiController) { + /** @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._wordInputTextarea = querySelectorNotNull(document, '#generate-anki-deck-textarea'); + /** @type {HTMLInputElement} */ + this._renderTextInput = querySelectorNotNull(document, '#generate-anki-deck-test-text-input'); + /** @type {HTMLElement} */ + this._renderResult = querySelectorNotNull(document, '#generate-anki-deck-render-result'); + /** @type {HTMLElement} */ + this._activeCardFormat = querySelectorNotNull(document, '#generate-anki-deck-active-card-format'); + /** @type {string} */ + this._notetype = ''; + /** @type {?import('./modal.js').Modal} */ + this._fieldTemplateResetModal = null; + /** @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-deck-test-render-button'); + /** @type {HTMLButtonElement} */ + const generateButton = querySelectorNotNull(document, '#generate-anki-deck-generate-button'); + + testRenderButton.addEventListener('click', this._onRender.bind(this), false); + generateButton.addEventListener('click', this._onGenerate.bind(this), false); + + void this._updateActiveCardFormat(); + } + + // Private + + /** + * + */ + async _updateActiveCardFormat() { + const activeCardFormat = /** @type {HTMLElement} */ (this._activeCardFormat); + const options = await this._settingsController.getOptions(); + this.notetype = options.anki.terms.model; + activeCardFormat.textContent = this.notetype; + } + + /** + * @param {MouseEvent} e + */ + async _onGenerate(e) { + e.preventDefault(); + const words = /** @type {HTMLTextAreaElement} */ (this._wordInputTextarea).value.split('\n'); + let ankiTSV = '#separator:tab\n#html:true\n#notetype column:1\n'; + for (const value of words) { + if (!value) { continue; } + const noteData = await this._generateNoteData(value, 'term-kanji'); + const fieldsTSV = noteData ? this._fieldsToTSV(noteData) : ''; + if (fieldsTSV) { + ankiTSV += this.notetype + '\t'; + ankiTSV += fieldsTSV; + ankiTSV += '\n'; + } + } + const today = new Date(); + const fileName = `anki-deck-${today.getFullYear()}-${today.getMonth()}-${today.getDay()}.txt`; + const blob = new Blob([ankiTSV], {type: 'application/octet-stream'}); + this._saveBlob(blob, fileName); + } + + /** + * @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); + result = noteData ? this._fieldsToTSV(noteData) : `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 + * @returns {Promise} + */ + async _generateNoteData(word, mode) { + 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 {note} = await this._ankiNoteBuilder.createNote(/** @type {import('anki-note-builder').CreateNoteDetails} */ ({ + dictionaryEntry, + mode, + context, + template, + deckName: '', + modelName: '', + fields: fields, + resultOutputMode, + glossaryLayoutMode, + compactTags + })); + return note.fields; + } + + /** + * @param {string} text + * @param {import('settings').OptionsContext} optionsContext + * @returns {Promise} + */ + async _getDictionaryEntry(text, optionsContext) { + const {dictionaryEntries} = await this._settingsController.application.api.termsFind(text, {}, optionsContext); + if (dictionaryEntries.length === 0) { return null; } + + this._cachedDictionaryEntryValue = dictionaryEntries[0]; + return { + dictionaryEntry: /** @type {import('dictionary').TermDictionaryEntry} */ (this._cachedDictionaryEntryValue), + text: text + }; + } + + /** + * @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); + } + + /** + * @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].replace('\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 df43df9bce..7520a4f800 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(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 3e28436b31..3be77e6e83 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -1842,6 +1842,14 @@

Yomitan Settings

+
+
+
Generate Anki Deck…
+
+
+ +
+
@@ -3213,6 +3221,35 @@

Yomitan Settings

+ + +