feat(frontend): in-book text search core (TDD)

This commit is contained in:
2026-09-16 16:51:36 +08:00
parent 0d7cd5f322
commit 74e5e9d5fe
2 changed files with 76 additions and 0 deletions
+48
View File
@@ -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),
};
}
+28
View File
@@ -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" });
});
});