Skip to content

Commit

Permalink
Merge pull request #10794 from DestinyItemManager/tag-caps-popularity…
Browse files Browse the repository at this point in the history
…-contest

Notes tags: use most popular capitalization
  • Loading branch information
chainrez authored Nov 16, 2024
2 parents 0fbad01 + d0ad2d9 commit 9fc1630
Show file tree
Hide file tree
Showing 8 changed files with 64 additions and 47 deletions.
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

0 comments on commit 9fc1630

Please sign in to comment.