Skip to content
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
150 changes: 150 additions & 0 deletions frontend/__tests__/utils/strings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,156 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as Strings from "../../src/ts/utils/strings";

describe("string utils", () => {
describe("highlightMatches", () => {
const shouldHighlight = [
{
description: "word at the beginning",
text: "Start here.",
matches: ["Start"],
expected: '<span class="highlight">Start</span> here.',
},
{
description: "word at the end",
text: "reach the end",
matches: ["end"],
expected: 'reach the <span class="highlight">end</span>',
},
{
description: "mutliple matches",
text: "one two three",
matches: ["one", "three"],
expected:
'<span class="highlight">one</span> two <span class="highlight">three</span>',
},
{
description: "repeated matches",
text: "one two two",
matches: ["two"],
expected:
'one <span class="highlight">two</span> <span class="highlight">two</span>',
},
{
description: "longest possible match",
text: "abc ab",
matches: ["ab", "abc"],
expected:
'<span class="highlight">abc</span> <span class="highlight">ab</span>',
},
{
description: "if wrapped in parenthesis",
text: "(test)",
matches: ["test"],
expected: '(<span class="highlight">test</span>)',
},
{
description: "if wrapped in commas",
text: ",test,",
matches: ["test"],
expected: ',<span class="highlight">test</span>,',
},
{
description: "if wrapped in underscores",
text: "_test_",
matches: ["test"],
expected: '_<span class="highlight">test</span>_',
},
{
description: "words in russian",
text: "Привет, мир!",
matches: ["Привет", "мир"],
expected:
'<span class="highlight">Привет</span>, <span class="highlight">мир</span>!',
},
{
description: "words with chinese punctuation",
text: "你好,世界!",
matches: ["你好", "世界"],
expected:
'<span class="highlight">你好</span>,<span class="highlight">世界</span>!',
},
{
description: "words with arabic punctuation",
text: "؟مرحبا، بكم؛",
matches: ["مرحبا", "بكم"],
expected:
'؟<span class="highlight">مرحبا</span>، <span class="highlight">بكم</span>؛',
},
{
description: "standalone numbers",
text: "My number is 1234.",
matches: ["1234"],
expected: 'My number is <span class="highlight">1234</span>.',
},
];
const shouldNotHighlight = [
{
description: "a match within a longer word",
text: "together",
matches: ["get"],
},
{
description: "a match with leading letters",
text: "welcome",
matches: ["come"],
},
{
description: "a match with trailing letters",
text: "comets",
matches: ["come"],
},
{
description: "japanese matches within longer words",
text: "こんにちは世界",
matches: ["こんにちは"],
},
{
description: "numbers within words",
text: "abc1234def",
matches: ["1234"],
},
];
const returnOriginal = [
{
description: "if matches is an empty array",
text: "Nothing to match.",
matches: [],
},
{
description: "if matches has an empty string only",
text: "Nothing to match.",
matches: [""],
},
{
description: "if no matches found in text",
text: "Hello world.",
matches: ["absent"],
},
{
description: "if text is empty",
text: "",
matches: ["anything"],
},
];
it.each(shouldHighlight)(
"should highlight $description",
({ text, matches, expected }) => {
expect(Strings.highlightMatches(text, matches)).toBe(expected);
}
);
it.each(shouldNotHighlight)(
"should not highlight $description",
({ text, matches }) => {
expect(Strings.highlightMatches(text, matches)).toBe(text);
}
);
it.each(returnOriginal)(
"should return original text $description",
({ text, matches }) => {
expect(Strings.highlightMatches(text, matches)).toBe(text);
}
);
});

describe("splitIntoCharacters", () => {
it("splits regular characters", () => {
expect(Strings.splitIntoCharacters("abc")).toEqual(["a", "b", "c"]);
Expand Down
19 changes: 1 addition & 18 deletions frontend/src/ts/modals/quote-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
SearchService,
TextExtractor,
} from "../utils/search-service";
import { splitByAndKeep } from "../utils/strings";
import QuotesController, { Quote } from "../controllers/quotes-controller";
import { isAuthenticated } from "../firebase";
import { debounce } from "throttle-debounce";
Expand All @@ -21,6 +20,7 @@ import * as TestState from "../test/test-state";
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
import * as TestLogic from "../test/test-logic";
import { createErrorMessage } from "../utils/misc";
import { highlightMatches } from "../utils/strings";

const searchServiceCache: Record<string, SearchService<Quote>> = {};

Expand All @@ -43,23 +43,6 @@ function getSearchService<T>(
return newSearchService;
}

function highlightMatches(text: string, matchedText: string[]): string {
if (matchedText.length === 0) {
return text;
}
const words = splitByAndKeep(text, `.,"/#!$%^&*;:{}=-_\`~() `.split(""));

const normalizedWords = words.map((word) => {
const shouldHighlight =
matchedText.find((match) => {
return word.startsWith(match);
}) !== undefined;
return shouldHighlight ? `<span class="highlight">${word}</span>` : word;
});

return normalizedWords.join("");
}

function applyQuoteLengthFilter(quotes: Quote[]): Quote[] {
if (!modal.isOpen()) return [];
const quoteLengthFilterValue = $(
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/ts/utils/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,28 @@ export function splitByAndKeep(text: string, delimiters: string[]): string[] {
return splitString;
}

/**
* Highlights all occurrences of specified words within a given text.
* Each match is wrapped in a <span class="highlight"> element.
* Matches are ignored if they appear as part of a larger word
* not included in the matches array.
* @param text The full text in which to highlight words.
* @param matches An array of words to highlight.
* @return The full text with all matching words highlighted.
*/
export function highlightMatches(text: string, matches: string[]): string {
matches = matches.filter((match) => match !== "");
if (matches.length === 0) return text;

// matches that don't have a letter before or after them
const pattern = new RegExp(
`(?<!\\p{L})(?:${matches.join("|")})(?!\\p{L})`,
"gu"
);

return text.replace(pattern, '<span class="highlight">$&</span>');
}

/**
* Returns a display string for the given language, optionally removing the size indicator.
* @param language The language string.
Expand Down
Loading