From defd7402cffcfb45d85a56b1f093c91d4fd6e866 Mon Sep 17 00:00:00 2001
From: toasted-nutbread
Date: Sun, 25 Oct 2020 13:34:42 -0400
Subject: Anki controller refactor (#954)
* Simplify data transform for anki.enable setting
* Refactor AnkiController
* Implement marker link clicking
* Request permissions for clipboard
---
ext/bg/css/settings.css | 7 +-
ext/bg/js/settings/anki-controller.js | 471 ++++++++++++++++++++++------------
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 @@
- Enable Anki integration
+ Enable Anki integration
@@ -894,9 +894,12 @@
-
+
-
+
Attempting to connect to Anki can sometimes return an error message which includes "Invalid response",
which may indicate that the value of the
Interface server option is incorrect.
The
Show advanced options checkbox under General Options must be ticked ticked to show this option.
@@ -955,41 +958,41 @@
-
+
-
@@ -1056,22 +1059,22 @@
-
-
+
+
-
-
+
--
cgit v1.2.3