Files
package-gutenberg-runtime/src/catalog.ts
T

132 lines
4.7 KiB
TypeScript

export interface Book {
ebook: number;
title: string;
author: string;
language: string;
}
/** RFC 4180 records, including quoted commas, escaped quotes, and multiline titles. */
export function parseIndex(text: string): Book[] {
const rows: string[][] = [];
let row: string[] = [],
field = "",
quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (ch === '"') {
if (quoted && text[i + 1] === '"') {
field += '"';
i++;
} else if (quoted || field === "") quoted = !quoted;
else throw Error("Unexpected CSV quote");
} else if (!quoted && (ch === "," || ch === "\n" || ch === "\r")) {
row.push(field);
field = "";
if (ch !== ",") {
rows.push(row);
row = [];
if (ch === "\r" && text[i + 1] === "\n") i++;
}
} else field += ch;
}
if (quoted) throw Error("Unclosed CSV quote");
if (field || row.length) {
row.push(field);
rows.push(row);
}
const header = rows.shift()!;
const columns = ["Text#", "Type", "Title", "Language", "Authors"].map((name) => header.indexOf(name));
if (columns.some((i) => i < 0)) throw Error("Unrecognized Gutenberg CSV header");
const books: Book[] = [],
seen = new Set<number>();
for (const row of rows) {
if (row.length === 1 && row[0] === "") continue;
if (row.length !== header.length) throw Error("Invalid Gutenberg CSV row");
if (row[columns[1]!] !== "Text") continue;
const ebook = Number(row[columns[0]!]!);
if (!Number.isSafeInteger(ebook) || ebook < 1 || seen.has(ebook)) throw Error("Invalid or repeated book ID");
seen.add(ebook);
books.push({
ebook,
title: row[columns[2]!]!.replace(/\s+/g, " ").trim(),
language: row[columns[3]!]!,
author: row[columns[4]!]!,
});
}
return books;
}
const normalized = (text: string) =>
text
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
const grams = (text: string) =>
new Set(Array.from({ length: Math.max(0, text.length - 2) }, (_, i) => text.slice(i, i + 3)));
export function searchIndex(books: readonly Book[]) {
const index = books.map((book) => ({
book,
title: normalized(book.title),
words: normalized(book.title + " " + book.author),
grams: grams(normalized(book.title)),
}));
return (query: string): Book[] => {
const q = normalized(query).slice(0, 120);
if (q.length < 2) return [];
const tokens = q.split(" "),
qgrams = grams(q);
const matches: { book: Book; score: number }[] = [];
for (const entry of index) {
const overlap = [...qgrams].filter((g) => entry.grams.has(g)).length;
const similarity = (2 * overlap) / Math.max(1, qgrams.size + entry.grams.size);
const score =
(entry.title === q ? 10 : entry.title.includes(q) ? 5 : 0) +
(tokens.every((t) => entry.words.includes(t)) ? 2 : 0) +
similarity;
if (score >= 0.24) matches.push({ book: entry.book, score });
}
return matches
.sort((a, b) => b.score - a.score || a.book.ebook - b.book.ebook)
.slice(0, 20)
.map((entry) => entry.book);
};
}
export interface Excerpt {
text: string;
words: number;
}
/** The first paragraph boundary at or after roughly 1,000 words. Streaming
* callers wait for that boundary (or EOF), not the rest of the book. */
export function openingExcerpt(text: string, complete = false): Excerpt | null {
let body = text.replace(/^\uFEFF/, "").replaceAll("\r", "");
const start = /\*\*\*\s*START OF (?:THE|THIS) PROJECT GUTENBERG EBOOK[^\n]*\n/i.exec(body);
if (start) body = body.slice(start.index + start[0].length);
else if (!complete) return null;
const end = /\*\*\*\s*END OF (?:THE|THIS) PROJECT GUTENBERG EBOOK/i.exec(body);
if (end) {
body = body.slice(0, end.index);
complete = true;
}
const paragraphs = body.split(/\n[ \t]*\n+/);
if (!complete) paragraphs.pop();
const result: string[] = [];
let words = 0;
for (const paragraph of paragraphs) {
const part = paragraph.trim();
if (!part) continue;
result.push(part);
words += part.split(/\s+/u).length;
if (words >= 1000) break;
}
// An unusually large single paragraph must still fit the document contract.
// Prefer its last word boundary; ordinary excerpts always keep paragraphs whole.
if (result.join("\n\n").length > 56000 || (body.length >= 56000 && words < 1000)) {
const prefix = body.slice(0, 56000),
at = prefix.lastIndexOf(" ");
const bounded = (at > 0 ? prefix.slice(0, at) : prefix).replace(/[\uD800-\uDBFF]$/, "").trim();
return { text: bounded, words: bounded ? bounded.split(/\s+/u).length : 0 };
}
if (words < 1000 && !complete) return null;
return { text: result.join("\n\n"), words };
}