Skip to content

Commit

Permalink
Show Anki card flags (#1571)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Kuuuube authored Nov 6, 2024
1 parent c95b938 commit adaf066
Show file tree
Hide file tree
Showing 13 changed files with 247 additions and 20 deletions.
1 change: 1 addition & 0 deletions ext/css/material.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
4 changes: 2 additions & 2 deletions ext/data/schemas/options-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -927,7 +927,7 @@
"checkForDuplicates",
"fieldTemplates",
"suspendNewCards",
"displayTags",
"displayTagsAndFlags",
"noteGuiMode",
"apiKey",
"downloadTimeout"
Expand Down Expand Up @@ -1046,7 +1046,7 @@
"type": "boolean",
"default": false
},
"displayTags": {
"displayTagsAndFlags": {
"type": "string",
"enum": ["never", "always", "non-standard"],
"default": "never"
Expand Down
1 change: 1 addition & 0 deletions ext/images/flag.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 22 additions & 1 deletion ext/js/background/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
51 changes: 51 additions & 0 deletions ext/js/comm/anki-connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>}
*/
Expand Down Expand Up @@ -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);
}
Expand Down
12 changes: 12 additions & 0 deletions ext/js/data/options-util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1509,6 +1510,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<chrome.tabs.Tab>}
Expand Down
141 changes: 133 additions & 8 deletions ext/js/display/display-anki.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>} */
this._updateSaveButtonsPromise = null;
/** @type {?import('core').TokenObject} */
Expand All @@ -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} */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -206,7 +210,7 @@ export class DisplayAnki {
duplicateBehavior,
suspendNewCards,
checkForDuplicates,
displayTags,
displayTagsAndFlags,
kanji,
terms,
noteGuiMode,
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down Expand Up @@ -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<number>} */
let allNoteIds = null;
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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);
}
Expand All @@ -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<string>} */
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<number, string>} */
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<string>} flags
* @returns {string}
*/
_getFlagColor(flags) {
/** @type {Record<string, import('display-anki').RGB>} */
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
*/
Expand Down Expand Up @@ -733,7 +858,7 @@ export class DisplayAnki {
* @returns {Promise<import('display-anki').DictionaryEntryDetails[]>}
*/
async _getDictionaryEntryDetails(dictionaryEntries) {
const fetchAdditionalInfo = (this._displayTags !== 'never');
const fetchAdditionalInfo = (this._displayTagsAndFlags !== 'never');

const notePromises = [];
const noteTargets = [];
Expand Down
Loading

0 comments on commit adaf066

Please sign in to comment.