mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
feat(cli): add solution activity tracking and ordering for test/submit commands
This commit is contained in:
@@ -10,6 +10,12 @@
|
|||||||
* are ever fetched, and paid-only problems are skipped because GraphQL
|
* are ever fetched, and paid-only problems are skipped because GraphQL
|
||||||
* returns null content for them.
|
* returns null content for them.
|
||||||
*
|
*
|
||||||
|
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
|
||||||
|
* `created_at` (backfilled from the file's birthtime on every picker run),
|
||||||
|
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
|
||||||
|
* The test picker sorts by last_tested, the submit picker by created_at, and
|
||||||
|
* submit hides anything with a last_submitted.
|
||||||
|
*
|
||||||
* Library: side-effect-free on import — nothing is opened or fetched until
|
* Library: side-effect-free on import — nothing is opened or fetched until
|
||||||
* problemDb() is called. Index upserts never touch the description column, so
|
* problemDb() is called. Index upserts never touch the description column, so
|
||||||
* a daily refresh cannot evict cached statements.
|
* a daily refresh cannot evict cached statements.
|
||||||
@@ -27,6 +33,13 @@ export interface Problem {
|
|||||||
paidOnly: boolean;
|
paidOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lifecycle of one work/ solution, keyed by LC number. ISO timestamps. */
|
||||||
|
export interface SolutionActivity {
|
||||||
|
createdAt: string;
|
||||||
|
lastTested: string | null;
|
||||||
|
lastSubmitted: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
|
||||||
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
|
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -109,6 +122,13 @@ export function problemDb(path = DB_PATH) {
|
|||||||
paid_only INTEGER NOT NULL,
|
paid_only INTEGER NOT NULL,
|
||||||
description TEXT
|
description TEXT
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS solutions (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_tested TEXT,
|
||||||
|
last_submitted TEXT
|
||||||
|
);
|
||||||
|
DROP TABLE IF EXISTS submissions;
|
||||||
CREATE TABLE IF NOT EXISTS meta (
|
CREATE TABLE IF NOT EXISTS meta (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
@@ -177,6 +197,37 @@ export function problemDb(path = DB_PATH) {
|
|||||||
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
|
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Per-solution lifecycle rows keyed by LC number. */
|
||||||
|
solutionActivity(): Map<number, SolutionActivity> {
|
||||||
|
const rows = db
|
||||||
|
.query<{ id: number; created_at: string; last_tested: string | null; last_submitted: string | null }, []>(
|
||||||
|
"SELECT id, created_at, last_tested, last_submitted FROM solutions",
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return new Map(
|
||||||
|
rows.map((r) => [r.id, { createdAt: r.created_at, lastTested: r.last_tested, lastSubmitted: r.last_submitted }]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Register a solution's creation time; existing rows are left alone. */
|
||||||
|
ensureSolution(id: number, createdAt: string): void {
|
||||||
|
db.query("INSERT INTO solutions (id, created_at) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING").run(id, createdAt);
|
||||||
|
},
|
||||||
|
|
||||||
|
markTested(id: number): void {
|
||||||
|
db.query(
|
||||||
|
`INSERT INTO solutions (id, created_at, last_tested) VALUES (?1, ?2, ?2)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET last_tested = excluded.last_tested`,
|
||||||
|
).run(id, new Date().toISOString());
|
||||||
|
},
|
||||||
|
|
||||||
|
markSubmitted(id: number): void {
|
||||||
|
db.query(
|
||||||
|
`INSERT INTO solutions (id, created_at, last_submitted) VALUES (?1, ?2, ?2)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET last_submitted = excluded.last_submitted`,
|
||||||
|
).run(id, new Date().toISOString());
|
||||||
|
},
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
db.close();
|
db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
+59
-6
@@ -1,18 +1,64 @@
|
|||||||
/**
|
/**
|
||||||
* Shared work/ solution picker for the test and submit entries: fuzzy-select
|
* 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
|
* a solution file, then hand it to leetcode-cli.
|
||||||
* import — nothing scans the filesystem until runOnSolution() is called.
|
*
|
||||||
|
* Every run backfills the db's solutions table with created_at (from the
|
||||||
|
* file's birthtime, mtime when the filesystem has none) so ordering works for
|
||||||
|
* files that predate the db. Ordering is per command — test lists the most
|
||||||
|
* recently tested first (never-tested after, by name); submit lists the most
|
||||||
|
* recently created first. Nothing is ever hidden. Exit 0 of leetcode-cli
|
||||||
|
* stamps last_tested / last_submitted.
|
||||||
|
*
|
||||||
|
* Library: side-effect-free on import — nothing scans the filesystem until
|
||||||
|
* runOnSolution() is called.
|
||||||
*/
|
*/
|
||||||
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
|
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
|
||||||
import { join, relative } from "node:path";
|
import { statSync } from "node:fs";
|
||||||
|
import { basename, join, relative } from "node:path";
|
||||||
|
import { problemDb } from "./db";
|
||||||
|
|
||||||
const ROOT = join(import.meta.dir, "..", "..");
|
const ROOT = join(import.meta.dir, "..", "..");
|
||||||
const WORK_DIR = join(ROOT, "work");
|
const WORK_DIR = join(ROOT, "work");
|
||||||
|
|
||||||
|
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
|
||||||
|
function lcNumber(rel: string): number | undefined {
|
||||||
|
const m = basename(rel).match(/^(\d+)\./);
|
||||||
|
return m ? Number(m[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||||
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
||||||
const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })]
|
const db = problemDb();
|
||||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
||||||
|
const scanned = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })];
|
||||||
|
|
||||||
|
// register creation times so files older than the db still sort correctly
|
||||||
|
for (const f of scanned) {
|
||||||
|
const num = lcNumber(f);
|
||||||
|
if (num === undefined) continue;
|
||||||
|
const stat = statSync(join(WORK_DIR, f));
|
||||||
|
db.ensureSolution(num, new Date(stat.birthtimeMs || stat.mtimeMs).toISOString());
|
||||||
|
}
|
||||||
|
const activity = db.solutionActivity();
|
||||||
|
|
||||||
|
// test → most recently tested first; submit → most recently created first
|
||||||
|
const sortKey = (f: string) => {
|
||||||
|
const num = lcNumber(f);
|
||||||
|
const row = num === undefined ? undefined : activity.get(num);
|
||||||
|
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const files = scanned
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ka = sortKey(a);
|
||||||
|
const kb = sortKey(b);
|
||||||
|
if (ka !== kb) {
|
||||||
|
if (ka === undefined) return 1;
|
||||||
|
if (kb === undefined) return -1;
|
||||||
|
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
|
||||||
|
}
|
||||||
|
return a.localeCompare(b, undefined, { numeric: true });
|
||||||
|
});
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
|
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
|
||||||
@@ -55,5 +101,12 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
|||||||
],
|
],
|
||||||
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
|
||||||
);
|
);
|
||||||
process.exit(await proc.exited);
|
const code = await proc.exited;
|
||||||
|
const num = lcNumber(picked);
|
||||||
|
if (code === 0 && num !== undefined) {
|
||||||
|
if (command === "test") db.markTested(num);
|
||||||
|
else db.markSubmitted(num);
|
||||||
|
}
|
||||||
|
db.close();
|
||||||
|
process.exit(code);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user