2026-08-25 11:19:26 -04:00
|
|
|
#!/usr/bin/env bun
|
|
|
|
|
/**
|
|
|
|
|
* Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli.
|
2026-08-26 15:25:35 -04:00
|
|
|
*
|
|
|
|
|
* 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.
|
2026-08-25 11:19:26 -04:00
|
|
|
*/
|
|
|
|
|
import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts";
|
2026-08-26 15:25:35 -04:00
|
|
|
import { basename, join } from "node:path";
|
|
|
|
|
import { fetchDescription, problemDb, readmeSlugs, type Problem } from "./db";
|
2026-08-25 11:19:26 -04:00
|
|
|
|
|
|
|
|
const ROOT = join(import.meta.dir, "..", "..");
|
2026-08-26 15:25:35 -04:00
|
|
|
const WORK_DIR = join(ROOT, "work");
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-08-26 15:25:35 -04:00
|
|
|
const db = problemDb();
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-08-26 15:25:35 -04:00
|
|
|
// ── problem index (24h TTL) ──────────────────────────────────────
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-08-26 15:25:35 -04:00
|
|
|
if (db.indexStale()) {
|
2026-08-25 11:19:26 -04:00
|
|
|
const s = spinner();
|
|
|
|
|
s.start("Fetching problem index from leetcode.com");
|
2026-08-26 15:25:35 -04:00
|
|
|
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`);
|
2026-08-25 11:19:26 -04:00
|
|
|
}
|
2026-08-26 15:25:35 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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/ ────────
|
2026-08-25 11:19:26 -04:00
|
|
|
|
2026-08-26 15:25:35 -04:00
|
|
|
const done = new Set<number>();
|
|
|
|
|
for (const rel of new Bun.Glob("**/*.*").scanSync({ cwd: WORK_DIR })) {
|
|
|
|
|
const m = basename(rel).match(/^(\d+)\./);
|
|
|
|
|
if (m) done.add(Number(m[1]));
|
2026-08-25 11:19:26 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-26 15:25:35 -04:00
|
|
|
const problems = db.all().filter((p) => !done.has(p.id));
|
|
|
|
|
db.close();
|
2026-08-25 11:19:26 -04:00
|
|
|
|
|
|
|
|
const picked = await autocomplete<Problem>({
|
|
|
|
|
message: "Pick a problem",
|
|
|
|
|
placeholder: "Type to search by number or title...",
|
|
|
|
|
maxItems: 12,
|
|
|
|
|
options: problems.map((p) => ({
|
|
|
|
|
value: p,
|
|
|
|
|
label: `${p.id}. ${p.title}`,
|
|
|
|
|
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
|
|
|
|
|
})),
|
|
|
|
|
filter: (search, option) => {
|
|
|
|
|
const haystack = option.label!.toLowerCase();
|
|
|
|
|
return search
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.every((token) => haystack.includes(token));
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (isCancel(picked)) {
|
|
|
|
|
cancel("Nothing picked.");
|
|
|
|
|
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"), "pick", picked.slug],
|
|
|
|
|
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
|
|
|
|
);
|
|
|
|
|
process.exit(await proc.exited);
|