yomichan/ext/js/display/display-anki.js
toasted-nutbread 310303ca1a
Audio download timeout (#2187)
* Add support for an idle timeout when downloading audio

* Update eslint rules

* Pass idleTimeout to the downloader from DisplayAnki

* Add anki.downloadTimeout setting

* Update tests

* Assign _audioDownloadIdleTimeout using settings

* Show info about cancelled downloads

* Handle Firefox bug

* Improve audio errors

* Refactor

* Move functions to RequestBuilder
2022-08-20 11:17:24 -04:00

725 lines
27 KiB
JavaScript

/*
* Copyright (C) 2021-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 <https://www.gnu.org/licenses/>.
*/
/* global
* AnkiNoteBuilder
* AnkiUtil
* PopupMenu
*/
class DisplayAnki {
constructor(display, displayAudio, japaneseUtil) {
this._display = display;
this._displayAudio = displayAudio;
this._ankiFieldTemplates = null;
this._ankiFieldTemplatesDefault = null;
this._ankiNoteBuilder = new AnkiNoteBuilder({japaneseUtil});
this._errorNotification = null;
this._errorNotificationEventListeners = null;
this._tagsNotification = null;
this._updateAdderButtonsPromise = Promise.resolve();
this._updateDictionaryEntryDetailsToken = null;
this._eventListeners = new EventListenerCollection();
this._dictionaryEntryDetails = null;
this._noteContext = null;
this._checkForDuplicates = false;
this._suspendNewCards = false;
this._compactTags = false;
this._resultOutputMode = 'split';
this._glossaryLayoutMode = 'default';
this._displayTags = 'never';
this._duplicateScope = 'collection';
this._duplicateScopeCheckAllModels = false;
this._screenshotFormat = 'png';
this._screenshotQuality = 100;
this._scanLength = 10;
this._noteGuiMode = 'browse';
this._audioDownloadIdleTimeout = null;
this._noteTags = [];
this._modeOptions = new Map();
this._dictionaryEntryTypeModeMap = new Map([
['kanji', ['kanji']],
['term', ['term-kanji', 'term-kana']]
]);
this._menuContainer = document.querySelector('#popup-menus');
this._onShowTagsBind = this._onShowTags.bind(this);
this._onNoteAddBind = this._onNoteAdd.bind(this);
this._onViewNoteButtonClickBind = this._onViewNoteButtonClick.bind(this);
this._onViewNoteButtonContextMenuBind = this._onViewNoteButtonContextMenu.bind(this);
this._onViewNoteButtonMenuCloseBind = this._onViewNoteButtonMenuClose.bind(this);
}
prepare() {
this._noteContext = this._getNoteContext();
this._display.hotkeyHandler.registerActions([
['addNoteKanji', () => { this._tryAddAnkiNoteForSelectedEntry('kanji'); }],
['addNoteTermKanji', () => { this._tryAddAnkiNoteForSelectedEntry('term-kanji'); }],
['addNoteTermKana', () => { this._tryAddAnkiNoteForSelectedEntry('term-kana'); }],
['viewNote', this._viewNoteForSelectedEntry.bind(this)]
]);
this._display.on('optionsUpdated', this._onOptionsUpdated.bind(this));
this._display.on('contentClear', this._onContentClear.bind(this));
this._display.on('contentUpdateStart', this._onContentUpdateStart.bind(this));
this._display.on('contentUpdateEntry', this._onContentUpdateEntry.bind(this));
this._display.on('contentUpdateComplete', this._onContentUpdateComplete.bind(this));
this._display.on('logDictionaryEntryData', this._onLogDictionaryEntryData.bind(this));
}
async getLogData(dictionaryEntry) {
const result = {};
// Anki note data
let ankiNoteData;
let ankiNoteDataException;
try {
ankiNoteData = await this._ankiNoteBuilder.getRenderingData({
dictionaryEntry,
mode: 'test',
context: this._noteContext,
resultOutputMode: this._resultOutputMode,
glossaryLayoutMode: this._glossaryLayoutMode,
compactTags: this._compactTags,
marker: 'test'
});
} catch (e) {
ankiNoteDataException = e;
}
result.ankiNoteData = ankiNoteData;
if (typeof ankiNoteDataException !== 'undefined') {
result.ankiNoteDataException = ankiNoteDataException;
}
// Anki notes
const ankiNotes = [];
const modes = this._getModes(dictionaryEntry.type === 'term');
for (const mode of modes) {
let note;
let errors;
let requirements;
try {
({note: note, errors, requirements} = await this._createNote(dictionaryEntry, mode, []));
} catch (e) {
errors = [e];
}
const entry = {mode, note};
if (Array.isArray(errors) && errors.length > 0) {
entry.errors = errors;
}
if (Array.isArray(requirements) && requirements.length > 0) {
entry.requirements = requirements;
}
ankiNotes.push(entry);
}
result.ankiNotes = ankiNotes;
return result;
}
// Private
_onOptionsUpdated({options}) {
const {
general: {resultOutputMode, glossaryLayoutMode, compactTags},
anki: {
tags,
duplicateScope,
duplicateScopeCheckAllModels,
suspendNewCards,
checkForDuplicates,
displayTags,
kanji,
terms,
noteGuiMode,
screenshot: {format, quality},
downloadTimeout
},
scanning: {length: scanLength}
} = options;
this._checkForDuplicates = checkForDuplicates;
this._suspendNewCards = suspendNewCards;
this._compactTags = compactTags;
this._resultOutputMode = resultOutputMode;
this._glossaryLayoutMode = glossaryLayoutMode;
this._displayTags = displayTags;
this._duplicateScope = duplicateScope;
this._duplicateScopeCheckAllModels = duplicateScopeCheckAllModels;
this._screenshotFormat = format;
this._screenshotQuality = quality;
this._scanLength = scanLength;
this._noteGuiMode = noteGuiMode;
this._noteTags = [...tags];
this._audioDownloadIdleTimeout = (Number.isFinite(downloadTimeout) && downloadTimeout > 0 ? downloadTimeout : null);
this._modeOptions.clear();
this._modeOptions.set('kanji', kanji);
this._modeOptions.set('term-kanji', terms);
this._modeOptions.set('term-kana', terms);
this._updateAnkiFieldTemplates(options);
}
_onContentClear() {
this._updateDictionaryEntryDetailsToken = null;
this._dictionaryEntryDetails = null;
this._hideErrorNotification(false);
}
_onContentUpdateStart() {
this._noteContext = this._getNoteContext();
}
_onContentUpdateEntry({element}) {
const eventListeners = this._eventListeners;
for (const node of element.querySelectorAll('.action-button[data-action=view-tags]')) {
eventListeners.addEventListener(node, 'click', this._onShowTagsBind);
}
for (const node of element.querySelectorAll('.action-button[data-action=add-note]')) {
eventListeners.addEventListener(node, 'click', this._onNoteAddBind);
}
for (const node of element.querySelectorAll('.action-button[data-action=view-note]')) {
eventListeners.addEventListener(node, 'click', this._onViewNoteButtonClickBind);
eventListeners.addEventListener(node, 'contextmenu', this._onViewNoteButtonContextMenuBind);
eventListeners.addEventListener(node, 'menuClose', this._onViewNoteButtonMenuCloseBind);
}
}
_onContentUpdateComplete() {
this._updateDictionaryEntryDetails();
}
_onLogDictionaryEntryData({dictionaryEntry, promises}) {
promises.push(this.getLogData(dictionaryEntry));
}
_onNoteAdd(e) {
e.preventDefault();
const node = e.currentTarget;
const index = this._display.getElementDictionaryEntryIndex(node);
this._addAnkiNote(index, node.dataset.mode);
}
_onShowTags(e) {
e.preventDefault();
const tags = e.currentTarget.title;
this._showTagsNotification(tags);
}
_adderButtonFind(index, mode) {
const entry = this._getEntry(index);
return entry !== null ? entry.querySelector(`.action-button[data-action=add-note][data-mode="${mode}"]`) : null;
}
_tagsIndicatorFind(index) {
const entry = this._getEntry(index);
return entry !== null ? entry.querySelector('.action-button[data-action=view-tags]') : null;
}
_getEntry(index) {
const entries = this._display.dictionaryEntryNodes;
return index >= 0 && index < entries.length ? entries[index] : null;
}
_getNoteContext() {
const {state} = this._display.history;
let {documentTitle, url, sentence} = (isObject(state) ? state : {});
if (typeof documentTitle !== 'string') {
documentTitle = document.title;
}
if (typeof url !== 'string') {
url = window.location.href;
}
const {query, fullQuery, queryOffset} = this._display;
sentence = this._getValidSentenceData(sentence, fullQuery, queryOffset);
return {
url,
sentence,
documentTitle,
query,
fullQuery
};
}
async _updateDictionaryEntryDetails() {
const {dictionaryEntries} = this._display;
const token = {};
this._updateDictionaryEntryDetailsToken = token;
if (this._updateAdderButtonsPromise !== null) {
await this._updateAdderButtonsPromise;
}
if (this._updateDictionaryEntryDetailsToken !== token) { return; }
const {promise, resolve} = deferPromise();
try {
this._updateAdderButtonsPromise = promise;
const dictionaryEntryDetails = await this._getDictionaryEntryDetails(dictionaryEntries);
if (this._updateDictionaryEntryDetailsToken !== token) { return; }
this._dictionaryEntryDetails = dictionaryEntryDetails;
this._updateAdderButtons();
} finally {
resolve();
if (this._updateAdderButtonsPromise === promise) {
this._updateAdderButtonsPromise = null;
}
}
}
_updateAdderButtons() {
const displayTags = this._displayTags;
const dictionaryEntryDetails = this._dictionaryEntryDetails;
for (let i = 0, ii = dictionaryEntryDetails.length; i < ii; ++i) {
let allNoteIds = null;
for (const {mode, canAdd, noteIds, noteInfos, ankiError} of dictionaryEntryDetails[i].modeMap.values()) {
const button = this._adderButtonFind(i, mode);
if (button !== null) {
button.disabled = !canAdd;
button.hidden = (ankiError !== null);
}
if (Array.isArray(noteIds) && noteIds.length > 0) {
if (allNoteIds === null) { allNoteIds = new Set(); }
for (const noteId of noteIds) { allNoteIds.add(noteId); }
}
if (displayTags !== 'never' && Array.isArray(noteInfos)) {
this._setupTagsIndicator(i, noteInfos);
}
}
this._updateViewNoteButton(i, allNoteIds !== null ? [...allNoteIds] : [], false);
}
}
_setupTagsIndicator(i, noteInfos) {
const tagsIndicator = this._tagsIndicatorFind(i);
if (tagsIndicator === null) {
return;
}
const displayTags = new Set();
for (const {tags} of noteInfos) {
for (const tag of tags) {
displayTags.add(tag);
}
}
if (this._displayTags === 'non-standard') {
for (const tag of this._noteTags) {
displayTags.delete(tag);
}
}
if (displayTags.size > 0) {
tagsIndicator.disabled = false;
tagsIndicator.hidden = false;
tagsIndicator.title = `Card tags: ${[...displayTags].join(', ')}`;
}
}
_showTagsNotification(message) {
if (this._tagsNotification === null) {
this._tagsNotification = this._display.createNotification(true);
}
this._tagsNotification.setContent(message);
this._tagsNotification.open();
}
_tryAddAnkiNoteForSelectedEntry(mode) {
const index = this._display.selectedIndex;
this._addAnkiNote(index, mode);
}
async _addAnkiNote(dictionaryEntryIndex, mode) {
const dictionaryEntries = this._display.dictionaryEntries;
const dictionaryEntryDetails = this._dictionaryEntryDetails;
if (!(
dictionaryEntryDetails !== null &&
dictionaryEntryIndex >= 0 &&
dictionaryEntryIndex < dictionaryEntries.length &&
dictionaryEntryIndex < dictionaryEntryDetails.length
)) {
return;
}
const dictionaryEntry = dictionaryEntries[dictionaryEntryIndex];
const details = dictionaryEntryDetails[dictionaryEntryIndex].modeMap.get(mode);
if (typeof details === 'undefined') { return; }
const {requirements} = details;
const button = this._adderButtonFind(dictionaryEntryIndex, mode);
if (button === null || button.disabled) { return; }
this._hideErrorNotification(true);
const allErrors = [];
const progressIndicatorVisible = this._display.progressIndicatorVisible;
const overrideToken = progressIndicatorVisible.setOverride(true);
try {
const {note, errors, requirements: outputRequirements} = await this._createNote(dictionaryEntry, mode, requirements);
allErrors.push(...errors);
const error = this._getAddNoteRequirementsError(requirements, outputRequirements);
if (error !== null) { allErrors.push(error); }
let noteId = null;
let addNoteOkay = false;
try {
noteId = await yomichan.api.addAnkiNote(note);
addNoteOkay = true;
} catch (e) {
allErrors.length = 0;
allErrors.push(e);
}
if (addNoteOkay) {
if (noteId === null) {
allErrors.push(new Error('Note could not be added'));
} else {
if (this._suspendNewCards) {
try {
await yomichan.api.suspendAnkiCardsForNote(noteId);
} catch (e) {
allErrors.push(e);
}
}
button.disabled = true;
this._updateViewNoteButton(dictionaryEntryIndex, [noteId], true);
}
}
} catch (e) {
allErrors.push(e);
} finally {
progressIndicatorVisible.clearOverride(overrideToken);
}
if (allErrors.length > 0) {
this._showErrorNotification(allErrors);
} else {
this._hideErrorNotification(true);
}
}
_getAddNoteRequirementsError(requirements, outputRequirements) {
if (outputRequirements.length === 0) { return null; }
let count = 0;
for (const requirement of outputRequirements) {
const {type} = requirement;
switch (type) {
case 'audio':
case 'clipboardImage':
break;
default:
++count;
break;
}
}
if (count === 0) { return null; }
const error = new Error('The created card may not have some content');
error.requirements = requirements;
error.outputRequirements = outputRequirements;
return error;
}
_showErrorNotification(errors, displayErrors) {
if (typeof displayErrors === 'undefined') { displayErrors = errors; }
if (this._errorNotificationEventListeners !== null) {
this._errorNotificationEventListeners.removeAllEventListeners();
}
if (this._errorNotification === null) {
this._errorNotification = this._display.createNotification(false);
this._errorNotificationEventListeners = new EventListenerCollection();
}
const content = this._display.displayGenerator.createAnkiNoteErrorsNotificationContent(displayErrors);
for (const node of content.querySelectorAll('.anki-note-error-log-link')) {
this._errorNotificationEventListeners.addEventListener(node, 'click', () => {
console.log({ankiNoteErrors: errors});
}, false);
}
this._errorNotification.setContent(content);
this._errorNotification.open();
}
_hideErrorNotification(animate) {
if (this._errorNotification === null) { return; }
this._errorNotification.close(animate);
this._errorNotificationEventListeners.removeAllEventListeners();
}
async _updateAnkiFieldTemplates(options) {
this._ankiFieldTemplates = await this._getAnkiFieldTemplates(options);
}
async _getAnkiFieldTemplates(options) {
let templates = options.anki.fieldTemplates;
if (typeof templates === 'string') { return templates; }
templates = this._ankiFieldTemplatesDefault;
if (typeof templates === 'string') { return templates; }
templates = await yomichan.api.getDefaultAnkiFieldTemplates();
this._ankiFieldTemplatesDefault = templates;
return templates;
}
async _getDictionaryEntryDetails(dictionaryEntries) {
const forceCanAddValue = (this._checkForDuplicates ? null : true);
const fetchAdditionalInfo = (this._displayTags !== 'never');
const notePromises = [];
const noteTargets = [];
for (let i = 0, ii = dictionaryEntries.length; i < ii; ++i) {
const dictionaryEntry = dictionaryEntries[i];
const {type} = dictionaryEntry;
const modes = this._dictionaryEntryTypeModeMap.get(type);
if (typeof modes === 'undefined') { continue; }
for (const mode of modes) {
const notePromise = this._createNote(dictionaryEntry, mode, []);
notePromises.push(notePromise);
noteTargets.push({index: i, mode});
}
}
const noteInfoList = await Promise.all(notePromises);
const notes = noteInfoList.map(({note}) => note);
let infos;
let ankiError = null;
try {
if (forceCanAddValue !== null) {
if (!await yomichan.api.isAnkiConnected()) {
throw new Error('Anki not connected');
}
infos = this._getAnkiNoteInfoForceValue(notes, forceCanAddValue);
} else {
infos = await yomichan.api.getAnkiNoteInfo(notes, fetchAdditionalInfo);
}
} catch (e) {
infos = this._getAnkiNoteInfoForceValue(notes, false);
ankiError = e;
}
const results = [];
for (let i = 0, ii = dictionaryEntries.length; i < ii; ++i) {
results.push({
modeMap: new Map()
});
}
for (let i = 0, ii = noteInfoList.length; i < ii; ++i) {
const {note, errors, requirements} = noteInfoList[i];
const {canAdd, valid, noteIds, noteInfos} = infos[i];
const {mode, index} = noteTargets[i];
results[index].modeMap.set(mode, {mode, note, errors, requirements, canAdd, valid, noteIds, noteInfos, ankiError});
}
return results;
}
_getAnkiNoteInfoForceValue(notes, canAdd) {
const results = [];
for (const note of notes) {
const valid = AnkiUtil.isNoteDataValid(note);
results.push({canAdd, valid, noteIds: null});
}
return results;
}
async _createNote(dictionaryEntry, mode, requirements) {
const context = this._noteContext;
const modeOptions = this._modeOptions.get(mode);
if (typeof modeOptions === 'undefined') { throw new Error(`Unsupported note type: ${mode}`); }
const template = this._ankiFieldTemplates;
const {deck: deckName, model: modelName} = modeOptions;
const fields = Object.entries(modeOptions.fields);
const contentOrigin = this._display.getContentOrigin();
const details = this._ankiNoteBuilder.getDictionaryEntryDetailsForNote(dictionaryEntry);
const audioDetails = this._getAnkiNoteMediaAudioDetails(details);
const optionsContext = this._display.getOptionsContext();
const {note, errors, requirements: outputRequirements} = await this._ankiNoteBuilder.createNote({
dictionaryEntry,
mode,
context,
template,
deckName,
modelName,
fields,
tags: this._noteTags,
checkForDuplicates: this._checkForDuplicates,
duplicateScope: this._duplicateScope,
duplicateScopeCheckAllModels: this._duplicateScopeCheckAllModels,
resultOutputMode: this._resultOutputMode,
glossaryLayoutMode: this._glossaryLayoutMode,
compactTags: this._compactTags,
mediaOptions: {
audio: audioDetails,
screenshot: {
format: this._screenshotFormat,
quality: this._screenshotQuality,
contentOrigin
},
textParsing: {
optionsContext,
scanLength: this._scanLength
}
},
requirements
});
return {note, errors, requirements: outputRequirements};
}
_getModes(isTerms) {
return isTerms ? ['term-kanji', 'term-kana'] : ['kanji'];
}
_getValidSentenceData(sentence, fallback, fallbackOffset) {
let {text, offset} = (isObject(sentence) ? sentence : {});
if (typeof text !== 'string') {
text = fallback;
offset = fallbackOffset;
} else {
if (typeof offset !== 'number') { offset = 0; }
}
return {text, offset};
}
_getAnkiNoteMediaAudioDetails(details) {
if (details.type !== 'term') { return null; }
const {sources, preferredAudioIndex} = this._displayAudio.getAnkiNoteMediaAudioDetails(details.term, details.reading);
return {sources, preferredAudioIndex, idleTimeout: this._audioDownloadIdleTimeout};
}
// View note functions
_onViewNoteButtonClick(e) {
e.preventDefault();
if (e.shiftKey) {
this._showViewNoteMenu(e.currentTarget);
} else {
this._viewNote(e.currentTarget);
}
}
_onViewNoteButtonContextMenu(e) {
e.preventDefault();
this._showViewNoteMenu(e.currentTarget);
}
_onViewNoteButtonMenuClose(e) {
const {detail: {action, item}} = e;
switch (action) {
case 'viewNote':
this._viewNote(item);
break;
}
}
_updateViewNoteButton(index, noteIds, prepend) {
const button = this._getViewNoteButton(index);
if (button === null) { return; }
if (prepend) {
const currentNoteIds = button.dataset.noteIds;
if (typeof currentNoteIds === 'string' && currentNoteIds.length > 0) {
noteIds = [...noteIds, currentNoteIds.split(' ')];
}
}
const disabled = (noteIds.length === 0);
button.disabled = disabled;
button.hidden = disabled;
button.dataset.noteIds = noteIds.join(' ');
const badge = button.querySelector('.action-button-badge');
if (badge !== null) {
const badgeData = badge.dataset;
if (noteIds.length > 1) {
badgeData.icon = 'plus-thick';
badgeData.hidden = false;
} else {
delete badgeData.icon;
badgeData.hidden = true;
}
}
}
async _viewNote(node) {
const noteIds = this._getNodeNoteIds(node);
if (noteIds.length === 0) { return; }
try {
await yomichan.api.noteView(noteIds[0], this._noteGuiMode, false);
} catch (e) {
const displayErrors = (
e.message === 'Mode not supported' ?
[this._display.displayGenerator.instantiateTemplateFragment('footer-notification-anki-view-note-error')] :
void 0
);
this._showErrorNotification([e], displayErrors);
return;
}
}
_showViewNoteMenu(node) {
const noteIds = this._getNodeNoteIds(node);
if (noteIds.length === 0) { return; }
const menuContainerNode = this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu');
const menuBodyNode = menuContainerNode.querySelector('.popup-menu-body');
for (let i = 0, ii = noteIds.length; i < ii; ++i) {
const noteId = noteIds[i];
const item = this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu-item');
item.querySelector('.popup-menu-item-label').textContent = `Note ${i + 1}: ${noteId}`;
item.dataset.menuAction = 'viewNote';
item.dataset.noteIds = `${noteId}`;
menuBodyNode.appendChild(item);
}
this._menuContainer.appendChild(menuContainerNode);
const popupMenu = new PopupMenu(node, menuContainerNode);
popupMenu.prepare();
}
_getNodeNoteIds(node) {
const {noteIds} = node.dataset;
const results = [];
if (typeof noteIds === 'string' && noteIds.length > 0) {
for (const noteId of noteIds.split(' ')) {
const noteIdInt = Number.parseInt(noteId, 10);
if (Number.isFinite(noteIdInt)) {
results.push(noteIdInt);
}
}
}
return results;
}
_getViewNoteButton(index) {
const entry = this._getEntry(index);
return entry !== null ? entry.querySelector('.action-button[data-action=view-note]') : null;
}
_viewNoteForSelectedEntry() {
const index = this._display.selectedIndex;
const button = this._getViewNoteButton(index);
if (button !== null) {
this._viewNote(button);
}
}
}