diff options
Diffstat (limited to 'ext/js/display/display-anki.js')
-rw-r--r-- | ext/js/display/display-anki.js | 582 |
1 files changed, 582 insertions, 0 deletions
diff --git a/ext/js/display/display-anki.js b/ext/js/display/display-anki.js new file mode 100644 index 00000000..8dd94214 --- /dev/null +++ b/ext/js/display/display-anki.js @@ -0,0 +1,582 @@ +/* + * Copyright (C) 2021 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 + * DisplayNotification + */ + +class DisplayAnki { + constructor(display) { + this._display = display; + this._ankiFieldTemplates = null; + this._ankiFieldTemplatesDefault = null; + this._ankiNoteBuilder = new AnkiNoteBuilder(); + this._ankiNoteNotification = null; + this._ankiNoteNotificationEventListeners = null; + this._ankiTagNotification = null; + this._updateAdderButtonsPromise = Promise.resolve(); + this._updateAdderButtonsToken = null; + this._eventListeners = new EventListenerCollection(); + this._checkForDuplicates = false; + this._suspendNewCards = false; + this._compactTags = false; + this._resultOutputMode = 'split'; + this._glossaryLayoutMode = 'default'; + this._displayTags = 'never'; + this._duplicateScope = 'collection'; + this._screenshotFormat = 'png'; + this._screenshotQuality = 100; + this._noteTags = []; + this._modeOptions = new Map(); + this._onShowTagsBind = this._onShowTags.bind(this); + this._onNoteAddBind = this._onNoteAdd.bind(this); + this._onNoteViewBind = this._onNoteView.bind(this); + } + + prepare() { + this._display.hotkeyHandler.registerActions([ + ['addNoteKanji', () => { this._tryAddAnkiNoteForSelectedEntry('kanji'); }], + ['addNoteTermKanji', () => { this._tryAddAnkiNoteForSelectedEntry('term-kanji'); }], + ['addNoteTermKana', () => { this._tryAddAnkiNoteForSelectedEntry('term-kana'); }], + ['viewNote', () => { this._tryViewAnkiNoteForSelectedEntry(); }] + ]); + this._display.on('optionsUpdated', this._onOptionsUpdated.bind(this)); + } + + cleanupEntries() { + this._updateAdderButtonsToken = null; + this._hideAnkiNoteErrors(false); + } + + setupEntry(entry) { + this._addMultipleEventListeners(entry, '.action-view-tags', 'click', this._onShowTagsBind); + this._addMultipleEventListeners(entry, '.action-add-note', 'click', this._onNoteAddBind); + this._addMultipleEventListeners(entry, '.action-view-note', 'click', this._onNoteViewBind); + } + + setupEntriesComplete(isTerms, dictionaryEntries) { // TODO : Don't pass (isTerms, dictionaryEntries) + this._updateAdderButtons(isTerms, dictionaryEntries); + } + + async getLogData(dictionaryEntry) { + const result = {}; + + // Anki note data + let ankiNoteData; + let ankiNoteDataException; + try { + const context = this._getNoteContext(); + ankiNoteData = await this._ankiNoteBuilder.getRenderingData({ + dictionaryEntry, + mode: 'test', + context, + resultOutputMode: this.resultOutputMode, + glossaryLayoutMode: this._glossaryLayoutMode, + compactTags: this._compactTags, + injectedMedia: null, + 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; + try { + const noteContext = this._getNoteContext(); + ({note: note, errors} = await this._createNote(dictionaryEntry, mode, noteContext, false)); + } catch (e) { + errors = [e]; + } + const entry = {mode, note}; + if (Array.isArray(errors) && errors.length > 0) { + entry.errors = errors; + } + ankiNotes.push(entry); + } + result.ankiNotes = ankiNotes; + + return result; + } + + // Private + + _onOptionsUpdated({options}) { + const { + general: {resultOutputMode, glossaryLayoutMode, compactTags}, + anki: {tags, duplicateScope, suspendNewCards, checkForDuplicates, displayTags, kanji, terms, screenshot: {format, quality}} + } = options; + + this._checkForDuplicates = checkForDuplicates; + this._suspendNewCards = suspendNewCards; + this._compactTags = compactTags; + this._resultOutputMode = resultOutputMode; + this._glossaryLayoutMode = glossaryLayoutMode; + this._displayTags = displayTags; + this._duplicateScope = duplicateScope; + this._screenshotFormat = format; + this._screenshotQuality = quality; + this._noteTags = [...tags]; + this._modeOptions.clear(); + this._modeOptions.set('kanji', kanji); + this._modeOptions.set('term-kanji', terms); + this._modeOptions.set('term-kana', terms); + + this._updateAnkiFieldTemplates(options); + } + + _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._showAnkiTagsNotification(tags); + } + + _onNoteView(e) { + e.preventDefault(); + const link = e.currentTarget; + yomichan.api.noteView(link.dataset.noteId); + } + + _addMultipleEventListeners(container, selector, ...args) { + for (const node of container.querySelectorAll(selector)) { + this._eventListeners.addEventListener(node, ...args); + } + } + + _adderButtonFind(index, mode) { + const entry = this._getEntry(index); + return entry !== null ? entry.querySelector(`.action-add-note[data-mode="${mode}"]`) : null; + } + + _tagsIndicatorFind(index) { + const entry = this._getEntry(index); + return entry !== null ? entry.querySelector('.action-view-tags') : null; + } + + _viewerButtonFind(index) { + const entry = this._getEntry(index); + return entry !== null ? entry.querySelector('.action-view-note') : null; + } + + _getEntry(index) { + const entries = this._display.dictionaryEntryNodes; + return index >= 0 && index < entries.length ? entries[index] : null; + } + + _viewerButtonShow(index, noteId) { + const viewerButton = this._viewerButtonFind(index); + if (viewerButton === null) { + return; + } + viewerButton.disabled = false; + viewerButton.hidden = false; + viewerButton.dataset.noteId = noteId; + } + + _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; + } + sentence = this._getValidSentenceData(sentence); + return { + url, + sentence, + documentTitle, + query: this._display.query, + fullQuery: this._display.fullQuery + }; + } + + _getDictionaryEntryDetailsForNote(dictionaryEntry) { + const {type} = dictionaryEntry; + if (type === 'kanji') { + const {character} = dictionaryEntry; + return {type, character}; + } + + const {headwords} = dictionaryEntry; + let bestIndex = -1; + for (let i = 0, ii = headwords.length; i < ii; ++i) { + const {term, reading, sources} = headwords[i]; + for (const {deinflectedText} of sources) { + if (term === deinflectedText) { + bestIndex = i; + i = ii; + break; + } else if (reading === deinflectedText && bestIndex < 0) { + bestIndex = i; + break; + } + } + } + + const {term, reading} = headwords[Math.max(0, bestIndex)]; + return {type, term, reading}; + } + + async _updateAdderButtons(isTerms, dictionaryEntries) { + const token = {}; + this._updateAdderButtonsToken = token; + await this._updateAdderButtonsPromise; + if (this._updateAdderButtonsToken !== token) { return; } + + const {promise, resolve} = deferPromise(); + try { + this._updateAdderButtonsPromise = promise; + + const modes = this._getModes(isTerms); + let states; + try { + const noteContext = this._getNoteContext(); + states = await this._areDictionaryEntriesAddable( + dictionaryEntries, + modes, + noteContext, + this._checkForDuplicates ? null : true, + this._displayTags !== 'never' + ); + } catch (e) { + return; + } + + if (this._updateAdderButtonsToken !== token) { return; } + + this._updateAdderButtons2(states, modes); + } finally { + resolve(); + } + } + + _updateAdderButtons2(states, modes) { + const displayTags = this._displayTags; + for (let i = 0, ii = states.length; i < ii; ++i) { + const infos = states[i]; + let noteId = null; + for (let j = 0, jj = infos.length; j < jj; ++j) { + const {canAdd, noteIds, noteInfos} = infos[j]; + const mode = modes[j]; + const button = this._adderButtonFind(i, mode); + if (button === null) { + continue; + } + + if (Array.isArray(noteIds) && noteIds.length > 0) { + noteId = noteIds[0]; + } + button.disabled = !canAdd; + button.hidden = false; + + if (displayTags !== 'never' && Array.isArray(noteInfos)) { + this._setupTagsIndicator(i, noteInfos); + } + } + if (noteId !== null) { + this._viewerButtonShow(i, noteId); + } + } + } + + _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(', ')}`; + } + } + + _showAnkiTagsNotification(message) { + if (this._ankiTagNotification === null) { + const node = this._display.displayGenerator.createEmptyFooterNotification(); + node.classList.add('click-scannable'); + this._ankiTagNotification = new DisplayNotification(this._display.notificationContainer, node); + } + + this._ankiTagNotification.setContent(message); + this._ankiTagNotification.open(); + } + + + _tryAddAnkiNoteForSelectedEntry(mode) { + const index = this._display.selectedIndex; + this._addAnkiNote(index, mode); + } + + _tryViewAnkiNoteForSelectedEntry() { + const index = this._display.selectedIndex; + const button = this._viewerButtonFind(index); + if (button !== null && !button.disabled) { + yomichan.api.noteView(button.dataset.noteId); + } + } + + async _addAnkiNote(dictionaryEntryIndex, mode) { + const dictionaryEntries = this._display.dictionaryEntries; + if (dictionaryEntryIndex < 0 || dictionaryEntryIndex >= dictionaryEntries.length) { return; } + const dictionaryEntry = dictionaryEntries[dictionaryEntryIndex]; + + const button = this._adderButtonFind(dictionaryEntryIndex, mode); + if (button === null || button.disabled) { return; } + + this._hideAnkiNoteErrors(true); + + const allErrors = []; + const progressIndicatorVisible = this._display.progressIndicatorVisible; + const overrideToken = progressIndicatorVisible.setOverride(true); + try { + const noteContext = this._getNoteContext(); + const {note, errors} = await this._createNote(dictionaryEntry, mode, noteContext, true); + allErrors.push(...errors); + + 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._viewerButtonShow(dictionaryEntryIndex, noteId); + } + } + } catch (e) { + allErrors.push(e); + } finally { + progressIndicatorVisible.clearOverride(overrideToken); + } + + if (allErrors.length > 0) { + this._showAnkiNoteErrors(allErrors); + } else { + this._hideAnkiNoteErrors(true); + } + } + + _showAnkiNoteErrors(errors) { + if (this._ankiNoteNotificationEventListeners !== null) { + this._ankiNoteNotificationEventListeners.removeAllEventListeners(); + } + + if (this._ankiNoteNotification === null) { + const node = this._display.displayGenerator.createEmptyFooterNotification(); + this._ankiNoteNotification = new DisplayNotification(this._display.notificationContainer, node); + this._ankiNoteNotificationEventListeners = new EventListenerCollection(); + } + + const content = this._display.displayGenerator.createAnkiNoteErrorsNotificationContent(errors); + for (const node of content.querySelectorAll('.anki-note-error-log-link')) { + this._ankiNoteNotificationEventListeners.addEventListener(node, 'click', () => { + console.log({ankiNoteErrors: errors}); + }, false); + } + + this._ankiNoteNotification.setContent(content); + this._ankiNoteNotification.open(); + } + + _hideAnkiNoteErrors(animate) { + if (this._ankiNoteNotification === null) { return; } + this._ankiNoteNotification.close(animate); + this._ankiNoteNotificationEventListeners.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 _areDictionaryEntriesAddable(dictionaryEntries, modes, context, forceCanAddValue, fetchAdditionalInfo) { + const modeCount = modes.length; + const notePromises = []; + for (const dictionaryEntry of dictionaryEntries) { + for (const mode of modes) { + const notePromise = this._createNote(dictionaryEntry, mode, context, false); + notePromises.push(notePromise); + } + } + const notes = (await Promise.all(notePromises)).map(({note}) => note); + + let infos; + 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); + } + + const results = []; + for (let i = 0, ii = infos.length; i < ii; i += modeCount) { + results.push(infos.slice(i, i + modeCount)); + } + 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, context, injectMedia) { + 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 errors = []; + let injectedMedia = null; + if (injectMedia) { + let errors2; + ({result: injectedMedia, errors: errors2} = await this._injectAnkiNoteMedia(dictionaryEntry, fields)); + for (const error of errors2) { + errors.push(deserializeError(error)); + } + } + + const {note, errors: createNoteErrors} = await this._ankiNoteBuilder.createNote({ + dictionaryEntry, + mode, + context, + template, + deckName, + modelName, + fields, + tags: this._noteTags, + checkForDuplicates: this._checkForDuplicates, + duplicateScope: this._duplicateScope, + resultOutputMode: this.resultOutputMode, + glossaryLayoutMode: this._glossaryLayoutMode, + compactTags: this._compactTags, + injectedMedia, + errors + }); + errors.push(...createNoteErrors); + return {note, errors}; + } + + async _injectAnkiNoteMedia(dictionaryEntry, fields) { + const timestamp = Date.now(); + + const dictionaryEntryDetails = this._getDictionaryEntryDetailsForNote(dictionaryEntry); + + const audioDetails = ( + dictionaryEntryDetails.type !== 'kanji' && AnkiUtil.fieldsObjectContainsMarker(fields, 'audio') ? + this._display.getAnkiNoteMediaAudioDetails(dictionaryEntryDetails.term, dictionaryEntryDetails.reading) : + null + ); + + const {tabId, frameId} = this._display.getContentOrigin(); + const screenshotDetails = ( + AnkiUtil.fieldsObjectContainsMarker(fields, 'screenshot') && typeof tabId === 'number' ? + {tabId, frameId, format: this._screenshotFormat, quality: this._screenshotQuality} : + null + ); + + const clipboardDetails = { + image: AnkiUtil.fieldsObjectContainsMarker(fields, 'clipboard-image'), + text: AnkiUtil.fieldsObjectContainsMarker(fields, 'clipboard-text') + }; + + return await yomichan.api.injectAnkiNoteMedia( + timestamp, + dictionaryEntryDetails, + audioDetails, + screenshotDetails, + clipboardDetails + ); + } + + _getModes(isTerms) { + return isTerms ? ['term-kanji', 'term-kana'] : ['kanji']; + } + + _getValidSentenceData(sentence) { + let {text, offset} = (isObject(sentence) ? sentence : {}); + if (typeof text !== 'string') { text = ''; } + if (typeof offset !== 'number') { offset = 0; } + return {text, offset}; + } +} |