diff --git a/apps/cli/sync.ts b/apps/cli/sync.ts index f6d0c83..36d88b9 100755 --- a/apps/cli/sync.ts +++ b/apps/cli/sync.ts @@ -27,6 +27,15 @@ * (never on a sync with nothing new); offline, or with no `gh` auth, the * page is written without it and the next sync retries. Delete the key to * refetch it. + * - Every page carries the companies that asked it in the last six months as + * frontmatter `companies:` (`{ name, period, frequency }`, the most recent + * window each company falls in and its frequency there, most recent first; + * `apps/docs/companies.ts`), read from the local DuckDB + * company-frequency database (`COMPANY_DB`, built by its own `mise run + * lc:build`) with the `duckdb` CLI. Unlike the keys above it is rewritten + * every run, since recency ages and a rebuilt database should show; a + * problem no company asked lately carries no key. Where the database or the + * CLI is missing (CI), the step is skipped and pages keep the key as is. * - Every tag a page carries that no campaign topic teaches (not a key of * `apps/docs/topic-landings.ts`) belongs in the Appendix glossary, * `apps/docs/appendix.ts`, which is hand-written: sync only checks it, @@ -63,10 +72,13 @@ * `apps/docs/redirects.ts`, which `blume.config.ts` imports — Blume takes exact * root-relative paths and prepends the deployment base itself. */ +import { existsSync } from "node:fs"; import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises"; +import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { APPENDIX } from "../docs/appendix.ts"; +import { RECENCY, type Company } from "../docs/companies.ts"; import { TOPIC_LANDINGS } from "../docs/topic-landings.ts"; import { github } from "./github.ts"; @@ -506,6 +518,62 @@ async function issueUrl(sol: Solution): Promise { return url; } +// ── company frequency ──────────────────────────────────────────── + +// The windows and the key's shape are `apps/docs/companies.ts`, shared with +// the schema and the readers; `More Than Six Months` and `All` are not recent. +const COMPANY_DB = join( + process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), + "duckdb", + "data", + "leetcode-company-frequency.db", +); + +/** + * LC number → the companies that asked it in the last six months, each at the + * most recent window it appears in (the windows nest), ordered most recent + * first, then by that window's frequency. Undefined when this machine has no + * database or no `duckdb` CLI — the step is skipped — or the query fails + * (warned). One query for every solved problem. + */ +function recentCompanies(nums: number[]): Map | undefined { + if (!existsSync(COMPANY_DB) || !Bun.which("duckdb")) return undefined; + const order = `list_position([${RECENCY.map((p) => `'${p}'`).join(", ")}], period)`; + const sql = `SELECT question_id, company, period, frequency FROM company_frequency + WHERE question_id IN (${nums.join(", ")}) AND ${order} IS NOT NULL + QUALIFY row_number() OVER (PARTITION BY question_id, company ORDER BY ${order}) = 1 + ORDER BY question_id, ${order}, frequency DESC, company`; + const proc = Bun.spawnSync(["duckdb", "-readonly", "-json", COMPANY_DB, "-c", sql]); + if (!proc.success) { + console.warn(`no companies: duckdb -> ${proc.stderr.toString().trim()}`); + return undefined; + } + const out = proc.stdout.toString().trim(); + const rows: unknown = out ? JSON.parse(out) : []; + const companies = new Map(nums.map((n) => [n, []])); + for (const row of Array.isArray(rows) ? rows : []) { + if (!row || typeof row !== "object") continue; + if (!("question_id" in row) || typeof row.question_id !== "number") continue; + if (!("company" in row) || typeof row.company !== "string") continue; + if (!("period" in row) || !("frequency" in row) || typeof row.frequency !== "number") continue; + const period = RECENCY.find((p) => p === row.period); + if (period) + companies.get(row.question_id)?.push({ name: row.company, period, frequency: row.frequency }); + } + return companies; +} + +/** The frontmatter `companies:` block, one flow mapping per company (JSON strings are valid YAML). */ +function companiesYaml(list: Company[]): string { + return [ + "companies:", + ...list.map( + (c) => + ` - { name: ${JSON.stringify(c.name)}, period: ${JSON.stringify(c.period)}, frequency: ${c.frequency} }`, + ), + ].join("\n"); +} + // ── inline-code formatting heuristics ──────────────────────────── /** Identifiers worth backticking in prose, harvested from the code. */ @@ -624,6 +692,7 @@ function renderPage( sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined, + companies: Company[] | undefined, ): string { const h = sol.header; const ids = identifiers(sol); @@ -639,6 +708,7 @@ function renderPage( `icon: ${PAGE_ICON}`, ...(tags ? [topicsYaml(tags)] : []), ...(issue ? [`issue: ${JSON.stringify(issue)}`] : []), + ...(companies?.length ? [companiesYaml(companies)] : []), "sidebar:", ` label: '${h.title.replaceAll("'", "''")}'`, ` badge: '${h.difficulty}'`, @@ -704,6 +774,7 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) { } const topics = await readmeTopics(); +const companies = recentCompanies([...solutions.keys()]); // An unplanned problem whose work/ category is also a README topic name (a // `Hash Table` warm-up) files under that topic, in its phase, not a second // root-level `hash-table/`. @@ -753,6 +824,23 @@ async function ensureIssue(page: string, sol: Solution): Promise { ); } +/** + * Replace the frontmatter's `companies:` with this run's list, inserted before + * `sidebar:` (dropped when no company asked lately). Without the database the + * page is returned untouched, so an environment lacking it never strips the key. + */ +function ensureCompanies(page: string, sol: Solution): string { + if (!companies) return page; + const fm = page.match(/^---\n[\s\S]*?\n---/); + if (!fm) return page; + const list = companies.get(sol.num) ?? []; + const without = fm[0].replace(/^companies:.*\n(?: {2}- .*\n)*/m, ""); + const next = list.length + ? without.replace(/^sidebar:$/m, `${companiesYaml(list)}\nsidebar:`) + : without; + return next + page.slice(fm[0].length); +} + /** * Offset just past the script-owned `## Solution` section: the next top-level * heading that is not inside a code fence, or end of file. Sections a human @@ -815,8 +903,11 @@ for (const sol of ordered) { const tail = page.slice(solutionEnd(page, at)); const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : ""); - const next = await ensureIssue( - await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), + const next = ensureCompanies( + await ensureIssue( + await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), + sol, + ), sol, ); const actions: string[] = []; @@ -836,6 +927,7 @@ for (const sol of ordered) { sol, await fetchTopicTags(sol), await issueUrl(sol), + companies?.get(sol.num), ); await Bun.write(join(DOCS, name), page); report.push([workRef, name, "created"]);