diff --git a/apps/cli/db.ts b/apps/cli/db.ts new file mode 100644 index 0000000..10b955a --- /dev/null +++ b/apps/cli/db.ts @@ -0,0 +1,184 @@ +/** + * Local LeetCode problem cache backed by SQLite at + * ~/.local/share/leetcode/data.db (XDG_DATA_HOME honored). + * + * One `problems` table, two freshness rules: + * - the full problem index (id / title / slug / difficulty / paid flag) is + * refreshed from leetcode.com at most once per 24h (`meta.index_fetched_at`); + * - descriptions (HTML from GraphQL `question.content`) are fetched once per + * slug and kept forever. Only the campaign problems linked from README.md + * are ever fetched, and paid-only problems are skipped because GraphQL + * returns null content for them. + * + * Library: side-effect-free on import — nothing is opened or fetched until + * problemDb() is called. Index upserts never touch the description column, so + * a daily refresh cannot evict cached statements. + */ +import { Database } from "bun:sqlite"; +import { mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export interface Problem { + id: number; + title: string; + slug: string; + difficulty: "Easy" | "Medium" | "Hard"; + paidOnly: boolean; +} + +const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const; +const INDEX_TTL_MS = 24 * 60 * 60 * 1000; + +export const DB_PATH = join( + process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), + "leetcode", + "data.db", +); + +// ── README parsing ─────────────────────────────────────────────── + +/** Campaign problem slugs linked from README.md, in order, deduplicated. */ +export function readmeSlugs(markdown: string): string[] { + const slugs = new Set(); + for (const m of markdown.matchAll(/https:\/\/leetcode\.com\/problems\/([\w-]+)\//g)) { + slugs.add(m[1]!); + } + return [...slugs]; +} + +// ── remote fetches ─────────────────────────────────────────────── + +async function fetchIndex(): Promise { + const res = await fetch("https://leetcode.com/api/problems/all/", { + headers: { "user-agent": "Mozilla/5.0" }, + }); + if (!res.ok) throw new Error(`GET /api/problems/all -> ${res.status} ${res.statusText}`); + const data = (await res.json()) as { + stat_status_pairs: Array<{ + stat: { + frontend_question_id: number; + question__title: string; + question__title_slug: string; + }; + difficulty: { level: 1 | 2 | 3 }; + paid_only: boolean; + }>; + }; + return data.stat_status_pairs + .map((p) => ({ + id: p.stat.frontend_question_id, + title: p.stat.question__title, + slug: p.stat.question__title_slug, + difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"], + paidOnly: p.paid_only, + })) + .sort((a, b) => a.id - b.id); +} + +/** HTML problem statement, or null when LeetCode withholds it (paid-only). */ +export async function fetchDescription(slug: string): Promise { + const res = await fetch("https://leetcode.com/graphql", { + method: "POST", + headers: { + "content-type": "application/json", + "user-agent": "Mozilla/5.0", + referer: `https://leetcode.com/problems/${slug}/`, + }, + body: JSON.stringify({ + query: "query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }", + variables: { titleSlug: slug }, + }), + }); + if (!res.ok) throw new Error(`POST /graphql (${slug}) -> ${res.status} ${res.statusText}`); + const data = (await res.json()) as { data?: { question?: { content?: string | null } | null } }; + return data.data?.question?.content ?? null; +} + +// ── the cache ──────────────────────────────────────────────────── + +export function problemDb(path = DB_PATH) { + mkdirSync(dirname(path), { recursive: true }); + const db = new Database(path); + db.exec(` + CREATE TABLE IF NOT EXISTS problems ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + difficulty TEXT NOT NULL, + paid_only INTEGER NOT NULL, + description TEXT + ); + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); + + const getMeta = db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?1"); + const setMeta = db.query( + "INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ); + const upsert = db.query( + `INSERT INTO problems (id, title, slug, difficulty, paid_only) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, slug = excluded.slug, + difficulty = excluded.difficulty, paid_only = excluded.paid_only`, + ); + const descState = db.query<{ paid_only: number; described: number }, [string]>( + "SELECT paid_only, description IS NOT NULL AS described FROM problems WHERE slug = ?1", + ); + + return { + path, + + /** True when the index has never been fetched or is older than 24h. */ + indexStale(): boolean { + const row = getMeta.get("index_fetched_at"); + return !row || Date.now() - Number(row.value) > INDEX_TTL_MS; + }, + + /** Pull the full index from leetcode.com and upsert it. Returns the count. */ + async refreshIndex(): Promise { + const problems = await fetchIndex(); + db.transaction(() => { + for (const p of problems) upsert.run(p.id, p.title, p.slug, p.difficulty, p.paidOnly ? 1 : 0); + setMeta.run("index_fetched_at", String(Date.now())); + })(); + return problems.length; + }, + + /** Every cached problem, ordered by LC number. */ + all(): Problem[] { + const rows = db + .query<{ id: number; title: string; slug: string; difficulty: string; paid_only: number }, []>( + "SELECT id, title, slug, difficulty, paid_only FROM problems ORDER BY id", + ) + .all(); + return rows.map((r) => ({ + id: r.id, + title: r.title, + slug: r.slug, + difficulty: r.difficulty as Problem["difficulty"], + paidOnly: r.paid_only === 1, + })); + }, + + /** Free (non-premium) slugs among `slugs` with no cached description yet. */ + missingDescriptions(slugs: string[]): string[] { + return slugs.filter((slug) => { + const row = descState.get(slug); + return row !== null && !row.paid_only && !row.described; + }); + }, + + saveDescription(slug: string, html: string): void { + db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html); + }, + + close(): void { + db.close(); + }, + }; +} diff --git a/apps/cli/package.json b/apps/cli/package.json index 6856ac7..d3c8fa8 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -5,6 +5,7 @@ "scripts": { "pick": "bun ./pick.ts", "test": "bun ./test.ts", + "submit": "bun ./submit.ts", "sync": "bun ./sync.ts", "close-solved": "bun ./close-solved.ts", "close-topics": "bun ./close-topics.ts" diff --git a/apps/cli/pick.ts b/apps/cli/pick.ts index 6b4b328..d74ad16 100755 --- a/apps/cli/pick.ts +++ b/apps/cli/pick.ts @@ -1,70 +1,72 @@ #!/usr/bin/env bun /** * Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli. - * Problem index is fetched from leetcode.com and cached for 24h in .cache/. + * + * The problem index lives in the local SQLite cache (see db.ts), refreshed at + * most once per 24h; campaign problem descriptions from README.md are + * backfilled into the same db on the way. Problems that already have a + * numbered file under work/ are excluded from the picker — re-picking them + * would only clobber the existing scaffold. */ import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; +import { basename, join } from "node:path"; +import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db"; const ROOT = join(import.meta.dir, "..", ".."); -const CACHE_FILE = join(ROOT, ".cache", "leetcode-problems.json"); -const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const WORK_DIR = join(ROOT, "work"); -interface Problem { - id: number; - title: string; - slug: string; - difficulty: "Easy" | "Medium" | "Hard"; - paidOnly: boolean; -} +const db = problemDb(); -const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const; - -async function loadProblems(): Promise { - const file = Bun.file(CACHE_FILE); - if (await file.exists()) { - const stale = Date.now() - file.lastModified > CACHE_TTL_MS; - if (!stale) return file.json(); - } +// ── problem index (24h TTL) ────────────────────────────────────── +if (db.indexStale()) { const s = spinner(); s.start("Fetching problem index from leetcode.com"); - const res = await fetch("https://leetcode.com/api/problems/all/", { - headers: { "user-agent": "Mozilla/5.0" }, - }); - if (!res.ok) { - s.error(`Fetch failed: ${res.status} ${res.statusText}`); - process.exit(1); + try { + s.stop(`Indexed ${await db.refreshIndex()} problems`); + } catch (err) { + if (db.all().length === 0) { + s.error(`Fetch failed: ${err}`); + process.exit(1); + } + s.stop(`Index refresh failed (${err}) — using cached copy`); } - const data = (await res.json()) as { - stat_status_pairs: Array<{ - stat: { - frontend_question_id: number; - question__title: string; - question__title_slug: string; - }; - difficulty: { level: 1 | 2 | 3 }; - paid_only: boolean; - }>; - }; - const problems: Problem[] = data.stat_status_pairs - .map((p) => ({ - id: p.stat.frontend_question_id, - title: p.stat.question__title, - slug: p.stat.question__title_slug, - difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"], - paidOnly: p.paid_only, - })) - .sort((a, b) => a.id - b.id); - s.stop(`Loaded ${problems.length} problems`); - - await mkdir(join(ROOT, ".cache"), { recursive: true }); - await Bun.write(CACHE_FILE, JSON.stringify(problems)); - return problems; } -const problems = await loadProblems(); +// ── description backfill for the campaign problems in README.md ── + +const missing = db.missingDescriptions(readmeSlugs(await Bun.file(join(ROOT, "README.md")).text())); +if (missing.length > 0) { + const s = spinner(); + s.start(`Caching ${missing.length} problem descriptions`); + let fetched = 0; + let failed = 0; + await Promise.all( + Array.from({ length: 6 }, async () => { + for (let slug = missing.shift(); slug; slug = missing.shift()) { + try { + const html = await fetchDescription(slug); + if (html !== null) db.saveDescription(slug, html); + s.message(`Caching problem descriptions (${++fetched})`); + } catch { + failed++; // left NULL — retried on the next run + } + } + }), + ); + s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`); +} + +// ── exclude problems already picked or solved under work/ ──────── + +const done = new Set(); +for (const rel of new Bun.Glob("**/*.*").scanSync({ cwd: WORK_DIR })) { + const m = basename(rel).match(/^(\d+)\./); + if (m) done.add(Number(m[1])); +} + +const problems = db.all().filter((p) => !done.has(p.id)); +db.close(); const picked = await autocomplete({ message: "Pick a problem", diff --git a/apps/cli/solutions.ts b/apps/cli/solutions.ts new file mode 100644 index 0000000..10132cb --- /dev/null +++ b/apps/cli/solutions.ts @@ -0,0 +1,59 @@ +/** + * Shared work/ solution picker for the test and submit entries: fuzzy-select + * a solution file, then hand it to leetcode-cli. Library: side-effect-free on + * import — nothing scans the filesystem until runOnSolution() is called. + */ +import { autocomplete, cancel, isCancel, log } from "@clack/prompts"; +import { join, relative } from "node:path"; + +const ROOT = join(import.meta.dir, "..", ".."); +const WORK_DIR = join(ROOT, "work"); + +/** Fuzzy-pick a solution under work/ and run `leetcode ` on it. */ +export async function runOnSolution(command: "test" | "submit", message: string): Promise { + const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })] + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + + if (files.length === 0) { + log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`); + process.exit(1); + } + + const picked = await autocomplete({ + message, + placeholder: "Type to search...", + maxItems: 12, + options: files.map((f) => { + // work layout: Difficulty/Category/.. + const [difficulty, category, name] = f.split("/"); + return { + value: f, + label: name ?? f, + hint: category ? `${difficulty} · ${category}` : difficulty, + }; + }), + filter: (search, option) => { + const haystack = option.value.toLowerCase(); + return search + .toLowerCase() + .split(/\s+/) + .every((token) => haystack.includes(token)); + }, + }); + + if (isCancel(picked)) { + cancel("Nothing selected."); + process.exit(0); + } + + const proc = Bun.spawn( + [ + // bun installs workspace-dep bins into this package's node_modules, not the root's + join(import.meta.dir, "node_modules", ".bin", "leetcode"), + command, + join(WORK_DIR, picked), + ], + { cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] }, + ); + process.exit(await proc.exited); +} diff --git a/apps/cli/submit.ts b/apps/cli/submit.ts new file mode 100755 index 0000000..4f77e6c --- /dev/null +++ b/apps/cli/submit.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env bun +/** + * Fuzzy-select a solution file from work/ and submit it to LeetCode's judge. + */ +import { runOnSolution } from "./solutions"; + +await runOnSolution("submit", "Submit which solution?"); diff --git a/apps/cli/test.ts b/apps/cli/test.ts index 17f7eed..34c1f7c 100755 --- a/apps/cli/test.ts +++ b/apps/cli/test.ts @@ -2,54 +2,6 @@ /** * Fuzzy-select a solution file from work/ and run leetcode-cli tests on it. */ -import { autocomplete, cancel, isCancel, log } from "@clack/prompts"; -import { join, relative } from "node:path"; +import { runOnSolution } from "./solutions"; -const ROOT = join(import.meta.dir, "..", ".."); -const WORK_DIR = join(ROOT, "work"); - -const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })] - .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); - -if (files.length === 0) { - log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`); - process.exit(1); -} - -const picked = await autocomplete({ - message: "Test which solution?", - placeholder: "Type to search...", - maxItems: 12, - options: files.map((f) => { - // work layout: Difficulty/Category/.. - const [difficulty, category, name] = f.split("/"); - return { - value: f, - label: name ?? f, - hint: category ? `${difficulty} · ${category}` : difficulty, - }; - }), - filter: (search, option) => { - const haystack = option.value.toLowerCase(); - return search - .toLowerCase() - .split(/\s+/) - .every((token) => haystack.includes(token)); - }, -}); - -if (isCancel(picked)) { - cancel("Nothing selected."); - process.exit(0); -} - -const proc = Bun.spawn( - [ - // bun installs workspace-dep bins into this package's node_modules, not the root's - join(import.meta.dir, "node_modules", ".bin", "leetcode"), - "test", - join(WORK_DIR, picked), - ], - { cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] }, -); -process.exit(await proc.exited); +await runOnSolution("test", "Test which solution?");