Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notes tags: use most popular capitalization #10794

Merged
merged 2 commits into from
Nov 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/app/dim-ui/text-complete/text-complete.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { StrategyProps, Textcomplete } from '@textcomplete/core';
import { TextareaEditor } from '@textcomplete/textarea';
import { getHashtagsFromNote } from 'app/inventory/note-hashtags';
import { getHashtagsFromString } from 'app/inventory/note-hashtags';
import clsx from 'clsx';
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
Expand All @@ -19,7 +19,9 @@ function createTagsCompleter(
const termLower = term.toLowerCase();
// need to build this list from the element ref, because relying
// on liveNotes state would re-instantiate Textcomplete every keystroke
const existingTags = getHashtagsFromNote(textArea.current!.value).map((t) => t.toLowerCase());
const existingTags = getHashtagsFromString(textArea.current!.value).map((t) =>
t.toLowerCase(),
);
const possibleTags: string[] = [];
for (const t of tags) {
const tagLower = t.toLowerCase();
Expand Down
50 changes: 37 additions & 13 deletions src/app/inventory/note-hashtags.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,50 @@
import { compact, filterMap, uniqBy } from 'app/utils/collections';
import { compact, filterMap } from 'app/utils/collections';
import { compareBy } from 'app/utils/comparators';
import { maxBy } from 'es-toolkit';
import { ItemInfos } from './dim-item-info';

/**
* collects all hashtags from item notes
* Collects all hashtags from all item notes.
*
* Orders by use count, de-dupes case-insensitive, and picks the most popular capitalization.
*/
export function collectNotesHashtags(itemInfos: ItemInfos) {
const hashTags = new Set<string>();
export function collectHashtagsFromInfos(itemInfos: ItemInfos) {
// {
// '#pve': {
// variants: {
// '#PVE': 4,
// '#pve': 2
// }, <- hashtagCollection
// count: 6 <- structure
// }
// }
const hashtagCollection: NodeJS.Dict<{ variants: NodeJS.Dict<number>; count: number }> = {};

for (const info of Object.values(itemInfos)) {
const matches = getHashtagsFromNote(info.notes);
if (matches) {
for (const match of matches) {
hashTags.add(match);
}
const hashtags = getHashtagsFromString(info.notes);
for (const h of hashtags) {
const lower = h.toLowerCase();
hashtagCollection[lower] ??= { count: 0, variants: {} };
hashtagCollection[lower].count++;
hashtagCollection[lower].variants[h] ??= 0;
hashtagCollection[lower].variants[h]++;
}
}
return uniqBy(hashTags, (t) => t.toLowerCase());

return Object.values(hashtagCollection)
.map((normalizedMeta) => {
const countsByVariant = Object.entries(normalizedMeta!.variants);
const mostPopularVariant = maxBy(countsByVariant, (v) => v[1]!)![0];
return [mostPopularVariant, normalizedMeta!.count] as const;
})
.sort(compareBy((t) => -t[1]))
.map((t) => t[0]);
}

const hashtagRegex = /(^|[\s,])(#[\p{L}\p{N}\p{Private_Use}\p{Other_Symbol}_:-]+)/gu;

export function getHashtagsFromNote(note?: string | null) {
return Array.from(note?.matchAll(hashtagRegex) ?? [], (m) => m[2]);
export function getHashtagsFromString(...notes: (string | null | undefined)[]) {
return notes.flatMap((note) => Array.from(note?.matchAll(hashtagRegex) ?? [], (m) => m[2]));
}

// TODO: am I really gonna need to write a parser again
Expand Down Expand Up @@ -59,7 +83,7 @@ export function removedFromNote(originalNote: string | undefined, removed: strin
const originalSegmented = segmentHashtags(originalNote);
// Treat it like a remove-hashtags operation and just remove all the named hashtags individually
if (removed.match(allHashtagsRegex)) {
const removeHashTags = new Set(getHashtagsFromNote(removed));
const removeHashTags = new Set(getHashtagsFromString(removed));

return originalSegmented
.filter((s) => typeof s === 'string' || !removeHashTags.has(s.hashtag))
Expand Down
21 changes: 12 additions & 9 deletions src/app/inventory/notes-hashtags.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ItemInfos } from './dim-item-info';
import {
appendedToNote,
collectNotesHashtags,
getHashtagsFromNote,
collectHashtagsFromInfos,
getHashtagsFromString,
removedFromNote,
} from './note-hashtags';

Expand All @@ -14,19 +14,22 @@ test.each([
['#foo,#bar', ['#foo', '#bar']],
['#foo-#bar', ['#foo-']], // Not great, could be better
['Emoji #🤯 tags', ['#🤯']],
])('getHashtagsFromNote: %s', (notes, expectedTags) => {
const tags = new Set(getHashtagsFromNote(notes));
])('getHashtagsFromString: %s', (notes, expectedTags) => {
const tags = new Set(getHashtagsFromString(notes));
expect(tags).toEqual(new Set(expectedTags));
});

test('collectNotesHashtags should get a unique set of hashtags from multiple notes', () => {
test('collectHashtagsFromInfos should get a unique set of hashtags from multiple notes', () => {
const itemInfos: ItemInfos = {
1: { id: '1', notes: 'This has #three #hash #tags' },
2: { id: '1', notes: '#Three #🤯' },
1: { id: '1', notes: 'This has #three #Hash #tags' }, // A lowercase #three occurs first,
2: { id: '1', notes: '#Three #🤯' }, // but #Three should be preferred (two occurences)
3: { id: '1', notes: '#Three' },
4: { id: '1', notes: '#Hash' },
5: { id: '1', notes: '#hash' }, // A lowercase #hash occured most recently, but #Hash should be preferred (two occurences)
};

expect(new Set(collectNotesHashtags(itemInfos))).toEqual(
new Set(['#three', '#hash', '#tags', '#🤯']),
expect(new Set(collectHashtagsFromInfos(itemInfos))).toEqual(
new Set(['#Three', '#Hash', '#tags', '#🤯']),
);
});

Expand Down
4 changes: 2 additions & 2 deletions src/app/inventory/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { getBuckets as getBucketsD2 } from '../destiny2/d2-buckets';
import { characterSortImportanceSelector, characterSortSelector } from '../settings/character-sort';
import { ItemInfos, getNotes, getTag } from './dim-item-info';
import { DimItem } from './item-types';
import { collectNotesHashtags } from './note-hashtags';
import { collectHashtagsFromInfos } from './note-hashtags';
import { AccountCurrency } from './store-types';
import { ItemCreationContext } from './store/d2-item-factory';
import { getCurrentStore, getVault } from './stores-helpers';
Expand Down Expand Up @@ -413,4 +413,4 @@ export const hasNotesSelector = (item: DimItem) => (state: RootState) =>
/**
* all hashtags used in existing item notes, with (case-insensitive) dupes removed
*/
export const allNotesHashtagsSelector = createSelector(itemInfosSelector, collectNotesHashtags);
export const allNotesHashtagsSelector = createSelector(itemInfosSelector, collectHashtagsFromInfos);
7 changes: 2 additions & 5 deletions src/app/loadout/loadout-ui/menu-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import FilterPills, { Option } from 'app/dim-ui/FilterPills';
import ColorDestinySymbols from 'app/dim-ui/destiny-symbols/ColorDestinySymbols';
import { DimLanguage } from 'app/i18n';
import { t, tl } from 'app/i18next-t';
import { getHashtagsFromNote } from 'app/inventory/note-hashtags';
import { getHashtagsFromString } from 'app/inventory/note-hashtags';
import { DimStore } from 'app/inventory/store-types';
import { findingDisplays } from 'app/loadout-analyzer/finding-display';
import { useSummaryLoadoutsAnalysis } from 'app/loadout-analyzer/hooks';
Expand Down Expand Up @@ -75,10 +75,7 @@ export function useLoadoutFilterPills(
const loadoutsByHashtag = useMemo(() => {
const loadoutsByHashtag: { [hashtag: string]: Loadout[] } = {};
for (const loadout of savedLoadouts) {
const hashtags = [
...getHashtagsFromNote(loadout.name),
...getHashtagsFromNote(loadout.notes),
];
const hashtags = getHashtagsFromString(loadout.name, loadout.notes);
for (const hashtag of hashtags) {
(loadoutsByHashtag[hashtag.replace('#', '').replace(/_/g, ' ')] ??= []).push(loadout);
}
Expand Down
9 changes: 2 additions & 7 deletions src/app/loadout/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { currentProfileSelector } from 'app/dim-api/selectors';
import { DimItem } from 'app/inventory/item-types';
import { getHashtagsFromNote } from 'app/inventory/note-hashtags';
import { getHashtagsFromString } from 'app/inventory/note-hashtags';
import {
allItemsSelector,
currentStoreSelector,
Expand All @@ -22,12 +22,7 @@ import { InGameLoadout, Loadout, LoadoutItem, isInGameLoadout } from './loadout-
import { loadoutsSelector } from './loadouts-selector';

export const loadoutsHashtagsSelector = createSelector(loadoutsSelector, (loadouts) => [
...new Set(
loadouts.flatMap((loadout) => [
...getHashtagsFromNote(loadout.name),
...getHashtagsFromNote(loadout.notes),
]),
),
...new Set(loadouts.flatMap((loadout) => getHashtagsFromString(loadout.name, loadout.notes))),
]);

export interface LoadoutsByItem {
Expand Down
7 changes: 3 additions & 4 deletions src/app/search/items/search-filters/loadouts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { tl } from 'app/i18next-t';
import { getHashtagsFromNote } from 'app/inventory/note-hashtags';
import { getHashtagsFromString } from 'app/inventory/note-hashtags';
import { InGameLoadout, isInGameLoadout, Loadout } from 'app/loadout/loadout-types';
import { quoteFilterString } from 'app/search/query-parser';
import { ItemFilterDefinition } from '../item-filter-types';
Expand All @@ -13,8 +13,7 @@ export function loadoutToSearchString(loadout: Loadout | InGameLoadout) {
export function loadoutToSuggestions(loadout: Loadout) {
return [
quoteFilterString(loadout.name.toLowerCase()), // loadout name
...getHashtagsFromNote(loadout.name), // #hashtags in the name
...getHashtagsFromNote(loadout.notes), // #hashtags in the notes
...getHashtagsFromString(loadout.name, loadout.notes), // #hashtags in the name/notes
].map((suggestion) => `inloadout:${suggestion}`);
}

Expand Down Expand Up @@ -47,7 +46,7 @@ const loadoutFilters: ItemFilterDefinition[] = [
loadout.name.toLowerCase().includes(filterValue) ||
(filterValue.startsWith('#') && // short circuit for less load
!isInGameLoadout(loadout) &&
getHashtagsFromNote(loadout.notes)
getHashtagsFromString(loadout.notes)
.map((t) => t.toLowerCase())
.includes(filterValue)),
);
Expand Down
7 changes: 2 additions & 5 deletions src/app/search/loadouts/search-filters/freeform.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { D2ManifestDefinitions } from 'app/destiny2/d2-definitions';
import { tl } from 'app/i18next-t';
import { DimItem } from 'app/inventory/item-types';
import { getHashtagsFromNote } from 'app/inventory/note-hashtags';
import { getHashtagsFromString } from 'app/inventory/note-hashtags';
import { DimStore } from 'app/inventory/store-types';
import { findItemForLoadout, getLight, getModsFromLoadout } from 'app/loadout-drawer/loadout-utils';
import { Loadout } from 'app/loadout/loadout-types';
Expand Down Expand Up @@ -253,10 +253,7 @@ const freeformFilters: FilterDefinition<
new Set([
...loadouts
.filter((loadout) => isLoadoutCompatibleWithStore(loadout, selectedLoadoutsStore))
.flatMap((loadout) => [
...getHashtagsFromNote(loadout.name),
...getHashtagsFromNote(loadout.notes),
]),
.flatMap((loadout) => getHashtagsFromString(loadout.notes, loadout.notes)),
]),
)
: [],
Expand Down
Loading