mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-17 15:36:26 +00:00
198 lines
7.3 KiB
TypeScript
198 lines
7.3 KiB
TypeScript
/**
|
|
* The work/ layout: one directory per spaced-repetition window.
|
|
*
|
|
* work/1/<Difficulty>/<Category>/<num>.<slug>.<ext> first solve
|
|
* work/3/<Difficulty>/<Category>/<num>.<slug>.<ext> 3-day blind re-solve
|
|
* work/7/<Difficulty>/<Category>/<num>.<slug>.<ext> 7-day blind re-solve
|
|
*
|
|
* The bucket name is the review window in days — the same +3/+7 windows the
|
|
* campaign's `Spaced Repetition — <date>` issues use (WINDOWS in
|
|
* spaced-repetition.ts). Below the bucket the layout is leetcode-cli's own
|
|
* `workDir/Difficulty/Category/` shape, so a re-solve lands beside its first
|
|
* solve under a different bucket.
|
|
*
|
|
* A window is *owed* when the first solve is at least that many days old and
|
|
* the bucket has no file for that problem yet. Reconciled from the filesystem
|
|
* on every run the way close-solved.ts reconciles issues: no state file, no
|
|
* bookkeeping, and a re-solve closes its window just by existing — presence,
|
|
* not content, so a stub in work/3 counts as done the same way `bun run pick`
|
|
* treats a scaffold under work/1 as picked. The first solve is the one place
|
|
* content is read: an empty scaffold in work/1 owes nothing, it is simply
|
|
* unsolved (isImplemented() in source.ts, the same gate close-solved.ts uses
|
|
* before it closes an issue).
|
|
*
|
|
* "First solve" is the ET date of the commit that first added the problem's
|
|
* work/ file — the same push that closes its problem issue and therefore
|
|
* starts the campaign's +3/+7 windows. An uncommitted solution has no date and
|
|
* is never owed a review yet: you just solved it. ET dates and the date
|
|
* arithmetic come from apps/api/src/srs.ts so the local picker and D1 cannot
|
|
* disagree about what day it is.
|
|
*
|
|
* Library: side-effect-free on import — nothing scans or shells out until a
|
|
* function is called.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { WINDOWS, type Window, daysBetween } from "../api/src/srs.ts";
|
|
import { isImplemented } from "./source.ts";
|
|
|
|
export const ROOT = join(import.meta.dir, "..", "..");
|
|
export const WORK = join(ROOT, "work");
|
|
|
|
// The windows are the SRS ladder's rungs: srs.ts defines them once and the
|
|
// bucket directories are named after them. Re-exported so the picker needs
|
|
// only this module to know the work/ layout.
|
|
export { WINDOWS, type Window };
|
|
|
|
/** work/1 is the first solve, the rest are the review windows. */
|
|
export const BUCKETS = [1, ...WINDOWS] as const;
|
|
export type Bucket = (typeof BUCKETS)[number];
|
|
const BUCKET_BY_DIR: Record<string, Bucket> = { "1": 1, "3": 3, "7": 7 };
|
|
|
|
export function bucketDir(bucket: Bucket): string {
|
|
return join(WORK, String(bucket));
|
|
}
|
|
|
|
export interface WorkFile {
|
|
lc: number;
|
|
slug: string;
|
|
bucket: Bucket;
|
|
/** Path relative to work/, bucket included. */
|
|
rel: string;
|
|
difficulty: string;
|
|
category: string;
|
|
}
|
|
|
|
/** `<num>.<slug>.<ext>` — the only file name the campaign scripts parse. */
|
|
function parseName(name: string): { lc: number; slug: string } | null {
|
|
const m = name.match(/^(\d+)\.(.+)\.[A-Za-z0-9]+$/);
|
|
return m ? { lc: Number(m[1]), slug: m[2]! } : null;
|
|
}
|
|
|
|
/** Parse `1/Medium/Array/15.3sum.py` — null for anything off-layout. */
|
|
export function parseWorkPath(rel: string): WorkFile | null {
|
|
const parts = rel.split("/");
|
|
const name = parts.pop();
|
|
const bucket = BUCKET_BY_DIR[parts[0] ?? ""];
|
|
if (name === undefined || bucket === undefined) return null;
|
|
const parsed = parseName(name);
|
|
if (!parsed) return null;
|
|
return { ...parsed, bucket, rel, difficulty: parts[1] ?? "", category: parts[2] ?? "" };
|
|
}
|
|
|
|
/** LC number -> its files in that bucket (a problem may have both js and py). */
|
|
export function scanBucket(bucket: Bucket): Map<number, WorkFile[]> {
|
|
const files = new Map<number, WorkFile[]>();
|
|
for (const name of new Bun.Glob("**/*.*").scanSync({ cwd: bucketDir(bucket) })) {
|
|
const file = parseWorkPath(`${bucket}/${name}`);
|
|
if (!file) continue;
|
|
const found = files.get(file.lc);
|
|
if (found) found.push(file);
|
|
else files.set(file.lc, [file]);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/**
|
|
* Is this file a real solution rather than an empty scaffold? The same call
|
|
* close-solved.ts makes before it closes an issue — a stub under work/1 is a
|
|
* problem still unsolved, and owes no review.
|
|
*/
|
|
export function isSolution(file: WorkFile): boolean {
|
|
const source = readFileSync(join(WORK, file.rel), "utf8");
|
|
return isImplemented(source, file.rel.endsWith(".py") ? "py" : "js");
|
|
}
|
|
|
|
/** Problems in a bucket with a solution on disk; scaffolds do not count. */
|
|
export function solvedInBucket(bucket: Bucket): number {
|
|
let solved = 0;
|
|
for (const files of scanBucket(bucket).values()) {
|
|
if (files.some(isSolution)) solved++;
|
|
}
|
|
return solved;
|
|
}
|
|
|
|
/**
|
|
* LC number -> ET date of the commit that first added a work/ file for it.
|
|
*
|
|
* One `git log` over the whole of work/, oldest commit first, so the first
|
|
* date seen for a problem wins. Keyed by LC number rather than by path, which
|
|
* makes every past reshuffle of the directory layout — including the move into
|
|
* work/1 — irrelevant: the original add still counts. TZ + `format-local`
|
|
* put the dates on the ET calendar srs.ts speaks.
|
|
*/
|
|
export function firstSolved(): Map<number, string> {
|
|
const git = Bun.spawnSync(
|
|
[
|
|
"git",
|
|
"log",
|
|
"--reverse",
|
|
"--no-merges",
|
|
"--diff-filter=A",
|
|
"--date=format-local:%Y-%m-%d",
|
|
"--format=%ad",
|
|
"--name-only",
|
|
"--",
|
|
"work",
|
|
],
|
|
{ cwd: ROOT, env: { ...process.env, TZ: "America/New_York" } },
|
|
);
|
|
if (git.exitCode !== 0) {
|
|
throw new Error(`git log failed: ${git.stderr.toString().trim()}`);
|
|
}
|
|
|
|
const dates = new Map<number, string>();
|
|
let date = "";
|
|
for (const line of git.stdout.toString().split("\n")) {
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(line)) {
|
|
date = line;
|
|
continue;
|
|
}
|
|
const parsed = line === "" ? null : parseName(line.slice(line.lastIndexOf("/") + 1));
|
|
if (parsed && !dates.has(parsed.lc)) dates.set(parsed.lc, date);
|
|
}
|
|
return dates;
|
|
}
|
|
|
|
export interface Owed extends WorkFile {
|
|
/** ET date of the first solve. */
|
|
solved: string;
|
|
/** Days since the first solve — at least the window. */
|
|
days: number;
|
|
}
|
|
|
|
/**
|
|
* Both review windows for `today`, oldest solve first.
|
|
*
|
|
* Uncapped, unlike the review issue's WINDOW_CAP: the issue is one day's
|
|
* assignment, this is everything still owed. A window skipped for a day stays
|
|
* on the list until its file exists.
|
|
*/
|
|
export function reviewQueues(today: string): Map<Window, Owed[]> {
|
|
const dates = firstSolved();
|
|
|
|
// An empty scaffold under work/1 is a problem still unsolved, not one owing
|
|
// a review.
|
|
const solved: { file: WorkFile; date: string }[] = [];
|
|
for (const files of scanBucket(1).values()) {
|
|
const date = dates.get(files[0]!.lc);
|
|
if (date === undefined) continue; // uncommitted: you only just solved it
|
|
const file = files.find(isSolution);
|
|
if (file) solved.push({ file, date });
|
|
}
|
|
|
|
const queues = new Map<Window, Owed[]>();
|
|
for (const window of WINDOWS) {
|
|
const done = scanBucket(window);
|
|
const owed: Owed[] = [];
|
|
for (const { file, date } of solved) {
|
|
if (done.has(file.lc)) continue;
|
|
const days = daysBetween(date, today);
|
|
if (days >= window) owed.push({ ...file, solved: date, days });
|
|
}
|
|
owed.sort((a, b) => b.days - a.days || a.lc - b.lc);
|
|
queues.set(window, owed);
|
|
}
|
|
return queues;
|
|
}
|