diff --git a/frontend/src/lib/search.ts b/frontend/src/lib/search.ts new file mode 100644 index 0000000..0652b19 --- /dev/null +++ b/frontend/src/lib/search.ts @@ -0,0 +1,48 @@ +import { chapterText, type TxtChapter } from "./chapters"; + +export interface SearchHit { + ch: number; + /** 命中在章内文本的起点 */ + pos: number; + snippet: string; + matchStart: number; // snippet 内 + matchLen: number; +} + +const CTX = 20; // snippet 前后文 + +/** 全书线性扫描(文本已分章在内存,量级足够;大小写不敏感,不折叠全半角)。 */ +export function searchChapters(text: string, chapters: TxtChapter[], q: string, limit = 500): SearchHit[] { + const needle = q.trim().toLowerCase(); + if (!needle) return []; + const bodies = chapters.map((_, ci) => chapterText(text, chapters, ci).toLowerCase()); + const hits: SearchHit[] = []; + for (let ci = 0; ci < chapters.length && hits.length < limit; ci++) { + const body = bodies[ci]; + let from = 0; + for (;;) { + const at = body.indexOf(needle, from); + if (at < 0 || hits.length >= limit) break; + const raw = chapterText(text, chapters, ci); + const s = Math.max(0, at - CTX); + const e = Math.min(raw.length, at + needle.length + CTX); + hits.push({ + ch: ci, + pos: at, + snippet: (s > 0 ? "…" : "") + raw.slice(s, e) + (e < raw.length ? "…" : ""), + matchStart: at - s + (s > 0 ? 1 : 0), + matchLen: needle.length, + }); + from = at + needle.length; + } + } + return hits; +} + +export function splitHighlight(snippet: string, matchStart: number, matchLen: number) { + return { + before: snippet.slice(0, matchStart), + match: snippet.slice(matchStart, matchStart + matchLen), + after: snippet.slice(matchStart + matchLen), + }; +} diff --git a/frontend/test/search.test.ts b/frontend/test/search.test.ts new file mode 100644 index 0000000..ebf52e8 --- /dev/null +++ b/frontend/test/search.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { searchChapters, splitHighlight } from "../src/lib/search"; +import { splitChapters } from "../src/lib/chapters"; + +const TEXT = ["第一章 起风", "风来了又走。", "第二章 落雨", "雨点敲窗,风声相伴。", "风止"].join("\n"); + +describe("searchChapters", () => { + const chs = splitChapters(TEXT); + it("finds case-insensitive hits with chapter index and position", () => { + const hits = searchChapters(TEXT, chs, "风"); + expect(hits.length).toBeGreaterThanOrEqual(3); + expect(hits[0].ch).toBe(0); + expect(hits.every((h) => h.snippet.includes("风"))).toBe(true); + }); + it("returns empty for no match / empty query", () => { + expect(searchChapters(TEXT, chs, "不存在")).toEqual([]); + expect(searchChapters(TEXT, chs, " ")).toEqual([]); + }); + it("caps at limit", () => { + expect(searchChapters(TEXT, chs, "风", 2)).toHaveLength(2); + }); +}); + +describe("splitHighlight", () => { + it("splits snippet into before/match/after", () => { + expect(splitHighlight("abc风def", 3, 1)).toEqual({ before: "abc", match: "风", after: "def" }); + }); +});