/*
* 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 .
*/
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} */
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}
*/
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} */
const statusLabels = progress.querySelectorAll('.progress-status');
for (const statusLabel of statusLabels) { statusLabel.textContent = ((current / end) * 100).toFixed(0).toString() + '%'; }
/** @type {NodeListOf} */
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}
*/
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 dictionaryStylesMap = this._ankiNoteBuilder.getDictionaryStylesMap(options.dictionaries);
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,
dictionaryStylesMap: dictionaryStylesMap,
}));
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