diff options
author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2020-10-25 13:34:42 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-10-25 13:34:42 -0400 |
commit | defd7402cffcfb45d85a56b1f093c91d4fd6e866 (patch) | |
tree | b62106cc7647952acc44b51a9f5eab3b190317ca /ext | |
parent | 9e9bd0dcf6b356d926ff46dc57b76b0d3734d8a6 (diff) |
Anki controller refactor (#954)
* Simplify data transform for anki.enable setting
* Refactor AnkiController
* Implement marker link clicking
* Request permissions for clipboard
Diffstat (limited to 'ext')
-rw-r--r-- | ext/bg/css/settings.css | 7 | ||||
-rw-r--r-- | ext/bg/js/settings/anki-controller.js | 471 | ||||
-rw-r--r-- | ext/bg/settings.html | 37 |
3 files changed, 336 insertions, 179 deletions
diff --git a/ext/bg/css/settings.css b/ext/bg/css/settings.css index 5d522209..231df1b3 100644 --- a/ext/bg/css/settings.css +++ b/ext/bg/css/settings.css @@ -331,17 +331,14 @@ input[type=checkbox].storage-button-checkbox { display: none; } -.error-data-show-button { +#anki-error-message-details-toggle { display: inline-block; margin-left: 0.5em; cursor: pointer; -} -.error-data-show-button:after { - content: "\2026"; font-weight: bold; } -.error-data-container { +#anki-error-message-details { margin-top: 0.25em; font-family: 'Courier New', Courier, monospace; white-space: pre; diff --git a/ext/bg/js/settings/anki-controller.js b/ext/bg/js/settings/anki-controller.js index 373e4d43..e7ee2074 100644 --- a/ext/bg/js/settings/anki-controller.js +++ b/ext/bg/js/settings/anki-controller.js @@ -17,26 +17,47 @@ /* global * AnkiConnect + * ObjectPropertyAccessor + * SelectorObserver */ class AnkiController { constructor(settingsController) { - this._ankiConnect = new AnkiConnect(); this._settingsController = settingsController; + this._ankiConnect = new AnkiConnect(); + 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._fieldMarkersRequiringClipboardPermission = new Set([ + 'clipboard-image', + 'clipboard-text' + ]); + this._ankiOptions = null; + this._getAnkiDataPromise = null; + this._ankiErrorContainer = null; + this._ankiErrorMessageContainer = null; + this._ankiErrorMessageDetailsContainer = null; + this._ankiErrorMessageDetailsToggle = null; + this._ankiErrorInvalidResponseInfo = null; } async prepare() { - for (const element of document.querySelectorAll('#anki-fields-container input,#anki-fields-container select')) { - element.addEventListener('change', this._onFieldsChanged.bind(this), false); - } + this._ankiErrorContainer = document.querySelector('#anki-error'); + this._ankiErrorMessageContainer = document.querySelector('#anki-error-message'); + this._ankiErrorMessageDetailsContainer = document.querySelector('#anki-error-message-details'); + 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"]'); - for (const element of document.querySelectorAll('#anki-terms-model,#anki-kanji-model')) { - element.addEventListener('change', this._onModelChanged.bind(this), false); - } - - this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + this._ankiErrorMessageDetailsToggle.addEventListener('click', this._onAnkiErrorMessageDetailsToggleClick.bind(this), false); + if (this._ankiEnableCheckbox !== null) { this._ankiEnableCheckbox.addEventListener('settingChanged', this._onAnkiEnableChanged.bind(this), false); } const options = await this._settingsController.getOptions(); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); this._onOptionsChanged({options}); } @@ -92,220 +113,356 @@ class AnkiController { getFieldMarkersHtml(markers) { const fragment = document.createDocumentFragment(); for (const marker of markers) { - const markerNode = this._settingsController.instantiateTemplate('anki-field-marker'); + 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); + } + + validateFieldPermissions(fieldValue) { + let requireClipboard = false; + const markers = this._getFieldMarkers(fieldValue); + for (const marker of markers) { + if (this._fieldMarkersRequiringClipboardPermission.has(marker)) { + requireClipboard = true; + } + } + + if (requireClipboard) { + this._requestClipboardReadPermission(); + } + } + // Private - async _onOptionsChanged({options}) { - const {server, enable: enabled} = options.anki; - this._ankiConnect.server = server; - this._ankiConnect.enabled = enabled; + async _onOptionsChanged({options: {anki}}) { + this._ankiOptions = anki; + this._ankiConnect.server = anki.server; + this._ankiConnect.enabled = anki.enable; - if (!enabled) { return; } + this._selectorObserver.disconnect(); + this._selectorObserver.observe(document.documentElement, true); + } - await this._deckAndModelPopulate(options); - await Promise.all([ - this._populateFields('terms', options.anki.terms.fields), - this._populateFields('kanji', options.anki.kanji.fields) - ]); + _onAnkiErrorMessageDetailsToggleClick() { + const node = this._ankiErrorMessageDetailsContainer; + node.hidden = !node.hidden; } - _fieldsToDict(elements) { - const result = {}; - for (const element of elements) { - result[element.dataset.field] = element.value; + _onAnkiEnableChanged({detail: {value}}) { + if (this._ankiOptions === null) { return; } + this._ankiConnect.enabled = value; + + for (const cardController of this._selectorObserver.datas()) { + cardController.updateAnkiState(); } - return result; } - _spinnerShow(show) { - const spinner = document.querySelector('#anki-spinner'); - spinner.hidden = !show; + _createCardController(node) { + const cardController = new AnkiCardController(this._settingsController, this, node); + cardController.prepare(this._ankiOptions); + return cardController; } - _setError(error) { - const node = document.querySelector('#anki-error'); - const node2 = document.querySelector('#anki-invalid-response-error'); - if (error) { - const errorString = `${error}`; - if (node !== null) { - node.hidden = false; - node.textContent = errorString; - this._setErrorData(node, error); - } + _removeCardController(node, cardController) { + cardController.cleanup(); + } - if (node2 !== null) { - node2.hidden = (errorString.indexOf('Invalid response') < 0); - } + _isCardControllerStale(node, cardController) { + return cardController.isStale(); + } + + async _getAnkiData() { + 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 { - if (node !== null) { - node.hidden = true; - node.textContent = ''; - } + this._hideAnkiError(); + } - if (node2 !== null) { - node2.hidden = true; - } + return {deckNames, modelNames}; + } + + async _getDeckNames() { + try { + const result = await this._ankiConnect.getDeckNames(); + return [result, null]; + } catch (e) { + return [[], e]; } } - _setErrorData(node, error) { - const data = error.data; - let message = ''; - if (typeof data !== 'undefined') { - message += `${JSON.stringify(data, null, 4)}\n\n`; + async _getModelNames() { + try { + const result = await this._ankiConnect.getModelNames(); + return [result, null]; + } catch (e) { + return [[], e]; } - message += `${error.stack}`.trimRight(); + } - const button = document.createElement('a'); - button.className = 'error-data-show-button'; + _hideAnkiError() { + this._ankiErrorContainer.hidden = true; + this._ankiErrorMessageDetailsContainer.hidden = true; + this._ankiErrorInvalidResponseInfo.hidden = true; + this._ankiErrorMessageContainer.textContent = ''; + this._ankiErrorMessageDetailsContainer.textContent = ''; + } - const content = document.createElement('div'); - content.className = 'error-data-container'; - content.textContent = message; - content.hidden = true; + _showAnkiError(error) { + const errorString = `${error}`; + this._ankiErrorMessageContainer.textContent = errorString; - button.addEventListener('click', () => content.hidden = !content.hidden, false); + const data = error.data; + let details = ''; + if (typeof data !== 'undefined') { + details += `${JSON.stringify(data, null, 4)}\n\n`; + } + details += `${error.stack}`.trimRight(); + this._ankiErrorMessageDetailsContainer.textContent = details; - node.appendChild(button); - node.appendChild(content); + this._ankiErrorContainer.hidden = false; + this._ankiErrorMessageDetailsContainer.hidden = true; + this._ankiErrorInvalidResponseInfo.hidden = (errorString.indexOf('Invalid response') < 0); } - _setDropdownOptions(dropdown, optionValues) { - const fragment = document.createDocumentFragment(); - for (const optionValue of optionValues) { - const option = document.createElement('option'); - option.value = optionValue; - option.textContent = optionValue; - fragment.appendChild(option); + async _requestClipboardReadPermission() { + const permissions = ['clipboardRead']; + + if (await new Promise((resolve) => chrome.permissions.contains({permissions}, resolve))) { + // Already has permission + return; } - dropdown.textContent = ''; - dropdown.appendChild(fragment); + + return await new Promise((resolve) => chrome.permissions.request({permissions}, resolve)); } - async _deckAndModelPopulate(options) { - const termsDeck = {value: options.anki.terms.deck, selector: '#anki-terms-deck'}; - const kanjiDeck = {value: options.anki.kanji.deck, selector: '#anki-kanji-deck'}; - const termsModel = {value: options.anki.terms.model, selector: '#anki-terms-model'}; - const kanjiModel = {value: options.anki.kanji.model, selector: '#anki-kanji-model'}; - try { - this._spinnerShow(true); - const [deckNames, modelNames] = await Promise.all([ - this._ankiConnect.getDeckNames(), - this._ankiConnect.getModelNames() - ]); - deckNames.sort(); - modelNames.sort(); - termsDeck.values = deckNames; - kanjiDeck.values = deckNames; - termsModel.values = modelNames; - kanjiModel.values = modelNames; - this._setError(null); - } catch (error) { - this._setError(error); - } finally { - this._spinnerShow(false); + _getFieldMarkers(fieldValue) { + const pattern = /\{([\w-]+)\}/g; + const markers = []; + let match; + while ((match = pattern.exec(fieldValue)) !== null) { + markers.push(match[1]); } + return markers; + } +} - for (const {value, values, selector} of [termsDeck, kanjiDeck, termsModel, kanjiModel]) { - const node = document.querySelector(selector); - this._setDropdownOptions(node, Array.isArray(values) ? values : [value]); - node.value = value; - } +class AnkiCardController { + constructor(settingsController, ankiController, node) { + this._settingsController = settingsController; + this._ankiController = ankiController; + this._node = node; + this._cardType = node.dataset.ankiCardType; + 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; } - _createFieldTemplate(name, value, markers) { - const content = this._settingsController.instantiateTemplate('anki-field'); + async prepare(ankiOptions) { + 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; - content.querySelector('.anki-field-name').textContent = name; + this._ankiCardDeckSelect = this._node.querySelector('.anki-card-deck'); + this._ankiCardModelSelect = this._node.querySelector('.anki-card-model'); + this._ankiCardFieldsContainer = this._node.querySelector('.anki-card-fields'); - const field = content.querySelector('.anki-field-value'); - field.dataset.field = name; - field.value = value; + this._setupSelects([], []); + this._setupFields(); - content.querySelector('.anki-field-marker-list').appendChild(this.getFieldMarkersHtml(markers)); + this._eventListeners.addEventListener(this._ankiCardDeckSelect, 'change', this._onCardDeckChange.bind(this), false); + this._eventListeners.addEventListener(this._ankiCardModelSelect, 'change', this._onCardModelChange.bind(this), false); - return content; + await this.updateAnkiState(); } - async _populateFields(tabId, fields) { - const tab = document.querySelector(`.tab-pane[data-anki-card-type=${tabId}]`); - const container = tab.querySelector('tbody'); - const markers = this.getFieldMarkers(tabId); + cleanup() { + this._eventListeners.removeAllEventListeners(); + } - const fragment = document.createDocumentFragment(); - for (const [name, value] of Object.entries(fields)) { - const html = this._createFieldTemplate(name, value, markers); - fragment.appendChild(html); - } + async updateAnkiState() { + if (this._fields === null) { return; } + const {deckNames, modelNames} = await this._ankiController.getAnkiData(); + this._setupSelects(deckNames, modelNames); + } - container.textContent = ''; - container.appendChild(fragment); + isStale() { + return (this._cardType !== this._node.dataset.ankiCardType); + } - for (const node of container.querySelectorAll('.anki-field-value')) { - node.addEventListener('change', this._onFieldsChanged.bind(this), false); - } - for (const node of container.querySelectorAll('.marker-link')) { - node.addEventListener('click', this._onMarkerClicked.bind(this), false); - } + // Private + + _onCardDeckChange(e) { + this._setDeck(e.currentTarget.value); + } + + _onCardModelChange(e) { + this._setModel(e.currentTarget.value); + } + + _onFieldChange(e) { + this._ankiController.validateFieldPermissions(e.currentTarget.value); } - _onMarkerClicked(e) { + _onFieldMarkerLinkClick(e) { e.preventDefault(); const link = e.currentTarget; - const input = link.closest('.input-group').querySelector('.anki-field-value'); + const input = link.closest('.anki-card-field-value-container').querySelector('.anki-card-field-value'); input.value = `{${link.textContent}}`; input.dispatchEvent(new Event('change')); } - async _onModelChanged(e) { - const node = e.currentTarget; + _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(); + for (const [fieldName, fieldValue] of Object.entries(this._fields)) { + const content = this._settingsController.instantiateTemplateFragment('anki-card-field'); + + content.querySelector('.anki-card-field-name').textContent = fieldName; + + const inputField = content.querySelector('.anki-card-field-value'); + inputField.value = fieldValue; + inputField.dataset.setting = ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'fields', fieldName]); + this._fieldEventListeners.addEventListener(inputField, 'change', this._onFieldChange.bind(this), false); + + 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); + } + + totalFragment.appendChild(content); + } + this._ankiCardFieldsContainer.textContent = ''; + this._ankiCardFieldsContainer.appendChild(totalFragment); + } + + 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; try { - const modelName = node.value; - fieldNames = await this._ankiConnect.getModelFieldNames(modelName); - this._setError(null); - } catch (error) { - this._setError(error); + this._modelChangingTo = value; + fieldNames = await this._ankiController.getModelFieldNames(value); + } catch (e) { + // Revert + this._ankiCardModelSelect.value = this._model; return; } finally { - this._spinnerShow(false); + this._modelChangingTo = null; } - const tabId = node.dataset.ankiCardType; - if (tabId !== 'terms' && tabId !== 'kanji') { return; } - const fields = {}; - for (const name of fieldNames) { - fields[name] = ''; + for (const fieldName of fieldNames) { + fields[fieldName] = ''; } - await this._settingsController.setProfileSetting(`anki["${tabId}"].fields`, fields); - await this._populateFields(tabId, fields); - } - - async _onFieldsChanged() { - const termsDeck = document.querySelector('#anki-terms-deck').value; - const termsModel = document.querySelector('#anki-terms-model').value; - const termsFields = this._fieldsToDict(document.querySelectorAll('#terms .anki-field-value')); - const kanjiDeck = document.querySelector('#anki-kanji-deck').value; - const kanjiModel = document.querySelector('#anki-kanji-model').value; - const kanjiFields = this._fieldsToDict(document.querySelectorAll('#kanji .anki-field-value')); - const targets = [ - {action: 'set', path: 'anki.terms.deck', value: termsDeck}, - {action: 'set', path: 'anki.terms.model', value: termsModel}, - {action: 'set', path: 'anki.terms.fields', value: termsFields}, - {action: 'set', path: 'anki.kanji.deck', value: kanjiDeck}, - {action: 'set', path: 'anki.kanji.model', value: kanjiModel}, - {action: 'set', path: 'anki.kanji.fields', value: kanjiFields} + { + 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(); } } diff --git a/ext/bg/settings.html b/ext/bg/settings.html index e385d074..94eca586 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -882,7 +882,7 @@ </p> <div class="checkbox"> - <label><input type="checkbox" id="anki-enable" data-setting="anki.enable" data-transform-pre="setDocumentAttribute" data-transform-post="setDocumentAttribute" data-document-attribute="data-options-anki-enable"> Enable Anki integration</label> + <label><input type="checkbox" id="anki-enable" data-setting="anki.enable" data-transform="setDocumentAttribute" data-document-attribute="data-options-anki-enable"> Enable Anki integration</label> </div> <div id="anki-general"> @@ -894,9 +894,12 @@ </div> </div> - <div class="alert alert-danger" id="anki-error" hidden></div> + <div class="alert alert-danger" id="anki-error" hidden> + <span id="anki-error-message"></span><a id="anki-error-message-details-toggle">…</a> + <div id="anki-error-message-details" hidden></div> + </div> - <div class="alert alert-danger" id="anki-invalid-response-error" hidden> + <div class="alert alert-danger" id="anki-error-invalid-response-info" hidden> Attempting to connect to Anki can sometimes return an error message which includes "Invalid response", which may indicate that the value of the <strong>Interface server</strong> option is incorrect. The <strong>Show advanced options</strong> checkbox under General Options must be ticked ticked to show this option. @@ -955,41 +958,41 @@ </ul> <div class="tab-content ignore-form-changes" id="anki-fields-container"> - <div id="terms" class="tab-pane fade in active" data-anki-card-type="terms"> + <div id="terms" class="tab-pane fade in active anki-card" data-anki-card-type="terms"> <div class="row"> <div class="form-group col-xs-6"> <label for="anki-terms-deck">Deck</label> - <select class="form-control anki-deck" id="anki-terms-deck" data-anki-card-type="terms"></select> + <select class="form-control anki-card-deck" id="anki-terms-deck" data-anki-card-type="terms"></select> </div> <div class="form-group col-xs-6"> <label for="anki-terms-model">Model</label> - <select class="form-control anki-model" id="anki-terms-model" data-anki-card-type="terms"></select> + <select class="form-control anki-card-model" id="anki-terms-model" data-anki-card-type="terms"></select> </div> </div> <table class="table table-bordered anki-fields"> <thead><tr><th>Field</th><th>Value</th></tr></thead> - <tbody></tbody> + <tbody class="anki-card-fields"></tbody> </table> </div> - <div id="kanji" class="tab-pane fade" data-anki-card-type="kanji"> + <div id="kanji" class="tab-pane fade anki-card" data-anki-card-type="kanji"> <div class="row"> <div class="form-group col-xs-6"> <label for="anki-kanji-deck">Deck</label> - <select class="form-control anki-deck" id="anki-kanji-deck" data-anki-card-type="kanji"></select> + <select class="form-control anki-card-deck" id="anki-kanji-deck" data-anki-card-type="kanji"></select> </div> <div class="form-group col-xs-6"> <label for="anki-kanji-model">Model</label> - <select class="form-control anki-model" id="anki-kanji-model" data-anki-card-type="kanji"></select> + <select class="form-control anki-card-model" id="anki-kanji-model" data-anki-card-type="kanji"></select> </div> </div> <table class="table table-bordered anki-fields"> <thead><tr><th>Field</th><th>Value</th></tr></thead> - <tbody></tbody> + <tbody class="anki-card-fields"></tbody> </table> </div> </div> @@ -1056,22 +1059,22 @@ </div> </div> - <template id="anki-field-template"><tr> - <td class="col-sm-2 anki-field-name"></td> + <template id="anki-card-field-template"><tr> + <td class="col-sm-2 anki-card-field-name"></td> <td class="col-sm-10"> - <div class="input-group"> - <input type="text" class="anki-field-value form-control" data-field="" value=""> + <div class="input-group anki-card-field-value-container"> + <input type="text" class="anki-card-field-value form-control" data-field="" value=""> <div class="input-group-btn"> <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown"> <span class="caret"></span> </button> - <ul class="dropdown-menu dropdown-menu-right anki-field-marker-list"></ul> + <ul class="dropdown-menu dropdown-menu-right anki-card-field-marker-list"></ul> </div> </div> </td> </tr></template> - <template id="anki-field-marker-template"><li><a class="marker-link" href="#"></a></li></template> + <template id="anki-card-field-marker-template"><li><a class="marker-link" href="#"></a></li></template> </div> </div> </div> |