From 7ee76d708934adeef06479f7757beb22c6c01d14 Mon Sep 17 00:00:00 2001 From: Eloy Robillard Date: Mon, 18 Mar 2024 12:19:27 +0100 Subject: Add an option to allow both viewing and adding duplicates (#693) * Detect duplicates when checking if can add note * Display the stacked add buttons --- ext/css/display.css | 2 + ext/images/add-duplicate-term-kana.svg | 40 + ext/images/add-duplicate-term-kanji.svg | 84 ++ ext/js/background/backend.js | 87 +- ext/js/comm/anki-connect.js | 57 + ext/js/data/anki-note-builder.js | 3 +- ext/js/display/display-anki.js | 55 +- ext/js/input/hotkey-help-controller.js | 32 + resources/icons.svg | 2449 ++++++++++++------------------- test/playwright/playwright-util.js | 2 +- test/utilities/anki.js | 1 - types/ext/anki-connect.d.ts | 22 + types/ext/anki-note-builder.d.ts | 1 - types/ext/backend.d.ts | 5 + 14 files changed, 1302 insertions(+), 1538 deletions(-) create mode 100644 ext/images/add-duplicate-term-kana.svg create mode 100644 ext/images/add-duplicate-term-kanji.svg create mode 100644 types/ext/anki-connect.d.ts diff --git a/ext/css/display.css b/ext/css/display.css index ca91a470..a148af13 100644 --- a/ext/css/display.css +++ b/ext/css/display.css @@ -669,6 +669,8 @@ 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-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=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'); } diff --git a/ext/images/add-duplicate-term-kana.svg b/ext/images/add-duplicate-term-kana.svg new file mode 100644 index 00000000..9b574aee --- /dev/null +++ b/ext/images/add-duplicate-term-kana.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ext/images/add-duplicate-term-kanji.svg b/ext/images/add-duplicate-term-kanji.svg new file mode 100644 index 00000000..809d0159 --- /dev/null +++ b/ext/images/add-duplicate-term-kanji.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js index 182f11aa..cd44a07f 100644 --- a/ext/js/background/backend.js +++ b/ext/js/background/backend.js @@ -544,22 +544,89 @@ export class Backend { return await this._anki.addNote(note); } + /** + * @param {import('anki').Note[]} notes + * @returns {Promise<({ canAdd: true; } | { canAdd: false; error: string; })[]>} + */ + async detectDuplicateNotes(notes) { + // `allowDuplicate` is on for all notes by default, so we temporarily set it to false + // to check which notes are duplicates. + const notesNoDuplicatesAllowed = notes.map((note) => ({...note, options: {...note.options, allowDuplicate: false}})); + + return await this._anki.canAddNotesWithErrorDetail(notesNoDuplicatesAllowed); + } + + /** + * Partitions notes between those that can / cannot be added. + * It further sets the `isDuplicate` strings for notes that have a duplicate. + * @param {import('anki').Note[]} notes + * @returns {Promise} + */ + async partitionAddibleNotes(notes) { + const canAddResults = await this.detectDuplicateNotes(notes); + + /** @type {{ note: import('anki').Note, isDuplicate: boolean }[]} */ + const canAddArray = []; + + /** @type {import('anki').Note[]} */ + const cannotAddArray = []; + + for (let i = 0; i < canAddResults.length; i++) { + const result = canAddResults[i]; + + // If the note is a duplicate, the error is "cannot create note because it is a duplicate". + if (result.canAdd) { + canAddArray.push({note: notes[i], isDuplicate: false}); + } else if (result.error.endsWith('duplicate')) { + canAddArray.push({note: notes[i], isDuplicate: true}); + } else { + cannotAddArray.push(notes[i]); + } + } + + return {canAddArray, cannotAddArray}; + } + /** @type {import('api').ApiHandler<'getAnkiNoteInfo'>} */ async _onApiGetAnkiNoteInfo({notes, fetchAdditionalInfo}) { - /** @type {import('anki').NoteInfoWrapper[]} */ - const results = []; + const {canAddArray, cannotAddArray} = await this.partitionAddibleNotes(notes); + /** @type {{note: import('anki').Note, info: import('anki').NoteInfoWrapper}[]} */ - const cannotAdd = []; - const canAddArray = await this._anki.canAddNotes(notes); + const cannotAdd = cannotAddArray.filter((note) => isNoteDataValid(note)).map((note) => ({note, info: {canAdd: false, valid: false, noteIds: null}})); + + /** @type {import('anki').NoteInfoWrapper[]} */ + const results = cannotAdd.map(({info}) => info); + + /** @type {import('anki').Note[]} */ + const duplicateNotes = []; + + /** @type {number[]} */ + const originalIndices = []; + + for (let i = 0; i < canAddArray.length; i++) { + if (canAddArray[i].isDuplicate) { + duplicateNotes.push(canAddArray[i].note); + // Keep original indices to locate duplicate inside `duplicateNoteIds` + originalIndices.push(i); + } + } + + const duplicateNoteIds = await this._anki.findNoteIds(duplicateNotes); + + for (let i = 0; i < canAddArray.length; ++i) { + const {note, isDuplicate} = canAddArray[i]; - for (let i = 0; i < notes.length; ++i) { - const note = notes[i]; - let canAdd = canAddArray[i]; const valid = isNoteDataValid(note); - if (!valid) { canAdd = false; } - const info = {canAdd, valid, noteIds: null}; + + const info = { + canAdd: valid, + valid, + noteIds: isDuplicate ? duplicateNoteIds[originalIndices.indexOf(i)] : null + }; + results.push(info); - if (!canAdd && valid) { + + if (!valid) { cannotAdd.push({note, info}); } } diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js index 0bf38bda..a763f394 100644 --- a/ext/js/comm/anki-connect.js +++ b/ext/js/comm/anki-connect.js @@ -139,6 +139,17 @@ export class AnkiConnect { return this._normalizeArray(result, notes.length, 'boolean'); } + /** + * @param {import('anki').Note[]} notes + * @returns {Promise<({ canAdd: true } | { canAdd: false, error: string })[]>} + */ + async canAddNotesWithErrorDetail(notes) { + if (!this._enabled) { return []; } + await this._checkVersion(); + const result = await this._invoke('canAddNotesWithErrorDetail', {notes}); + return this._normalizeCanAddNotesWithErrorDetailArray(result, notes.length); + } + /** * @param {import('anki').NoteId[]} noteIds * @returns {Promise<(?import('anki').NoteInfo)[]>} @@ -579,6 +590,52 @@ export class AnkiConnect { return /** @type {T[]} */ (result); } + /** + * @param {unknown} result + * @param {number} expectedCount + * @returns {import('anki-connect.js').CanAddResult[]} + * @throws {Error} + */ + _normalizeCanAddNotesWithErrorDetailArray(result, expectedCount) { + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError('array', result, ''); + } + if (expectedCount !== result.length) { + throw this._createError(`Unexpected result array size: expected ${expectedCount}, received ${result.length}`, result); + } + /** @type {import('anki-connect.js').CanAddResult[]} */ + const result2 = []; + for (let i = 0; i < expectedCount; ++i) { + const item = /** @type {unknown} */ (result[i]); + if (item === null || typeof item !== 'object') { + throw this._createError(`Unexpected result type at index ${i}: expected object, received ${this._getTypeName(item)}`, result); + } + + const {canAdd, error} = /** @type {{[key: string]: unknown}} */ (item); + if (typeof canAdd !== 'boolean') { + throw this._createError(`Unexpected result type at index ${i}, field canAdd: expected boolean, received ${this._getTypeName(canAdd)}`, result); + } + + if (canAdd && typeof error !== 'undefined') { + throw this._createError(`Unexpected result type at index ${i}, field error: expected undefined, received ${this._getTypeName(error)}`, result); + } + if (!canAdd && typeof error !== 'string') { + throw this._createError(`Unexpected result type at index ${i}, field error: expected string, received ${this._getTypeName(error)}`, result); + } + + if (canAdd) { + result2.push({canAdd}); + } else if (typeof error === 'string') { + const item2 = { + canAdd, + error + }; + result2.push(item2); + } + } + return result2; + } + /** * @param {unknown} result * @returns {(?import('anki').NoteInfo)[]} diff --git a/ext/js/data/anki-note-builder.js b/ext/js/data/anki-note-builder.js index e156103a..6a6a6177 100644 --- a/ext/js/data/anki-note-builder.js +++ b/ext/js/data/anki-note-builder.js @@ -54,7 +54,6 @@ export class AnkiNoteBuilder { fields, tags = [], requirements = [], - checkForDuplicates = true, duplicateScope = 'collection', duplicateScopeCheckAllModels = false, resultOutputMode = 'split', @@ -111,7 +110,7 @@ export class AnkiNoteBuilder { deckName, modelName, options: { - allowDuplicate: !checkForDuplicates, + allowDuplicate: true, duplicateScope, duplicateScopeOptions: { deckName: duplicateScopeDeckName, diff --git a/ext/js/display/display-anki.js b/ext/js/display/display-anki.js index 9a6b96c7..b7d118da 100644 --- a/ext/js/display/display-anki.js +++ b/ext/js/display/display-anki.js @@ -370,6 +370,36 @@ export class DisplayAnki { } } + /** + * @param {HTMLButtonElement} button + */ + _showDuplicateAddButton(button) { + const isKanjiAdd = button.dataset.mode === 'term-kanji'; + + const title = button.getAttribute('title'); + if (title) { + button.setAttribute('title', title.replace(/Add (?!duplicate)/, 'Add duplicate ')); + } + + // 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})'); + } + } + + const actionIcon = button.querySelector('.action-icon'); + if (actionIcon instanceof HTMLElement) { + actionIcon.dataset.icon = isKanjiAdd ? 'add-duplicate-term-kanji' : 'add-duplicate-term-kana'; + } + } + /** * @param {import('display-anki').DictionaryEntryDetails[]} dictionaryEntryDetails */ @@ -383,6 +413,11 @@ export class DisplayAnki { if (button !== null) { button.disabled = !canAdd; button.hidden = (ankiError !== null); + + // If entry has noteIds, show the "add duplicate" button. + if (Array.isArray(noteIds) && noteIds.length > 0) { + this._showDuplicateAddButton(button); + } } if (Array.isArray(noteIds) && noteIds.length > 0) { @@ -394,6 +429,7 @@ export class DisplayAnki { this._setupTagsIndicator(i, noteInfos); } } + this._updateViewNoteButton(i, allNoteIds !== null ? [...allNoteIds] : [], false); } } @@ -506,7 +542,9 @@ export class DisplayAnki { allErrors.push(toError(e)); } } - button.disabled = true; + // Now that this dictionary entry has a duplicate in Anki, show the "add duplicate" buttons. + this._showDuplicateAddButton(button); + this._updateViewNoteButton(dictionaryEntryIndex, [noteId], true); } } @@ -615,7 +653,6 @@ export class DisplayAnki { * @returns {Promise} */ async _getDictionaryEntryDetails(dictionaryEntries) { - const forceCanAddValue = (this._checkForDuplicates ? null : true); const fetchAdditionalInfo = (this._displayTags !== 'never'); const notePromises = []; @@ -638,14 +675,13 @@ export class DisplayAnki { let infos; let ankiError = null; try { - if (forceCanAddValue !== null) { - if (!await this._display.application.api.isAnkiConnected()) { - throw new Error('Anki not connected'); - } - infos = this._getAnkiNoteInfoForceValue(notes, forceCanAddValue); - } else { - infos = await this._display.application.api.getAnkiNoteInfo(notes, fetchAdditionalInfo); + if (!await this._display.application.api.isAnkiConnected()) { + throw new Error('Anki not connected'); } + + infos = this._checkForDuplicates ? + await this._display.application.api.getAnkiNoteInfo(notes, fetchAdditionalInfo) : + this._getAnkiNoteInfoForceValue(notes, true); } catch (e) { infos = this._getAnkiNoteInfoForceValue(notes, false); ankiError = toError(e); @@ -711,7 +747,6 @@ export class DisplayAnki { modelName, fields, tags: this._noteTags, - checkForDuplicates: this._checkForDuplicates, duplicateScope: this._duplicateScope, duplicateScopeCheckAllModels: this._duplicateScopeCheckAllModels, resultOutputMode: this._resultOutputMode, diff --git a/ext/js/input/hotkey-help-controller.js b/ext/js/input/hotkey-help-controller.js index 16f3d26f..b495365d 100644 --- a/ext/js/input/hotkey-help-controller.js +++ b/ext/js/input/hotkey-help-controller.js @@ -181,4 +181,36 @@ export class HotkeyHelpController { defaultAttributeValues }; } + + /** + * @param {HTMLElement} node + * @returns {?string} + */ + getHotkeyLabel(node) { + const {hotkey} = node.dataset; + if (typeof hotkey !== 'string') { return null; } + + const data = /** @type {unknown} */ (parseJson(hotkey)); + if (!Array.isArray(data)) { return null; } + + const values = /** @type {unknown[]} */ (data)[2]; + if (typeof values !== 'string') { return null; } + + return values; + } + + /** + * @param {HTMLElement} node + * @param {string} label + */ + setHotkeyLabel(node, label) { + const {hotkey} = node.dataset; + if (typeof hotkey !== 'string') { return; } + + const data = /** @type {unknown} */ (parseJson(hotkey)); + if (!Array.isArray(data)) { return; } + + data[2] = label; + node.dataset.hotkey = JSON.stringify(data); + } } diff --git a/resources/icons.svg b/resources/icons.svg index 01cd259d..58e3b23a 100644 --- a/resources/icons.svg +++ b/resources/icons.svg @@ -1,1676 +1,1099 @@ - - - - + + + + - - - - - - + + + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + image/svg+xml - + - + \ No newline at end of file diff --git a/test/playwright/playwright-util.js b/test/playwright/playwright-util.js index 1ea2e604..5481eb75 100644 --- a/test/playwright/playwright-util.js +++ b/test/playwright/playwright-util.js @@ -114,7 +114,7 @@ export function getExpectedAddNoteBody() { deckName: 'Mock Deck', modelName: 'Mock Model', options: { - allowDuplicate: false, + allowDuplicate: true, duplicateScope: 'collection', duplicateScopeOptions: { deckName: null, diff --git a/test/utilities/anki.js b/test/utilities/anki.js index f5d281e4..550d0c0b 100644 --- a/test/utilities/anki.js +++ b/test/utilities/anki.js @@ -107,7 +107,6 @@ export async function getTemplateRenderResults(dictionaryEntries, mode, template modelName: 'modelName', fields: createTestFields(dictionaryEntry.type), tags: ['yomitan'], - checkForDuplicates: true, duplicateScope: 'collection', duplicateScopeCheckAllModels: false, resultOutputMode: mode, diff --git a/types/ext/anki-connect.d.ts b/types/ext/anki-connect.d.ts new file mode 100644 index 00000000..07e6dd7f --- /dev/null +++ b/types/ext/anki-connect.d.ts @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2023-2024 Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +type CanAddNote = {canAdd: true}; + +type CannotAddNote = {canAdd: false, error: string}; + +export type CanAddResult = CanAddNote | CannotAddNote; diff --git a/types/ext/anki-note-builder.d.ts b/types/ext/anki-note-builder.d.ts index 8aec3342..e3a1ed6a 100644 --- a/types/ext/anki-note-builder.d.ts +++ b/types/ext/anki-note-builder.d.ts @@ -35,7 +35,6 @@ export type CreateNoteDetails = { fields: Field[]; tags: string[]; requirements: Requirement[]; - checkForDuplicates: boolean; duplicateScope: Settings.AnkiDuplicateScope; duplicateScopeCheckAllModels: boolean; resultOutputMode: Settings.ResultOutputMode; diff --git a/types/ext/backend.d.ts b/types/ext/backend.d.ts index a832e434..c7d66522 100644 --- a/types/ext/backend.d.ts +++ b/types/ext/backend.d.ts @@ -32,3 +32,8 @@ export type TabInfo = { }; export type FindTabsPredicate = (tabInfo: TabInfo) => boolean | Promise; + +export type CanAddResults = { + canAddArray: {note: import('anki').Note, isDuplicate: boolean}[]; + cannotAddArray: import('anki').Note[]; +}; -- cgit v1.2.3