diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts index cf46f24449..64e8183347 100644 --- a/frontend/pages/SoftwarePage/components/icons/index.ts +++ b/frontend/pages/SoftwarePage/components/icons/index.ts @@ -3,6 +3,7 @@ import { HOST_LINUX_PLATFORMS } from "interfaces/platform"; import { ISoftware } from "interfaces/software"; +import { matchLoosePrefixToKey } from "utilities/strings/stringUtils"; import Backblaze from "./Backblaze"; import BetterDisplay from "./BetterDisplay"; @@ -535,26 +536,6 @@ export const SOFTWARE_SOURCE_TO_ICON_MAP = { jetbrains_plugins: Extension, } as const; -/** - * This attempts to loosely match the provided string to a key in a provided dictionary, returning the key if the - * provided string starts with the key or undefined otherwise. Keys are sorted by length (longest first) to ensure - * more specific matches are checked before shorter, more general ones (e.g., "archaeology" before "arc"). - */ -const matchLoosePrefixToKey = >( - dict: T, - s: string -) => { - s = s.trim().toLowerCase(); - if (!s) { - return undefined; - } - // Sort keys by length (longest first) to prioritize more specific matches - const sortedKeys = Object.keys(dict).sort((a, b) => b.length - a.length); - const match = sortedKeys.find((k) => s.startsWith(k.trim().toLowerCase())); - - return match ? (match as keyof T) : undefined; -}; - /** * This strictly matches the provided name and source to a software icon, returning the icon if a match is found or * null otherwise. It is intended to be used for special cases where a strict match is required diff --git a/frontend/utilities/strings/stringUtils.tests.ts b/frontend/utilities/strings/stringUtils.tests.ts index 93b5ef4f66..47a1ece3ff 100644 --- a/frontend/utilities/strings/stringUtils.tests.ts +++ b/frontend/utilities/strings/stringUtils.tests.ts @@ -5,6 +5,7 @@ import { stripQuotes, isIncompleteQuoteQuery, hyphenateString, + matchLoosePrefixToKey, } from "./stringUtils"; describe("string utilities", () => { @@ -142,4 +143,65 @@ describe("string utilities", () => { ); }); }); + + describe("matchLoosePrefixToKey", () => { + const MAP = { + arc: "Arc", + code: "VisualStudioCode", + archaeology: "Archaeology", + "visual studio code": "VisualStudioCode", + "windows app": "WindowsApp", + "windows app remote": "WindowsAppRemote", + } as const; + + it("matches exact key", () => { + expect(matchLoosePrefixToKey(MAP, "Arc")).toBe("arc"); + }); + + it("matches key followed by space and suffix", () => { + expect(matchLoosePrefixToKey(MAP, "Visual Studio Code - Insiders")).toBe( + "visual studio code" + ); + expect(matchLoosePrefixToKey(MAP, "Code 2")).toBe("code"); + expect( + matchLoosePrefixToKey(MAP, "Visual Studio Code 2025 Edition") + ).toBe("visual studio code"); + }); + + it("does not match non-word prefix (Archive vs arc)", () => { + expect(matchLoosePrefixToKey(MAP, "Archive")).toBeUndefined(); + }); + + it("matches 'code' as a whole word but not 'Codex'", () => { + expect(matchLoosePrefixToKey(MAP, "Code")).toBe("code"); + expect(matchLoosePrefixToKey(MAP, "Code Helper")).toBe("code"); + expect(matchLoosePrefixToKey(MAP, "Codex")).toBeUndefined(); + }); + + it("matches variants that use a space before another variant correctly by matching the longest match key", () => { + // Exact matches + expect(matchLoosePrefixToKey(MAP, "Windows App")).toBe("windows app"); + expect(matchLoosePrefixToKey(MAP, "Windows App Remote")).toBe( + "windows app remote" + ); + + // With suffixes + expect(matchLoosePrefixToKey(MAP, "Windows App - Something")).toBe( + "windows app" + ); + expect( + matchLoosePrefixToKey(MAP, "Windows App Remote - Something Else") + ).toBe("windows app remote"); + }); + + it("is case-insensitive and trims surrounding whitespace", () => { + expect(matchLoosePrefixToKey(MAP, " arc ")).toBe("arc"); + expect(matchLoosePrefixToKey(MAP, "VISUAL STUDIO CODE")).toBe( + "visual studio code" + ); + expect( + matchLoosePrefixToKey(MAP, " Visual Studio Code - Insiders ") + ).toBe("visual studio code"); + }); + }); }); diff --git a/frontend/utilities/strings/stringUtils.ts b/frontend/utilities/strings/stringUtils.ts index fc6c1ad541..291a1e56b9 100644 --- a/frontend/utilities/strings/stringUtils.ts +++ b/frontend/utilities/strings/stringUtils.ts @@ -103,6 +103,33 @@ export const hyphenateString = (str: string): string => { return str.trim().toLowerCase().replace(/\s+/g, "-"); }; +/** + * This attempts to loosely match the provided string to a key in a provided dictionary, returning the key if the + * provided string starts with the key or undefined otherwise. Keys are sorted by length (longest first) to ensure + * more specific matches are checked before shorter, more general ones (e.g., "archaeology" before "arc"). + */ +export const matchLoosePrefixToKey = >( + dict: T, + s: string +) => { + s = s.trim().toLowerCase(); + if (!s) { + return undefined; + } + + const sortedKeys = Object.keys(dict).sort((a, b) => b.length - a.length); + + const match = sortedKeys.find((rawKey) => { + const key = rawKey.trim().toLowerCase(); + if (!key) return false; + + // Treat keys as whole words at the start: exact match or followed by space + return s === key || s.startsWith(`${key} `); + }); + + return match ? (match as keyof T) : undefined; +}; + export default { capitalize, capitalizeRole, @@ -110,4 +137,5 @@ export default { strToBool, stripQuotes, isIncompleteQuoteQuery, + matchLoosePrefixToKey, };