diff options
Diffstat (limited to 'ext/js/pages/settings/anki-controller.js')
-rw-r--r-- | ext/js/pages/settings/anki-controller.js | 729 |
1 files changed, 729 insertions, 0 deletions
diff --git a/ext/js/pages/settings/anki-controller.js b/ext/js/pages/settings/anki-controller.js new file mode 100644 index 00000000..26cab68f --- /dev/null +++ b/ext/js/pages/settings/anki-controller.js @@ -0,0 +1,729 @@ +/* + * 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 ''; + } +} |