Anki controller refactor (#954)

* Simplify data transform for anki.enable setting

* Refactor AnkiController

* Implement marker link clicking

* Request permissions for clipboard
This commit is contained in:
toasted-nutbread 2020-10-25 13:34:42 -04:00 committed by GitHub
parent 9e9bd0dcf6
commit defd7402cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 349 additions and 192 deletions

View File

@ -331,17 +331,14 @@ input[type=checkbox].storage-button-checkbox {
display: none; display: none;
} }
.error-data-show-button { #anki-error-message-details-toggle {
display: inline-block; display: inline-block;
margin-left: 0.5em; margin-left: 0.5em;
cursor: pointer; cursor: pointer;
}
.error-data-show-button:after {
content: "\2026";
font-weight: bold; font-weight: bold;
} }
.error-data-container { #anki-error-message-details {
margin-top: 0.25em; margin-top: 0.25em;
font-family: 'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
white-space: pre; white-space: pre;

View File

@ -17,26 +17,47 @@
/* global /* global
* AnkiConnect * AnkiConnect
* ObjectPropertyAccessor
* SelectorObserver
*/ */
class AnkiController { class AnkiController {
constructor(settingsController) { constructor(settingsController) {
this._ankiConnect = new AnkiConnect();
this._settingsController = settingsController; 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() { async prepare() {
for (const element of document.querySelectorAll('#anki-fields-container input,#anki-fields-container select')) { this._ankiErrorContainer = document.querySelector('#anki-error');
element.addEventListener('change', this._onFieldsChanged.bind(this), false); 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')) { this._ankiErrorMessageDetailsToggle.addEventListener('click', this._onAnkiErrorMessageDetailsToggleClick.bind(this), false);
element.addEventListener('change', this._onModelChanged.bind(this), false); if (this._ankiEnableCheckbox !== null) { this._ankiEnableCheckbox.addEventListener('settingChanged', this._onAnkiEnableChanged.bind(this), false); }
}
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
const options = await this._settingsController.getOptions(); const options = await this._settingsController.getOptions();
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
this._onOptionsChanged({options}); this._onOptionsChanged({options});
} }
@ -92,91 +113,261 @@ class AnkiController {
getFieldMarkersHtml(markers) { getFieldMarkersHtml(markers) {
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (const marker of markers) { 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; markerNode.querySelector('.marker-link').textContent = marker;
fragment.appendChild(markerNode); fragment.appendChild(markerNode);
} }
return fragment; 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 // Private
async _onOptionsChanged({options}) { async _onOptionsChanged({options: {anki}}) {
const {server, enable: enabled} = options.anki; this._ankiOptions = anki;
this._ankiConnect.server = server; this._ankiConnect.server = anki.server;
this._ankiConnect.enabled = enabled; this._ankiConnect.enabled = anki.enable;
if (!enabled) { return; } this._selectorObserver.disconnect();
this._selectorObserver.observe(document.documentElement, true);
}
await this._deckAndModelPopulate(options); _onAnkiErrorMessageDetailsToggleClick() {
await Promise.all([ const node = this._ankiErrorMessageDetailsContainer;
this._populateFields('terms', options.anki.terms.fields), node.hidden = !node.hidden;
this._populateFields('kanji', options.anki.kanji.fields) }
_onAnkiEnableChanged({detail: {value}}) {
if (this._ankiOptions === null) { return; }
this._ankiConnect.enabled = value;
for (const cardController of this._selectorObserver.datas()) {
cardController.updateAnkiState();
}
}
_createCardController(node) {
const cardController = new AnkiCardController(this._settingsController, this, node);
cardController.prepare(this._ankiOptions);
return cardController;
}
_removeCardController(node, cardController) {
cardController.cleanup();
}
_isCardControllerStale(node, cardController) {
return cardController.isStale();
}
async _getAnkiData() {
const [
[deckNames, error1],
[modelNames, error2]
] = await Promise.all([
this._getDeckNames(),
this._getModelNames()
]); ]);
}
_fieldsToDict(elements) { if (error1 !== null) {
const result = {}; this._showAnkiError(error1);
for (const element of elements) { } else if (error2 !== null) {
result[element.dataset.field] = element.value; this._showAnkiError(error2);
}
return result;
}
_spinnerShow(show) {
const spinner = document.querySelector('#anki-spinner');
spinner.hidden = !show;
}
_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);
}
if (node2 !== null) {
node2.hidden = (errorString.indexOf('Invalid response') < 0);
}
} else { } else {
if (node !== null) { this._hideAnkiError();
node.hidden = true; }
node.textContent = '';
}
if (node2 !== null) { return {deckNames, modelNames};
node2.hidden = true; }
}
async _getDeckNames() {
try {
const result = await this._ankiConnect.getDeckNames();
return [result, null];
} catch (e) {
return [[], e];
} }
} }
_setErrorData(node, error) { async _getModelNames() {
try {
const result = await this._ankiConnect.getModelNames();
return [result, null];
} catch (e) {
return [[], e];
}
}
_hideAnkiError() {
this._ankiErrorContainer.hidden = true;
this._ankiErrorMessageDetailsContainer.hidden = true;
this._ankiErrorInvalidResponseInfo.hidden = true;
this._ankiErrorMessageContainer.textContent = '';
this._ankiErrorMessageDetailsContainer.textContent = '';
}
_showAnkiError(error) {
const errorString = `${error}`;
this._ankiErrorMessageContainer.textContent = errorString;
const data = error.data; const data = error.data;
let message = ''; let details = '';
if (typeof data !== 'undefined') { if (typeof data !== 'undefined') {
message += `${JSON.stringify(data, null, 4)}\n\n`; details += `${JSON.stringify(data, null, 4)}\n\n`;
} }
message += `${error.stack}`.trimRight(); details += `${error.stack}`.trimRight();
this._ankiErrorMessageDetailsContainer.textContent = details;
const button = document.createElement('a'); this._ankiErrorContainer.hidden = false;
button.className = 'error-data-show-button'; this._ankiErrorMessageDetailsContainer.hidden = true;
this._ankiErrorInvalidResponseInfo.hidden = (errorString.indexOf('Invalid response') < 0);
const content = document.createElement('div');
content.className = 'error-data-container';
content.textContent = message;
content.hidden = true;
button.addEventListener('click', () => content.hidden = !content.hidden, false);
node.appendChild(button);
node.appendChild(content);
} }
_setDropdownOptions(dropdown, optionValues) { async _requestClipboardReadPermission() {
const permissions = ['clipboardRead'];
if (await new Promise((resolve) => chrome.permissions.contains({permissions}, resolve))) {
// Already has permission
return;
}
return await new Promise((resolve) => chrome.permissions.request({permissions}, resolve));
}
_getFieldMarkers(fieldValue) {
const pattern = /\{([\w-]+)\}/g;
const markers = [];
let match;
while ((match = pattern.exec(fieldValue)) !== null) {
markers.push(match[1]);
}
return markers;
}
}
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;
}
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;
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);
await this.updateAnkiState();
}
cleanup() {
this._eventListeners.removeAllEventListeners();
}
async updateAnkiState() {
if (this._fields === null) { return; }
const {deckNames, modelNames} = await this._ankiController.getAnkiData();
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(e) {
this._ankiController.validateFieldPermissions(e.currentTarget.value);
}
_onFieldMarkerLinkClick(e) {
e.preventDefault();
const link = e.currentTarget;
const input = link.closest('.anki-card-field-value-container').querySelector('.anki-card-field-value');
input.value = `{${link.textContent}}`;
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(); const fragment = document.createDocumentFragment();
for (const optionValue of optionValues) { for (const optionValue of optionValues) {
const option = document.createElement('option'); const option = document.createElement('option');
@ -184,128 +375,94 @@ class AnkiController {
option.textContent = optionValue; option.textContent = optionValue;
fragment.appendChild(option); fragment.appendChild(option);
} }
dropdown.textContent = ''; select.textContent = '';
dropdown.appendChild(fragment); select.appendChild(fragment);
} }
async _deckAndModelPopulate(options) { _setupFields() {
const termsDeck = {value: options.anki.terms.deck, selector: '#anki-terms-deck'}; this._fieldEventListeners.removeAllEventListeners();
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);
}
for (const {value, values, selector} of [termsDeck, kanjiDeck, termsModel, kanjiModel]) { const markers = this._ankiController.getFieldMarkers(this._cardType);
const node = document.querySelector(selector); const totalFragment = document.createDocumentFragment();
this._setDropdownOptions(node, Array.isArray(values) ? values : [value]); for (const [fieldName, fieldValue] of Object.entries(this._fields)) {
node.value = value; 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);
} }
_createFieldTemplate(name, value, markers) { async _setDeck(value) {
const content = this._settingsController.instantiateTemplate('anki-field'); if (this._deck === value) { return; }
this._deck = value;
content.querySelector('.anki-field-name').textContent = name; await this._settingsController.modifyProfileSettings([{
action: 'set',
const field = content.querySelector('.anki-field-value'); path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'deck']),
field.dataset.field = name; value
field.value = value; }]);
content.querySelector('.anki-field-marker-list').appendChild(this.getFieldMarkersHtml(markers));
return content;
} }
async _populateFields(tabId, fields) { async _setModel(value) {
const tab = document.querySelector(`.tab-pane[data-anki-card-type=${tabId}]`); if (this._modelChangingTo !== null) {
const container = tab.querySelector('tbody'); // Revert
const markers = this.getFieldMarkers(tabId); this._ankiCardModelSelect.value = this._modelChangingTo;
return;
const fragment = document.createDocumentFragment();
for (const [name, value] of Object.entries(fields)) {
const html = this._createFieldTemplate(name, value, markers);
fragment.appendChild(html);
} }
if (this._model === value) { return; }
container.textContent = '';
container.appendChild(fragment);
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);
}
}
_onMarkerClicked(e) {
e.preventDefault();
const link = e.currentTarget;
const input = link.closest('.input-group').querySelector('.anki-field-value');
input.value = `{${link.textContent}}`;
input.dispatchEvent(new Event('change'));
}
async _onModelChanged(e) {
const node = e.currentTarget;
let fieldNames; let fieldNames;
try { try {
const modelName = node.value; this._modelChangingTo = value;
fieldNames = await this._ankiConnect.getModelFieldNames(modelName); fieldNames = await this._ankiController.getModelFieldNames(value);
this._setError(null); } catch (e) {
} catch (error) { // Revert
this._setError(error); this._ankiCardModelSelect.value = this._model;
return; return;
} finally { } finally {
this._spinnerShow(false); this._modelChangingTo = null;
} }
const tabId = node.dataset.ankiCardType;
if (tabId !== 'terms' && tabId !== 'kanji') { return; }
const fields = {}; const fields = {};
for (const name of fieldNames) { for (const fieldName of fieldNames) {
fields[name] = ''; 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 = [ const targets = [
{action: 'set', path: 'anki.terms.deck', value: termsDeck}, {
{action: 'set', path: 'anki.terms.model', value: termsModel}, action: 'set',
{action: 'set', path: 'anki.terms.fields', value: termsFields}, path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'model']),
{action: 'set', path: 'anki.kanji.deck', value: kanjiDeck}, value
{action: 'set', path: 'anki.kanji.model', value: kanjiModel}, },
{action: 'set', path: 'anki.kanji.fields', value: kanjiFields} {
action: 'set',
path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'fields']),
value: fields
}
]; ];
this._model = value;
this._fields = fields;
await this._settingsController.modifyProfileSettings(targets); await this._settingsController.modifyProfileSettings(targets);
this._setupFields();
} }
} }

View File

@ -882,7 +882,7 @@
</p> </p>
<div class="checkbox"> <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>
<div id="anki-general"> <div id="anki-general">
@ -894,9 +894,12 @@
</div> </div>
</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">&hellip;</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", 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. 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. The <strong>Show advanced options</strong> checkbox under General Options must be ticked ticked to show this option.
@ -955,41 +958,41 @@
</ul> </ul>
<div class="tab-content ignore-form-changes" id="anki-fields-container"> <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="row">
<div class="form-group col-xs-6"> <div class="form-group col-xs-6">
<label for="anki-terms-deck">Deck</label> <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>
<div class="form-group col-xs-6"> <div class="form-group col-xs-6">
<label for="anki-terms-model">Model</label> <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>
</div> </div>
<table class="table table-bordered anki-fields"> <table class="table table-bordered anki-fields">
<thead><tr><th>Field</th><th>Value</th></tr></thead> <thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody></tbody> <tbody class="anki-card-fields"></tbody>
</table> </table>
</div> </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="row">
<div class="form-group col-xs-6"> <div class="form-group col-xs-6">
<label for="anki-kanji-deck">Deck</label> <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>
<div class="form-group col-xs-6"> <div class="form-group col-xs-6">
<label for="anki-kanji-model">Model</label> <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>
</div> </div>
<table class="table table-bordered anki-fields"> <table class="table table-bordered anki-fields">
<thead><tr><th>Field</th><th>Value</th></tr></thead> <thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody></tbody> <tbody class="anki-card-fields"></tbody>
</table> </table>
</div> </div>
</div> </div>
@ -1056,22 +1059,22 @@
</div> </div>
</div> </div>
<template id="anki-field-template"><tr> <template id="anki-card-field-template"><tr>
<td class="col-sm-2 anki-field-name"></td> <td class="col-sm-2 anki-card-field-name"></td>
<td class="col-sm-10"> <td class="col-sm-10">
<div class="input-group"> <div class="input-group anki-card-field-value-container">
<input type="text" class="anki-field-value form-control" data-field="" value=""> <input type="text" class="anki-card-field-value form-control" data-field="" value="">
<div class="input-group-btn"> <div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown"> <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span> <span class="caret"></span>
</button> </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>
</div> </div>
</td> </td>
</tr></template> </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> </div>
</div> </div>