/* * Copyright (C) 2019-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 * AnkiConnect * AnkiNoteBuilder * ObjectPropertyAccessor * SelectorObserver */ class AnkiController { constructor(settingsController) { this._settingsController = settingsController; this._ankiConnect = new AnkiConnect(); this._ankiNoteBuilder = new AnkiNoteBuilder(false); this._selectorObserver = new SelectorObserver({ selector: '.anki-card', ignoreSelector: null, onAdded: this._createCardController.bind(this), onRemoved: this._removeCardController.bind(this), isStale: this._isCardControllerStale.bind(this) }); this._stringComparer = new Intl.Collator(); // Locale does not matter this._getAnkiDataPromise = null; this._ankiErrorContainer = null; this._ankiErrorMessageNode = null; this._ankiErrorMessageNodeDefaultContent = ''; this._ankiErrorMessageDetailsNode = null; this._ankiErrorMessageDetailsContainer = null; this._ankiErrorMessageDetailsToggle = null; this._ankiErrorInvalidResponseInfo = null; this._ankiCardPrimary = null; this._ankiCardPrimaryType = null; this._validateFieldsToken = null; } get settingsController() { return this._settingsController; } async prepare() { this._ankiErrorContainer = document.querySelector('#anki-error'); this._ankiErrorMessageNode = document.querySelector('#anki-error-message'); this._ankiErrorMessageNodeDefaultContent = this._ankiErrorMessageNode.textContent; this._ankiErrorMessageDetailsNode = document.querySelector('#anki-error-message-details'); this._ankiErrorMessageDetailsContainer = document.querySelector('#anki-error-message-details-container'); this._ankiErrorMessageDetailsToggle = document.querySelector('#anki-error-message-details-toggle'); this._ankiErrorInvalidResponseInfo = document.querySelector('#anki-error-invalid-response-info'); this._ankiEnableCheckbox = document.querySelector('[data-setting="anki.enable"]'); this._ankiCardPrimary = document.querySelector('#anki-card-primary'); this._ankiCardPrimaryType = document.querySelector('#anki-card-primary-type'); this._setupFieldMenus(); this._ankiErrorMessageDetailsToggle.addEventListener('click', this._onAnkiErrorMessageDetailsToggleClick.bind(this), false); if (this._ankiEnableCheckbox !== null) { this._ankiEnableCheckbox.addEventListener('settingChanged', this._onAnkiEnableChanged.bind(this), false); } if (this._ankiCardPrimaryType !== null) { this._ankiCardPrimaryType.addEventListener('change', this._onAnkiCardPrimaryTypeChange.bind(this), false); } const options = await this._settingsController.getOptions(); this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); this._onOptionsChanged({options}); } getFieldMarkers(type) { switch (type) { case 'terms': return [ 'audio', 'clipboard-image', 'clipboard-text', 'cloze-body', 'cloze-prefix', 'cloze-suffix', 'conjugation', 'dictionary', 'document-title', 'expression', 'frequencies', 'furigana', 'furigana-plain', 'glossary', 'glossary-brief', 'glossary-no-dictionary', 'pitch-accents', 'pitch-accent-graphs', 'pitch-accent-positions', 'reading', 'screenshot', 'sentence', 'tags', 'url' ]; case 'kanji': return [ 'character', 'clipboard-image', 'clipboard-text', 'cloze-body', 'cloze-prefix', 'cloze-suffix', 'dictionary', 'document-title', 'glossary', 'kunyomi', 'onyomi', 'screenshot', 'sentence', 'stroke-count', 'tags', 'url' ]; default: return []; } } getFieldMarkersHtml(markers) { const fragment = document.createDocumentFragment(); for (const marker of markers) { const markerNode = this._settingsController.instantiateTemplate('anki-card-field-marker'); markerNode.querySelector('.marker-link').textContent = marker; fragment.appendChild(markerNode); } return fragment; } async getAnkiData() { let promise = this._getAnkiDataPromise; if (promise === null) { promise = this._getAnkiData(); this._getAnkiDataPromise = promise; promise.finally(() => { this._getAnkiDataPromise = null; }); } return promise; } async getModelFieldNames(model) { return await this._ankiConnect.getModelFieldNames(model); } getRequiredPermissions(fieldValue) { return this._settingsController.permissionsUtil.getRequiredPermissionsForAnkiFieldValue(fieldValue); } containsAnyMarker(field) { return this._ankiNoteBuilder.containsAnyMarker(field); } // Private async _onOptionsChanged({options: {anki}}) { this._ankiConnect.server = anki.server; this._ankiConnect.enabled = anki.enable; this._selectorObserver.disconnect(); this._selectorObserver.observe(document.documentElement, true); } _onAnkiErrorMessageDetailsToggleClick() { const node = this._ankiErrorMessageDetailsContainer; node.hidden = !node.hidden; } _onAnkiEnableChanged({detail: {value}}) { if (this._ankiConnect.server === null) { return; } this._ankiConnect.enabled = value; for (const cardController of this._selectorObserver.datas()) { cardController.updateAnkiState(); } } _onAnkiCardPrimaryTypeChange(e) { if (this._ankiCardPrimary === null) { return; } const node = e.currentTarget; let ankiCardMenu; if (node.selectedIndex >= 0) { const option = node.options[node.selectedIndex]; ankiCardMenu = option.dataset.ankiCardMenu; } this._ankiCardPrimary.dataset.ankiCardType = node.value; if (typeof ankiCardMenu !== 'undefined') { this._ankiCardPrimary.dataset.ankiCardMenu = ankiCardMenu; } else { delete this._ankiCardPrimary.dataset.ankiCardMenu; } } _createCardController(node) { const cardController = new AnkiCardController(this._settingsController, this, node); cardController.prepare(); return cardController; } _removeCardController(node, cardController) { cardController.cleanup(); } _isCardControllerStale(node, cardController) { return cardController.isStale(); } _setupFieldMenus() { const fieldMenuTargets = [ [['terms'], '#anki-card-terms-field-menu-template'], [['kanji'], '#anki-card-kanji-field-menu-template'], [['terms', 'kanji'], '#anki-card-all-field-menu-template'] ]; for (const [types, selector] of fieldMenuTargets) { const element = document.querySelector(selector); if (element === null) { continue; } let markers = []; for (const type of types) { markers.push(...this.getFieldMarkers(type)); } markers = [...new Set(markers)]; const container = element.content.querySelector('.popup-menu-body'); if (container === null) { return; } const fragment = document.createDocumentFragment(); for (const marker of markers) { const option = document.createElement('button'); option.textContent = marker; option.className = 'popup-menu-item'; option.dataset.menuAction = 'setFieldMarker'; option.dataset.marker = marker; fragment.appendChild(option); } container.appendChild(fragment); } } async _getAnkiData() { this._setAnkiStatusChanging(); const [ [deckNames, error1], [modelNames, error2] ] = await Promise.all([ this._getDeckNames(), this._getModelNames() ]); if (error1 !== null) { this._showAnkiError(error1); } else if (error2 !== null) { this._showAnkiError(error2); } else { this._hideAnkiError(); } return {deckNames, modelNames}; } async _getDeckNames() { try { const result = await this._ankiConnect.getDeckNames(); this._sortStringArray(result); return [result, null]; } catch (e) { return [[], e]; } } async _getModelNames() { try { const result = await this._ankiConnect.getModelNames(); this._sortStringArray(result); return [result, null]; } catch (e) { return [[], e]; } } _setAnkiStatusChanging() { this._ankiErrorMessageNode.textContent = this._ankiErrorMessageNodeDefaultContent; this._ankiErrorMessageNode.classList.remove('danger-text'); } _hideAnkiError() { if (this._ankiErrorContainer !== null) { this._ankiErrorContainer.hidden = true; } this._ankiErrorMessageDetailsContainer.hidden = true; this._ankiErrorMessageDetailsToggle.hidden = true; this._ankiErrorInvalidResponseInfo.hidden = true; this._ankiErrorMessageNode.textContent = (this._ankiConnect.enabled ? 'Connected' : 'Not enabled'); this._ankiErrorMessageNode.classList.remove('danger-text'); this._ankiErrorMessageDetailsNode.textContent = ''; } _showAnkiError(error) { let errorString = typeof error === 'object' && error !== null ? error.message : null; if (!errorString) { errorString = `${error}`; } if (!/[.!?]$/.test(errorString)) { errorString += '.'; } this._ankiErrorMessageNode.textContent = errorString; this._ankiErrorMessageNode.classList.add('danger-text'); const data = error.data; let details = ''; if (typeof data !== 'undefined') { details += `${JSON.stringify(data, null, 4)}\n\n`; } details += `${error.stack}`.trimRight(); this._ankiErrorMessageDetailsNode.textContent = details; if (this._ankiErrorContainer !== null) { this._ankiErrorContainer.hidden = false; } this._ankiErrorMessageDetailsContainer.hidden = true; this._ankiErrorInvalidResponseInfo.hidden = (errorString.indexOf('Invalid response') < 0); this._ankiErrorMessageDetailsToggle.hidden = false; } _sortStringArray(array) { const stringComparer = this._stringComparer; array.sort((a, b) => stringComparer.compare(a, b)); } } class AnkiCardController { constructor(settingsController, ankiController, node) { this._settingsController = settingsController; this._ankiController = ankiController; this._node = node; this._cardType = node.dataset.ankiCardType; this._cardMenu = node.dataset.ankiCardMenu; this._eventListeners = new EventListenerCollection(); this._fieldEventListeners = new EventListenerCollection(); this._deck = null; this._model = null; this._fields = null; this._modelChangingTo = null; this._ankiCardDeckSelect = null; this._ankiCardModelSelect = null; this._ankiCardFieldsContainer = null; this._cleaned = false; this._fieldEntries = []; } async prepare() { const options = await this._settingsController.getOptions(); const ankiOptions = options.anki; if (this._cleaned) { return; } const cardOptions = this._getCardOptions(ankiOptions, this._cardType); if (cardOptions === null) { return; } const {deck, model, fields} = cardOptions; this._deck = deck; this._model = model; this._fields = fields; this._ankiCardDeckSelect = this._node.querySelector('.anki-card-deck'); this._ankiCardModelSelect = this._node.querySelector('.anki-card-model'); this._ankiCardFieldsContainer = this._node.querySelector('.anki-card-fields'); this._setupSelects([], []); this._setupFields(); this._eventListeners.addEventListener(this._ankiCardDeckSelect, 'change', this._onCardDeckChange.bind(this), false); this._eventListeners.addEventListener(this._ankiCardModelSelect, 'change', this._onCardModelChange.bind(this), false); this._eventListeners.on(this._settingsController, 'permissionsChanged', this._onPermissionsChanged.bind(this)); await this.updateAnkiState(); } cleanup() { this._cleaned = true; this._fieldEntries = []; this._eventListeners.removeAllEventListeners(); } async updateAnkiState() { if (this._fields === null) { return; } const {deckNames, modelNames} = await this._ankiController.getAnkiData(); if (this._cleaned) { return; } this._setupSelects(deckNames, modelNames); } isStale() { return (this._cardType !== this._node.dataset.ankiCardType); } // Private _onCardDeckChange(e) { this._setDeck(e.currentTarget.value); } _onCardModelChange(e) { this._setModel(e.currentTarget.value); } _onFieldChange(index, e) { const node = e.currentTarget; this._validateFieldPermissions(node, index, true); this._validateField(node, index); } _onFieldInput(index, e) { const node = e.currentTarget; this._validateField(node, index); } _onFieldSettingChanged(index, e) { const node = e.currentTarget; this._validateFieldPermissions(node, index, false); } _onFieldMenuClose({currentTarget: button, detail: {action, item}}) { switch (action) { case 'setFieldMarker': this._setFieldMarker(button, item.dataset.marker); break; } } _onFieldMarkerLinkClick(e) { e.preventDefault(); const link = e.currentTarget; this._setFieldMarker(link, link.textContent); } _validateField(node, index) { let valid = (node.dataset.hasPermissions !== 'false'); if (valid && index === 0 && !this._ankiController.containsAnyMarker(node.value)) { valid = false; } node.dataset.invalid = `${!valid}`; } _setFieldMarker(element, marker) { const input = element.closest('.anki-card-field-value-container').querySelector('.anki-card-field-value'); input.value = `{${marker}}`; input.dispatchEvent(new Event('change')); } _getCardOptions(ankiOptions, cardType) { switch (cardType) { case 'terms': return ankiOptions.terms; case 'kanji': return ankiOptions.kanji; default: return null; } } _setupSelects(deckNames, modelNames) { const deck = this._deck; const model = this._model; if (!deckNames.includes(deck)) { deckNames = [...deckNames, deck]; } if (!modelNames.includes(model)) { modelNames = [...modelNames, model]; } this._setSelectOptions(this._ankiCardDeckSelect, deckNames); this._ankiCardDeckSelect.value = deck; this._setSelectOptions(this._ankiCardModelSelect, modelNames); this._ankiCardModelSelect.value = model; } _setSelectOptions(select, optionValues) { const fragment = document.createDocumentFragment(); for (const optionValue of optionValues) { const option = document.createElement('option'); option.value = optionValue; option.textContent = optionValue; fragment.appendChild(option); } select.textContent = ''; select.appendChild(fragment); } _setupFields() { this._fieldEventListeners.removeAllEventListeners(); const markers = this._ankiController.getFieldMarkers(this._cardType); const totalFragment = document.createDocumentFragment(); this._fieldEntries = []; let index = 0; for (const [fieldName, fieldValue] of Object.entries(this._fields)) { const content = this._settingsController.instantiateTemplateFragment('anki-card-field'); const fieldNameContainerNode = content.querySelector('.anki-card-field-name-container'); fieldNameContainerNode.dataset.index = `${index}`; const fieldNameNode = content.querySelector('.anki-card-field-name'); fieldNameNode.textContent = fieldName; const valueContainer = content.querySelector('.anki-card-field-value-container'); valueContainer.dataset.index = `${index}`; const inputField = content.querySelector('.anki-card-field-value'); inputField.value = fieldValue; inputField.dataset.setting = ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'fields', fieldName]); this._validateFieldPermissions(inputField, index, false); this._fieldEventListeners.addEventListener(inputField, 'change', this._onFieldChange.bind(this, index), false); this._fieldEventListeners.addEventListener(inputField, 'input', this._onFieldInput.bind(this, index), false); this._fieldEventListeners.addEventListener(inputField, 'settingChanged', this._onFieldSettingChanged.bind(this, index), false); this._validateField(inputField, index); const markerList = content.querySelector('.anki-card-field-marker-list'); if (markerList !== null) { const markersFragment = this._ankiController.getFieldMarkersHtml(markers); for (const element of markersFragment.querySelectorAll('.marker-link')) { this._fieldEventListeners.addEventListener(element, 'click', this._onFieldMarkerLinkClick.bind(this), false); } markerList.appendChild(markersFragment); } const menuButton = content.querySelector('.anki-card-field-value-menu-button'); if (menuButton !== null) { if (typeof this._cardMenu !== 'undefined') { menuButton.dataset.menu = this._cardMenu; } else { delete menuButton.dataset.menu; } this._fieldEventListeners.addEventListener(menuButton, 'menuClose', this._onFieldMenuClose.bind(this), false); } totalFragment.appendChild(content); this._fieldEntries.push({fieldName, inputField, fieldNameContainerNode}); ++index; } const ELEMENT_NODE = Node.ELEMENT_NODE; const container = this._ankiCardFieldsContainer; for (const node of [...container.childNodes]) { if (node.nodeType === ELEMENT_NODE && node.dataset.persistent === 'true') { continue; } container.removeChild(node); } container.appendChild(totalFragment); this._validateFields(); } async _validateFields() { const token = {}; this._validateFieldsToken = token; let fieldNames; try { fieldNames = await this._ankiController.getModelFieldNames(this._model); } catch (e) { return; } if (token !== this._validateFieldsToken) { return; } const fieldNamesSet = new Set(fieldNames); let index = 0; for (const {fieldName, fieldNameContainerNode} of this._fieldEntries) { fieldNameContainerNode.dataset.invalid = `${!fieldNamesSet.has(fieldName)}`; fieldNameContainerNode.dataset.orderMatches = `${index < fieldNames.length && fieldName === fieldNames[index]}`; ++index; } } async _setDeck(value) { if (this._deck === value) { return; } this._deck = value; await this._settingsController.modifyProfileSettings([{ action: 'set', path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'deck']), value }]); } async _setModel(value) { if (this._modelChangingTo !== null) { // Revert this._ankiCardModelSelect.value = this._modelChangingTo; return; } if (this._model === value) { return; } let fieldNames; let options; try { this._modelChangingTo = value; fieldNames = await this._ankiController.getModelFieldNames(value); options = await this._ankiController.settingsController.getOptions(); } catch (e) { // Revert this._ankiCardModelSelect.value = this._model; return; } finally { this._modelChangingTo = null; } const cardType = this._cardType; const cardOptions = this._getCardOptions(options.anki, cardType); const oldFields = cardOptions !== null ? cardOptions.fields : null; const fields = {}; for (let i = 0, ii = fieldNames.length; i < ii; ++i) { const fieldName = fieldNames[i]; fields[fieldName] = this._getDefaultFieldValue(fieldName, i, cardType, oldFields); } const targets = [ { action: 'set', path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'model']), value }, { action: 'set', path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'fields']), value: fields } ]; this._model = value; this._fields = fields; await this._settingsController.modifyProfileSettings(targets); this._setupFields(); } async _requestPermissions(permissions) { try { await this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, true); } catch (e) { log.error(e); } } async _validateFieldPermissions(node, index, request) { const fieldValue = node.value; const permissions = this._ankiController.getRequiredPermissions(fieldValue); if (permissions.length > 0) { node.dataset.requiredPermission = permissions.join(' '); const hasPermissions = await ( request ? this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, true) : this._settingsController.permissionsUtil.hasPermissions({permissions}) ); node.dataset.hasPermissions = `${hasPermissions}`; } else { delete node.dataset.requiredPermission; delete node.dataset.hasPermissions; } this._validateField(node, index); } _onPermissionsChanged({permissions: {permissions}}) { const permissionsSet = new Set(permissions); for (let i = 0, ii = this._fieldEntries.length; i < ii; ++i) { const {inputField} = this._fieldEntries[i]; let {requiredPermission} = inputField.dataset; if (typeof requiredPermission !== 'string') { continue; } requiredPermission = (requiredPermission.length === 0 ? [] : requiredPermission.split(' ')); let hasPermissions = true; for (const permission of requiredPermission) { if (!permissionsSet.has(permission)) { hasPermissions = false; break; } } inputField.dataset.hasPermissions = `${hasPermissions}`; this._validateField(inputField, i); } } _getDefaultFieldValue(fieldName, index, cardType, oldFields) { if ( typeof oldFields === 'object' && oldFields !== null && Object.prototype.hasOwnProperty.call(oldFields, fieldName) ) { return oldFields[fieldName]; } if (index === 0) { return (cardType === 'kanji' ? '{character}' : '{expression}'); } const markers = this._ankiController.getFieldMarkers(cardType); const markerAliases = new Map([ ['glossary', ['definition', 'meaning']], ['audio', ['sound']], ['dictionary', ['dict']] ]); const hyphenPattern = /-/g; for (const marker of markers) { const names = [marker]; const aliases = markerAliases.get(marker); if (typeof aliases !== 'undefined') { names.push(...aliases); } let pattern = '^(?:'; for (let i = 0, ii = names.length; i < ii; ++i) { const name = names[i]; if (i > 0) { pattern += '|'; } pattern += name.replace(hyphenPattern, '[-_ ]*'); } pattern += ')$'; pattern = new RegExp(pattern, 'i'); if (pattern.test(fieldName)) { return `{${marker}}`; } } return ''; } }