From b9b69edc97087bfa18b77bf4a4568faaed3056e6 Mon Sep 17 00:00:00 2001 From: Jason Ojisan Date: Wed, 6 Nov 2024 14:18:00 -0500 Subject: [PATCH] Show Anki card flags * Add support for cardsInfo from ankiconnect api * Add cardsInfo to NoteInfo * Normalize cardInfo data * Add cardsInfo into notesInfo with a single extra request * Add flag button * Populate flag data in button * Add flag notification on click * Update option text * Add flag icon * Set flag names * Add color to flags * Fix gradient direction * Fix bad space * Remove no flag from flagnames * Allow flagsIndicatorIcon to be null * Clean up variable naming * Clarify behavior on tags and flags setting info * Use array for gradient slices instead of string to avoid weird comma * Rename displayTags to displayTagsAndFlags * Add description to _normalizeCardInfoArray YWRhZjA2NjUyMTdmZTZkYzFiNjA2OTliYWE1YjgzMWRlNzA0YjA1Ygo= --- ext/css/material.css | 1 + ext/data/schemas/options-schema.json | 4 +- ext/images/flag.svg | 1 + ext/js/background/backend.js | 23 ++++- ext/js/comm/anki-connect.js | 51 ++++++++++ ext/js/data/options-util.js | 12 +++ ext/js/display/display-anki.js | 141 +++++++++++++++++++++++++-- ext/settings.html | 10 +- ext/templates-display.html | 3 + test/options-util.test.js | 4 +- types/ext/anki.d.ts | 7 ++ types/ext/display-anki.d.ts | 6 ++ types/ext/settings.d.ts | 4 +- 13 files changed, 247 insertions(+), 20 deletions(-) create mode 100644 ext/images/flag.svg diff --git a/ext/css/material.css b/ext/css/material.css index 7caf87660d..b007db21d8 100644 --- a/ext/css/material.css +++ b/ext/css/material.css @@ -278,6 +278,7 @@ body { .icon[data-icon=clipboard] { --icon-image: url(/images/clipboard.svg); } .icon[data-icon=key] { --icon-image: url(/images/key.svg); } .icon[data-icon=tag] { --icon-image: url(/images/tag.svg); } +.icon[data-icon=flag] { --icon-image: url(/images/flag.svg); } .icon[data-icon=accessibility] { --icon-image: url(/images/accessibility.svg); } .icon[data-icon=connection] { --icon-image: url(/images/connection.svg); } .icon[data-icon=external-link] { --icon-image: url(/images/external-link.svg); } diff --git a/ext/data/schemas/options-schema.json b/ext/data/schemas/options-schema.json index df39804786..4d21984833 100644 --- a/ext/data/schemas/options-schema.json +++ b/ext/data/schemas/options-schema.json @@ -927,7 +927,7 @@ "checkForDuplicates", "fieldTemplates", "suspendNewCards", - "displayTags", + "displayTagsAndFlags", "noteGuiMode", "apiKey", "downloadTimeout" @@ -1046,7 +1046,7 @@ "type": "boolean", "default": false }, - "displayTags": { + "displayTagsAndFlags": { "type": "string", "enum": ["never", "always", "non-standard"], "default": "never" diff --git a/ext/images/flag.svg b/ext/images/flag.svg new file mode 100644 index 0000000000..940da218bc --- /dev/null +++ b/ext/images/flag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js index 558e794137..5bd6c3e9d9 100644 --- a/ext/js/background/backend.js +++ b/ext/js/background/backend.js @@ -613,7 +613,7 @@ export class Backend { } const noteIds = isDuplicate ? duplicateNoteIds[originalIndices.indexOf(i)] : null; - const noteInfos = (fetchAdditionalInfo && noteIds !== null && noteIds.length > 0) ? await this._anki.notesInfo(noteIds) : []; + const noteInfos = (fetchAdditionalInfo && noteIds !== null && noteIds.length > 0) ? await this._notesCardsInfo(noteIds) : []; const info = { canAdd: valid, @@ -628,6 +628,27 @@ export class Backend { return results; } + /** + * @param {number[]} noteIds + * @returns {Promise<(?import('anki').NoteInfo)[]>} + */ + async _notesCardsInfo(noteIds) { + const notesInfo = await this._anki.notesInfo(noteIds); + /** @type {number[]} */ + // @ts-expect-error - ts is not smart enough to realize that filtering !!x removes null and undefined + const cardIds = notesInfo.flatMap((x) => x?.cards).filter((x) => !!x); + const cardsInfo = await this._anki.cardsInfo(cardIds); + for (let i = 0; i < notesInfo.length; i++) { + if (notesInfo[i] !== null) { + const cardInfo = cardsInfo.find((x) => x?.noteId === notesInfo[i]?.noteId); + if (cardInfo) { + notesInfo[i]?.cardsInfo.push(cardInfo); + } + } + } + return notesInfo; + } + /** @type {import('api').ApiHandler<'injectAnkiNoteMedia'>} */ async _onApiInjectAnkiNoteMedia({timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails}) { return await this._injectAnkNoteMedia( diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js index 520cddcfcc..20b7833765 100644 --- a/ext/js/comm/anki-connect.js +++ b/ext/js/comm/anki-connect.js @@ -180,6 +180,17 @@ export class AnkiConnect { return this._normalizeNoteInfoArray(result); } + /** + * @param {import('anki').CardId[]} cardIds + * @returns {Promise<(?import('anki').CardInfo)[]>} + */ + async cardsInfo(cardIds) { + if (!this._enabled) { return []; } + await this._checkVersion(); + const result = await this._invoke('cardsInfo', {cards: cardIds}); + return this._normalizeCardInfoArray(result); + } + /** * @returns {Promise} */ @@ -655,6 +666,46 @@ export class AnkiConnect { fields: fields2, modelName, cards: cards2, + cardsInfo: [], + }; + result2.push(item2); + } + return result2; + } + + /** + * Transforms raw AnkiConnect data into the CardInfo type. + * @param {unknown} result + * @returns {(?import('anki').CardInfo)[]} + * @throws {Error} + */ + _normalizeCardInfoArray(result) { + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError('array', result, ''); + } + /** @type {(?import('anki').CardInfo)[]} */ + const result2 = []; + for (let i = 0, ii = result.length; i < ii; ++i) { + const item = /** @type {unknown} */ (result[i]); + if (item === null || typeof item !== 'object') { + throw this._createError(`Unexpected result type at index ${i}: expected Cards.CardInfo, received ${this._getTypeName(item)}`, result); + } + const {cardId} = /** @type {{[key: string]: unknown}} */ (item); + if (typeof cardId !== 'number') { + result2.push(null); + continue; + } + const {note, flags} = /** @type {{[key: string]: unknown}} */ (item); + if (typeof note !== 'number') { + result2.push(null); + continue; + } + + /** @type {import('anki').CardInfo} */ + const item2 = { + noteId: note, + cardId, + flags: typeof flags === 'number' ? flags : 0, }; result2.push(item2); } diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js index e073d8ad4e..b73b21d354 100644 --- a/ext/js/data/options-util.js +++ b/ext/js/data/options-util.js @@ -565,6 +565,7 @@ export class OptionsUtil { this._updateVersion51, this._updateVersion52, this._updateVersion53, + this._updateVersion54, ]; /* eslint-enable @typescript-eslint/unbound-method */ if (typeof targetVersion === 'number' && targetVersion < result.length) { @@ -1495,6 +1496,17 @@ export class OptionsUtil { } } + /** + * - Renamed anki.displayTags to anki.displayTagsAndFlags + * @type {import('options-util').UpdateFunction} + */ + async _updateVersion54(options) { + for (const profile of options.profiles) { + profile.options.anki.displayTagsAndFlags = profile.options.anki.displayTags; + delete profile.options.anki.displayTags; + } + } + /** * @param {string} url * @returns {Promise} diff --git a/ext/js/display/display-anki.js b/ext/js/display/display-anki.js index efcafd5554..1a2a9ec6f0 100644 --- a/ext/js/display/display-anki.js +++ b/ext/js/display/display-anki.js @@ -49,6 +49,8 @@ export class DisplayAnki { this._errorNotificationEventListeners = null; /** @type {?import('./display-notification.js').DisplayNotification} */ this._tagsNotification = null; + /** @type {?import('./display-notification.js').DisplayNotification} */ + this._flagsNotification = null; /** @type {?Promise} */ this._updateSaveButtonsPromise = null; /** @type {?import('core').TokenObject} */ @@ -69,8 +71,8 @@ export class DisplayAnki { this._resultOutputMode = 'split'; /** @type {import('settings').GlossaryLayoutMode} */ this._glossaryLayoutMode = 'default'; - /** @type {import('settings').AnkiDisplayTags} */ - this._displayTags = 'never'; + /** @type {import('settings').AnkiDisplayTagsAndFlags} */ + this._displayTagsAndFlags = 'never'; /** @type {import('settings').AnkiDuplicateScope} */ this._duplicateScope = 'collection'; /** @type {boolean} */ @@ -103,6 +105,8 @@ export class DisplayAnki { /** @type {(event: MouseEvent) => void} */ this._onShowTagsBind = this._onShowTags.bind(this); /** @type {(event: MouseEvent) => void} */ + this._onShowFlagsBind = this._onShowFlags.bind(this); + /** @type {(event: MouseEvent) => void} */ this._onNoteSaveBind = this._onNoteSave.bind(this); /** @type {(event: MouseEvent) => void} */ this._onViewNotesButtonClickBind = this._onViewNotesButtonClick.bind(this); @@ -206,7 +210,7 @@ export class DisplayAnki { duplicateBehavior, suspendNewCards, checkForDuplicates, - displayTags, + displayTagsAndFlags, kanji, terms, noteGuiMode, @@ -221,7 +225,7 @@ export class DisplayAnki { this._compactTags = compactTags; this._resultOutputMode = resultOutputMode; this._glossaryLayoutMode = glossaryLayoutMode; - this._displayTags = displayTags; + this._displayTagsAndFlags = displayTagsAndFlags; this._duplicateScope = duplicateScope; this._duplicateScopeCheckAllModels = duplicateScopeCheckAllModels; this._duplicateBehavior = duplicateBehavior; @@ -260,6 +264,9 @@ 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=view-flags]')) { + eventListeners.addEventListener(node, 'click', this._onShowFlagsBind); + } for (const node of element.querySelectorAll('.action-button[data-action=save-note]')) { eventListeners.addEventListener(node, 'click', this._onNoteSaveBind); } @@ -304,6 +311,16 @@ export class DisplayAnki { this._showTagsNotification(tags); } + /** + * @param {MouseEvent} e + */ + _onShowFlags(e) { + e.preventDefault(); + const element = /** @type {HTMLElement} */ (e.currentTarget); + const flags = element.title; + this._showFlagsNotification(flags); + } + /** * @param {number} index * @param {import('display-anki').CreateMode} mode @@ -323,6 +340,15 @@ export class DisplayAnki { return entry !== null ? entry.querySelector('.action-button[data-action=view-tags]') : null; } + /** + * @param {number} index + * @returns {?HTMLButtonElement} + */ + _flagsIndicatorFind(index) { + const entry = this._getEntry(index); + return entry !== null ? entry.querySelector('.action-button[data-action=view-flags]') : null; + } + /** * @param {number} index * @returns {?HTMLElement} @@ -429,7 +455,7 @@ export class DisplayAnki { * @param {import('display-anki').DictionaryEntryDetails[]} dictionaryEntryDetails */ _updateSaveButtons(dictionaryEntryDetails) { - const displayTags = this._displayTags; + const displayTagsAndFlags = this._displayTagsAndFlags; for (let i = 0, ii = dictionaryEntryDetails.length; i < ii; ++i) { /** @type {?Set} */ let allNoteIds = null; @@ -457,8 +483,9 @@ export class DisplayAnki { } } - if (displayTags !== 'never' && Array.isArray(noteInfos)) { + if (displayTagsAndFlags !== 'never' && Array.isArray(noteInfos)) { this._setupTagsIndicator(i, noteInfos); + this._setupFlagsIndicator(i, noteInfos); } } @@ -483,7 +510,7 @@ export class DisplayAnki { displayTags.add(tag); } } - if (this._displayTags === 'non-standard') { + if (this._displayTagsAndFlags === 'non-standard') { for (const tag of this._noteTags) { displayTags.delete(tag); } @@ -508,6 +535,104 @@ export class DisplayAnki { this._tagsNotification.open(); } + /** + * @param {number} i + * @param {(?import('anki').NoteInfo)[]} noteInfos + */ + _setupFlagsIndicator(i, noteInfos) { + const flagsIndicator = this._flagsIndicatorFind(i); + if (flagsIndicator === null) { + return; + } + + /** @type {Set} */ + const displayFlags = new Set(); + for (const item of noteInfos) { + if (item === null) { continue; } + for (const cardInfo of item.cardsInfo) { + if (cardInfo.flags !== 0) { + displayFlags.add(this._getFlagName(cardInfo.flags)); + } + } + } + + if (displayFlags.size > 0) { + flagsIndicator.disabled = false; + flagsIndicator.hidden = false; + flagsIndicator.title = `Card flags: ${[...displayFlags].join(', ')}`; + /** @type {HTMLElement | null} */ + const flagsIndicatorIcon = flagsIndicator.querySelector('.action-icon'); + if (flagsIndicatorIcon !== null && flagsIndicator instanceof HTMLElement) { + flagsIndicatorIcon.style.background = this._getFlagColor(displayFlags); + } + } + } + + /** + * @param {number} flag + * @returns {string} + */ + _getFlagName(flag) { + /** @type {Record} */ + const flagNamesDict = { + 1: 'Red', + 2: 'Orange', + 3: 'Green', + 4: 'Blue', + 5: 'Pink', + 6: 'Turquoise', + 7: 'Purple', + }; + if (flag in flagNamesDict) { + return flagNamesDict[flag]; + } + return ''; + } + + /** + * @param {Set} flags + * @returns {string} + */ + _getFlagColor(flags) { + /** @type {Record} */ + const flagColorsDict = { + Red: {red: 248, green: 113, blue: 113}, + Orange: {red: 253, green: 186, blue: 116}, + Green: {red: 134, green: 239, blue: 172}, + Blue: {red: 96, green: 165, blue: 250}, + Pink: {red: 240, green: 171, blue: 252}, + Turquoise: {red: 94, green: 234, blue: 212}, + Purple: {red: 192, green: 132, blue: 252}, + }; + + const gradientSliceSize = 100 / flags.size; + let currentGradientPercent = 0; + + const gradientSlices = []; + for (const flag of flags) { + const flagColor = flagColorsDict[flag]; + gradientSlices.push( + 'rgb(' + flagColor.red + ',' + flagColor.green + ',' + flagColor.blue + ') ' + currentGradientPercent + '%', + 'rgb(' + flagColor.red + ',' + flagColor.green + ',' + flagColor.blue + ') ' + (currentGradientPercent + gradientSliceSize) + '%', + ); + currentGradientPercent += gradientSliceSize; + } + + return 'linear-gradient(to right,' + gradientSlices.join(',') + ')'; + } + + /** + * @param {string} message + */ + _showFlagsNotification(message) { + if (this._flagsNotification === null) { + this._flagsNotification = this._display.createNotification(true); + } + + this._flagsNotification.setContent(message); + this._flagsNotification.open(); + } + /** * @param {import('display-anki').CreateMode} mode */ @@ -733,7 +858,7 @@ export class DisplayAnki { * @returns {Promise} */ async _getDictionaryEntryDetails(dictionaryEntries) { - const fetchAdditionalInfo = (this._displayTags !== 'never'); + const fetchAdditionalInfo = (this._displayTagsAndFlags !== 'never'); const notePromises = []; const noteTargets = []; diff --git a/ext/settings.html b/ext/settings.html index 1444c309ef..73a12fbb0c 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -1964,12 +1964,12 @@

Rikaitan Settings

- Show card tags + Show card tags and flags (?)
- @@ -1978,10 +1978,10 @@

Rikaitan Settings