readthemanual 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +196 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +206 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +134 -0
- package/dist/db.d.ts +33 -0
- package/dist/db.js +153 -0
- package/dist/ingest.d.ts +9 -0
- package/dist/ingest.js +163 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +105 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +111 -0
- package/docs-search/package-lock.json +1090 -0
- package/docs-search/package.json +30 -0
- package/docs-search/src/cli.ts +129 -0
- package/docs-search/src/db.ts +192 -0
- package/docs-search/src/ingest.ts +168 -0
- package/docs-search/src/server.ts +145 -0
- package/docs-search/tsconfig.json +17 -0
- package/package.json +32 -0
- package/src/cli.ts +221 -0
- package/src/config.ts +143 -0
- package/src/db.ts +207 -0
- package/src/ingest.ts +200 -0
- package/src/mcp.ts +124 -0
- package/src/server.ts +145 -0
- package/tsconfig.json +17 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "readthemanual",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Read the Manual — fast local full-text search for any project's docs",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"rtm": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsx src/cli.ts",
|
|
12
|
+
"start": "node dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [],
|
|
15
|
+
"author": "",
|
|
16
|
+
"license": "ISC",
|
|
17
|
+
"type": "commonjs",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@hono/node-server": "^1.19.9",
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
21
|
+
"better-sqlite3": "^12.6.2",
|
|
22
|
+
"commander": "^14.0.3",
|
|
23
|
+
"hono": "^4.12.3",
|
|
24
|
+
"zod": "^4.3.6"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
28
|
+
"@types/node": "^25.3.3",
|
|
29
|
+
"tsx": "^4.21.0",
|
|
30
|
+
"typescript": "^5.9.3"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { ingest } from "./ingest";
|
|
4
|
+
import { startServer } from "./server";
|
|
5
|
+
import { startMcp } from "./mcp";
|
|
6
|
+
import { openDb, search, getDocument, listDocuments } from "./db";
|
|
7
|
+
import {
|
|
8
|
+
loadConfig, getConfigPath, getDbPath, getDefaultSource,
|
|
9
|
+
addSource, removeSource, setLastSource, isGlobal,
|
|
10
|
+
} from "./config";
|
|
11
|
+
import path from "path";
|
|
12
|
+
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const defaultDb = getDbPath(config);
|
|
15
|
+
|
|
16
|
+
const program = new Command();
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name("rtm")
|
|
20
|
+
.description("Read the Manual — fast local full-text search for documentation")
|
|
21
|
+
.version("0.0.1");
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command("ingest")
|
|
25
|
+
.description("Fetch and index docs from configured GitHub sources")
|
|
26
|
+
.option("-d, --db <path>", "SQLite database path", defaultDb)
|
|
27
|
+
.option("-s, --source <name>", "Ingest only this source")
|
|
28
|
+
.option("--all-langs", "Index all languages, not just English", false)
|
|
29
|
+
.option("-v, --verbose", "Verbose output", false)
|
|
30
|
+
.action(async (opts) => {
|
|
31
|
+
const result = await ingest({
|
|
32
|
+
dbPath: opts.db,
|
|
33
|
+
source: opts.source,
|
|
34
|
+
allLangs: opts.allLangs,
|
|
35
|
+
verbose: opts.verbose,
|
|
36
|
+
});
|
|
37
|
+
// Update last source if a specific one was ingested
|
|
38
|
+
if (opts.source) {
|
|
39
|
+
setLastSource(opts.source);
|
|
40
|
+
}
|
|
41
|
+
console.log(
|
|
42
|
+
`Indexed ${result.filesProcessed} files (${result.sectionsInserted} sections).`
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command("serve")
|
|
48
|
+
.description("Start the JSON-RPC HTTP server")
|
|
49
|
+
.option("-d, --db <path>", "SQLite database path", defaultDb)
|
|
50
|
+
.option("-p, --port <number>", "Port to listen on", "3777")
|
|
51
|
+
.action((opts) => {
|
|
52
|
+
startServer({ dbPath: opts.db, port: parseInt(opts.port, 10) });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
program
|
|
56
|
+
.command("mcp")
|
|
57
|
+
.description("Start as an MCP server over stdio")
|
|
58
|
+
.option("-d, --db <path>", "SQLite database path", defaultDb)
|
|
59
|
+
.action((opts) => {
|
|
60
|
+
startMcp({ dbPath: opts.db });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
program
|
|
64
|
+
.command("search <query>")
|
|
65
|
+
.description("Search the docs index")
|
|
66
|
+
.option("-d, --db <path>", "SQLite database path", defaultDb)
|
|
67
|
+
.option("-s, --source <name>", "Search only this source")
|
|
68
|
+
.option("-l, --lang <language>", "Filter by language")
|
|
69
|
+
.option("-n, --limit <number>", "Max results", "10")
|
|
70
|
+
.option("--json", "Output raw JSON", false)
|
|
71
|
+
.action((query, opts) => {
|
|
72
|
+
const cfg = loadConfig();
|
|
73
|
+
const db = openDb(opts.db);
|
|
74
|
+
|
|
75
|
+
// Determine source scope: explicit flag > lastSource > all
|
|
76
|
+
const sourceName = opts.source ?? getDefaultSource(cfg);
|
|
77
|
+
|
|
78
|
+
const { results, total } = search(db, query, {
|
|
79
|
+
language: opts.lang,
|
|
80
|
+
limit: parseInt(opts.limit, 10),
|
|
81
|
+
sourcePrefix: sourceName ? `${sourceName}/` : undefined,
|
|
82
|
+
});
|
|
83
|
+
db.close();
|
|
84
|
+
if (opts.json) {
|
|
85
|
+
console.log(JSON.stringify({ results, total }, null, 2));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (results.length === 0) {
|
|
89
|
+
console.log("No results found.");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const r of results) {
|
|
93
|
+
const fullPath = path.join(cfg.dataDir, r.file_path);
|
|
94
|
+
const snippet = r.snippet.replace(/<\/?mark>/g, (m) => m === "<mark>" ? "\x1b[1;33m" : "\x1b[0m");
|
|
95
|
+
console.log(`\x1b[36m[${r.id}]\x1b[0m \x1b[1m${r.heading}\x1b[0m`);
|
|
96
|
+
console.log(` ${fullPath} (${r.language})`);
|
|
97
|
+
console.log(` ${snippet}`);
|
|
98
|
+
console.log();
|
|
99
|
+
}
|
|
100
|
+
if (sourceName) {
|
|
101
|
+
console.log(`${results.length} of ${total} match(es) [source: ${sourceName}]`);
|
|
102
|
+
} else {
|
|
103
|
+
console.log(`${results.length} of ${total} match(es)`);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
program
|
|
108
|
+
.command("get <id>")
|
|
109
|
+
.description("Get a document section by ID")
|
|
110
|
+
.option("-d, --db <path>", "SQLite database path", defaultDb)
|
|
111
|
+
.option("--json", "Output raw JSON", false)
|
|
112
|
+
.action((id, opts) => {
|
|
113
|
+
const db = openDb(opts.db);
|
|
114
|
+
const doc = getDocument(db, parseInt(id, 10));
|
|
115
|
+
db.close();
|
|
116
|
+
if (!doc) {
|
|
117
|
+
console.error(`Document ${id} not found.`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
if (opts.json) {
|
|
121
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const cfg = loadConfig();
|
|
125
|
+
const fullPath = path.join(cfg.dataDir, doc.file_path);
|
|
126
|
+
console.log(`\x1b[1m${doc.heading}\x1b[0m`);
|
|
127
|
+
console.log(`${fullPath} (${doc.language})`);
|
|
128
|
+
console.log();
|
|
129
|
+
console.log(doc.content);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
program
|
|
133
|
+
.command("list")
|
|
134
|
+
.description("List indexed documents")
|
|
135
|
+
.option("-d, --db <path>", "SQLite database path", defaultDb)
|
|
136
|
+
.option("-l, --lang <language>", "Filter by language")
|
|
137
|
+
.option("-n, --limit <number>", "Max results", "50")
|
|
138
|
+
.option("-o, --offset <number>", "Offset", "0")
|
|
139
|
+
.option("--json", "Output raw JSON", false)
|
|
140
|
+
.action((opts) => {
|
|
141
|
+
const db = openDb(opts.db);
|
|
142
|
+
const result = listDocuments(db, {
|
|
143
|
+
language: opts.lang,
|
|
144
|
+
limit: parseInt(opts.limit, 10),
|
|
145
|
+
offset: parseInt(opts.offset, 10),
|
|
146
|
+
});
|
|
147
|
+
db.close();
|
|
148
|
+
if (opts.json) {
|
|
149
|
+
console.log(JSON.stringify(result, null, 2));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const cfg = loadConfig();
|
|
153
|
+
for (const d of result.docs) {
|
|
154
|
+
const fullPath = path.join(cfg.dataDir, d.file_path);
|
|
155
|
+
console.log(`\x1b[36m[${d.id}]\x1b[0m ${fullPath} — ${d.heading} (${d.language})`);
|
|
156
|
+
}
|
|
157
|
+
console.log(`\nShowing ${result.docs.length} of ${result.total} total sections`);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
program
|
|
161
|
+
.command("add <name> <repo>")
|
|
162
|
+
.description("Add a GitHub repo as a doc source (repo = owner/repo)")
|
|
163
|
+
.option("-b, --branch <branch>", "Branch to index", "main")
|
|
164
|
+
.option("-p, --prefix <prefix>", "Path prefix for docs in the repo", "docs/")
|
|
165
|
+
.action((name, repo, opts) => {
|
|
166
|
+
addSource({ name, repo, branch: opts.branch, docsPrefix: opts.prefix });
|
|
167
|
+
console.log(`Added source "${name}" → ${repo}@${opts.branch} (prefix: ${opts.prefix})`);
|
|
168
|
+
console.log(`Run \`rtm ingest -s ${name}\` to index it.`);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
program
|
|
172
|
+
.command("remove <name>")
|
|
173
|
+
.description("Remove a doc source")
|
|
174
|
+
.action((name) => {
|
|
175
|
+
if (removeSource(name)) {
|
|
176
|
+
console.log(`Removed source "${name}".`);
|
|
177
|
+
} else {
|
|
178
|
+
console.error(`Source "${name}" not found.`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
program
|
|
184
|
+
.command("use <name>")
|
|
185
|
+
.description("Set the default source for searches")
|
|
186
|
+
.action((name) => {
|
|
187
|
+
const cfg = loadConfig();
|
|
188
|
+
if (!cfg.sources.some((s) => s.name === name)) {
|
|
189
|
+
const available = cfg.sources.map((s) => s.name).join(", ");
|
|
190
|
+
console.error(`Source "${name}" not found. Available: ${available}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
setLastSource(name);
|
|
194
|
+
console.log(`Default source set to "${name}".`);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
program
|
|
198
|
+
.command("config")
|
|
199
|
+
.description("Show config file path and contents")
|
|
200
|
+
.action(() => {
|
|
201
|
+
const cfg = loadConfig();
|
|
202
|
+
console.log(`Config: ${getConfigPath()}`);
|
|
203
|
+
console.log(`Data: ${cfg.dataDir}`);
|
|
204
|
+
console.log(`Install: ${isGlobal() ? "global (/usr/local)" : "local (~/.local)"}`);
|
|
205
|
+
console.log(`Default: ${getDefaultSource(cfg) ?? "(none)"}`);
|
|
206
|
+
console.log();
|
|
207
|
+
console.log(`Sources (${cfg.sources.length}):`);
|
|
208
|
+
for (const s of cfg.sources) {
|
|
209
|
+
const marker = s.name === getDefaultSource(cfg) ? " *" : "";
|
|
210
|
+
console.log(` - ${s.name}: ${s.repo}@${s.branch ?? "main"} (prefix: ${s.docsPrefix ?? "docs/"})${marker}`);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Default: bare args with no subcommand → search
|
|
215
|
+
const knownCommands = ["ingest", "serve", "mcp", "search", "get", "list", "add", "remove", "use", "config", "help"];
|
|
216
|
+
const firstArg = process.argv[2];
|
|
217
|
+
if (firstArg && !firstArg.startsWith("-") && !knownCommands.includes(firstArg)) {
|
|
218
|
+
process.argv.splice(2, 0, "search");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
program.parse();
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
function isGlobalInstall(): boolean {
|
|
6
|
+
return __dirname.startsWith("/usr/local");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Data always lives in user's home — it's user data, not system data
|
|
10
|
+
const DATA_DIR = path.join(os.homedir(), ".local", "rtm");
|
|
11
|
+
const CONFIG_PATH = path.join(DATA_DIR, "config.json");
|
|
12
|
+
|
|
13
|
+
export interface SourceConfig {
|
|
14
|
+
name: string;
|
|
15
|
+
repo: string;
|
|
16
|
+
branch?: string;
|
|
17
|
+
docsPrefix?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Config {
|
|
21
|
+
sources: SourceConfig[];
|
|
22
|
+
dataDir: string;
|
|
23
|
+
lastSource?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CONFIG: Config = {
|
|
27
|
+
sources: [
|
|
28
|
+
{
|
|
29
|
+
name: "zeroclaw",
|
|
30
|
+
repo: "zeroclaw-labs/zeroclaw",
|
|
31
|
+
branch: "main",
|
|
32
|
+
docsPrefix: "docs/",
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
dataDir: DATA_DIR,
|
|
36
|
+
lastSource: "zeroclaw",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function getConfigPath(): string {
|
|
40
|
+
return CONFIG_PATH;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isGlobal(): boolean {
|
|
44
|
+
return isGlobalInstall();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasWriteAccess(dir: string): boolean {
|
|
48
|
+
// Walk up to find the first existing ancestor and check write permission
|
|
49
|
+
let check = dir;
|
|
50
|
+
while (!fs.existsSync(check)) {
|
|
51
|
+
const parent = path.dirname(check);
|
|
52
|
+
if (parent === check) return false;
|
|
53
|
+
check = parent;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
fs.accessSync(check, fs.constants.W_OK);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function checkWriteAccess(): void {
|
|
64
|
+
if (!hasWriteAccess(DATA_DIR)) {
|
|
65
|
+
console.error(`Error: No write permission to ${DATA_DIR}\nCheck directory permissions.`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function loadConfig(): Config {
|
|
71
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
72
|
+
// Return defaults in memory; only write if we have access
|
|
73
|
+
if (hasWriteAccess(DATA_DIR) || hasWriteAccess(path.dirname(DATA_DIR))) {
|
|
74
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
75
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf-8");
|
|
76
|
+
}
|
|
77
|
+
return DEFAULT_CONFIG;
|
|
78
|
+
}
|
|
79
|
+
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
80
|
+
return {
|
|
81
|
+
sources: raw.sources ?? DEFAULT_CONFIG.sources,
|
|
82
|
+
dataDir: raw.dataDir ?? DATA_DIR,
|
|
83
|
+
lastSource: raw.lastSource,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getDbPath(config: Config): string {
|
|
88
|
+
return path.join(config.dataDir, "rtm.db");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getDocsDir(config: Config, sourceName: string): string {
|
|
92
|
+
return path.join(config.dataDir, sourceName);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function saveConfig(config: Config): void {
|
|
96
|
+
checkWriteAccess();
|
|
97
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
98
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function addSource(source: SourceConfig): void {
|
|
102
|
+
const config = loadConfig();
|
|
103
|
+
const existing = config.sources.findIndex((s) => s.name === source.name);
|
|
104
|
+
if (existing >= 0) {
|
|
105
|
+
config.sources[existing] = source;
|
|
106
|
+
} else {
|
|
107
|
+
config.sources.push(source);
|
|
108
|
+
}
|
|
109
|
+
config.lastSource = source.name;
|
|
110
|
+
saveConfig(config);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function removeSource(name: string): boolean {
|
|
114
|
+
const config = loadConfig();
|
|
115
|
+
const before = config.sources.length;
|
|
116
|
+
config.sources = config.sources.filter((s) => s.name !== name);
|
|
117
|
+
if (config.sources.length === before) return false;
|
|
118
|
+
// If we removed the last-used source, reset to the final remaining one
|
|
119
|
+
if (config.lastSource === name) {
|
|
120
|
+
config.lastSource = config.sources.length > 0
|
|
121
|
+
? config.sources[config.sources.length - 1].name
|
|
122
|
+
: undefined;
|
|
123
|
+
}
|
|
124
|
+
saveConfig(config);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function setLastSource(name: string): void {
|
|
129
|
+
const config = loadConfig();
|
|
130
|
+
config.lastSource = name;
|
|
131
|
+
saveConfig(config);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function getDefaultSource(config: Config): string | undefined {
|
|
135
|
+
// Last-used source, or the last one in the list, or undefined
|
|
136
|
+
if (config.lastSource && config.sources.some((s) => s.name === config.lastSource)) {
|
|
137
|
+
return config.lastSource;
|
|
138
|
+
}
|
|
139
|
+
if (config.sources.length > 0) {
|
|
140
|
+
return config.sources[config.sources.length - 1].name;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DB_PATH = path.join(os.homedir(), ".local", "rtm", "rtm.db");
|
|
7
|
+
|
|
8
|
+
export interface DocSection {
|
|
9
|
+
id: number;
|
|
10
|
+
file_path: string;
|
|
11
|
+
heading: string;
|
|
12
|
+
content: string;
|
|
13
|
+
language: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SearchResult extends DocSection {
|
|
17
|
+
rank: number;
|
|
18
|
+
snippet: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function openDb(dbPath: string = DEFAULT_DB_PATH): Database.Database {
|
|
22
|
+
const dir = path.dirname(dbPath);
|
|
23
|
+
if (!fs.existsSync(dir)) {
|
|
24
|
+
try {
|
|
25
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
+
} catch (err: any) {
|
|
27
|
+
if (err.code === "EACCES") {
|
|
28
|
+
console.error(
|
|
29
|
+
`Error: No write permission to ${dir}\n` +
|
|
30
|
+
`If installed globally, run with sudo or install locally.`
|
|
31
|
+
);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const db = new Database(dbPath);
|
|
38
|
+
db.pragma("journal_mode = WAL");
|
|
39
|
+
db.pragma("foreign_keys = ON");
|
|
40
|
+
initSchema(db);
|
|
41
|
+
return db;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function initSchema(db: Database.Database): void {
|
|
45
|
+
db.exec(`
|
|
46
|
+
CREATE TABLE IF NOT EXISTS docs (
|
|
47
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
48
|
+
file_path TEXT NOT NULL,
|
|
49
|
+
heading TEXT NOT NULL,
|
|
50
|
+
content TEXT NOT NULL,
|
|
51
|
+
language TEXT NOT NULL DEFAULT 'en'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_docs_path ON docs(file_path);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_docs_lang ON docs(language);
|
|
56
|
+
|
|
57
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
|
|
58
|
+
heading,
|
|
59
|
+
content,
|
|
60
|
+
content='docs',
|
|
61
|
+
content_rowid='id',
|
|
62
|
+
tokenize='porter unicode61'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN
|
|
66
|
+
INSERT INTO docs_fts(rowid, heading, content)
|
|
67
|
+
VALUES (new.id, new.heading, new.content);
|
|
68
|
+
END;
|
|
69
|
+
|
|
70
|
+
CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs BEGIN
|
|
71
|
+
INSERT INTO docs_fts(docs_fts, rowid, heading, content)
|
|
72
|
+
VALUES ('delete', old.id, old.heading, old.content);
|
|
73
|
+
END;
|
|
74
|
+
|
|
75
|
+
CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs BEGIN
|
|
76
|
+
INSERT INTO docs_fts(docs_fts, rowid, heading, content)
|
|
77
|
+
VALUES ('delete', old.id, old.heading, old.content);
|
|
78
|
+
INSERT INTO docs_fts(rowid, heading, content)
|
|
79
|
+
VALUES (new.id, new.heading, new.content);
|
|
80
|
+
END;
|
|
81
|
+
`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function clearDocs(db: Database.Database): void {
|
|
85
|
+
db.exec("DELETE FROM docs");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function insertSection(
|
|
89
|
+
db: Database.Database,
|
|
90
|
+
section: Omit<DocSection, "id">
|
|
91
|
+
): number {
|
|
92
|
+
const stmt = db.prepare(
|
|
93
|
+
"INSERT INTO docs (file_path, heading, content, language) VALUES (?, ?, ?, ?)"
|
|
94
|
+
);
|
|
95
|
+
const result = stmt.run(
|
|
96
|
+
section.file_path,
|
|
97
|
+
section.heading,
|
|
98
|
+
section.content,
|
|
99
|
+
section.language
|
|
100
|
+
);
|
|
101
|
+
return Number(result.lastInsertRowid);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface SearchResponse {
|
|
105
|
+
results: SearchResult[];
|
|
106
|
+
total: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sanitizeFtsQuery(query: string): string {
|
|
110
|
+
const tokens = query
|
|
111
|
+
.replace(/"/g, "")
|
|
112
|
+
.split(/\s+/)
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
if (tokens.length === 0) return '""';
|
|
115
|
+
return tokens.map((t) => `"${t}"`).join(" ");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function search(
|
|
119
|
+
db: Database.Database,
|
|
120
|
+
query: string,
|
|
121
|
+
opts: { language?: string; limit?: number; sourcePrefix?: string } = {}
|
|
122
|
+
): SearchResponse {
|
|
123
|
+
const limit = opts.limit ?? 20;
|
|
124
|
+
const ftsQuery = sanitizeFtsQuery(query);
|
|
125
|
+
|
|
126
|
+
let countSql = `
|
|
127
|
+
SELECT COUNT(*) as total
|
|
128
|
+
FROM docs_fts
|
|
129
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
130
|
+
WHERE docs_fts MATCH ?
|
|
131
|
+
`;
|
|
132
|
+
let sql = `
|
|
133
|
+
SELECT
|
|
134
|
+
d.id, d.file_path, d.heading, d.content, d.language,
|
|
135
|
+
docs_fts.rank AS rank,
|
|
136
|
+
snippet(docs_fts, 1, '<mark>', '</mark>', '...', 40) AS snippet
|
|
137
|
+
FROM docs_fts
|
|
138
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
139
|
+
WHERE docs_fts MATCH ?
|
|
140
|
+
`;
|
|
141
|
+
const params: (string | number)[] = [ftsQuery];
|
|
142
|
+
const countParams: (string | number)[] = [ftsQuery];
|
|
143
|
+
|
|
144
|
+
if (opts.sourcePrefix) {
|
|
145
|
+
countSql += " AND d.file_path LIKE ?";
|
|
146
|
+
sql += " AND d.file_path LIKE ?";
|
|
147
|
+
params.push(opts.sourcePrefix + "%");
|
|
148
|
+
countParams.push(opts.sourcePrefix + "%");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (opts.language) {
|
|
152
|
+
countSql += " AND d.language = ?";
|
|
153
|
+
sql += " AND d.language = ?";
|
|
154
|
+
params.push(opts.language);
|
|
155
|
+
countParams.push(opts.language);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
sql += " ORDER BY docs_fts.rank LIMIT ?";
|
|
159
|
+
params.push(limit);
|
|
160
|
+
|
|
161
|
+
const { total } = db.prepare(countSql).get(...countParams) as { total: number };
|
|
162
|
+
const results = db.prepare(sql).all(...params) as SearchResult[];
|
|
163
|
+
|
|
164
|
+
return { results, total };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getDocument(
|
|
168
|
+
db: Database.Database,
|
|
169
|
+
id: number
|
|
170
|
+
): DocSection | null {
|
|
171
|
+
return (
|
|
172
|
+
(db.prepare("SELECT * FROM docs WHERE id = ?").get(id) as
|
|
173
|
+
| DocSection
|
|
174
|
+
| undefined) ?? null
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function listDocuments(
|
|
179
|
+
db: Database.Database,
|
|
180
|
+
opts: { language?: string; limit?: number; offset?: number } = {}
|
|
181
|
+
): { docs: Pick<DocSection, "id" | "file_path" | "heading" | "language">[]; total: number } {
|
|
182
|
+
const limit = opts.limit ?? 100;
|
|
183
|
+
const offset = opts.offset ?? 0;
|
|
184
|
+
|
|
185
|
+
let countSql = "SELECT COUNT(*) as total FROM docs";
|
|
186
|
+
let sql = "SELECT id, file_path, heading, language FROM docs";
|
|
187
|
+
const params: (string | number)[] = [];
|
|
188
|
+
const countParams: string[] = [];
|
|
189
|
+
|
|
190
|
+
if (opts.language) {
|
|
191
|
+
countSql += " WHERE language = ?";
|
|
192
|
+
sql += " WHERE language = ?";
|
|
193
|
+
params.push(opts.language);
|
|
194
|
+
countParams.push(opts.language);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
sql += " ORDER BY file_path, id LIMIT ? OFFSET ?";
|
|
198
|
+
params.push(limit, offset);
|
|
199
|
+
|
|
200
|
+
const { total } = db.prepare(countSql).get(...countParams) as { total: number };
|
|
201
|
+
const docs = db.prepare(sql).all(...params) as Pick<
|
|
202
|
+
DocSection,
|
|
203
|
+
"id" | "file_path" | "heading" | "language"
|
|
204
|
+
>[];
|
|
205
|
+
|
|
206
|
+
return { docs, total };
|
|
207
|
+
}
|