feat(frontend): in-book text search core (TDD)
This commit is contained in:
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user