wp-devdocs-mcp 1.1.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/LICENSE +21 -0
- package/README.md +407 -0
- package/bin/wp-hooks.js +533 -0
- package/package.json +62 -0
- package/src/constants.js +22 -0
- package/src/db/sqlite.js +1012 -0
- package/src/docs/doc-index-manager.js +128 -0
- package/src/docs/parsers/admin-handbook-parser.js +48 -0
- package/src/docs/parsers/base-doc-parser.js +175 -0
- package/src/docs/parsers/block-editor-parser.js +49 -0
- package/src/docs/parsers/general-doc-parser.js +53 -0
- package/src/docs/parsers/plugin-handbook-parser.js +60 -0
- package/src/docs/parsers/rest-api-parser.js +57 -0
- package/src/docs/parsers/wp-cli-parser.js +61 -0
- package/src/indexer/index-manager.js +187 -0
- package/src/indexer/js-parser.js +306 -0
- package/src/indexer/parser-utils.js +158 -0
- package/src/indexer/php-parser.js +205 -0
- package/src/indexer/sources/github-private.js +57 -0
- package/src/indexer/sources/github-public.js +38 -0
- package/src/indexer/sources/index.js +19 -0
- package/src/indexer/sources/local-folder.js +17 -0
- package/src/mcp-entry.js +109 -0
- package/src/presets.js +68 -0
- package/src/server/tools/get-doc.js +79 -0
- package/src/server/tools/get-hook-context.js +67 -0
- package/src/server/tools/list-docs.js +71 -0
- package/src/server/tools/search-block-apis.js +72 -0
- package/src/server/tools/search-docs.js +55 -0
- package/src/server/tools/search-hooks.js +64 -0
- package/src/server/tools/validate-hook.js +66 -0
package/src/db/sqlite.js
ADDED
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { DB_PATH } from '../constants.js';
|
|
5
|
+
|
|
6
|
+
let db;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get or create the SQLite database connection.
|
|
10
|
+
* Creates the database directory and initializes the schema on first call.
|
|
11
|
+
* @returns {import('better-sqlite3').Database}
|
|
12
|
+
*/
|
|
13
|
+
export function getDb() {
|
|
14
|
+
if (!db) {
|
|
15
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
16
|
+
db = new Database(DB_PATH);
|
|
17
|
+
db.pragma('journal_mode = WAL');
|
|
18
|
+
db.pragma('foreign_keys = ON');
|
|
19
|
+
initDb(db);
|
|
20
|
+
}
|
|
21
|
+
return db;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Close the database connection and clear the statement cache.
|
|
26
|
+
*/
|
|
27
|
+
export function closeDb() {
|
|
28
|
+
if (db) {
|
|
29
|
+
stmtCache.clear();
|
|
30
|
+
db.close();
|
|
31
|
+
db = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function initDb(db) {
|
|
36
|
+
db.exec(`
|
|
37
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
name TEXT UNIQUE NOT NULL,
|
|
40
|
+
type TEXT NOT NULL,
|
|
41
|
+
repo_url TEXT,
|
|
42
|
+
subfolder TEXT,
|
|
43
|
+
local_path TEXT,
|
|
44
|
+
token_env_var TEXT,
|
|
45
|
+
branch TEXT DEFAULT 'main',
|
|
46
|
+
enabled INTEGER DEFAULT 1,
|
|
47
|
+
content_type TEXT DEFAULT 'source'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS hooks (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
|
53
|
+
file_path TEXT NOT NULL,
|
|
54
|
+
line_number INTEGER NOT NULL,
|
|
55
|
+
name TEXT NOT NULL,
|
|
56
|
+
type TEXT NOT NULL,
|
|
57
|
+
php_function TEXT,
|
|
58
|
+
params TEXT,
|
|
59
|
+
param_count INTEGER DEFAULT 0,
|
|
60
|
+
docblock TEXT,
|
|
61
|
+
inferred_description TEXT,
|
|
62
|
+
function_context TEXT,
|
|
63
|
+
class_name TEXT,
|
|
64
|
+
code_before TEXT,
|
|
65
|
+
code_after TEXT,
|
|
66
|
+
hook_line TEXT,
|
|
67
|
+
is_dynamic INTEGER DEFAULT 0,
|
|
68
|
+
content_hash TEXT,
|
|
69
|
+
status TEXT DEFAULT 'active',
|
|
70
|
+
removed_at TEXT,
|
|
71
|
+
first_seen_at TEXT DEFAULT (datetime('now')),
|
|
72
|
+
last_seen_at TEXT DEFAULT (datetime('now')),
|
|
73
|
+
UNIQUE(source_id, file_path, line_number, name)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_hooks_source_id ON hooks(source_id);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_hooks_name ON hooks(name);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_hooks_type ON hooks(type);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_hooks_status ON hooks(status);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_hooks_source_status ON hooks(source_id, status);
|
|
81
|
+
|
|
82
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS hooks_fts USING fts5(
|
|
83
|
+
name,
|
|
84
|
+
type,
|
|
85
|
+
docblock,
|
|
86
|
+
inferred_description,
|
|
87
|
+
function_context,
|
|
88
|
+
class_name,
|
|
89
|
+
params,
|
|
90
|
+
content='hooks',
|
|
91
|
+
content_rowid='id'
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
CREATE TABLE IF NOT EXISTS indexed_files (
|
|
95
|
+
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
|
96
|
+
file_path TEXT NOT NULL,
|
|
97
|
+
mtime_ms REAL,
|
|
98
|
+
content_hash TEXT,
|
|
99
|
+
UNIQUE(source_id, file_path)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE IF NOT EXISTS block_registrations (
|
|
103
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
|
+
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
|
105
|
+
file_path TEXT NOT NULL,
|
|
106
|
+
line_number INTEGER NOT NULL,
|
|
107
|
+
block_name TEXT,
|
|
108
|
+
block_title TEXT,
|
|
109
|
+
block_category TEXT,
|
|
110
|
+
block_attributes TEXT,
|
|
111
|
+
supports TEXT,
|
|
112
|
+
code_context TEXT,
|
|
113
|
+
content_hash TEXT,
|
|
114
|
+
first_seen_at TEXT DEFAULT (datetime('now')),
|
|
115
|
+
last_seen_at TEXT DEFAULT (datetime('now'))
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS block_registrations_fts USING fts5(
|
|
119
|
+
block_name,
|
|
120
|
+
block_title,
|
|
121
|
+
block_category,
|
|
122
|
+
block_attributes,
|
|
123
|
+
supports,
|
|
124
|
+
code_context,
|
|
125
|
+
content='block_registrations',
|
|
126
|
+
content_rowid='id'
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
CREATE TABLE IF NOT EXISTS api_usages (
|
|
130
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
131
|
+
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
|
132
|
+
file_path TEXT NOT NULL,
|
|
133
|
+
line_number INTEGER NOT NULL,
|
|
134
|
+
api_call TEXT,
|
|
135
|
+
namespace TEXT,
|
|
136
|
+
method TEXT,
|
|
137
|
+
code_context TEXT,
|
|
138
|
+
content_hash TEXT,
|
|
139
|
+
first_seen_at TEXT DEFAULT (datetime('now')),
|
|
140
|
+
last_seen_at TEXT DEFAULT (datetime('now'))
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS api_usages_fts USING fts5(
|
|
144
|
+
api_call,
|
|
145
|
+
namespace,
|
|
146
|
+
method,
|
|
147
|
+
code_context,
|
|
148
|
+
content='api_usages',
|
|
149
|
+
content_rowid='id'
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
CREATE TABLE IF NOT EXISTS docs (
|
|
153
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
154
|
+
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
|
155
|
+
file_path TEXT NOT NULL,
|
|
156
|
+
slug TEXT NOT NULL,
|
|
157
|
+
title TEXT NOT NULL,
|
|
158
|
+
doc_type TEXT NOT NULL,
|
|
159
|
+
category TEXT,
|
|
160
|
+
subcategory TEXT,
|
|
161
|
+
description TEXT,
|
|
162
|
+
content TEXT NOT NULL,
|
|
163
|
+
code_examples TEXT,
|
|
164
|
+
metadata TEXT,
|
|
165
|
+
content_hash TEXT,
|
|
166
|
+
status TEXT DEFAULT 'active',
|
|
167
|
+
first_seen_at TEXT DEFAULT (datetime('now')),
|
|
168
|
+
last_seen_at TEXT DEFAULT (datetime('now')),
|
|
169
|
+
UNIQUE(source_id, file_path)
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
CREATE INDEX IF NOT EXISTS idx_docs_source_id ON docs(source_id);
|
|
173
|
+
CREATE INDEX IF NOT EXISTS idx_docs_slug ON docs(slug);
|
|
174
|
+
CREATE INDEX IF NOT EXISTS idx_docs_doc_type ON docs(doc_type);
|
|
175
|
+
CREATE INDEX IF NOT EXISTS idx_docs_category ON docs(category);
|
|
176
|
+
CREATE INDEX IF NOT EXISTS idx_docs_status ON docs(status);
|
|
177
|
+
|
|
178
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
|
|
179
|
+
title,
|
|
180
|
+
slug,
|
|
181
|
+
doc_type,
|
|
182
|
+
category,
|
|
183
|
+
description,
|
|
184
|
+
content,
|
|
185
|
+
content='docs',
|
|
186
|
+
content_rowid='id',
|
|
187
|
+
tokenize='porter unicode61'
|
|
188
|
+
);
|
|
189
|
+
`);
|
|
190
|
+
|
|
191
|
+
// Migration: add content_type to sources if missing (for existing databases)
|
|
192
|
+
try {
|
|
193
|
+
db.exec(`ALTER TABLE sources ADD COLUMN content_type TEXT DEFAULT 'source'`);
|
|
194
|
+
} catch {
|
|
195
|
+
// Column already exists — ignore
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Migration: add last_indexed_at to sources if missing
|
|
199
|
+
try {
|
|
200
|
+
db.exec(`ALTER TABLE sources ADD COLUMN last_indexed_at TEXT`);
|
|
201
|
+
} catch {
|
|
202
|
+
// Column already exists — ignore
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Prepared statement cache ---
|
|
207
|
+
const stmtCache = new Map();
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get or create a cached prepared statement for the given SQL.
|
|
211
|
+
* @param {import('better-sqlite3').Database} db
|
|
212
|
+
* @param {string} sql
|
|
213
|
+
* @returns {import('better-sqlite3').Statement}
|
|
214
|
+
*/
|
|
215
|
+
function stmt(db, sql) {
|
|
216
|
+
if (!stmtCache.has(sql)) {
|
|
217
|
+
stmtCache.set(sql, db.prepare(sql));
|
|
218
|
+
}
|
|
219
|
+
return stmtCache.get(sql);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Sources ---
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Register a new source in the database.
|
|
226
|
+
* @param {object} data - Source configuration (name, type, repo_url, subfolder, local_path, token_env_var, branch, enabled)
|
|
227
|
+
* @returns {import('better-sqlite3').RunResult}
|
|
228
|
+
*/
|
|
229
|
+
export function addSource(data) {
|
|
230
|
+
const db = getDb();
|
|
231
|
+
return stmt(db, `
|
|
232
|
+
INSERT INTO sources (name, type, repo_url, subfolder, local_path, token_env_var, branch, enabled, content_type)
|
|
233
|
+
VALUES (@name, @type, @repo_url, @subfolder, @local_path, @token_env_var, @branch, @enabled, @content_type)
|
|
234
|
+
`).run({
|
|
235
|
+
name: data.name,
|
|
236
|
+
type: data.type,
|
|
237
|
+
repo_url: data.repo_url || null,
|
|
238
|
+
subfolder: data.subfolder || null,
|
|
239
|
+
local_path: data.local_path || null,
|
|
240
|
+
token_env_var: data.token_env_var || null,
|
|
241
|
+
branch: data.branch || 'main',
|
|
242
|
+
enabled: data.enabled !== undefined ? (data.enabled ? 1 : 0) : 1,
|
|
243
|
+
content_type: data.content_type || 'source',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* List all registered sources.
|
|
249
|
+
* @returns {Array<object>}
|
|
250
|
+
*/
|
|
251
|
+
export function listSources() {
|
|
252
|
+
const db = getDb();
|
|
253
|
+
return stmt(db, 'SELECT * FROM sources ORDER BY name').all();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get a source by its unique name.
|
|
258
|
+
* @param {string} name
|
|
259
|
+
* @returns {object|undefined}
|
|
260
|
+
*/
|
|
261
|
+
export function getSource(name) {
|
|
262
|
+
const db = getDb();
|
|
263
|
+
return stmt(db, 'SELECT * FROM sources WHERE name = ?').get(name);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Get a source by its numeric ID.
|
|
268
|
+
* @param {number} id
|
|
269
|
+
* @returns {object|undefined}
|
|
270
|
+
*/
|
|
271
|
+
export function getSourceById(id) {
|
|
272
|
+
const db = getDb();
|
|
273
|
+
return stmt(db, 'SELECT * FROM sources WHERE id = ?').get(id);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Remove a source and all its associated data (hooks, blocks, APIs, FTS entries).
|
|
278
|
+
* Runs within a transaction for consistency.
|
|
279
|
+
* @param {string} name - Source name
|
|
280
|
+
* @returns {object|null} The removed source object, or null if not found
|
|
281
|
+
*/
|
|
282
|
+
export function removeSource(name) {
|
|
283
|
+
const db = getDb();
|
|
284
|
+
const source = getSource(name);
|
|
285
|
+
if (!source) return null;
|
|
286
|
+
const tx = db.transaction(() => {
|
|
287
|
+
stmt(db, 'DELETE FROM hooks_fts WHERE rowid IN (SELECT id FROM hooks WHERE source_id = ?)').run(source.id);
|
|
288
|
+
stmt(db, 'DELETE FROM block_registrations_fts WHERE rowid IN (SELECT id FROM block_registrations WHERE source_id = ?)').run(source.id);
|
|
289
|
+
stmt(db, 'DELETE FROM api_usages_fts WHERE rowid IN (SELECT id FROM api_usages WHERE source_id = ?)').run(source.id);
|
|
290
|
+
stmt(db, 'DELETE FROM docs_fts WHERE rowid IN (SELECT id FROM docs WHERE source_id = ?)').run(source.id);
|
|
291
|
+
stmt(db, 'DELETE FROM sources WHERE name = ?').run(name);
|
|
292
|
+
});
|
|
293
|
+
tx();
|
|
294
|
+
return source;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function getActiveDocId(sourceId, filePath) {
|
|
298
|
+
const db = getDb();
|
|
299
|
+
const row = stmt(db, "SELECT id FROM docs WHERE source_id = ? AND file_path = ? AND status = 'active'").get(sourceId, filePath);
|
|
300
|
+
return row ? row.id : null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Check whether a source has any indexed files.
|
|
305
|
+
* @param {number} sourceId
|
|
306
|
+
* @returns {boolean}
|
|
307
|
+
*/
|
|
308
|
+
export function isSourceIndexed(sourceId) {
|
|
309
|
+
const db = getDb();
|
|
310
|
+
const row = stmt(db, 'SELECT COUNT(*) as count FROM indexed_files WHERE source_id = ?').get(sourceId);
|
|
311
|
+
return row.count > 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function updateSourceLastIndexed(sourceId) {
|
|
315
|
+
const db = getDb();
|
|
316
|
+
stmt(db, "UPDATE sources SET last_indexed_at = datetime('now') WHERE id = ?").run(sourceId);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function getStaleSources(maxAgeMs) {
|
|
320
|
+
const db = getDb();
|
|
321
|
+
const cutoffSeconds = Math.floor(maxAgeMs / 1000);
|
|
322
|
+
return stmt(db, `
|
|
323
|
+
SELECT * FROM sources
|
|
324
|
+
WHERE enabled = 1
|
|
325
|
+
AND (last_indexed_at IS NULL OR last_indexed_at < datetime('now', '-' || ? || ' seconds'))
|
|
326
|
+
ORDER BY name
|
|
327
|
+
`).all(cutoffSeconds);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- Hooks ---
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Insert or update a hook. Uses content_hash to detect changes.
|
|
334
|
+
* Returns { id, action } where action is 'inserted', 'updated', or 'skipped'.
|
|
335
|
+
* @param {object} data - Hook data including source_id, file_path, line_number, name, type, etc.
|
|
336
|
+
* @returns {{ id: number, action: string }}
|
|
337
|
+
*/
|
|
338
|
+
export function upsertHook(data) {
|
|
339
|
+
const db = getDb();
|
|
340
|
+
const upsertTx = db.transaction((d) => {
|
|
341
|
+
const existing = stmt(db, `
|
|
342
|
+
SELECT id, content_hash FROM hooks
|
|
343
|
+
WHERE source_id = @source_id AND file_path = @file_path AND line_number = @line_number AND name = @name
|
|
344
|
+
`).get(d);
|
|
345
|
+
|
|
346
|
+
if (existing) {
|
|
347
|
+
if (existing.content_hash === d.content_hash) {
|
|
348
|
+
// No change — just bump last_seen_at
|
|
349
|
+
stmt(db, 'UPDATE hooks SET last_seen_at = datetime(\'now\'), status = \'active\' WHERE id = ?').run(existing.id);
|
|
350
|
+
return { id: existing.id, action: 'skipped' };
|
|
351
|
+
}
|
|
352
|
+
// Update
|
|
353
|
+
stmt(db, `
|
|
354
|
+
UPDATE hooks SET
|
|
355
|
+
type = @type, php_function = @php_function, params = @params, param_count = @param_count,
|
|
356
|
+
docblock = @docblock, inferred_description = @inferred_description,
|
|
357
|
+
function_context = @function_context, class_name = @class_name,
|
|
358
|
+
code_before = @code_before, code_after = @code_after, hook_line = @hook_line,
|
|
359
|
+
is_dynamic = @is_dynamic, content_hash = @content_hash,
|
|
360
|
+
status = 'active', removed_at = NULL, last_seen_at = datetime('now')
|
|
361
|
+
WHERE id = @id
|
|
362
|
+
`).run({ ...d, id: existing.id });
|
|
363
|
+
// Update FTS — delete old, insert new
|
|
364
|
+
stmt(db, 'DELETE FROM hooks_fts WHERE rowid = ?').run(existing.id);
|
|
365
|
+
stmt(db, `
|
|
366
|
+
INSERT INTO hooks_fts(rowid, name, type, docblock, inferred_description, function_context, class_name, params)
|
|
367
|
+
VALUES (@id, @name, @type, @docblock, @inferred_description, @function_context, @class_name, @params)
|
|
368
|
+
`).run({ ...d, id: existing.id });
|
|
369
|
+
return { id: existing.id, action: 'updated' };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Insert
|
|
373
|
+
const result = stmt(db, `
|
|
374
|
+
INSERT INTO hooks (
|
|
375
|
+
source_id, file_path, line_number, name, type, php_function, params, param_count,
|
|
376
|
+
docblock, inferred_description, function_context, class_name,
|
|
377
|
+
code_before, code_after, hook_line, is_dynamic, content_hash, status
|
|
378
|
+
) VALUES (
|
|
379
|
+
@source_id, @file_path, @line_number, @name, @type, @php_function, @params, @param_count,
|
|
380
|
+
@docblock, @inferred_description, @function_context, @class_name,
|
|
381
|
+
@code_before, @code_after, @hook_line, @is_dynamic, @content_hash, 'active'
|
|
382
|
+
)
|
|
383
|
+
`).run(d);
|
|
384
|
+
|
|
385
|
+
stmt(db, `
|
|
386
|
+
INSERT INTO hooks_fts(rowid, name, type, docblock, inferred_description, function_context, class_name, params)
|
|
387
|
+
VALUES (@id, @name, @type, @docblock, @inferred_description, @function_context, @class_name, @params)
|
|
388
|
+
`).run({ ...d, id: result.lastInsertRowid });
|
|
389
|
+
|
|
390
|
+
return { id: result.lastInsertRowid, action: 'inserted' };
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
return upsertTx(data);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Soft-delete hooks that are no longer present in a file.
|
|
398
|
+
* Marks hooks as 'removed' if their ID is not in the activeIds list.
|
|
399
|
+
* @param {number} sourceId
|
|
400
|
+
* @param {string} filePath
|
|
401
|
+
* @param {Array<number>} activeIds - IDs of hooks still found in the file
|
|
402
|
+
* @returns {number} Count of hooks marked as removed
|
|
403
|
+
*/
|
|
404
|
+
export function markHooksRemoved(sourceId, filePath, activeIds) {
|
|
405
|
+
const db = getDb();
|
|
406
|
+
const tx = db.transaction(() => {
|
|
407
|
+
const allHooks = stmt(db, `
|
|
408
|
+
SELECT id FROM hooks WHERE source_id = ? AND file_path = ? AND status = 'active'
|
|
409
|
+
`).all(sourceId, filePath);
|
|
410
|
+
|
|
411
|
+
const activeSet = new Set(activeIds.map(Number));
|
|
412
|
+
const toRemove = allHooks.filter(h => !activeSet.has(h.id));
|
|
413
|
+
|
|
414
|
+
const removeStmt = stmt(db, `
|
|
415
|
+
UPDATE hooks SET status = 'removed', removed_at = datetime('now') WHERE id = ?
|
|
416
|
+
`);
|
|
417
|
+
|
|
418
|
+
for (const h of toRemove) {
|
|
419
|
+
removeStmt.run(h.id);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return toRemove.length;
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return tx();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Full-text search hooks using FTS5 with BM25 ranking.
|
|
430
|
+
* @param {string} query - Search keywords
|
|
431
|
+
* @param {object} [opts] - { type, source, isDynamic, includeRemoved, limit }
|
|
432
|
+
* @returns {Array<object>} Ranked search results with source_name joined
|
|
433
|
+
*/
|
|
434
|
+
export function searchHooks(query, opts = {}) {
|
|
435
|
+
const db = getDb();
|
|
436
|
+
const { type, source, isDynamic, includeRemoved, limit = 20 } = opts;
|
|
437
|
+
|
|
438
|
+
// Build FTS query — escape special chars
|
|
439
|
+
const ftsQuery = query.replace(/['"(){}[\]*:^~!]/g, ' ').trim();
|
|
440
|
+
if (!ftsQuery) return [];
|
|
441
|
+
|
|
442
|
+
// Tokenize and add wildcards for prefix matching
|
|
443
|
+
const terms = ftsQuery.split(/\s+/).filter(Boolean).map(t => `"${t}"*`).join(' ');
|
|
444
|
+
|
|
445
|
+
// Boost exact hook name matches so core hooks like "init" surface above
|
|
446
|
+
// longer-named hooks like "woocommerce_shipping_init" that have richer BM25 context.
|
|
447
|
+
// Exact matches get rank bonus of -100 (lower = better in BM25).
|
|
448
|
+
let sql = `
|
|
449
|
+
SELECT h.*, s.name AS source_name,
|
|
450
|
+
bm25(hooks_fts, 10, 5, 2, 3, 1, 1, 1)
|
|
451
|
+
+ CASE WHEN h.name = @rawQuery THEN -100 ELSE 0 END
|
|
452
|
+
AS rank
|
|
453
|
+
FROM hooks_fts
|
|
454
|
+
JOIN hooks h ON h.id = hooks_fts.rowid
|
|
455
|
+
JOIN sources s ON s.id = h.source_id
|
|
456
|
+
WHERE hooks_fts MATCH @terms
|
|
457
|
+
`;
|
|
458
|
+
|
|
459
|
+
const params = { terms, rawQuery: ftsQuery };
|
|
460
|
+
|
|
461
|
+
if (!includeRemoved) {
|
|
462
|
+
sql += ` AND h.status = 'active'`;
|
|
463
|
+
}
|
|
464
|
+
if (type) {
|
|
465
|
+
sql += ` AND h.type = @type`;
|
|
466
|
+
params.type = type;
|
|
467
|
+
}
|
|
468
|
+
if (source) {
|
|
469
|
+
sql += ` AND s.name = @source`;
|
|
470
|
+
params.source = source;
|
|
471
|
+
}
|
|
472
|
+
if (isDynamic !== undefined) {
|
|
473
|
+
sql += ` AND h.is_dynamic = @isDynamic`;
|
|
474
|
+
params.isDynamic = isDynamic ? 1 : 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
sql += ` ORDER BY rank LIMIT @limit`;
|
|
478
|
+
params.limit = limit;
|
|
479
|
+
|
|
480
|
+
return db.prepare(sql).all(params);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Validate whether a hook name exists in the index.
|
|
485
|
+
* Returns VALID (with locations), REMOVED, or NOT_FOUND (with FTS-based suggestions).
|
|
486
|
+
* @param {string} hookName - Exact hook name to check
|
|
487
|
+
* @returns {{ status: string, hooks?: Array, similar?: Array }}
|
|
488
|
+
*/
|
|
489
|
+
export function validateHook(hookName) {
|
|
490
|
+
const db = getDb();
|
|
491
|
+
|
|
492
|
+
const exact = stmt(db, `
|
|
493
|
+
SELECT h.*, s.name AS source_name FROM hooks h
|
|
494
|
+
JOIN sources s ON s.id = h.source_id
|
|
495
|
+
WHERE h.name = @name AND h.status = 'active'
|
|
496
|
+
`).all({ name: hookName });
|
|
497
|
+
|
|
498
|
+
if (exact.length > 0) {
|
|
499
|
+
return { status: 'VALID', hooks: exact };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const removed = stmt(db, `
|
|
503
|
+
SELECT h.*, s.name AS source_name FROM hooks h
|
|
504
|
+
JOIN sources s ON s.id = h.source_id
|
|
505
|
+
WHERE h.name = @name AND h.status = 'removed'
|
|
506
|
+
`).all({ name: hookName });
|
|
507
|
+
|
|
508
|
+
if (removed.length > 0) {
|
|
509
|
+
return { status: 'REMOVED', hooks: removed };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Try FTS for similar suggestions
|
|
513
|
+
const ftsQuery = hookName.replace(/['"(){}[\]*:^~!]/g, ' ').replace(/_/g, ' ').trim();
|
|
514
|
+
const terms = ftsQuery.split(/\s+/).filter(Boolean).map(t => `"${t}"*`).join(' ');
|
|
515
|
+
|
|
516
|
+
let similar = [];
|
|
517
|
+
if (terms) {
|
|
518
|
+
try {
|
|
519
|
+
similar = db.prepare(`
|
|
520
|
+
SELECT h.name, h.type, s.name AS source_name,
|
|
521
|
+
bm25(hooks_fts, 10, 5, 2, 3, 1, 1, 1) AS rank
|
|
522
|
+
FROM hooks_fts
|
|
523
|
+
JOIN hooks h ON h.id = hooks_fts.rowid
|
|
524
|
+
JOIN sources s ON s.id = h.source_id
|
|
525
|
+
WHERE hooks_fts MATCH @terms AND h.status = 'active'
|
|
526
|
+
ORDER BY rank LIMIT 5
|
|
527
|
+
`).all({ terms });
|
|
528
|
+
} catch {
|
|
529
|
+
// FTS query may fail on edge cases — return empty suggestions
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return { status: 'NOT_FOUND', similar };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Get full hook details including code context. Looks up by numeric ID first, then by name.
|
|
538
|
+
* @param {string|number} idOrName - Hook ID or exact hook name
|
|
539
|
+
* @returns {object|undefined}
|
|
540
|
+
*/
|
|
541
|
+
export function getHookContext(idOrName) {
|
|
542
|
+
const db = getDb();
|
|
543
|
+
|
|
544
|
+
// Try by ID first
|
|
545
|
+
if (typeof idOrName === 'number' || /^\d+$/.test(idOrName)) {
|
|
546
|
+
const hook = stmt(db, `
|
|
547
|
+
SELECT h.*, s.name AS source_name FROM hooks h
|
|
548
|
+
JOIN sources s ON s.id = h.source_id
|
|
549
|
+
WHERE h.id = ?
|
|
550
|
+
`).get(Number(idOrName));
|
|
551
|
+
if (hook) return hook;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Try by name
|
|
555
|
+
return stmt(db, `
|
|
556
|
+
SELECT h.*, s.name AS source_name FROM hooks h
|
|
557
|
+
JOIN sources s ON s.id = h.source_id
|
|
558
|
+
WHERE h.name = ? AND h.status = 'active'
|
|
559
|
+
ORDER BY h.last_seen_at DESC LIMIT 1
|
|
560
|
+
`).get(idOrName);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// --- Block Registrations ---
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Insert or update a block registration. Uses content_hash for change detection.
|
|
567
|
+
* @param {object} data - Block data including source_id, file_path, line_number, block_name, etc.
|
|
568
|
+
* @returns {{ id: number, action: string }}
|
|
569
|
+
*/
|
|
570
|
+
export function upsertBlockRegistration(data) {
|
|
571
|
+
const db = getDb();
|
|
572
|
+
const tx = db.transaction((d) => {
|
|
573
|
+
const existing = stmt(db, `
|
|
574
|
+
SELECT id, content_hash FROM block_registrations
|
|
575
|
+
WHERE source_id = @source_id AND file_path = @file_path AND line_number = @line_number AND block_name = @block_name
|
|
576
|
+
`).get(d);
|
|
577
|
+
|
|
578
|
+
if (existing) {
|
|
579
|
+
if (existing.content_hash === d.content_hash) {
|
|
580
|
+
stmt(db, 'UPDATE block_registrations SET last_seen_at = datetime(\'now\') WHERE id = ?').run(existing.id);
|
|
581
|
+
return { id: existing.id, action: 'skipped' };
|
|
582
|
+
}
|
|
583
|
+
stmt(db, `
|
|
584
|
+
UPDATE block_registrations SET
|
|
585
|
+
block_title = @block_title, block_category = @block_category,
|
|
586
|
+
block_attributes = @block_attributes, supports = @supports,
|
|
587
|
+
code_context = @code_context, content_hash = @content_hash,
|
|
588
|
+
last_seen_at = datetime('now')
|
|
589
|
+
WHERE id = @id
|
|
590
|
+
`).run({ ...d, id: existing.id });
|
|
591
|
+
stmt(db, 'DELETE FROM block_registrations_fts WHERE rowid = ?').run(existing.id);
|
|
592
|
+
stmt(db, `
|
|
593
|
+
INSERT INTO block_registrations_fts(rowid, block_name, block_title, block_category, block_attributes, supports, code_context)
|
|
594
|
+
VALUES (@id, @block_name, @block_title, @block_category, @block_attributes, @supports, @code_context)
|
|
595
|
+
`).run({ ...d, id: existing.id });
|
|
596
|
+
return { id: existing.id, action: 'updated' };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const result = stmt(db, `
|
|
600
|
+
INSERT INTO block_registrations (source_id, file_path, line_number, block_name, block_title, block_category, block_attributes, supports, code_context, content_hash)
|
|
601
|
+
VALUES (@source_id, @file_path, @line_number, @block_name, @block_title, @block_category, @block_attributes, @supports, @code_context, @content_hash)
|
|
602
|
+
`).run(d);
|
|
603
|
+
|
|
604
|
+
stmt(db, `
|
|
605
|
+
INSERT INTO block_registrations_fts(rowid, block_name, block_title, block_category, block_attributes, supports, code_context)
|
|
606
|
+
VALUES (@id, @block_name, @block_title, @block_category, @block_attributes, @supports, @code_context)
|
|
607
|
+
`).run({ ...d, id: result.lastInsertRowid });
|
|
608
|
+
|
|
609
|
+
return { id: result.lastInsertRowid, action: 'inserted' };
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
return tx(data);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// --- API Usages ---
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Insert or update a JS API usage record. Uses content_hash for change detection.
|
|
619
|
+
* @param {object} data - API data including source_id, file_path, line_number, api_call, namespace, method, etc.
|
|
620
|
+
* @returns {{ id: number, action: string }}
|
|
621
|
+
*/
|
|
622
|
+
export function upsertApiUsage(data) {
|
|
623
|
+
const db = getDb();
|
|
624
|
+
const tx = db.transaction((d) => {
|
|
625
|
+
const existing = stmt(db, `
|
|
626
|
+
SELECT id, content_hash FROM api_usages
|
|
627
|
+
WHERE source_id = @source_id AND file_path = @file_path AND line_number = @line_number AND api_call = @api_call
|
|
628
|
+
`).get(d);
|
|
629
|
+
|
|
630
|
+
if (existing) {
|
|
631
|
+
if (existing.content_hash === d.content_hash) {
|
|
632
|
+
stmt(db, 'UPDATE api_usages SET last_seen_at = datetime(\'now\') WHERE id = ?').run(existing.id);
|
|
633
|
+
return { id: existing.id, action: 'skipped' };
|
|
634
|
+
}
|
|
635
|
+
stmt(db, `
|
|
636
|
+
UPDATE api_usages SET
|
|
637
|
+
namespace = @namespace, method = @method,
|
|
638
|
+
code_context = @code_context, content_hash = @content_hash,
|
|
639
|
+
last_seen_at = datetime('now')
|
|
640
|
+
WHERE id = @id
|
|
641
|
+
`).run({ ...d, id: existing.id });
|
|
642
|
+
stmt(db, 'DELETE FROM api_usages_fts WHERE rowid = ?').run(existing.id);
|
|
643
|
+
stmt(db, `
|
|
644
|
+
INSERT INTO api_usages_fts(rowid, api_call, namespace, method, code_context)
|
|
645
|
+
VALUES (@id, @api_call, @namespace, @method, @code_context)
|
|
646
|
+
`).run({ ...d, id: existing.id });
|
|
647
|
+
return { id: existing.id, action: 'updated' };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const result = stmt(db, `
|
|
651
|
+
INSERT INTO api_usages (source_id, file_path, line_number, api_call, namespace, method, code_context, content_hash)
|
|
652
|
+
VALUES (@source_id, @file_path, @line_number, @api_call, @namespace, @method, @code_context, @content_hash)
|
|
653
|
+
`).run(d);
|
|
654
|
+
|
|
655
|
+
stmt(db, `
|
|
656
|
+
INSERT INTO api_usages_fts(rowid, api_call, namespace, method, code_context)
|
|
657
|
+
VALUES (@id, @api_call, @namespace, @method, @code_context)
|
|
658
|
+
`).run({ ...d, id: result.lastInsertRowid });
|
|
659
|
+
|
|
660
|
+
return { id: result.lastInsertRowid, action: 'inserted' };
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
return tx(data);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// --- Indexed Files ---
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Get the indexed file record for mtime/hash comparison.
|
|
670
|
+
* @param {number} sourceId
|
|
671
|
+
* @param {string} filePath
|
|
672
|
+
* @returns {object|undefined}
|
|
673
|
+
*/
|
|
674
|
+
export function getIndexedFile(sourceId, filePath) {
|
|
675
|
+
const db = getDb();
|
|
676
|
+
return stmt(db, 'SELECT * FROM indexed_files WHERE source_id = ? AND file_path = ?').get(sourceId, filePath);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Track a file as indexed with its mtime and content hash.
|
|
681
|
+
* @param {number} sourceId
|
|
682
|
+
* @param {string} filePath
|
|
683
|
+
* @param {number} mtimeMs
|
|
684
|
+
* @param {string} contentHash
|
|
685
|
+
*/
|
|
686
|
+
export function upsertIndexedFile(sourceId, filePath, mtimeMs, contentHash) {
|
|
687
|
+
const db = getDb();
|
|
688
|
+
stmt(db, `
|
|
689
|
+
INSERT INTO indexed_files (source_id, file_path, mtime_ms, content_hash)
|
|
690
|
+
VALUES (@source_id, @file_path, @mtime_ms, @content_hash)
|
|
691
|
+
ON CONFLICT(source_id, file_path)
|
|
692
|
+
DO UPDATE SET mtime_ms = @mtime_ms, content_hash = @content_hash
|
|
693
|
+
`).run({ source_id: sourceId, file_path: filePath, mtime_ms: mtimeMs, content_hash: contentHash });
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// --- FTS Rebuild ---
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Rebuild all FTS5 indexes from the content tables.
|
|
700
|
+
* Useful for recovery when FTS gets out of sync.
|
|
701
|
+
*/
|
|
702
|
+
export function rebuildFtsIndex() {
|
|
703
|
+
const db = getDb();
|
|
704
|
+
const tx = db.transaction(() => {
|
|
705
|
+
// Rebuild hooks FTS
|
|
706
|
+
db.exec('DELETE FROM hooks_fts');
|
|
707
|
+
db.exec(`
|
|
708
|
+
INSERT INTO hooks_fts(rowid, name, type, docblock, inferred_description, function_context, class_name, params)
|
|
709
|
+
SELECT id, name, type, docblock, inferred_description, function_context, class_name, params FROM hooks
|
|
710
|
+
`);
|
|
711
|
+
|
|
712
|
+
// Rebuild block_registrations FTS
|
|
713
|
+
db.exec('DELETE FROM block_registrations_fts');
|
|
714
|
+
db.exec(`
|
|
715
|
+
INSERT INTO block_registrations_fts(rowid, block_name, block_title, block_category, block_attributes, supports, code_context)
|
|
716
|
+
SELECT id, block_name, block_title, block_category, block_attributes, supports, code_context FROM block_registrations
|
|
717
|
+
`);
|
|
718
|
+
|
|
719
|
+
// Rebuild api_usages FTS
|
|
720
|
+
db.exec('DELETE FROM api_usages_fts');
|
|
721
|
+
db.exec(`
|
|
722
|
+
INSERT INTO api_usages_fts(rowid, api_call, namespace, method, code_context)
|
|
723
|
+
SELECT id, api_call, namespace, method, code_context FROM api_usages
|
|
724
|
+
`);
|
|
725
|
+
|
|
726
|
+
// Rebuild docs FTS
|
|
727
|
+
db.exec('DELETE FROM docs_fts');
|
|
728
|
+
db.exec(`
|
|
729
|
+
INSERT INTO docs_fts(rowid, title, slug, doc_type, category, description, content)
|
|
730
|
+
SELECT id, title, slug, doc_type, category, description, content FROM docs
|
|
731
|
+
`);
|
|
732
|
+
});
|
|
733
|
+
tx();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// --- Stats ---
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Get overall and per-source indexing statistics.
|
|
740
|
+
* @returns {{ totals: object, per_source: Array<object> }}
|
|
741
|
+
*/
|
|
742
|
+
export function getStats() {
|
|
743
|
+
const db = getDb();
|
|
744
|
+
const sources = stmt(db, 'SELECT COUNT(*) as count FROM sources').get();
|
|
745
|
+
const hooks = stmt(db, "SELECT COUNT(*) as count FROM hooks WHERE status = 'active'").get();
|
|
746
|
+
const removedHooks = stmt(db, "SELECT COUNT(*) as count FROM hooks WHERE status = 'removed'").get();
|
|
747
|
+
const blocks = stmt(db, 'SELECT COUNT(*) as count FROM block_registrations').get();
|
|
748
|
+
const apis = stmt(db, 'SELECT COUNT(*) as count FROM api_usages').get();
|
|
749
|
+
const docs = stmt(db, "SELECT COUNT(*) as count FROM docs WHERE status = 'active'").get();
|
|
750
|
+
|
|
751
|
+
const perSource = db.prepare(`
|
|
752
|
+
SELECT s.name, s.content_type,
|
|
753
|
+
(SELECT COUNT(*) FROM hooks WHERE source_id = s.id AND status = 'active') AS hooks,
|
|
754
|
+
(SELECT COUNT(*) FROM hooks WHERE source_id = s.id AND status = 'removed') AS removed_hooks,
|
|
755
|
+
(SELECT COUNT(*) FROM block_registrations WHERE source_id = s.id) AS blocks,
|
|
756
|
+
(SELECT COUNT(*) FROM api_usages WHERE source_id = s.id) AS apis,
|
|
757
|
+
(SELECT COUNT(*) FROM docs WHERE source_id = s.id AND status = 'active') AS docs,
|
|
758
|
+
(SELECT COUNT(*) FROM indexed_files WHERE source_id = s.id) AS files
|
|
759
|
+
FROM sources s ORDER BY s.name
|
|
760
|
+
`).all();
|
|
761
|
+
|
|
762
|
+
return {
|
|
763
|
+
totals: {
|
|
764
|
+
sources: sources.count,
|
|
765
|
+
active_hooks: hooks.count,
|
|
766
|
+
removed_hooks: removedHooks.count,
|
|
767
|
+
block_registrations: blocks.count,
|
|
768
|
+
api_usages: apis.count,
|
|
769
|
+
docs: docs.count,
|
|
770
|
+
},
|
|
771
|
+
per_source: perSource,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// --- Search block APIs ---
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Full-text search for block registrations and API usages.
|
|
779
|
+
* Uses FTS5 column filters to match only structured columns (not code_context).
|
|
780
|
+
* @param {string} query - Search keywords
|
|
781
|
+
* @param {object} [opts] - { limit }
|
|
782
|
+
* @returns {{ blocks: Array<object>, apis: Array<object> }}
|
|
783
|
+
*/
|
|
784
|
+
export function searchBlockApis(query, opts = {}) {
|
|
785
|
+
const db = getDb();
|
|
786
|
+
const { limit = 20 } = opts;
|
|
787
|
+
const ftsQuery = query.replace(/['"(){}[\]*:^~!]/g, ' ').trim();
|
|
788
|
+
if (!ftsQuery) return { blocks: [], apis: [] };
|
|
789
|
+
|
|
790
|
+
const terms = ftsQuery.split(/\s+/).filter(Boolean).map(t => `"${t}"*`).join(' ');
|
|
791
|
+
|
|
792
|
+
// Use FTS5 column filters to restrict matching to structured columns only.
|
|
793
|
+
// code_context is still returned in results but won't cause false positives.
|
|
794
|
+
const blockTerms = `{block_name block_title block_category} : ${terms}`;
|
|
795
|
+
const apiTerms = `{api_call namespace method} : ${terms}`;
|
|
796
|
+
|
|
797
|
+
let blocks = [];
|
|
798
|
+
let apis = [];
|
|
799
|
+
|
|
800
|
+
try {
|
|
801
|
+
blocks = db.prepare(`
|
|
802
|
+
SELECT br.*, s.name AS source_name,
|
|
803
|
+
bm25(block_registrations_fts, 10, 5, 3, 1, 1, 0) AS rank
|
|
804
|
+
FROM block_registrations_fts
|
|
805
|
+
JOIN block_registrations br ON br.id = block_registrations_fts.rowid
|
|
806
|
+
JOIN sources s ON s.id = br.source_id
|
|
807
|
+
WHERE block_registrations_fts MATCH @terms
|
|
808
|
+
ORDER BY rank LIMIT @limit
|
|
809
|
+
`).all({ terms: blockTerms, limit });
|
|
810
|
+
} catch {
|
|
811
|
+
// FTS query may fail
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
try {
|
|
815
|
+
apis = db.prepare(`
|
|
816
|
+
SELECT au.*, s.name AS source_name,
|
|
817
|
+
bm25(api_usages_fts, 10, 3, 5, 0) AS rank
|
|
818
|
+
FROM api_usages_fts
|
|
819
|
+
JOIN api_usages au ON au.id = api_usages_fts.rowid
|
|
820
|
+
JOIN sources s ON s.id = au.source_id
|
|
821
|
+
WHERE api_usages_fts MATCH @terms
|
|
822
|
+
ORDER BY rank LIMIT @limit
|
|
823
|
+
`).all({ terms: apiTerms, limit });
|
|
824
|
+
} catch {
|
|
825
|
+
// FTS query may fail
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
return { blocks, apis };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// --- Docs ---
|
|
832
|
+
|
|
833
|
+
export function upsertDoc(data) {
|
|
834
|
+
const db = getDb();
|
|
835
|
+
const upsertTx = db.transaction((d) => {
|
|
836
|
+
const existing = stmt(db, `
|
|
837
|
+
SELECT id, content_hash FROM docs
|
|
838
|
+
WHERE source_id = @source_id AND file_path = @file_path
|
|
839
|
+
`).get(d);
|
|
840
|
+
|
|
841
|
+
if (existing) {
|
|
842
|
+
if (existing.content_hash === d.content_hash) {
|
|
843
|
+
stmt(db, "UPDATE docs SET last_seen_at = datetime('now'), status = 'active' WHERE id = ?").run(existing.id);
|
|
844
|
+
return { id: existing.id, action: 'skipped' };
|
|
845
|
+
}
|
|
846
|
+
stmt(db, `
|
|
847
|
+
UPDATE docs SET
|
|
848
|
+
slug = @slug, title = @title, doc_type = @doc_type, category = @category,
|
|
849
|
+
subcategory = @subcategory, description = @description, content = @content,
|
|
850
|
+
code_examples = @code_examples, metadata = @metadata, content_hash = @content_hash,
|
|
851
|
+
status = 'active', last_seen_at = datetime('now')
|
|
852
|
+
WHERE id = @id
|
|
853
|
+
`).run({ ...d, id: existing.id });
|
|
854
|
+
stmt(db, 'DELETE FROM docs_fts WHERE rowid = ?').run(existing.id);
|
|
855
|
+
stmt(db, `
|
|
856
|
+
INSERT INTO docs_fts(rowid, title, slug, doc_type, category, description, content)
|
|
857
|
+
VALUES (@id, @title, @slug, @doc_type, @category, @description, @content)
|
|
858
|
+
`).run({ ...d, id: existing.id });
|
|
859
|
+
return { id: existing.id, action: 'updated' };
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const result = stmt(db, `
|
|
863
|
+
INSERT INTO docs (
|
|
864
|
+
source_id, file_path, slug, title, doc_type, category, subcategory,
|
|
865
|
+
description, content, code_examples, metadata, content_hash, status
|
|
866
|
+
) VALUES (
|
|
867
|
+
@source_id, @file_path, @slug, @title, @doc_type, @category, @subcategory,
|
|
868
|
+
@description, @content, @code_examples, @metadata, @content_hash, 'active'
|
|
869
|
+
)
|
|
870
|
+
`).run(d);
|
|
871
|
+
|
|
872
|
+
stmt(db, `
|
|
873
|
+
INSERT INTO docs_fts(rowid, title, slug, doc_type, category, description, content)
|
|
874
|
+
VALUES (@id, @title, @slug, @doc_type, @category, @description, @content)
|
|
875
|
+
`).run({ ...d, id: result.lastInsertRowid });
|
|
876
|
+
|
|
877
|
+
return { id: result.lastInsertRowid, action: 'inserted' };
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
return upsertTx(data);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export function markDocsRemoved(sourceId, activeDocIds) {
|
|
884
|
+
const db = getDb();
|
|
885
|
+
const tx = db.transaction(() => {
|
|
886
|
+
const allDocs = stmt(db, `
|
|
887
|
+
SELECT id FROM docs WHERE source_id = ? AND status = 'active'
|
|
888
|
+
`).all(sourceId);
|
|
889
|
+
|
|
890
|
+
const activeSet = new Set(activeDocIds.map(Number));
|
|
891
|
+
const toRemove = allDocs.filter(d => !activeSet.has(d.id));
|
|
892
|
+
|
|
893
|
+
const removeStmt = stmt(db, `
|
|
894
|
+
UPDATE docs SET status = 'removed' WHERE id = ?
|
|
895
|
+
`);
|
|
896
|
+
|
|
897
|
+
for (const d of toRemove) {
|
|
898
|
+
removeStmt.run(d.id);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return toRemove.length;
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
return tx();
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export function searchDocs(query, opts = {}) {
|
|
908
|
+
const db = getDb();
|
|
909
|
+
const { doc_type, category, source, limit = 20 } = opts;
|
|
910
|
+
|
|
911
|
+
const ftsQuery = query.replace(/['"(){}[\]*:^~!]/g, ' ').trim();
|
|
912
|
+
if (!ftsQuery) return [];
|
|
913
|
+
|
|
914
|
+
const terms = ftsQuery.split(/\s+/).filter(Boolean).map(t => `"${t}"*`).join(' ');
|
|
915
|
+
|
|
916
|
+
let sql = `
|
|
917
|
+
SELECT d.id, d.source_id, d.file_path, d.slug, d.title, d.doc_type, d.category,
|
|
918
|
+
d.subcategory, d.description, d.status, s.name AS source_name,
|
|
919
|
+
bm25(docs_fts, 10, 5, 3, 2, 5, 1) AS rank
|
|
920
|
+
FROM docs_fts
|
|
921
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
922
|
+
JOIN sources s ON s.id = d.source_id
|
|
923
|
+
WHERE docs_fts MATCH @terms
|
|
924
|
+
AND d.status = 'active'
|
|
925
|
+
`;
|
|
926
|
+
|
|
927
|
+
const params = { terms };
|
|
928
|
+
|
|
929
|
+
if (doc_type) {
|
|
930
|
+
sql += ` AND d.doc_type = @doc_type`;
|
|
931
|
+
params.doc_type = doc_type;
|
|
932
|
+
}
|
|
933
|
+
if (category) {
|
|
934
|
+
sql += ` AND d.category = @category`;
|
|
935
|
+
params.category = category;
|
|
936
|
+
}
|
|
937
|
+
if (source) {
|
|
938
|
+
sql += ` AND s.name = @source`;
|
|
939
|
+
params.source = source;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
sql += ` ORDER BY rank LIMIT @limit`;
|
|
943
|
+
params.limit = limit;
|
|
944
|
+
|
|
945
|
+
try {
|
|
946
|
+
return db.prepare(sql).all(params);
|
|
947
|
+
} catch (err) {
|
|
948
|
+
console.error(`searchDocs FTS error: ${err.message}`);
|
|
949
|
+
return [];
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
export function getDoc(idOrSlug) {
|
|
954
|
+
const db = getDb();
|
|
955
|
+
|
|
956
|
+
if (typeof idOrSlug === 'number' || /^\d+$/.test(idOrSlug)) {
|
|
957
|
+
return stmt(db, `
|
|
958
|
+
SELECT d.*, s.name AS source_name FROM docs d
|
|
959
|
+
JOIN sources s ON s.id = d.source_id
|
|
960
|
+
WHERE d.id = ?
|
|
961
|
+
`).get(Number(idOrSlug));
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return stmt(db, `
|
|
965
|
+
SELECT d.*, s.name AS source_name FROM docs d
|
|
966
|
+
JOIN sources s ON s.id = d.source_id
|
|
967
|
+
WHERE d.slug = ? AND d.status = 'active'
|
|
968
|
+
ORDER BY d.last_seen_at DESC LIMIT 1
|
|
969
|
+
`).get(idOrSlug);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
export function getDocCategoryCounts() {
|
|
973
|
+
const db = getDb();
|
|
974
|
+
return db.prepare(`
|
|
975
|
+
SELECT category, COUNT(*) as count
|
|
976
|
+
FROM docs WHERE status = 'active'
|
|
977
|
+
GROUP BY category ORDER BY category
|
|
978
|
+
`).all();
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export function listDocs(opts = {}) {
|
|
982
|
+
const db = getDb();
|
|
983
|
+
const { doc_type, category, source, limit = 50 } = opts;
|
|
984
|
+
|
|
985
|
+
let sql = `
|
|
986
|
+
SELECT d.id, d.slug, d.title, d.doc_type, d.category, d.subcategory,
|
|
987
|
+
d.description, s.name AS source_name
|
|
988
|
+
FROM docs d
|
|
989
|
+
JOIN sources s ON s.id = d.source_id
|
|
990
|
+
WHERE d.status = 'active'
|
|
991
|
+
`;
|
|
992
|
+
|
|
993
|
+
const params = {};
|
|
994
|
+
|
|
995
|
+
if (doc_type) {
|
|
996
|
+
sql += ` AND d.doc_type = @doc_type`;
|
|
997
|
+
params.doc_type = doc_type;
|
|
998
|
+
}
|
|
999
|
+
if (category) {
|
|
1000
|
+
sql += ` AND d.category = @category`;
|
|
1001
|
+
params.category = category;
|
|
1002
|
+
}
|
|
1003
|
+
if (source) {
|
|
1004
|
+
sql += ` AND s.name = @source`;
|
|
1005
|
+
params.source = source;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
sql += ` ORDER BY d.category, d.title LIMIT @limit`;
|
|
1009
|
+
params.limit = limit;
|
|
1010
|
+
|
|
1011
|
+
return db.prepare(sql).all(params);
|
|
1012
|
+
}
|