diff --git a/ext/css/material.css b/ext/css/material.css index 4d8eda51..4d319349 100644 --- a/ext/css/material.css +++ b/ext/css/material.css @@ -723,7 +723,8 @@ select::-ms-expand { /* Material design inputs */ input[type=text], -input[type=number] { +input[type=number], +input[type=password] { width: var(--input-width); height: var(--input-height); line-height: var(--line-height); @@ -745,7 +746,8 @@ input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } -input[type=text] { +input[type=text], +input[type=password] { width: var(--input-width-large); } textarea { @@ -763,23 +765,27 @@ select:invalid, textarea:invalid, input[type=text]:invalid, input[type=number]:invalid, +input[type=password]:invalid, select[data-invalid=true], textarea[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); } select, textarea, input[type=text], -input[type=number] { +input[type=number], +input[type=password] { box-shadow: none; transition: box-shadow calc(var(--animation-duration) / 2) linear; } select:focus, textarea: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); outline: none; } @@ -787,15 +793,18 @@ select:invalid:focus, textarea:invalid:focus, input[type=text]:invalid:focus, input[type=number]:invalid:focus, +input[type=password]:invalid:focus, select[data-invalid=true]:focus, textarea[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); outline: none; } input[type=text].code, -input[type=number].code { +input[type=number].code, +input[type=password].code { font-family: 'Courier New', Courier, monospace; } @@ -807,6 +816,7 @@ input[type=number].code { } .input-group>input[type=text], .input-group>input[type=number], +.input-group>input[type=password], .input-group>button.input-button { flex: 1 1 auto; border-top-right-radius: 0; @@ -815,6 +825,7 @@ input[type=number].code { z-index: 1; } .input-suffix, +.button.input-suffix, button.input-suffix { display: flex; flex-flow: row nowrap; @@ -828,11 +839,13 @@ button.input-suffix { position: relative; } .input-suffix:not(:first-child), +.button.input-suffix:not(:first-child), button.input-suffix:not(:first-child) { border-top-left-radius: 0; border-bottom-left-radius: 0; } .input-suffix:not(:last-child), +.button.input-suffix:not(:last-child), button.input-suffix:not(:last-child) { border-top-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=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=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-width: var(--thin-border-size); border-style: solid; @@ -1079,8 +1094,10 @@ button.input-suffix.input-suffix-icon-button>.icon { } input[type=text]: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=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-hover-border-color: var(--danger-color); --button-active-border-color: var(--danger-color); diff --git a/ext/data/schemas/options-schema.json b/ext/data/schemas/options-schema.json index 46d8a32a..215ca32c 100644 --- a/ext/data/schemas/options-schema.json +++ b/ext/data/schemas/options-schema.json @@ -845,7 +845,8 @@ "fieldTemplates", "suspendNewCards", "displayTags", - "noteGuiMode" + "noteGuiMode", + "apiKey" ], "properties": { "enable": { @@ -965,6 +966,10 @@ "type": "string", "enum": ["browse", "edit"], "default": "browse" + }, + "apiKey": { + "type": "string", + "default": "" } } }, diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js index 07d6fd98..75ff7bee 100644 --- a/ext/js/background/backend.js +++ b/ext/js/background/backend.js @@ -998,8 +998,11 @@ class Backend { const enabled = options.general.enable; + let {apiKey} = options.anki; + if (apiKey === '') { apiKey = null; } this._anki.server = options.anki.server; this._anki.enabled = options.anki.enable && enabled; + this._anki.apiKey = apiKey; this._mecab.setEnabled(options.parsing.enableMecabParser && enabled); diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js index f5dc62f2..f0aff8fa 100644 --- a/ext/js/comm/anki-connect.js +++ b/ext/js/comm/anki-connect.js @@ -26,6 +26,7 @@ class AnkiConnect { this._localVersion = 2; this._remoteVersion = 0; this._versionCheckPromise = null; + this._apiKey = null; } get server() { @@ -44,6 +45,14 @@ class AnkiConnect { this._enabled = value; } + get apiKey() { + return this._apiKey; + } + + set apiKey(value) { + this._apiKey = value; + } + async isConnected() { try { await this._invoke('version'); @@ -230,6 +239,8 @@ class AnkiConnect { } async _invoke(action, params) { + const body = {action, params, version: this._localVersion}; + if (this._apiKey !== null) { body.key = this._apiKey; } let response; try { response = await fetch(this._server, { @@ -242,7 +253,7 @@ class AnkiConnect { }, redirect: 'follow', referrerPolicy: 'no-referrer', - body: JSON.stringify({action, params, version: this._localVersion}) + body: JSON.stringify(body) }); } catch (e) { const error = new Error('Anki connection failure'); diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js index f87bfa4b..f19094df 100644 --- a/ext/js/data/options-util.js +++ b/ext/js/data/options-util.js @@ -952,8 +952,10 @@ class OptionsUtil { _updateVersion19(options) { // Version 19 changes: // Added anki.noteGuiMode. + // Added anki.apiKey. for (const profile of options.profiles) { profile.options.anki.noteGuiMode = 'browse'; + profile.options.anki.apiKey = ''; } return options; } diff --git a/ext/js/dom/dom-data-binder.js b/ext/js/dom/dom-data-binder.js index 4f35ba33..185dc439 100644 --- a/ext/js/dom/dom-data-binder.js +++ b/ext/js/dom/dom-data-binder.js @@ -126,10 +126,9 @@ class DOMDataBinder { _createObserver(element) { const metadata = this._createElementMetadata(element); - const nodeName = element.nodeName.toUpperCase(); const observer = { element, - type: (nodeName === 'INPUT' ? element.type : null), + type: this._getNormalizedElementType(element), value: null, hasValue: false, onChange: null, @@ -157,28 +156,21 @@ class DOMDataBinder { _isObserverStale(element, observer) { const {type, metadata} = observer; - const nodeName = element.nodeName.toUpperCase(); return !( - type === (nodeName === 'INPUT' ? element.type : null) && + type === this._getNormalizedElementType(element) && this._compareElementMetadata(metadata, this._createElementMetadata(element)) ); } _setElementValue(element, value) { - switch (element.nodeName.toUpperCase()) { - case 'INPUT': - switch (element.type) { - case 'checkbox': - element.checked = value; - break; - case 'text': - case 'number': - element.value = value; - break; - } + switch (this._getNormalizedElementType(element)) { + case 'checkbox': + element.checked = value; break; - case 'TEXTAREA': - case 'SELECT': + case 'text': + case 'number': + case 'textarea': + case 'select': element.value = value; break; } @@ -188,24 +180,37 @@ class DOMDataBinder { } _getElementValue(element) { - switch (element.nodeName.toUpperCase()) { - case 'INPUT': - switch (element.type) { - case 'checkbox': - return !!element.checked; - case 'text': - return `${element.value}`; - case 'number': - return DOMDataBinder.convertToNumber(element.value, element); - } - break; - case 'TEXTAREA': - case 'SELECT': + switch (this._getNormalizedElementType(element)) { + case 'checkbox': + return !!element.checked; + case 'text': + return `${element.value}`; + case 'number': + return DOMDataBinder.convertToNumber(element.value, element); + case 'textarea': + case 'select': return element.value; } 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 static convertToNumber(value, constraints) { diff --git a/ext/js/pages/settings/anki-controller.js b/ext/js/pages/settings/anki-controller.js index d03fa535..cfbac0ea 100644 --- a/ext/js/pages/settings/anki-controller.js +++ b/ext/js/pages/settings/anki-controller.js @@ -61,6 +61,7 @@ class AnkiController { this._ankiErrorInvalidResponseInfo = document.querySelector('#anki-error-invalid-response-info'); this._ankiEnableCheckbox = document.querySelector('[data-setting="anki.enable"]'); 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]'); this._setupFieldMenus(); @@ -79,9 +80,17 @@ class AnkiController { 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._onOptionsChanged({options}); } getFieldMarkers(type) { @@ -164,9 +173,17 @@ class AnkiController { // Private + async _updateOptions() { + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + async _onOptionsChanged({options: {anki}}) { + let {apiKey} = anki; + if (apiKey === '') { apiKey = null; } this._ankiConnect.server = anki.server; this._ankiConnect.enabled = anki.enable; + this._ankiConnect.apiKey = apiKey; this._selectorObserver.disconnect(); this._selectorObserver.observe(document.documentElement, true); @@ -202,6 +219,14 @@ class AnkiController { this._testAnkiNoteViewerSafe(e.currentTarget.dataset.mode); } + _onApiKeyInputFocus(e) { + e.currentTarget.type = 'text'; + } + + _onApiKeyInputBlur(e) { + e.currentTarget.type = 'password'; + } + _setAnkiCardPrimaryType(ankiCardType, ankiCardMenu) { if (this._ankiCardPrimary === null) { return; } this._ankiCardPrimary.dataset.ankiCardType = ankiCardType; diff --git a/ext/settings.html b/ext/settings.html index 95ab8a03..3284117f 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -1653,6 +1653,15 @@ > +
+
+
API key
+
Pass a secret value to AnkiConnect API calls.
+
+
+ +
+
diff --git a/test/test-options-util.js b/test/test-options-util.js index 425201ce..c4f9a3a9 100644 --- a/test/test-options-util.js +++ b/test/test-options-util.js @@ -454,7 +454,8 @@ function createProfileOptionsUpdatedTestData1() { checkForDuplicates: true, fieldTemplates: null, suspendNewCards: false, - noteGuiMode: 'browse' + noteGuiMode: 'browse', + apiKey: '' }, sentenceParsing: { scanExtent: 200,