Add support for Anki API key (#2169)

* Update material.css to support password fields

* Support password

* Add "apiKey" setting

* Use apiKey

* Update options if API key changes

* Update tests
This commit is contained in:
toasted-nutbread 2022-05-30 12:03:24 -04:00 committed by GitHub
parent 0b5d54e7c6
commit 19bba07a8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 122 additions and 44 deletions

View File

@ -723,7 +723,8 @@ select::-ms-expand {
/* Material design inputs */ /* Material design inputs */
input[type=text], input[type=text],
input[type=number] { input[type=number],
input[type=password] {
width: var(--input-width); width: var(--input-width);
height: var(--input-height); height: var(--input-height);
line-height: var(--line-height); line-height: var(--line-height);
@ -745,7 +746,8 @@ input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
margin: 0; margin: 0;
} }
input[type=text] { input[type=text],
input[type=password] {
width: var(--input-width-large); width: var(--input-width-large);
} }
textarea { textarea {
@ -763,23 +765,27 @@ select:invalid,
textarea:invalid, textarea:invalid,
input[type=text]:invalid, input[type=text]:invalid,
input[type=number]:invalid, input[type=number]:invalid,
input[type=password]:invalid,
select[data-invalid=true], select[data-invalid=true],
textarea[data-invalid=true], textarea[data-invalid=true],
input[type=text][data-invalid=true], input[type=text][data-invalid=true],
input[type=number][data-invalid=true] { input[type=number][data-invalid=true],
input[type=password][data-invalid=true] {
border: var(--thin-border-size) solid var(--danger-color); border: var(--thin-border-size) solid var(--danger-color);
} }
select, select,
textarea, textarea,
input[type=text], input[type=text],
input[type=number] { input[type=number],
input[type=password] {
box-shadow: none; box-shadow: none;
transition: box-shadow calc(var(--animation-duration) / 2) linear; transition: box-shadow calc(var(--animation-duration) / 2) linear;
} }
select:focus, select:focus,
textarea:focus, textarea:focus,
input[type=text]:focus, input[type=text]:focus,
input[type=number]:focus { input[type=number]:focus,
input[type=password]:focus {
box-shadow: 0 0 0 calc(2em / var(--font-size-no-units)) var(--input-outline-color); box-shadow: 0 0 0 calc(2em / var(--font-size-no-units)) var(--input-outline-color);
outline: none; outline: none;
} }
@ -787,15 +793,18 @@ select:invalid:focus,
textarea:invalid:focus, textarea:invalid:focus,
input[type=text]:invalid:focus, input[type=text]:invalid:focus,
input[type=number]:invalid:focus, input[type=number]:invalid:focus,
input[type=password]:invalid:focus,
select[data-invalid=true]:focus, select[data-invalid=true]:focus,
textarea[data-invalid=true]:focus, textarea[data-invalid=true]:focus,
input[type=text][data-invalid=true]:focus, input[type=text][data-invalid=true]:focus,
input[type=number][data-invalid=true]:focus { input[type=number][data-invalid=true]:focus,
input[type=password][data-invalid=true]:focus {
box-shadow: 0 0 0 calc(2em / var(--font-size-no-units)) var(--danger-color); box-shadow: 0 0 0 calc(2em / var(--font-size-no-units)) var(--danger-color);
outline: none; outline: none;
} }
input[type=text].code, input[type=text].code,
input[type=number].code { input[type=number].code,
input[type=password].code {
font-family: 'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
} }
@ -807,6 +816,7 @@ input[type=number].code {
} }
.input-group>input[type=text], .input-group>input[type=text],
.input-group>input[type=number], .input-group>input[type=number],
.input-group>input[type=password],
.input-group>button.input-button { .input-group>button.input-button {
flex: 1 1 auto; flex: 1 1 auto;
border-top-right-radius: 0; border-top-right-radius: 0;
@ -815,6 +825,7 @@ input[type=number].code {
z-index: 1; z-index: 1;
} }
.input-suffix, .input-suffix,
.button.input-suffix,
button.input-suffix { button.input-suffix {
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
@ -828,11 +839,13 @@ button.input-suffix {
position: relative; position: relative;
} }
.input-suffix:not(:first-child), .input-suffix:not(:first-child),
.button.input-suffix:not(:first-child),
button.input-suffix:not(:first-child) { button.input-suffix:not(:first-child) {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
} }
.input-suffix:not(:last-child), .input-suffix:not(:last-child),
.button.input-suffix:not(:last-child),
button.input-suffix:not(:last-child) { button.input-suffix:not(:last-child) {
border-top-right-radius: 0; border-top-right-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
@ -842,8 +855,10 @@ button.input-suffix:not(:last-child) {
} }
input[type=text]:invalid~.input-suffix:not(button), input[type=text]:invalid~.input-suffix:not(button),
input[type=number]:invalid~.input-suffix:not(button), input[type=number]:invalid~.input-suffix:not(button),
input[type=password]:invalid~.input-suffix:not(button),
input[type=text][data-invalid=true]~.input-suffix:not(button), input[type=text][data-invalid=true]~.input-suffix:not(button),
input[type=number][data-invalid=true]~.input-suffix:not(button) { input[type=number][data-invalid=true]~.input-suffix:not(button),
input[type=password][data-invalid=true]~.input-suffix:not(button) {
border-color: var(--danger-color); border-color: var(--danger-color);
border-width: var(--thin-border-size); border-width: var(--thin-border-size);
border-style: solid; border-style: solid;
@ -1079,8 +1094,10 @@ button.input-suffix.input-suffix-icon-button>.icon {
} }
input[type=text]:invalid~button.input-suffix, input[type=text]:invalid~button.input-suffix,
input[type=number]:invalid~button.input-suffix, input[type=number]:invalid~button.input-suffix,
input[type=password]:invalid~button.input-suffix,
input[type=text][data-invalid=true]~button.input-suffix, input[type=text][data-invalid=true]~button.input-suffix,
input[type=number][data-invalid=true]~button.input-suffix { input[type=number][data-invalid=true]~button.input-suffix,
input[type=password][data-invalid=true]~button.input-suffix {
--button-border-color: var(--danger-color); --button-border-color: var(--danger-color);
--button-hover-border-color: var(--danger-color); --button-hover-border-color: var(--danger-color);
--button-active-border-color: var(--danger-color); --button-active-border-color: var(--danger-color);

View File

@ -845,7 +845,8 @@
"fieldTemplates", "fieldTemplates",
"suspendNewCards", "suspendNewCards",
"displayTags", "displayTags",
"noteGuiMode" "noteGuiMode",
"apiKey"
], ],
"properties": { "properties": {
"enable": { "enable": {
@ -965,6 +966,10 @@
"type": "string", "type": "string",
"enum": ["browse", "edit"], "enum": ["browse", "edit"],
"default": "browse" "default": "browse"
},
"apiKey": {
"type": "string",
"default": ""
} }
} }
}, },

View File

@ -998,8 +998,11 @@ class Backend {
const enabled = options.general.enable; const enabled = options.general.enable;
let {apiKey} = options.anki;
if (apiKey === '') { apiKey = null; }
this._anki.server = options.anki.server; this._anki.server = options.anki.server;
this._anki.enabled = options.anki.enable && enabled; this._anki.enabled = options.anki.enable && enabled;
this._anki.apiKey = apiKey;
this._mecab.setEnabled(options.parsing.enableMecabParser && enabled); this._mecab.setEnabled(options.parsing.enableMecabParser && enabled);

View File

@ -26,6 +26,7 @@ class AnkiConnect {
this._localVersion = 2; this._localVersion = 2;
this._remoteVersion = 0; this._remoteVersion = 0;
this._versionCheckPromise = null; this._versionCheckPromise = null;
this._apiKey = null;
} }
get server() { get server() {
@ -44,6 +45,14 @@ class AnkiConnect {
this._enabled = value; this._enabled = value;
} }
get apiKey() {
return this._apiKey;
}
set apiKey(value) {
this._apiKey = value;
}
async isConnected() { async isConnected() {
try { try {
await this._invoke('version'); await this._invoke('version');
@ -230,6 +239,8 @@ class AnkiConnect {
} }
async _invoke(action, params) { async _invoke(action, params) {
const body = {action, params, version: this._localVersion};
if (this._apiKey !== null) { body.key = this._apiKey; }
let response; let response;
try { try {
response = await fetch(this._server, { response = await fetch(this._server, {
@ -242,7 +253,7 @@ class AnkiConnect {
}, },
redirect: 'follow', redirect: 'follow',
referrerPolicy: 'no-referrer', referrerPolicy: 'no-referrer',
body: JSON.stringify({action, params, version: this._localVersion}) body: JSON.stringify(body)
}); });
} catch (e) { } catch (e) {
const error = new Error('Anki connection failure'); const error = new Error('Anki connection failure');

View File

@ -952,8 +952,10 @@ class OptionsUtil {
_updateVersion19(options) { _updateVersion19(options) {
// Version 19 changes: // Version 19 changes:
// Added anki.noteGuiMode. // Added anki.noteGuiMode.
// Added anki.apiKey.
for (const profile of options.profiles) { for (const profile of options.profiles) {
profile.options.anki.noteGuiMode = 'browse'; profile.options.anki.noteGuiMode = 'browse';
profile.options.anki.apiKey = '';
} }
return options; return options;
} }

View File

@ -126,10 +126,9 @@ class DOMDataBinder {
_createObserver(element) { _createObserver(element) {
const metadata = this._createElementMetadata(element); const metadata = this._createElementMetadata(element);
const nodeName = element.nodeName.toUpperCase();
const observer = { const observer = {
element, element,
type: (nodeName === 'INPUT' ? element.type : null), type: this._getNormalizedElementType(element),
value: null, value: null,
hasValue: false, hasValue: false,
onChange: null, onChange: null,
@ -157,28 +156,21 @@ class DOMDataBinder {
_isObserverStale(element, observer) { _isObserverStale(element, observer) {
const {type, metadata} = observer; const {type, metadata} = observer;
const nodeName = element.nodeName.toUpperCase();
return !( return !(
type === (nodeName === 'INPUT' ? element.type : null) && type === this._getNormalizedElementType(element) &&
this._compareElementMetadata(metadata, this._createElementMetadata(element)) this._compareElementMetadata(metadata, this._createElementMetadata(element))
); );
} }
_setElementValue(element, value) { _setElementValue(element, value) {
switch (element.nodeName.toUpperCase()) { switch (this._getNormalizedElementType(element)) {
case 'INPUT': case 'checkbox':
switch (element.type) { element.checked = value;
case 'checkbox':
element.checked = value;
break;
case 'text':
case 'number':
element.value = value;
break;
}
break; break;
case 'TEXTAREA': case 'text':
case 'SELECT': case 'number':
case 'textarea':
case 'select':
element.value = value; element.value = value;
break; break;
} }
@ -188,24 +180,37 @@ class DOMDataBinder {
} }
_getElementValue(element) { _getElementValue(element) {
switch (element.nodeName.toUpperCase()) { switch (this._getNormalizedElementType(element)) {
case 'INPUT': case 'checkbox':
switch (element.type) { return !!element.checked;
case 'checkbox': case 'text':
return !!element.checked; return `${element.value}`;
case 'text': case 'number':
return `${element.value}`; return DOMDataBinder.convertToNumber(element.value, element);
case 'number': case 'textarea':
return DOMDataBinder.convertToNumber(element.value, element); case 'select':
}
break;
case 'TEXTAREA':
case 'SELECT':
return element.value; return element.value;
} }
return null; return null;
} }
_getNormalizedElementType(element) {
switch (element.nodeName.toUpperCase()) {
case 'INPUT':
{
let {type} = element;
if (type === 'password') { type = 'text'; }
return type;
}
case 'TEXTAREA':
return 'textarea';
case 'SELECT':
return 'select';
default:
return null;
}
}
// Utilities // Utilities
static convertToNumber(value, constraints) { static convertToNumber(value, constraints) {

View File

@ -61,6 +61,7 @@ class AnkiController {
this._ankiErrorInvalidResponseInfo = document.querySelector('#anki-error-invalid-response-info'); this._ankiErrorInvalidResponseInfo = document.querySelector('#anki-error-invalid-response-info');
this._ankiEnableCheckbox = document.querySelector('[data-setting="anki.enable"]'); this._ankiEnableCheckbox = document.querySelector('[data-setting="anki.enable"]');
this._ankiCardPrimary = document.querySelector('#anki-card-primary'); this._ankiCardPrimary = document.querySelector('#anki-card-primary');
const ankiApiKeyInput = document.querySelector('#anki-api-key-input');
const ankiCardPrimaryTypeRadios = document.querySelectorAll('input[type=radio][name=anki-card-primary-type]'); const ankiCardPrimaryTypeRadios = document.querySelectorAll('input[type=radio][name=anki-card-primary-type]');
this._setupFieldMenus(); this._setupFieldMenus();
@ -79,9 +80,17 @@ class AnkiController {
document.querySelector('#anki-error-log').addEventListener('click', this._onAnkiErrorLogLinkClick.bind(this)); document.querySelector('#anki-error-log').addEventListener('click', this._onAnkiErrorLogLinkClick.bind(this));
const options = await this._settingsController.getOptions(); ankiApiKeyInput.addEventListener('focus', this._onApiKeyInputFocus.bind(this));
ankiApiKeyInput.addEventListener('blur', this._onApiKeyInputBlur.bind(this));
const onAnkiSettingChanged = () => { this._updateOptions(); };
const nodes = [ankiApiKeyInput, ...document.querySelectorAll('[data-setting="anki.enable"]')];
for (const node of nodes) {
node.addEventListener('settingChanged', onAnkiSettingChanged);
}
await this._updateOptions();
this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this));
this._onOptionsChanged({options});
} }
getFieldMarkers(type) { getFieldMarkers(type) {
@ -164,9 +173,17 @@ class AnkiController {
// Private // Private
async _updateOptions() {
const options = await this._settingsController.getOptions();
this._onOptionsChanged({options});
}
async _onOptionsChanged({options: {anki}}) { async _onOptionsChanged({options: {anki}}) {
let {apiKey} = anki;
if (apiKey === '') { apiKey = null; }
this._ankiConnect.server = anki.server; this._ankiConnect.server = anki.server;
this._ankiConnect.enabled = anki.enable; this._ankiConnect.enabled = anki.enable;
this._ankiConnect.apiKey = apiKey;
this._selectorObserver.disconnect(); this._selectorObserver.disconnect();
this._selectorObserver.observe(document.documentElement, true); this._selectorObserver.observe(document.documentElement, true);
@ -202,6 +219,14 @@ class AnkiController {
this._testAnkiNoteViewerSafe(e.currentTarget.dataset.mode); this._testAnkiNoteViewerSafe(e.currentTarget.dataset.mode);
} }
_onApiKeyInputFocus(e) {
e.currentTarget.type = 'text';
}
_onApiKeyInputBlur(e) {
e.currentTarget.type = 'password';
}
_setAnkiCardPrimaryType(ankiCardType, ankiCardMenu) { _setAnkiCardPrimaryType(ankiCardType, ankiCardMenu) {
if (this._ankiCardPrimary === null) { return; } if (this._ankiCardPrimary === null) { return; }
this._ankiCardPrimary.dataset.ankiCardType = ankiCardType; this._ankiCardPrimary.dataset.ankiCardType = ankiCardType;

View File

@ -1653,6 +1653,15 @@
> >
</div> </div>
</div></div> </div></div>
<div class="settings-item advanced-only"><div class="settings-item-inner settings-item-inner-wrappable">
<div class="settings-item-left">
<div class="settings-item-label">API key</div>
<div class="settings-item-description">Pass a secret value to AnkiConnect API calls.</div>
</div>
<div class="settings-item-right">
<input type="password" placeholder="Disabled" spellcheck="false" autocomplete="off" data-setting="anki.apiKey" id="anki-api-key-input">
</div>
</div></div>
<div class="settings-item advanced-only"> <div class="settings-item advanced-only">
<div class="settings-item-inner"> <div class="settings-item-inner">
<div class="settings-item-left"> <div class="settings-item-left">

View File

@ -454,7 +454,8 @@ function createProfileOptionsUpdatedTestData1() {
checkForDuplicates: true, checkForDuplicates: true,
fieldTemplates: null, fieldTemplates: null,
suspendNewCards: false, suspendNewCards: false,
noteGuiMode: 'browse' noteGuiMode: 'browse',
apiKey: ''
}, },
sentenceParsing: { sentenceParsing: {
scanExtent: 200, scanExtent: 200,