Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ ft search "distributed systems"

# 3. Explore
ft viz
ft web # browser-based dashboard
ft categories
ft stats
```
Expand Down Expand Up @@ -58,6 +59,7 @@ On first run, `ft sync` extracts your X session from your browser and downloads
| `ft sample <category>` | Random sample from a category |
| `ft stats` | Top authors, languages, date range |
| `ft viz` | Terminal dashboard with sparklines, categories, and domains |
| `ft web` | Browser-based bookmark dashboard with charts, search, and filters (default port 4321) |
| `ft categories` | Show category distribution |
| `ft domains` | Subject domain distribution |
| `ft folders` | Show X bookmark folder distribution (requires `ft sync --folders` first) |
Expand Down
106 changes: 104 additions & 2 deletions src/bookmarks-db.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Database } from 'sql.js';
import { openDb, saveDb } from './db.js';
import { parseTimestampMs, toIsoDate } from './date-utils.js';
import { readJsonLines } from './fs.js';
import { twitterBookmarksCachePath, twitterBookmarksIndexPath } from './paths.js';
import { unlink } from 'node:fs/promises';
import { readJsonLines, writeJsonLines, readJson, writeJson, pathExists } from './fs.js';
import { twitterBookmarksCachePath, twitterBookmarksIndexPath, bookmarkMediaManifestPath } from './paths.js';
import type { MediaFetchManifest } from './bookmark-media.js';
import type { BookmarkRecord, QuotedTweetSnapshot } from './types.js';
import { classifyCorpus, formatClassificationSummary } from './bookmark-classify.js';
import type { ClassificationSummary } from './bookmark-classify.js';
Expand Down Expand Up @@ -681,6 +683,56 @@ export async function listBookmarks(
}
}

export async function getFilterSuggestions(
field: 'author' | 'category' | 'domain',
prefix: string = '',
limit: number = 20,
): Promise<string[]> {
const columnMap = {
author: 'author_handle',
category: 'primary_category',
domain: 'primary_domain',
} as const;
const col = columnMap[field];
const dbPath = twitterBookmarksIndexPath();
const db = await openDb(dbPath);
ensureMigrations(db);

try {
let sql: string;
let params: Array<string | number>;

if (prefix) {
sql = `
SELECT ${col}, COUNT(*) AS cnt
FROM bookmarks
WHERE ${col} IS NOT NULL AND ${col} != ''
AND ${col} LIKE ? COLLATE NOCASE
GROUP BY ${col}
ORDER BY cnt DESC
LIMIT ?
`;
params = [`${prefix}%`, limit];
} else {
sql = `
SELECT ${col}, COUNT(*) AS cnt
FROM bookmarks
WHERE ${col} IS NOT NULL AND ${col} != ''
GROUP BY ${col}
ORDER BY cnt DESC
LIMIT ?
`;
params = [limit];
}

const rows = db.exec(sql, params);
if (!rows.length) return [];
return rows[0].values.map((row) => row[0] as string);
} finally {
db.close();
}
}

export async function countBookmarks(
filters: BookmarkTimelineFilters = {},
): Promise<number> {
Expand Down Expand Up @@ -825,6 +877,56 @@ export async function getBookmarkById(id: string): Promise<BookmarkTimelineItem
}
}

/**
* Delete a bookmark by id from both the SQLite index and the JSONL cache.
* Returns the deleted bookmark's URL (for opening the tweet on Twitter) or
* null when no matching record was found.
*/
export async function deleteBookmark(id: string): Promise<{ url: string } | null> {
const dbPath = twitterBookmarksIndexPath();
const cachePath = twitterBookmarksCachePath();

const db = await openDb(dbPath);
ensureMigrations(db);

let url: string | null = null;
try {
const rows = db.exec('SELECT url FROM bookmarks WHERE id = ? LIMIT 1', [id]);
url = (rows[0]?.values?.[0]?.[0] as string) ?? null;
if (!url) return null;

db.run('DELETE FROM bookmarks WHERE id = ?', [id]);
// FTS5 content table is auto-updated via triggers set up in initSchema
db.run(`INSERT INTO bookmarks_fts(bookmarks_fts) VALUES('rebuild')`);
saveDb(db, dbPath);
} finally {
db.close();
}

// Remove from the JSONL cache so it won't re-appear on the next buildIndex
const records = await readJsonLines<{ id: string }>(cachePath);
const filtered = records.filter((r) => r.id !== id);
if (filtered.length !== records.length) {
await writeJsonLines(cachePath, filtered);
}

// Remove associated media files and manifest entries
const manifestPath = bookmarkMediaManifestPath();
if (await pathExists(manifestPath)) {
const manifest = await readJson<MediaFetchManifest>(manifestPath);
const toRemove = manifest.entries.filter((e) => e.bookmarkId === id);
for (const entry of toRemove) {
if (entry.localPath) {
await unlink(entry.localPath).catch(() => { /* already gone */ });
}
}
manifest.entries = manifest.entries.filter((e) => e.bookmarkId !== id);
await writeJson(manifestPath, manifest);
}

return { url };
}

export async function getStats(): Promise<{
totalBookmarks: number;
uniqueAuthors: number;
Expand Down
40 changes: 20 additions & 20 deletions src/bookmarks-viz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ const rgb = (r: number, g: number, b: number) => `${ESC}38;2;${r};${g};${b}m`;

// Palette — muted, tasteful
const C = {
title: rgb(199, 146, 234), // soft lavender
accent: rgb(130, 170, 255), // periwinkle
warm: rgb(255, 180, 120), // peach
green: rgb(120, 220, 170), // mint
dim: rgb(100, 100, 120), // muted gray
text: rgb(200, 200, 210), // light gray
hot: rgb(255, 120, 140), // coral
gold: rgb(240, 200, 100), // amber
cyan: rgb(100, 220, 230), // teal
violet: rgb(170, 130, 255), // violet
title: rgb(199, 146, 234), // soft lavender
accent: rgb(130, 170, 255), // periwinkle
warm: rgb(255, 180, 120), // peach
green: rgb(120, 220, 170), // mint
dim: rgb(100, 100, 120), // muted gray
text: rgb(200, 200, 210), // light gray
hot: rgb(255, 120, 140), // coral
gold: rgb(240, 200, 100), // amber
cyan: rgb(100, 220, 230), // teal
violet: rgb(170, 130, 255), // violet
};

// ── Block characters for bar charts ──────────────────────────────────────────
Expand Down Expand Up @@ -116,14 +116,14 @@ function lerpColor(

// ── Data queries ─────────────────────────────────────────────────────────────

interface GemBookmark {
export interface GemBookmark {
author: string;
text: string;
tweetId: string;
postedAt: string;
}

interface VizData {
export interface VizData {
total: number;
uniqueAuthors: number;
dateRange: { earliest: string; latest: string };
Expand Down Expand Up @@ -255,10 +255,10 @@ function aggregateTimelineData(rows: TimelineAggregateRow[]): {
const risingVoices = latestPostedMonth == null
? []
: [...authorPostedCounts.entries()]
.filter(([handle, count]) => count >= 3 && authorPostedMonths.get(handle)?.size === 1 && authorPostedMonths.get(handle)?.has(latestPostedMonth))
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 8)
.map(([handle, count]) => ({ handle, count }));
.filter(([handle, count]) => count >= 3 && authorPostedMonths.get(handle)?.size === 1 && authorPostedMonths.get(handle)?.has(latestPostedMonth))
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 8)
.map(([handle, count]) => ({ handle, count }));

return {
dateRange: { earliest, latest },
Expand All @@ -270,7 +270,7 @@ function aggregateTimelineData(rows: TimelineAggregateRow[]): {
};
}

async function queryVizData(): Promise<VizData> {
export async function buildVizData(): Promise<VizData> {
const db = await openDb(twitterBookmarksIndexPath());

try {
Expand Down Expand Up @@ -310,9 +310,9 @@ async function queryVizData(): Promise<VizData> {
if (domain && domain !== 'x.com' && domain !== 't.co') {
domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
}
} catch {}
} catch { }
}
} catch {}
} catch { }
}
const topDomains = [...domainCounts.entries()]
.sort((a, b) => b[1] - a[1])
Expand Down Expand Up @@ -788,7 +788,7 @@ function renderRisingVoices(data: VizData): string[] {
// ── Main render ──────────────────────────────────────────────────────────────

export async function renderViz(): Promise<string> {
const data = await queryVizData();
const data = await buildVizData();

const sections = [
...renderHiddenGems(data),
Expand Down
13 changes: 13 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { askMd } from './md-ask.js';
import { lintMd, fixLintIssues } from './md-lint.js';
import { exportBookmarks } from './md-export.js';
import { renderViz } from './bookmarks-viz.js';
import { startWeb } from './web.js';
import { listBrowserIds } from './browsers.js';
import { configureHttpProxyFromEnv } from './http-proxy.js';
import { dataDir, ensureDataDir, isFirstRun, migrateLegacyIdeasData, twitterBookmarksIndexPath, twitterBackfillStatePath, mdDir, bookmarkMediaDir, bookmarkMediaManifestPath } from './paths.js';
Expand Down Expand Up @@ -1233,6 +1234,18 @@ export function buildCli() {
console.log(await renderViz());
}));

// ── web ──────────────────────────────────────────────────────────────────

program
.command('web')
.description('Open a browser-based dashboard for your bookmarks')
.option('--port <number>', 'Port to listen on', '4321')
.option('--no-open', 'Do not open the browser automatically')
.action(safe(async (options) => {
if (!requireIndex()) return;
await startWeb(parseInt(String(options.port), 10), options.open !== false);
}));

// ── classify ────────────────────────────────────────────────────────────

program
Expand Down
Loading