fieldtheory 1.0.0

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.
@@ -0,0 +1,623 @@
1
+ import { openDb, saveDb } from './db.js';
2
+ import { readJsonLines } from './fs.js';
3
+ import { twitterBookmarksCachePath, twitterBookmarksIndexPath } from './paths.js';
4
+ import { classifyCorpus } from './bookmark-classify.js';
5
+ const SCHEMA_VERSION = 3;
6
+ function parseJsonArray(value) {
7
+ if (typeof value !== 'string' || !value.trim())
8
+ return [];
9
+ try {
10
+ const parsed = JSON.parse(value);
11
+ return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
12
+ }
13
+ catch {
14
+ return [];
15
+ }
16
+ }
17
+ function parseCsv(value) {
18
+ if (typeof value !== 'string' || !value.trim())
19
+ return [];
20
+ return value
21
+ .split(',')
22
+ .map((entry) => entry.trim())
23
+ .filter(Boolean);
24
+ }
25
+ function mapTimelineRow(row) {
26
+ return {
27
+ id: row[0],
28
+ tweetId: row[1],
29
+ url: row[2],
30
+ text: row[3],
31
+ authorHandle: row[4] ?? undefined,
32
+ authorName: row[5] ?? undefined,
33
+ authorProfileImageUrl: row[6] ?? undefined,
34
+ postedAt: row[7] ?? null,
35
+ bookmarkedAt: row[8] ?? null,
36
+ categories: parseCsv(row[9]),
37
+ primaryCategory: row[10] ?? null,
38
+ domains: parseCsv(row[11]),
39
+ primaryDomain: row[12] ?? null,
40
+ githubUrls: parseJsonArray(row[13]),
41
+ links: parseJsonArray(row[14]),
42
+ mediaCount: Number(row[15] ?? 0),
43
+ linkCount: Number(row[16] ?? 0),
44
+ likeCount: row[17],
45
+ repostCount: row[18],
46
+ replyCount: row[19],
47
+ quoteCount: row[20],
48
+ bookmarkCount: row[21],
49
+ viewCount: row[22],
50
+ };
51
+ }
52
+ function buildBookmarkWhereClause(filters) {
53
+ const conditions = [];
54
+ const params = [];
55
+ if (filters.query) {
56
+ conditions.push(`b.rowid IN (SELECT rowid FROM bookmarks_fts WHERE bookmarks_fts MATCH ?)`);
57
+ params.push(filters.query);
58
+ }
59
+ if (filters.author) {
60
+ conditions.push(`b.author_handle = ? COLLATE NOCASE`);
61
+ params.push(filters.author);
62
+ }
63
+ if (filters.after) {
64
+ conditions.push(`COALESCE(b.posted_at, b.bookmarked_at) >= ?`);
65
+ params.push(filters.after);
66
+ }
67
+ if (filters.before) {
68
+ conditions.push(`COALESCE(b.posted_at, b.bookmarked_at) <= ?`);
69
+ params.push(filters.before);
70
+ }
71
+ if (filters.category) {
72
+ conditions.push(`b.categories LIKE ?`);
73
+ params.push(`%${filters.category}%`);
74
+ }
75
+ if (filters.domain) {
76
+ conditions.push(`b.domains LIKE ?`);
77
+ params.push(`%${filters.domain}%`);
78
+ }
79
+ return {
80
+ where: conditions.length ? `WHERE ${conditions.join(' AND ')}` : '',
81
+ params,
82
+ };
83
+ }
84
+ function bookmarkSortClause(direction = 'desc') {
85
+ const normalized = direction === 'asc' ? 'ASC' : 'DESC';
86
+ return `
87
+ ORDER BY
88
+ CASE
89
+ WHEN b.bookmarked_at GLOB '____-__-__*' THEN b.bookmarked_at
90
+ WHEN b.posted_at GLOB '____-__-__*' THEN b.posted_at
91
+ ELSE ''
92
+ END ${normalized},
93
+ CAST(b.tweet_id AS INTEGER) ${normalized}
94
+ `;
95
+ }
96
+ function initSchema(db) {
97
+ db.run(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)`);
98
+ db.run(`CREATE TABLE IF NOT EXISTS bookmarks (
99
+ id TEXT PRIMARY KEY,
100
+ tweet_id TEXT NOT NULL,
101
+ url TEXT NOT NULL,
102
+ text TEXT NOT NULL,
103
+ author_handle TEXT,
104
+ author_name TEXT,
105
+ author_profile_image_url TEXT,
106
+ posted_at TEXT,
107
+ bookmarked_at TEXT,
108
+ synced_at TEXT NOT NULL,
109
+ conversation_id TEXT,
110
+ in_reply_to_status_id TEXT,
111
+ quoted_status_id TEXT,
112
+ language TEXT,
113
+ like_count INTEGER,
114
+ repost_count INTEGER,
115
+ reply_count INTEGER,
116
+ quote_count INTEGER,
117
+ bookmark_count INTEGER,
118
+ view_count INTEGER,
119
+ media_count INTEGER DEFAULT 0,
120
+ link_count INTEGER DEFAULT 0,
121
+ links_json TEXT,
122
+ tags_json TEXT,
123
+ ingested_via TEXT,
124
+ categories TEXT,
125
+ primary_category TEXT,
126
+ github_urls TEXT,
127
+ domains TEXT,
128
+ primary_domain TEXT
129
+ )`);
130
+ db.run(`CREATE INDEX IF NOT EXISTS idx_bookmarks_author ON bookmarks(author_handle)`);
131
+ db.run(`CREATE INDEX IF NOT EXISTS idx_bookmarks_posted ON bookmarks(posted_at)`);
132
+ db.run(`CREATE INDEX IF NOT EXISTS idx_bookmarks_language ON bookmarks(language)`);
133
+ db.run(`CREATE INDEX IF NOT EXISTS idx_bookmarks_category ON bookmarks(primary_category)`);
134
+ db.run(`CREATE INDEX IF NOT EXISTS idx_bookmarks_domain ON bookmarks(primary_domain)`);
135
+ db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5(
136
+ text,
137
+ author_handle,
138
+ author_name,
139
+ content=bookmarks,
140
+ content_rowid=rowid,
141
+ tokenize='porter unicode61'
142
+ )`);
143
+ db.run(`REPLACE INTO meta VALUES ('schema_version', '${SCHEMA_VERSION}')`);
144
+ }
145
+ function ensureMigrations(db) {
146
+ const rows = db.exec("SELECT value FROM meta WHERE key = 'schema_version'");
147
+ const version = rows.length ? Number(rows[0].values[0]?.[0] ?? 0) : 0;
148
+ if (version < 3) {
149
+ try {
150
+ db.run('ALTER TABLE bookmarks ADD COLUMN domains TEXT');
151
+ }
152
+ catch { /* already exists */ }
153
+ try {
154
+ db.run('ALTER TABLE bookmarks ADD COLUMN primary_domain TEXT');
155
+ }
156
+ catch { /* already exists */ }
157
+ db.run('CREATE INDEX IF NOT EXISTS idx_bookmarks_domain ON bookmarks(primary_domain)');
158
+ db.run("REPLACE INTO meta VALUES ('schema_version', '3')");
159
+ }
160
+ }
161
+ function insertRecord(db, r) {
162
+ // Extract GitHub URLs (kept inline — no LLM needed for URL parsing)
163
+ const text = r.text ?? '';
164
+ const githubMatches = text.match(/github\.com\/[\w.-]+\/[\w.-]+/gi) ?? [];
165
+ const githubFromLinks = (r.links ?? []).filter((l) => /github\.com/i.test(l));
166
+ const githubUrls = [...new Set([...githubMatches.map((m) => `https://${m}`), ...githubFromLinks])];
167
+ db.run(`INSERT OR REPLACE INTO bookmarks VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [
168
+ r.id,
169
+ r.tweetId,
170
+ r.url,
171
+ r.text,
172
+ r.authorHandle ?? null,
173
+ r.authorName ?? null,
174
+ r.authorProfileImageUrl ?? null,
175
+ r.postedAt ?? null,
176
+ r.bookmarkedAt ?? null,
177
+ r.syncedAt,
178
+ r.conversationId ?? null,
179
+ r.inReplyToStatusId ?? null,
180
+ r.quotedStatusId ?? null,
181
+ r.language ?? null,
182
+ r.engagement?.likeCount ?? null,
183
+ r.engagement?.repostCount ?? null,
184
+ r.engagement?.replyCount ?? null,
185
+ r.engagement?.quoteCount ?? null,
186
+ r.engagement?.bookmarkCount ?? null,
187
+ r.engagement?.viewCount ?? null,
188
+ r.media?.length ?? 0,
189
+ r.links?.length ?? 0,
190
+ r.links?.length ? JSON.stringify(r.links) : null,
191
+ r.tags?.length ? JSON.stringify(r.tags) : null,
192
+ r.ingestedVia ?? null,
193
+ null, // categories — populated by classify pass
194
+ 'unclassified', // primary_category
195
+ githubUrls.length ? JSON.stringify(githubUrls) : null,
196
+ null, // domains — populated by classify-domains pass
197
+ null, // primary_domain
198
+ ]);
199
+ }
200
+ export async function buildIndex() {
201
+ const cachePath = twitterBookmarksCachePath();
202
+ const dbPath = twitterBookmarksIndexPath();
203
+ const records = await readJsonLines(cachePath);
204
+ const db = await openDb(dbPath);
205
+ try {
206
+ // Drop and recreate for a clean rebuild
207
+ db.run('DROP TABLE IF EXISTS bookmarks_fts');
208
+ db.run('DROP TABLE IF EXISTS bookmarks');
209
+ db.run('DROP TABLE IF EXISTS meta');
210
+ initSchema(db);
211
+ db.run('BEGIN TRANSACTION');
212
+ for (const record of records) {
213
+ insertRecord(db, record);
214
+ }
215
+ // Rebuild FTS index from content table
216
+ db.run(`INSERT INTO bookmarks_fts(bookmarks_fts) VALUES('rebuild')`);
217
+ db.run('COMMIT');
218
+ saveDb(db, dbPath);
219
+ return { dbPath, recordCount: records.length };
220
+ }
221
+ finally {
222
+ db.close();
223
+ }
224
+ }
225
+ export async function searchBookmarks(options) {
226
+ const dbPath = twitterBookmarksIndexPath();
227
+ const db = await openDb(dbPath);
228
+ ensureMigrations(db);
229
+ const limit = options.limit ?? 20;
230
+ try {
231
+ const conditions = [];
232
+ const params = [];
233
+ if (options.query) {
234
+ conditions.push(`b.rowid IN (SELECT rowid FROM bookmarks_fts WHERE bookmarks_fts MATCH ?)`);
235
+ params.push(options.query);
236
+ }
237
+ if (options.author) {
238
+ conditions.push(`b.author_handle = ? COLLATE NOCASE`);
239
+ params.push(options.author);
240
+ }
241
+ if (options.after) {
242
+ conditions.push(`b.posted_at >= ?`);
243
+ params.push(options.after);
244
+ }
245
+ if (options.before) {
246
+ conditions.push(`b.posted_at <= ?`);
247
+ params.push(options.before);
248
+ }
249
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
250
+ // If we have an FTS query, use bm25 for ranking; otherwise sort by posted_at
251
+ const orderBy = options.query
252
+ ? `ORDER BY bm25(bookmarks_fts, 5.0, 1.0, 1.0) ASC`
253
+ : `ORDER BY b.posted_at DESC`;
254
+ // For FTS ranking we need to join with the FTS table for bm25
255
+ let sql;
256
+ if (options.query) {
257
+ sql = `
258
+ SELECT b.id, b.url, b.text, b.author_handle, b.author_name, b.posted_at,
259
+ bm25(bookmarks_fts, 5.0, 1.0, 1.0) as score
260
+ FROM bookmarks b
261
+ JOIN bookmarks_fts ON bookmarks_fts.rowid = b.rowid
262
+ ${where}
263
+ ${orderBy}
264
+ LIMIT ?
265
+ `;
266
+ }
267
+ else {
268
+ sql = `
269
+ SELECT b.id, b.url, b.text, b.author_handle, b.author_name, b.posted_at,
270
+ 0 as score
271
+ FROM bookmarks b
272
+ ${where}
273
+ ORDER BY b.posted_at DESC
274
+ LIMIT ?
275
+ `;
276
+ }
277
+ params.push(limit);
278
+ const rows = db.exec(sql, params);
279
+ if (!rows.length)
280
+ return [];
281
+ return rows[0].values.map((row) => ({
282
+ id: row[0],
283
+ url: row[1],
284
+ text: row[2],
285
+ authorHandle: row[3],
286
+ authorName: row[4],
287
+ postedAt: row[5],
288
+ score: row[6],
289
+ }));
290
+ }
291
+ finally {
292
+ db.close();
293
+ }
294
+ }
295
+ export async function listBookmarks(filters = {}) {
296
+ const dbPath = twitterBookmarksIndexPath();
297
+ const db = await openDb(dbPath);
298
+ ensureMigrations(db);
299
+ const limit = filters.limit ?? 30;
300
+ const offset = filters.offset ?? 0;
301
+ try {
302
+ const { where, params } = buildBookmarkWhereClause(filters);
303
+ const sql = `
304
+ SELECT
305
+ b.id,
306
+ b.tweet_id,
307
+ b.url,
308
+ b.text,
309
+ b.author_handle,
310
+ b.author_name,
311
+ b.author_profile_image_url,
312
+ b.posted_at,
313
+ b.bookmarked_at,
314
+ b.categories,
315
+ b.primary_category,
316
+ b.domains,
317
+ b.primary_domain,
318
+ b.github_urls,
319
+ b.links_json,
320
+ b.media_count,
321
+ b.link_count,
322
+ b.like_count,
323
+ b.repost_count,
324
+ b.reply_count,
325
+ b.quote_count,
326
+ b.bookmark_count,
327
+ b.view_count
328
+ FROM bookmarks b
329
+ ${where}
330
+ ${bookmarkSortClause(filters.sort)}
331
+ LIMIT ?
332
+ OFFSET ?
333
+ `;
334
+ params.push(limit, offset);
335
+ const rows = db.exec(sql, params);
336
+ if (!rows.length)
337
+ return [];
338
+ return rows[0].values.map((row) => mapTimelineRow(row));
339
+ }
340
+ finally {
341
+ db.close();
342
+ }
343
+ }
344
+ export async function countBookmarks(filters = {}) {
345
+ const dbPath = twitterBookmarksIndexPath();
346
+ const db = await openDb(dbPath);
347
+ ensureMigrations(db);
348
+ try {
349
+ const { where, params } = buildBookmarkWhereClause(filters);
350
+ const sql = `
351
+ SELECT COUNT(*)
352
+ FROM bookmarks b
353
+ ${where}
354
+ `;
355
+ const rows = db.exec(sql, params);
356
+ return Number(rows[0]?.values?.[0]?.[0] ?? 0);
357
+ }
358
+ finally {
359
+ db.close();
360
+ }
361
+ }
362
+ export async function exportBookmarksForSyncSeed() {
363
+ const dbPath = twitterBookmarksIndexPath();
364
+ const db = await openDb(dbPath);
365
+ ensureMigrations(db);
366
+ try {
367
+ const sql = `
368
+ SELECT
369
+ b.id,
370
+ b.tweet_id,
371
+ b.url,
372
+ b.text,
373
+ b.author_handle,
374
+ b.author_name,
375
+ b.author_profile_image_url,
376
+ b.posted_at,
377
+ b.bookmarked_at,
378
+ b.synced_at,
379
+ b.conversation_id,
380
+ b.in_reply_to_status_id,
381
+ b.quoted_status_id,
382
+ b.language,
383
+ b.like_count,
384
+ b.repost_count,
385
+ b.reply_count,
386
+ b.quote_count,
387
+ b.bookmark_count,
388
+ b.view_count,
389
+ b.links_json
390
+ FROM bookmarks b
391
+ ${bookmarkSortClause('desc')}
392
+ `;
393
+ const rows = db.exec(sql);
394
+ if (!rows.length)
395
+ return [];
396
+ return rows[0].values.map((row) => ({
397
+ id: String(row[0]),
398
+ tweetId: String(row[1]),
399
+ url: String(row[2]),
400
+ text: String(row[3] ?? ''),
401
+ authorHandle: row[4] ?? undefined,
402
+ authorName: row[5] ?? undefined,
403
+ authorProfileImageUrl: row[6] ?? undefined,
404
+ postedAt: row[7] ?? null,
405
+ bookmarkedAt: row[8] ?? null,
406
+ syncedAt: String(row[9] ?? row[8] ?? row[7] ?? new Date(0).toISOString()),
407
+ conversationId: row[10] ?? undefined,
408
+ inReplyToStatusId: row[11] ?? undefined,
409
+ quotedStatusId: row[12] ?? undefined,
410
+ language: row[13] ?? undefined,
411
+ engagement: {
412
+ likeCount: row[14],
413
+ repostCount: row[15],
414
+ replyCount: row[16],
415
+ quoteCount: row[17],
416
+ bookmarkCount: row[18],
417
+ viewCount: row[19],
418
+ },
419
+ links: parseJsonArray(row[20]),
420
+ tags: [],
421
+ ingestedVia: 'graphql',
422
+ }));
423
+ }
424
+ finally {
425
+ db.close();
426
+ }
427
+ }
428
+ export async function getBookmarkById(id) {
429
+ const dbPath = twitterBookmarksIndexPath();
430
+ const db = await openDb(dbPath);
431
+ ensureMigrations(db);
432
+ try {
433
+ const rows = db.exec(`SELECT
434
+ b.id,
435
+ b.tweet_id,
436
+ b.url,
437
+ b.text,
438
+ b.author_handle,
439
+ b.author_name,
440
+ b.author_profile_image_url,
441
+ b.posted_at,
442
+ b.bookmarked_at,
443
+ b.categories,
444
+ b.primary_category,
445
+ b.domains,
446
+ b.primary_domain,
447
+ b.github_urls,
448
+ b.links_json,
449
+ b.media_count,
450
+ b.link_count,
451
+ b.like_count,
452
+ b.repost_count,
453
+ b.reply_count,
454
+ b.quote_count,
455
+ b.bookmark_count,
456
+ b.view_count
457
+ FROM bookmarks b
458
+ WHERE b.id = ?
459
+ LIMIT 1`, [id]);
460
+ const row = rows[0]?.values?.[0];
461
+ return row ? mapTimelineRow(row) : null;
462
+ }
463
+ finally {
464
+ db.close();
465
+ }
466
+ }
467
+ export async function getStats() {
468
+ const dbPath = twitterBookmarksIndexPath();
469
+ const db = await openDb(dbPath);
470
+ try {
471
+ const total = db.exec('SELECT COUNT(*) FROM bookmarks')[0]?.values[0]?.[0];
472
+ const authors = db.exec('SELECT COUNT(DISTINCT author_handle) FROM bookmarks')[0]?.values[0]?.[0];
473
+ const range = db.exec('SELECT MIN(posted_at), MAX(posted_at) FROM bookmarks WHERE posted_at IS NOT NULL')[0]?.values[0];
474
+ const topAuthorsRows = db.exec(`SELECT author_handle, COUNT(*) as c FROM bookmarks
475
+ WHERE author_handle IS NOT NULL
476
+ GROUP BY author_handle ORDER BY c DESC LIMIT 15`);
477
+ const topAuthors = (topAuthorsRows[0]?.values ?? []).map((r) => ({
478
+ handle: r[0],
479
+ count: r[1],
480
+ }));
481
+ const langRows = db.exec(`SELECT language, COUNT(*) as c FROM bookmarks
482
+ WHERE language IS NOT NULL
483
+ GROUP BY language ORDER BY c DESC LIMIT 10`);
484
+ const languageBreakdown = (langRows[0]?.values ?? []).map((r) => ({
485
+ language: r[0],
486
+ count: r[1],
487
+ }));
488
+ return {
489
+ totalBookmarks: total,
490
+ uniqueAuthors: authors,
491
+ dateRange: { earliest: range?.[0] ?? null, latest: range?.[1] ?? null },
492
+ topAuthors,
493
+ languageBreakdown,
494
+ };
495
+ }
496
+ finally {
497
+ db.close();
498
+ }
499
+ }
500
+ // ── Classification ───────────────────────────────────────────────────────
501
+ export async function classifyAndRebuild() {
502
+ const cachePath = twitterBookmarksCachePath();
503
+ const dbPath = twitterBookmarksIndexPath();
504
+ const records = await readJsonLines(cachePath);
505
+ const { results, summary } = classifyCorpus(records);
506
+ // Rebuild index then apply regex classifications
507
+ const buildResult = await buildIndex();
508
+ const db = await openDb(dbPath);
509
+ ensureMigrations(db);
510
+ try {
511
+ const stmt = db.prepare(`UPDATE bookmarks SET categories = ?, primary_category = ?, github_urls = ? WHERE id = ?`);
512
+ for (const [id, r] of results) {
513
+ if (r.categories.length > 0) {
514
+ stmt.run([r.categories.join(','), r.primary, r.githubUrls.length ? JSON.stringify(r.githubUrls) : null, id]);
515
+ }
516
+ }
517
+ stmt.free();
518
+ saveDb(db, dbPath);
519
+ }
520
+ finally {
521
+ db.close();
522
+ }
523
+ return { ...buildResult, summary };
524
+ }
525
+ export async function sampleByCategory(category, limit) {
526
+ const dbPath = twitterBookmarksIndexPath();
527
+ const db = await openDb(dbPath);
528
+ try {
529
+ const rows = db.exec(`SELECT id, url, text, author_handle, categories, github_urls, links_json
530
+ FROM bookmarks
531
+ WHERE categories LIKE ?
532
+ ORDER BY RANDOM()
533
+ LIMIT ?`, [`%${category}%`, limit]);
534
+ if (!rows.length)
535
+ return [];
536
+ return rows[0].values.map((r) => ({
537
+ id: r[0],
538
+ url: r[1],
539
+ text: r[2],
540
+ authorHandle: r[3] ?? undefined,
541
+ categories: r[4] ?? '',
542
+ githubUrls: r[5] ?? undefined,
543
+ links: r[6] ?? undefined,
544
+ }));
545
+ }
546
+ finally {
547
+ db.close();
548
+ }
549
+ }
550
+ export async function getCategoryCounts() {
551
+ const dbPath = twitterBookmarksIndexPath();
552
+ const db = await openDb(dbPath);
553
+ ensureMigrations(db);
554
+ try {
555
+ const rows = db.exec(`SELECT primary_category, COUNT(*) as c FROM bookmarks
556
+ WHERE primary_category IS NOT NULL
557
+ GROUP BY primary_category ORDER BY c DESC`);
558
+ const counts = {};
559
+ for (const row of rows[0]?.values ?? []) {
560
+ counts[row[0]] = row[1];
561
+ }
562
+ return counts;
563
+ }
564
+ finally {
565
+ db.close();
566
+ }
567
+ }
568
+ export async function getDomainCounts() {
569
+ const dbPath = twitterBookmarksIndexPath();
570
+ const db = await openDb(dbPath);
571
+ ensureMigrations(db);
572
+ try {
573
+ const rows = db.exec(`SELECT primary_domain, COUNT(*) as c FROM bookmarks
574
+ WHERE primary_domain IS NOT NULL
575
+ GROUP BY primary_domain ORDER BY c DESC`);
576
+ const counts = {};
577
+ for (const row of rows[0]?.values ?? []) {
578
+ counts[row[0]] = row[1];
579
+ }
580
+ return counts;
581
+ }
582
+ finally {
583
+ db.close();
584
+ }
585
+ }
586
+ export async function sampleByDomain(domain, limit) {
587
+ const dbPath = twitterBookmarksIndexPath();
588
+ const db = await openDb(dbPath);
589
+ ensureMigrations(db);
590
+ try {
591
+ const rows = db.exec(`SELECT id, url, text, author_handle, categories, github_urls, links_json
592
+ FROM bookmarks
593
+ WHERE domains LIKE ?
594
+ ORDER BY RANDOM()
595
+ LIMIT ?`, [`%${domain}%`, limit]);
596
+ if (!rows.length)
597
+ return [];
598
+ return rows[0].values.map((r) => ({
599
+ id: r[0],
600
+ url: r[1],
601
+ text: r[2],
602
+ authorHandle: r[3] ?? undefined,
603
+ categories: r[4] ?? '',
604
+ githubUrls: r[5] ?? undefined,
605
+ links: r[6] ?? undefined,
606
+ }));
607
+ }
608
+ finally {
609
+ db.close();
610
+ }
611
+ }
612
+ export function formatSearchResults(results) {
613
+ if (results.length === 0)
614
+ return 'No results found.';
615
+ return results
616
+ .map((r, i) => {
617
+ const author = r.authorHandle ? `@${r.authorHandle}` : 'unknown';
618
+ const date = r.postedAt ? r.postedAt.slice(0, 10) : '?';
619
+ const text = r.text.length > 140 ? r.text.slice(0, 140) + '...' : r.text;
620
+ return `${i + 1}. [${date}] ${author}\n ${text}\n ${r.url}`;
621
+ })
622
+ .join('\n\n');
623
+ }
@@ -0,0 +1,49 @@
1
+ import { getTwitterBookmarksStatus } from './bookmarks.js';
2
+ import { buildIndex } from './bookmarks-db.js';
3
+ import { loadTwitterOAuthToken } from './xauth.js';
4
+ import { syncBookmarksGraphQL } from './graphql-bookmarks.js';
5
+ export async function enableBookmarks() {
6
+ const syncResult = await syncBookmarksGraphQL({
7
+ onProgress: (status) => {
8
+ if (status.page % 25 === 0 || status.done) {
9
+ process.stderr.write(`\r[sync] page ${status.page} | ${status.totalFetched} fetched | ${status.newAdded} new${status.done ? ` | ${status.stopReason}\n` : ''}`);
10
+ }
11
+ },
12
+ });
13
+ const indexResult = await buildIndex();
14
+ return {
15
+ synced: true,
16
+ bookmarkCount: syncResult.totalBookmarks,
17
+ indexedCount: indexResult.recordCount,
18
+ cachePath: syncResult.cachePath,
19
+ messageLines: [
20
+ 'Bookmarks enabled.',
21
+ `- sync completed: ${syncResult.totalBookmarks} bookmarks (${syncResult.added} new)`,
22
+ `- indexed: ${indexResult.recordCount} records → ${indexResult.dbPath}`,
23
+ `- cache: ${syncResult.cachePath}`,
24
+ ],
25
+ };
26
+ }
27
+ export async function getBookmarkStatusView() {
28
+ const token = await loadTwitterOAuthToken();
29
+ const status = await getTwitterBookmarksStatus();
30
+ return {
31
+ connected: Boolean(token?.access_token),
32
+ bookmarkCount: status.totalBookmarks,
33
+ lastUpdated: status.lastIncrementalSyncAt ?? status.lastFullSyncAt ?? null,
34
+ mode: token?.access_token ? 'Incremental by default (GraphQL + API available)' : 'Incremental by default (GraphQL)',
35
+ cachePath: status.cachePath,
36
+ };
37
+ }
38
+ export function formatBookmarkStatus(view) {
39
+ return [
40
+ 'Bookmarks',
41
+ ` bookmarks: ${view.bookmarkCount}`,
42
+ ` last updated: ${view.lastUpdated ?? 'never'}`,
43
+ ` sync mode: ${view.mode}`,
44
+ ` cache: ${view.cachePath}`,
45
+ ].join('\n');
46
+ }
47
+ export function formatBookmarkSummary(view) {
48
+ return `bookmarks=${view.bookmarkCount} updated=${view.lastUpdated ?? 'never'} mode="${view.mode}"`;
49
+ }