diff options
author | StefanVukovic99 <stefanvukovic44@gmail.com> | 2024-05-21 18:55:02 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-05-21 16:55:02 +0000 |
commit | ba9fa339a43a5f494785877018b10f111ccafff5 (patch) | |
tree | 1a4a0b141e08cfe767170425d5afda253bf92990 | |
parent | 737e7eab8182fc4e083f7fd6df840327ab83287c (diff) |
add support for overwriting existing card (#859)
* frontend
* wip
* minimum viable
* minor simplification
* simplify gradients in duplicate svg
* simplify add-duplicate svg
* colors good
* arrows shape
* disable overwrite if no valid duplicate IDs
* add warning
-rw-r--r-- | ext/css/display.css | 7 | ||||
-rw-r--r-- | ext/data/schemas/options-schema.json | 2 | ||||
-rw-r--r-- | ext/images/add-duplicate-term-kana.svg | 8 | ||||
-rw-r--r-- | ext/images/add-duplicate-term-kanji.svg | 8 | ||||
-rw-r--r-- | ext/images/overwrite-term-kana.svg | 30 | ||||
-rw-r--r-- | ext/images/overwrite-term-kanji.svg | 31 | ||||
-rw-r--r-- | ext/js/background/backend.js | 10 | ||||
-rw-r--r-- | ext/js/comm/anki-connect.js | 15 | ||||
-rw-r--r-- | ext/js/comm/api.js | 8 | ||||
-rw-r--r-- | ext/js/data/anki-util.js | 2 | ||||
-rw-r--r-- | ext/js/display/display-anki.js | 195 | ||||
-rw-r--r-- | ext/js/pages/settings/anki-controller.js | 24 | ||||
-rw-r--r-- | ext/settings.html | 10 | ||||
-rw-r--r-- | ext/templates-display.html | 8 | ||||
-rw-r--r-- | types/ext/anki.d.ts | 2 | ||||
-rw-r--r-- | types/ext/api.d.ts | 6 | ||||
-rw-r--r-- | types/ext/display-anki.d.ts | 3 | ||||
-rw-r--r-- | types/ext/settings.d.ts | 2 |
18 files changed, 274 insertions, 97 deletions
diff --git a/ext/css/display.css b/ext/css/display.css index 198ff662..9b4e0022 100644 --- a/ext/css/display.css +++ b/ext/css/display.css @@ -669,8 +669,13 @@ button.action-button:active { .icon[data-icon=view-note] { background-image: url('/images/view-note.svg'); } .icon[data-icon=add-term-kanji] { background-image: url('/images/add-term-kanji.svg'); } .icon[data-icon=add-term-kana] { background-image: url('/images/add-term-kana.svg'); } +.icon[data-icon=add-kanji] { background-image: url('/images/add-term-kanji.svg'); } +.icon[data-icon=overwrite-term-kanji] { background-image: url('/images/overwrite-term-kanji.svg'); } +.icon[data-icon=overwrite-term-kana] { background-image: url('/images/overwrite-term-kana.svg'); } +.icon[data-icon=overwrite-kanji] { background-image: url('/images/overwrite-term-kanji.svg'); } .icon[data-icon=add-duplicate-term-kanji] { background-image: url('/images/add-duplicate-term-kanji.svg'); } .icon[data-icon=add-duplicate-term-kana] { background-image: url('/images/add-duplicate-term-kana.svg'); } +.icon[data-icon=add-duplicate-kanji] { background-image: url('/images/add-duplicate-term-kanji.svg'); } .icon[data-icon=play-audio] { background-image: url('/images/play-audio.svg'); } .icon[data-icon=source-term] { background-image: url('/images/source-term.svg'); } .icon[data-icon=entry-current] { background-image: url('/images/entry-current.svg'); } @@ -1920,7 +1925,7 @@ button.footer-notification-close-button { display: none; } :root[data-anki-enabled=false] .action-button[data-action=view-note], -:root[data-anki-enabled=false] .action-button[data-action=add-note] { +:root[data-anki-enabled=false] .action-button[data-action=save-note] { display: none; } :root[data-audio-enabled=false] .action-button[data-action=play-audio] { diff --git a/ext/data/schemas/options-schema.json b/ext/data/schemas/options-schema.json index 0cb6db0a..5db4c087 100644 --- a/ext/data/schemas/options-schema.json +++ b/ext/data/schemas/options-schema.json @@ -990,7 +990,7 @@ }, "duplicateBehavior": { "type": "string", - "enum": ["prevent", "new"], + "enum": ["prevent", "overwrite", "new"], "default": "prevent" }, "fieldTemplates": { diff --git a/ext/images/add-duplicate-term-kana.svg b/ext/images/add-duplicate-term-kana.svg index 267c55ec..6bc430e6 100644 --- a/ext/images/add-duplicate-term-kana.svg +++ b/ext/images/add-duplicate-term-kana.svg @@ -13,8 +13,8 @@ <stop stop-opacity=".28986" offset="0"/> <stop stop-opacity="0" offset="1"/> </radialGradient> - <filter id="green-to-red" x="-.083333" y="-.083333" width="1.1667" height="1.1667" color-interpolation-filters="sRGB"> - <feColorMatrix result="color1" type="hueRotate" values="250"/> + <filter id="green-to-purple" x="-.083333" y="-.083333" width="1.1667" height="1.1667" color-interpolation-filters="sRGB"> + <feColorMatrix result="color1" type="hueRotate" values="180"/> <feColorMatrix result="color2" type="saturate" values="1"/> </filter> <symbol id="plus-in-circle" viewBox="0 0 16 16"> @@ -24,10 +24,10 @@ <path d="m6 8h1v-1h1v1h1v1h-1v1h-1v-1h-1v-1" fill="#fff"/> </symbol> </defs> - <g transform="translate(1,-1)" filter="url(#green-to-red)"> + <g transform="translate(1,-1)" filter="url(#green-to-purple)"> <use xlink:href="#plus-in-circle"/> </g> - <g transform="translate(-1,1)" filter="url(#green-to-red)"> + <g transform="translate(-1,1)" filter="url(#green-to-purple)"> <use xlink:href="#plus-in-circle"/> </g> </svg> diff --git a/ext/images/add-duplicate-term-kanji.svg b/ext/images/add-duplicate-term-kanji.svg index fc3279d9..da39eb1c 100644 --- a/ext/images/add-duplicate-term-kanji.svg +++ b/ext/images/add-duplicate-term-kanji.svg @@ -13,8 +13,8 @@ <stop stop-opacity=".28986" offset="0"/> <stop stop-opacity="0" offset="1"/> </radialGradient> - <filter id="green-to-red" x="-.038462" y="-.038462" width="1.0769" height="1.0769" color-interpolation-filters="sRGB"> - <feColorMatrix type="hueRotate" values="250"/> + <filter id="green-to-purple" x="-.038462" y="-.038462" width="1.0769" height="1.0769" color-interpolation-filters="sRGB"> + <feColorMatrix type="hueRotate" values="180"/> <feColorMatrix type="saturate" values="1"/> <feColorMatrix type="hueRotate" values="0"/> </filter> @@ -28,10 +28,10 @@ </symbol> </defs> - <g transform="matrix(.91504 0 0 .91504 2.2745 -.91504)" filter="url(#green-to-red)"> + <g transform="matrix(.91504 0 0 .91504 2.2745 -.91504)" filter="url(#green-to-purple)"> <use xlink:href="#plus-in-circle"/> </g> - <g transform="matrix(.91504 0 0 .91504 -.91504 2.2745)" filter="url(#green-to-red)"> + <g transform="matrix(.91504 0 0 .91504 -.91504 2.2745)" filter="url(#green-to-purple)"> <use xlink:href="#plus-in-circle"/> </g> </svg> diff --git a/ext/images/overwrite-term-kana.svg b/ext/images/overwrite-term-kana.svg new file mode 100644 index 00000000..7bcb1926 --- /dev/null +++ b/ext/images/overwrite-term-kana.svg @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="inner-fill" x1="-1.7198" x2="-1.7198" y1="2.7781" y2="1.4552" gradientTransform="matrix(3.7795 0 0 3.7795 14 .5)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#6fb558" offset="0"/> + <stop stop-color="#a5db9b" offset="1"/> + </linearGradient> + <linearGradient id="outer-rim" x1="2.1167" x2="1.4552" y1="2.3812" y2="1.7198" gradientTransform="scale(3.7795 3.7795)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#34812c" offset="0"/> + <stop stop-color="#87b870" offset="1"/> + </linearGradient> + <radialGradient id="center-shadow" cx="2.1167" cy="2.1167" r=".66146" gradientTransform="matrix(2.2677 -7.9311e-7 7.9312e-7 2.2677 2.7 3.7)" gradientUnits="userSpaceOnUse"> + <stop stop-opacity=".28986" offset="0"/> + <stop stop-opacity="0" offset="1"/> + </radialGradient> + <filter id="green-to-orange" x="-.083333" y="-.083333" width="1.1667" height="1.1667" color-interpolation-filters="sRGB"> + <feColorMatrix result="color1" type="hueRotate" values="280"/> + <feColorMatrix result="color2" type="saturate" values="2"/> + </filter> + </defs> + <g filter="url(#green-to-orange)"> + <circle cx="7.5" cy="8.5" r="3" fill="url(#inner-fill)"/> + <circle cx="7.5" cy="8.5" r="3" fill="none" stroke="url(#outer-rim)"/> + <path + transform="matrix(.49952 0 0 .49952 .84885 3.6971)" + d="m15.631 9.9045 0.66159 0.08268c-0.18327 1.4812-1.4463 2.6278-2.9771 2.6278-0.94244 0-1.7834-0.43457-2.3334-1.1143l3.1e-5 1.1143h-0.66667v-2.3333h2.3333v0.66667l-1.2482 3.45e-4c0.42167 0.60433 1.1221 0.99971 1.9149 0.99971 1.1906 0 2.173-0.89179 2.3156-2.0439zm0.68445-3.2895v2.3333h-2.3333v-0.66666l1.2484-5.09e-5c-0.42165-0.60446-1.1222-0.99995-1.9151-0.99995-1.1907 0-2.173 0.8918-2.3156 2.0439l-0.66159-0.082684c0.18327-1.4812 1.4463-2.6279 2.9771-2.6279 0.94244 0 1.7834 0.43457 2.3334 1.1143l-2.9e-5 -1.1143z" + fill="#fff" + style="stroke:#ffffff;stroke-opacity:1;stroke-width:0.40038437;stroke-dasharray:none" /> + </g> +</svg> diff --git a/ext/images/overwrite-term-kanji.svg b/ext/images/overwrite-term-kanji.svg new file mode 100644 index 00000000..d7d48ad8 --- /dev/null +++ b/ext/images/overwrite-term-kanji.svg @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <linearGradient id="inner-fill" x1="-1.7198" x2="-1.7198" y1="3.5719" y2=".79375" gradientTransform="matrix(3.7795 0 0 3.7795 14.5 -6.308e-7)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#6fb558" offset="0"/> + <stop stop-color="#a5db9b" offset="1"/> + </linearGradient> + <linearGradient id="outer-rim" x1="7.5406" x2="5.1594" y1="3.3073" y2=".92604" gradientTransform="matrix(3.7795 0 0 3.7795 -16 -6e-7)" gradientUnits="userSpaceOnUse"> + <stop stop-color="#34812c" offset="0"/> + <stop stop-color="#87b870" offset="1"/> + </linearGradient> + <radialGradient id="center-shadow" cx="2.1167" cy="2.1167" r=".66146" gradientTransform="matrix(4.5354 8.0301e-7 -8.0301e-7 4.5354 -1.6 -1.6)" gradientUnits="userSpaceOnUse"> + <stop stop-opacity=".28986" offset="0"/> + <stop stop-opacity="0" offset="1"/> + </radialGradient> + <filter id="green-to-orange" x="-.083333" y="-.083333" width="1.1667" height="1.1667" color-interpolation-filters="sRGB"> + <feColorMatrix result="color1" type="hueRotate" values="280"/> + <feColorMatrix result="color2" type="saturate" values="2"/> + </filter> + </defs> + <g filter="url(#green-to-orange)"> + <circle cx="8" cy="8" r="6.5" fill="url(#inner-fill)"/> + <circle cx="8" cy="8" r="5.75" fill="none" stroke="#fff" stroke-opacity=".50196" stroke-width="1.5"/> + <circle cx="8" cy="8" r="6.5" fill="none" stroke="url(#outer-rim)"/> + <path + d="m10.309 8.2795 0.66159 0.082684c-0.18327 1.4812-1.4463 2.6278-2.9771 2.6278-0.94244 0-1.7834-0.43457-2.3334-1.1143l3.11e-5 1.1143h-0.66667v-2.3333h2.3333v0.66667l-1.2482 3.453e-4c0.42167 0.60433 1.1221 0.99971 1.9149 0.99971 1.1906 0 2.173-0.89179 2.3156-2.0439zm0.68445-3.2895v2.3333h-2.3333v-0.66666l1.2484-5.09e-5c-0.42165-0.60446-1.1222-0.99995-1.9151-0.99995-1.1907 0-2.173 0.8918-2.3156 2.0439l-0.66159-0.082684c0.18327-1.4813 1.4463-2.6279 2.9771-2.6279 0.94244 0 1.7834 0.43457 2.3334 1.1143l-2.9e-5 -1.1143z" + fill="#fff" + style="stroke:#ffffff;stroke-opacity:1;stroke-width:0.4;stroke-dasharray:none" + /> + </g> +</svg> diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js index dfb85e05..b9e23cbb 100644 --- a/ext/js/background/backend.js +++ b/ext/js/background/backend.js @@ -28,7 +28,7 @@ import {logErrorLevelToNumber} from '../core/log-utilities.js'; import {log} from '../core/log.js'; import {isObjectNotArray} from '../core/object-utilities.js'; import {clone, deferPromise, promiseTimeout} from '../core/utilities.js'; -import {invalidNoteId, isNoteDataValid} from '../data/anki-util.js'; +import {INVALID_NOTE_ID, isNoteDataValid} from '../data/anki-util.js'; import {arrayBufferToBase64} from '../data/array-buffer-util.js'; import {OptionsUtil} from '../data/options-util.js'; import {getAllPermissions, hasPermissions, hasRequiredPermissionsForOptions} from '../data/permissions-util.js'; @@ -153,6 +153,7 @@ export class Backend { ['getAnkiConnectVersion', this._onApiGetAnkiConnectVersion.bind(this)], ['isAnkiConnected', this._onApiIsAnkiConnected.bind(this)], ['addAnkiNote', this._onApiAddAnkiNote.bind(this)], + ['updateAnkiNote', this._onApiUpdateAnkiNote.bind(this)], ['getAnkiNoteInfo', this._onApiGetAnkiNoteInfo.bind(this)], ['injectAnkiNoteMedia', this._onApiInjectAnkiNoteMedia.bind(this)], ['viewNotes', this._onApiViewNotes.bind(this)], @@ -539,6 +540,11 @@ export class Backend { return await this._anki.addNote(note); } + /** @type {import('api').ApiHandler<'updateAnkiNote'>} */ + async _onApiUpdateAnkiNote({noteWithId}) { + return await this._anki.updateNoteFields(noteWithId); + } + /** * @param {import('anki').Note[]} notes * @returns {Promise<import('backend').CanAddResults>} @@ -600,7 +606,7 @@ export class Backend { const valid = isNoteDataValid(note); if (isDuplicate && duplicateNoteIds[originalIndices.indexOf(i)].length === 0) { - duplicateNoteIds[originalIndices.indexOf(i)] = [invalidNoteId]; + duplicateNoteIds[originalIndices.indexOf(i)] = [INVALID_NOTE_ID]; } const noteIds = isDuplicate ? duplicateNoteIds[originalIndices.indexOf(i)] : null; diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js index 446b2139..8c5e2c85 100644 --- a/ext/js/comm/anki-connect.js +++ b/ext/js/comm/anki-connect.js @@ -144,6 +144,21 @@ export class AnkiConnect { } /** + * @param {import('anki').Note} noteWithId + * @returns {Promise<null>} + */ + async updateNoteFields(noteWithId) { + if (!this._enabled) { return null; } + await this._checkVersion(); + const result = await this._invoke('updateNoteFields', {note: noteWithId}); + if (result !== null) { + throw this._createUnexpectedResultError('null', result); + } + return result; + } + + + /** * @param {import('anki').Note[]} notes * @returns {Promise<boolean[]>} */ diff --git a/ext/js/comm/api.js b/ext/js/comm/api.js index e8db7846..b044a783 100644 --- a/ext/js/comm/api.js +++ b/ext/js/comm/api.js @@ -96,6 +96,14 @@ export class API { } /** + * @param {import('api').ApiParam<'updateAnkiNote', 'noteWithId'>} noteWithId + * @returns {Promise<import('api').ApiReturn<'updateAnkiNote'>>} + */ + updateAnkiNote(noteWithId) { + return this._invoke('updateAnkiNote', {noteWithId}); + } + + /** * @param {import('api').ApiParam<'getAnkiNoteInfo', 'notes'>} notes * @param {import('api').ApiParam<'getAnkiNoteInfo', 'fetchAdditionalInfo'>} fetchAdditionalInfo * @returns {Promise<import('api').ApiReturn<'getAnkiNoteInfo'>>} diff --git a/ext/js/data/anki-util.js b/ext/js/data/anki-util.js index c076c482..88b3c251 100644 --- a/ext/js/data/anki-util.js +++ b/ext/js/data/anki-util.js @@ -83,4 +83,4 @@ export function isNoteDataValid(note) { ); } -export const invalidNoteId = -1; +export const INVALID_NOTE_ID = -1; diff --git a/ext/js/display/display-anki.js b/ext/js/display/display-anki.js index 442319f7..6ac84ede 100644 --- a/ext/js/display/display-anki.js +++ b/ext/js/display/display-anki.js @@ -22,7 +22,7 @@ import {toError} from '../core/to-error.js'; import {deferPromise} from '../core/utilities.js'; import {AnkiNoteBuilder} from '../data/anki-note-builder.js'; import {getDynamicTemplates} from '../data/anki-template-util.js'; -import {invalidNoteId, isNoteDataValid} from '../data/anki-util.js'; +import {INVALID_NOTE_ID, isNoteDataValid} from '../data/anki-util.js'; import {PopupMenu} from '../dom/popup-menu.js'; import {querySelectorNotNull} from '../dom/query-selector.js'; import {TemplateRendererProxy} from '../templates/template-renderer-proxy.js'; @@ -50,7 +50,7 @@ export class DisplayAnki { /** @type {?import('./display-notification.js').DisplayNotification} */ this._tagsNotification = null; /** @type {?Promise<void>} */ - this._updateAdderButtonsPromise = null; + this._updateSaveButtonsPromise = null; /** @type {?import('core').TokenObject} */ this._updateDictionaryEntryDetailsToken = null; /** @type {EventListenerCollection} */ @@ -101,7 +101,7 @@ export class DisplayAnki { /** @type {(event: MouseEvent) => void} */ this._onShowTagsBind = this._onShowTags.bind(this); /** @type {(event: MouseEvent) => void} */ - this._onNoteAddBind = this._onNoteAdd.bind(this); + this._onNoteSaveBind = this._onNoteSave.bind(this); /** @type {(event: MouseEvent) => void} */ this._onViewNotesButtonClickBind = this._onViewNotesButtonClick.bind(this); /** @type {(event: MouseEvent) => void} */ @@ -115,9 +115,9 @@ export class DisplayAnki { this._noteContext = this._getNoteContext(); /* eslint-disable @stylistic/no-multi-spaces */ this._display.hotkeyHandler.registerActions([ - ['addNoteKanji', () => { this._tryAddAnkiNoteForSelectedEntry('kanji'); }], - ['addNoteTermKanji', () => { this._tryAddAnkiNoteForSelectedEntry('term-kanji'); }], - ['addNoteTermKana', () => { this._tryAddAnkiNoteForSelectedEntry('term-kana'); }], + ['addNoteKanji', () => { this._hotkeySaveAnkiNoteForSelectedEntry('kanji'); }], + ['addNoteTermKanji', () => { this._hotkeySaveAnkiNoteForSelectedEntry('term-kanji'); }], + ['addNoteTermKana', () => { this._hotkeySaveAnkiNoteForSelectedEntry('term-kana'); }], ['viewNotes', this._viewNotesForSelectedEntry.bind(this)] ]); /* eslint-enable @stylistic/no-multi-spaces */ @@ -251,8 +251,8 @@ export class DisplayAnki { for (const node of element.querySelectorAll('.action-button[data-action=view-tags]')) { eventListeners.addEventListener(node, 'click', this._onShowTagsBind); } - for (const node of element.querySelectorAll('.action-button[data-action=add-note]')) { - eventListeners.addEventListener(node, 'click', this._onNoteAddBind); + for (const node of element.querySelectorAll('.action-button[data-action=save-note]')) { + eventListeners.addEventListener(node, 'click', this._onNoteSaveBind); } for (const node of element.querySelectorAll('.action-button[data-action=view-note]')) { eventListeners.addEventListener(node, 'click', this._onViewNotesButtonClickBind); @@ -276,13 +276,13 @@ export class DisplayAnki { /** * @param {MouseEvent} e */ - _onNoteAdd(e) { + _onNoteSave(e) { e.preventDefault(); const element = /** @type {HTMLElement} */ (e.currentTarget); const mode = this._getValidCreateMode(element.dataset.mode); if (mode === null) { return; } const index = this._display.getElementDictionaryEntryIndex(element); - void this._addAnkiNote(index, mode); + void this._saveAnkiNote(index, mode); } /** @@ -300,9 +300,9 @@ export class DisplayAnki { * @param {import('display-anki').CreateMode} mode * @returns {?HTMLButtonElement} */ - _adderButtonFind(index, mode) { + _saveButtonFind(index, mode) { const entry = this._getEntry(index); - return entry !== null ? entry.querySelector(`.action-button[data-action=add-note][data-mode="${mode}"]`) : null; + return entry !== null ? entry.querySelector(`.action-button[data-action=save-note][data-mode="${mode}"]`) : null; } /** @@ -356,66 +356,76 @@ export class DisplayAnki { /** @type {?import('core').TokenObject} */ const token = {}; this._updateDictionaryEntryDetailsToken = token; - if (this._updateAdderButtonsPromise !== null) { - await this._updateAdderButtonsPromise; + if (this._updateSaveButtonsPromise !== null) { + await this._updateSaveButtonsPromise; } if (this._updateDictionaryEntryDetailsToken !== token) { return; } const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise()); try { - this._updateAdderButtonsPromise = promise; + this._updateSaveButtonsPromise = promise; const dictionaryEntryDetails = await this._getDictionaryEntryDetails(dictionaryEntries); if (this._updateDictionaryEntryDetailsToken !== token) { return; } this._dictionaryEntryDetails = dictionaryEntryDetails; - this._updateAdderButtons(dictionaryEntryDetails); + this._updateSaveButtons(dictionaryEntryDetails); } finally { resolve(); - if (this._updateAdderButtonsPromise === promise) { - this._updateAdderButtonsPromise = null; + if (this._updateSaveButtonsPromise === promise) { + this._updateSaveButtonsPromise = null; } } } /** * @param {HTMLButtonElement} button + * @param {number[]} noteIds */ - _showDuplicateAddButton(button) { - const isKanjiAdd = button.dataset.mode === 'term-kanji'; + _updateSaveButtonForDuplicateBehavior(button, noteIds) { + const behavior = this._duplicateBehavior; + if (behavior === 'prevent') { + button.disabled = true; + return; + } - const title = button.getAttribute('title'); - if (title) { - button.setAttribute('title', title.replace(/Add (?!duplicate)/, 'Add duplicate ')); + const mode = button.dataset.mode; + const verb = behavior === 'overwrite' ? 'Overwrite' : 'Add duplicate'; + const iconPrefix = behavior === 'overwrite' ? 'overwrite' : 'add-duplicate'; + const target = mode === 'term-kanji' ? 'expression' : 'reading'; + + if (behavior === 'overwrite') { + button.dataset.overwrite = 'true'; + if (!noteIds.some((id) => id !== INVALID_NOTE_ID)) { + button.disabled = true; + } + } else { + delete button.dataset.overwrite; } + button.setAttribute('title', `${verb} ${target}`); + // eslint-disable-next-line no-underscore-dangle const hotkeyLabel = this._display._hotkeyHelpController.getHotkeyLabel(button); - if (hotkeyLabel) { - if (hotkeyLabel === 'Add expression ({0})') { - // eslint-disable-next-line no-underscore-dangle - this._display._hotkeyHelpController.setHotkeyLabel(button, 'Add duplicate expression ({0})'); - } else if (hotkeyLabel === 'Add reading ({0})') { - // eslint-disable-next-line no-underscore-dangle - this._display._hotkeyHelpController.setHotkeyLabel(button, 'Add duplicate reading ({0})'); - } + // eslint-disable-next-line no-underscore-dangle + this._display._hotkeyHelpController.setHotkeyLabel(button, `${verb} ${target} ({0})`); } const actionIcon = button.querySelector('.action-icon'); if (actionIcon instanceof HTMLElement) { - actionIcon.dataset.icon = isKanjiAdd ? 'add-duplicate-term-kanji' : 'add-duplicate-term-kana'; + actionIcon.dataset.icon = `${iconPrefix}-${mode}`; } } /** * @param {import('display-anki').DictionaryEntryDetails[]} dictionaryEntryDetails */ - _updateAdderButtons(dictionaryEntryDetails) { + _updateSaveButtons(dictionaryEntryDetails) { const displayTags = this._displayTags; for (let i = 0, ii = dictionaryEntryDetails.length; i < ii; ++i) { /** @type {?Set<number>} */ let allNoteIds = null; for (const {mode, canAdd, noteIds, noteInfos, ankiError} of dictionaryEntryDetails[i].modeMap.values()) { - const button = this._adderButtonFind(i, mode); + const button = this._saveButtonFind(i, mode); if (button !== null) { button.disabled = !canAdd; button.hidden = (ankiError !== null); @@ -425,14 +435,14 @@ export class DisplayAnki { // If entry has noteIds, show the "add duplicate" button. if (Array.isArray(noteIds) && noteIds.length > 0) { - this._updateButtonForDuplicate(button); + this._updateSaveButtonForDuplicateBehavior(button, noteIds); } } if (Array.isArray(noteIds) && noteIds.length > 0) { if (allNoteIds === null) { allNoteIds = new Set(); } for (const noteId of noteIds) { - if (noteId !== invalidNoteId) { + if (noteId !== INVALID_NOTE_ID) { allNoteIds.add(noteId); } } @@ -448,17 +458,6 @@ export class DisplayAnki { } /** - * @param {HTMLButtonElement} button - */ - _updateButtonForDuplicate(button) { - if (this._duplicateBehavior === 'prevent') { - button.disabled = true; - } else { - this._showDuplicateAddButton(button); - } - } - - /** * @param {number} i * @param {(?import('anki').NoteInfo)[]} noteInfos */ @@ -503,16 +502,16 @@ export class DisplayAnki { /** * @param {import('display-anki').CreateMode} mode */ - _tryAddAnkiNoteForSelectedEntry(mode) { + _hotkeySaveAnkiNoteForSelectedEntry(mode) { const index = this._display.selectedIndex; - void this._addAnkiNote(index, mode); + void this._saveAnkiNote(index, mode); } /** * @param {number} dictionaryEntryIndex * @param {import('display-anki').CreateMode} mode */ - async _addAnkiNote(dictionaryEntryIndex, mode) { + async _saveAnkiNote(dictionaryEntryIndex, mode) { const dictionaryEntries = this._display.dictionaryEntries; const dictionaryEntryDetails = this._dictionaryEntryDetails; if (!( @@ -529,7 +528,7 @@ export class DisplayAnki { const {requirements} = details; - const button = this._adderButtonFind(dictionaryEntryIndex, mode); + const button = this._saveButtonFind(dictionaryEntryIndex, mode); if (button === null || button.disabled) { return; } this._hideErrorNotification(true); @@ -544,34 +543,9 @@ export class DisplayAnki { const error = this._getAddNoteRequirementsError(requirements, outputRequirements); if (error !== null) { allErrors.push(error); } - - let noteId = null; - let addNoteOkay = false; - try { - noteId = await this._display.application.api.addAnkiNote(note); - addNoteOkay = true; - } catch (e) { - allErrors.length = 0; - allErrors.push(toError(e)); - } - - if (addNoteOkay) { - if (noteId === null) { - allErrors.push(new Error('Note could not be added')); - } else { - if (this._suspendNewCards) { - try { - await this._display.application.api.suspendAnkiCardsForNote(noteId); - } catch (e) { - allErrors.push(toError(e)); - } - } - // Now that this dictionary entry has a duplicate in Anki, show the "add duplicate" buttons. - this._updateButtonForDuplicate(button); - - this._updateViewNoteButton(dictionaryEntryIndex, [noteId], true); - } - } + await (button.dataset.overwrite ? + this._updateAnkiNote(note, allErrors, button, dictionaryEntryIndex) : + this._addNewAnkiNote(note, allErrors, button, dictionaryEntryIndex)); } catch (e) { allErrors.push(toError(e)); } finally { @@ -586,6 +560,69 @@ export class DisplayAnki { } /** + * @param {import('anki').Note} note + * @param {Error[]} allErrors + * @param {HTMLButtonElement} button + * @param {number} dictionaryEntryIndex + */ + async _addNewAnkiNote(note, allErrors, button, dictionaryEntryIndex) { + let noteId = null; + let addNoteOkay = false; + try { + noteId = await this._display.application.api.addAnkiNote(note); + addNoteOkay = true; + } catch (e) { + allErrors.length = 0; + allErrors.push(toError(e)); + } + + if (addNoteOkay) { + if (noteId === null) { + allErrors.push(new Error('Note could not be added')); + } else { + if (this._suspendNewCards) { + try { + await this._display.application.api.suspendAnkiCardsForNote(noteId); + } catch (e) { + allErrors.push(toError(e)); + } + } + this._updateSaveButtonForDuplicateBehavior(button, [noteId]); + + this._updateViewNoteButton(dictionaryEntryIndex, [noteId], true); + } + } + } + + /** + * @param {import('anki').Note} note + * @param {Error[]} allErrors + * @param {HTMLButtonElement} button + * @param {number} dictionaryEntryIndex + */ + async _updateAnkiNote(note, allErrors, button, dictionaryEntryIndex) { + const dictionaryEntries = this._display.dictionaryEntries; + const allEntryDetails = await this._getDictionaryEntryDetails(dictionaryEntries); + const relevantEntryDetails = allEntryDetails[dictionaryEntryIndex]; + const mode = this._getValidCreateMode(button.dataset.mode); + if (mode === null) { return; } + const relevantModeDetails = relevantEntryDetails.modeMap.get(mode); + if (typeof relevantModeDetails === 'undefined') { return; } + const {noteIds} = relevantModeDetails; + if (noteIds === null) { return; } + const overwriteTarget = noteIds.find((id) => id !== INVALID_NOTE_ID); + if (typeof overwriteTarget === 'undefined') { return; } + + try { + const noteWithId = {...note, id: overwriteTarget}; + await this._display.application.api.updateAnkiNote(noteWithId); + } catch (e) { + allErrors.length = 0; + allErrors.push(toError(e)); + } + } + + /** * @param {import('anki-note-builder').Requirement[]} requirements * @param {import('anki-note-builder').Requirement[]} outputRequirements * @returns {?DisplayAnkiError} diff --git a/ext/js/pages/settings/anki-controller.js b/ext/js/pages/settings/anki-controller.js index 2d461336..de8b2c39 100644 --- a/ext/js/pages/settings/anki-controller.js +++ b/ext/js/pages/settings/anki-controller.js @@ -63,6 +63,10 @@ export class AnkiController { /** @type {HTMLElement} */ this._ankiErrorInvalidResponseInfo = querySelectorNotNull(document, '#anki-error-invalid-response-info'); /** @type {HTMLElement} */ + this._duplicateBehaviorSelect = querySelectorNotNull(document, '[data-setting="anki.duplicateBehavior"]'); + /** @type {HTMLElement} */ + this._duplicateOverwriteWarning = querySelectorNotNull(document, '#anki-overwrite-warning'); + /** @type {HTMLElement} */ this._ankiCardPrimary = querySelectorNotNull(document, '#anki-card-primary'); /** @type {?Error} */ this._ankiError = null; @@ -109,6 +113,8 @@ export class AnkiController { ankiApiKeyInput.addEventListener('focus', this._onApiKeyInputFocus.bind(this)); ankiApiKeyInput.addEventListener('blur', this._onApiKeyInputBlur.bind(this)); + this._duplicateBehaviorSelect.addEventListener('change', this._onDuplicateBehaviorSelectChange.bind(this)); + await this._updateOptions(); this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); @@ -171,6 +177,8 @@ export class AnkiController { this._selectorObserver.disconnect(); this._selectorObserver.observe(document.documentElement, true); + this._updateDuplicateOverwriteWarning(anki.duplicateBehavior); + this._setupFieldMenus(dictionaries); } @@ -240,6 +248,22 @@ export class AnkiController { } /** + * @param {Event} e + */ + _onDuplicateBehaviorSelectChange(e) { + const node = /** @type {HTMLSelectElement} */ (e.currentTarget); + const behavior = node.value; + this._updateDuplicateOverwriteWarning(behavior); + } + + /** + * @param {string} behavior + */ + _updateDuplicateOverwriteWarning(behavior) { + this._duplicateOverwriteWarning.hidden = behavior !== 'overwrite'; + } + + /** * @param {string} ankiCardType * @param {string} [ankiCardMenu] */ diff --git a/ext/settings.html b/ext/settings.html index 525e99b1..12281764 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -1709,10 +1709,20 @@ <div class="settings-item-right"> <select data-setting="anki.duplicateBehavior"> <option value="prevent">Prevent adding</option> + <option value="overwrite">Allow overwriting</option> <option value="new">Allow adding</option> </select> </div> </div> + <div class="settings-item-children" id="anki-overwrite-warning" hidden> + <p class="danger-text"> + Overwriting a card can result in the loss of data. + </p> + <p class="warning-text"> + Duplicate cards of a different note type cannot be overwritten. + If there are multiple duplicate cards, the first card found will be overwritten. + </p> + </div> </div> </div> </div> diff --git a/ext/templates-display.html b/ext/templates-display.html index 944a10ab..a948d4c3 100644 --- a/ext/templates-display.html +++ b/ext/templates-display.html @@ -12,10 +12,10 @@ <span class="action-icon icon color-icon" data-icon="view-note"></span> <span class="action-button-badge icon" hidden></span> </button> - <button type="button" class="action-button" data-action="add-note" hidden disabled data-mode="term-kanji" title="Add expression" data-hotkey='["addNoteTermKanji","title","Add expression ({0})"]'> + <button type="button" class="action-button" data-action="save-note" hidden disabled data-mode="term-kanji" title="Add expression" data-hotkey='["addNoteTermKanji","title","Add expression ({0})"]'> <span class="action-icon icon color-icon" data-icon="add-term-kanji"></span> </button> - <button type="button" class="action-button" data-action="add-note" hidden disabled data-mode="term-kana" title="Add reading" data-hotkey='["addNoteTermKana","title","Add reading ({0})"]'> + <button type="button" class="action-button" data-action="save-note" hidden disabled data-mode="term-kana" title="Add reading" data-hotkey='["addNoteTermKana","title","Add reading ({0})"]'> <span class="action-icon icon color-icon" data-icon="add-term-kana"></span> </button> <button type="button" class="action-button" data-action="play-audio" title="Play audio" data-title-default="Play audio" data-hotkey='["playAudio",["title","data-title-default"],"Play audio ({0})"]' data-menu-position="left below h-cover v-cover"> @@ -114,8 +114,8 @@ <button type="button" class="action-button" data-action="view-note" hidden disabled title="View added note" data-hotkey='["viewNotes","title","View added note ({0})"]'> <span class="action-icon icon color-icon" data-icon="view-note"></span> </button> - <button type="button" class="action-button" data-action="add-note" hidden disabled data-mode="kanji" title="Add kanji" data-hotkey='["addNoteKanji","title","Add kanji ({0})"]'> - <span class="action-icon icon color-icon" data-icon="add-term-kanji"></span> + <button type="button" class="action-button" data-action="save-note" hidden disabled data-mode="kanji" title="Add kanji" data-hotkey='["addNoteKanji","title","Add kanji ({0})"]'> + <span class="action-icon icon color-icon" data-icon="add-kanji"></span> </button> <span class="entry-current-indicator-icon" title="Current entry"> <span class="icon color-icon" data-icon="entry-current"></span> diff --git a/types/ext/anki.d.ts b/types/ext/anki.d.ts index 4a903c52..8ad0890f 100644 --- a/types/ext/anki.d.ts +++ b/types/ext/anki.d.ts @@ -22,6 +22,8 @@ export type NoteId = number; export type CardId = number; +export type NoteWithId = Note & {id: NoteId}; + export type Note = { fields: NoteFields; tags: string[]; diff --git a/types/ext/api.d.ts b/types/ext/api.d.ts index 9a922fb0..3a2fc0b8 100644 --- a/types/ext/api.d.ts +++ b/types/ext/api.d.ts @@ -169,6 +169,12 @@ type ApiSurface = { }; return: Anki.NoteId | null; }; + updateAnkiNote: { + params: { + noteWithId: Anki.NoteWithId; + }; + return: null; + }; getAnkiNoteInfo: { params: { notes: Anki.Note[]; diff --git a/types/ext/display-anki.d.ts b/types/ext/display-anki.d.ts index 1b0dd4ff..4b6c6e37 100644 --- a/types/ext/display-anki.d.ts +++ b/types/ext/display-anki.d.ts @@ -46,6 +46,9 @@ export type DictionaryEntryModeDetails = { requirements: AnkiNoteBuilder.Requirement[]; canAdd: boolean; valid: boolean; + /** + * Anki IDs of duplicate notes. May contain INVALID_NOTE_ID for notes whose ID could not be found. + */ noteIds: Anki.NoteId[] | null; noteInfos?: (Anki.NoteInfo | null)[]; ankiError: Error | null; diff --git a/types/ext/settings.d.ts b/types/ext/settings.d.ts index 1fce0f59..48a66728 100644 --- a/types/ext/settings.d.ts +++ b/types/ext/settings.d.ts @@ -395,7 +395,7 @@ export type AnkiScreenshotFormat = 'png' | 'jpeg'; export type AnkiDuplicateScope = 'collection' | 'deck' | 'deck-root'; -export type AnkiDuplicateBehavior = 'prevent' | 'new'; +export type AnkiDuplicateBehavior = 'prevent' | 'overwrite' | 'new'; export type AnkiDisplayTags = 'never' | 'always' | 'non-standard'; |