From fee8bb96686765fc492a62691eaccd11ccf9888d Mon Sep 17 00:00:00 2001 From: acid-boi Date: Thu, 10 Sep 2026 16:09:51 +0530 Subject: [PATCH 1/3] feat(notes): accept a problem name as well as a problem ID The note command only accepted a numeric problem ID, while pick, show and hint already take either an ID or a title slug. Notes now do the same: a title slug is resolved through the LeetCode API and its frontend ID names the note file, so both forms point at the same file on disk. The resolved problem is passed to the note template generator, so creating a note by name still costs a single API call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4JwzivBEbeq9u3zAXKUu2 Signed-off-by: acid-boi --- src/commands/notes.ts | 86 ++++++++++++++++++++++++++++++++++------- src/index.ts | 7 +++- src/utils/validation.ts | 9 +++++ 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/src/commands/notes.ts b/src/commands/notes.ts index 3a5502b..f2f464a 100644 --- a/src/commands/notes.ts +++ b/src/commands/notes.ts @@ -3,27 +3,25 @@ import { readFile, writeFile, mkdir } from 'fs/promises'; import { join } from 'path'; import { existsSync } from 'fs'; import chalk from 'chalk'; +import ora from 'ora'; import { config } from '../storage/config.js'; import { requireAuth } from '../utils/auth.js'; import { openInEditor } from '../utils/editor.js'; import { leetcodeClient } from '../api/client.js'; -import { isProblemId } from '../utils/validation.js'; +import { isProblemId, isTitleSlug } from '../utils/validation.js'; +import type { ProblemDetail } from '../types.js'; type NoteAction = 'view' | 'edit'; export async function notesCommand( - problemId: string, + idOrName: string, action?: string, options: { silent?: boolean } = {} ): Promise { - if (!isProblemId(problemId)) { - if (!options.silent) { - console.log(chalk.red(`Invalid problem ID: ${problemId}`)); - console.log(chalk.gray('Problem ID must be a positive integer')); - } - return; - } + const resolved = await resolveProblem(idOrName, options); + if (!resolved) return; + const { problemId, problem } = resolved; const noteAction: NoteAction = action === 'view' ? 'view' : 'edit'; const notesDir = join(config.getWorkDir(), '.notes'); @@ -36,7 +34,63 @@ export async function notesCommand( if (noteAction === 'view') { await viewNote(notePath, problemId); } else { - await editNote(notePath, problemId, options); + await editNote(notePath, problemId, problem, options); + } +} + +interface ResolvedProblem { + problemId: string; + problem: ProblemDetail | null; +} + +/** + * Accept either a problem ID ("1") or a problem name ("two-sum"). + * Names are looked up on LeetCode so both forms share the same note file. + */ +async function resolveProblem( + idOrName: string, + options: { silent?: boolean } +): Promise { + const slug = idOrName.trim(); + + if (isProblemId(slug)) { + return { problemId: slug, problem: null }; + } + + if (!isTitleSlug(slug)) { + if (!options.silent) { + console.log(chalk.red(`Invalid problem ID or name: ${idOrName}`)); + console.log(chalk.gray('Use a problem ID (e.g. 1) or a problem name (e.g. two-sum)')); + } + return null; + } + + const { authorized } = await requireAuth(); + if (!authorized) return null; + + const spinner = options.silent + ? null + : ora({ text: `Looking up "${slug}"...`, spinner: 'dots' }).start(); + + try { + const problem = await leetcodeClient.getProblem(slug); + spinner?.succeed(`Found ${problem.questionFrontendId}. ${problem.title}`); + return { problemId: problem.questionFrontendId, problem }; + } catch (error) { + // The API returns a null question for an unknown slug, which fails schema validation. + const message = error instanceof Error ? error.message : ''; + const notFound = message.includes('expected object, received null'); + + spinner?.fail(notFound ? `Problem "${slug}" not found` : `Failed to look up "${slug}"`); + + if (!options.silent) { + if (notFound) { + console.log(chalk.gray('Check the problem name, or use the problem ID instead.')); + } else if (message) { + console.log(chalk.red(message)); + } + } + return null; } } @@ -65,10 +119,11 @@ async function viewNote(notePath: string, problemId: string): Promise { async function editNote( notePath: string, problemId: string, + problem: ProblemDetail | null, options: { silent?: boolean } = {} ): Promise { if (!existsSync(notePath)) { - const template = await generateNoteTemplate(problemId); + const template = await generateNoteTemplate(problemId, problem); await writeFile(notePath, template, 'utf-8'); if (!options.silent) console.log(chalk.green(`✓ Created notes file for problem ${problemId}`)); } @@ -77,13 +132,16 @@ async function editNote( await openInEditor(notePath); } -async function generateNoteTemplate(problemId: string): Promise { +async function generateNoteTemplate( + problemId: string, + fetchedProblem: ProblemDetail | null = null +): Promise { let header = `# Problem ${problemId} Notes\n\n`; - const { authorized } = await requireAuth(); + const { authorized } = fetchedProblem ? { authorized: true } : await requireAuth(); if (authorized) { try { - const problem = await leetcodeClient.getProblemById(problemId); + const problem = fetchedProblem ?? (await leetcodeClient.getProblemById(problemId)); if (problem) { header = `# ${problemId}. ${problem.title}\n\n`; header += `**Difficulty:** ${problem.difficulty}\n`; diff --git a/src/index.ts b/src/index.ts index cdd5fee..4d59115 100644 --- a/src/index.ts +++ b/src/index.ts @@ -414,7 +414,7 @@ ${chalk.yellow('Examples:')} .action(bookmarkCommand); program - .command('note [action]') + .command('note [action]') .description('View or edit notes for a problem') .addHelpText( 'after', @@ -425,8 +425,11 @@ ${chalk.yellow('Actions:')} ${chalk.yellow('Examples:')} ${chalk.cyan('$ leetcode note 1')} Edit notes for problem 1 + ${chalk.cyan('$ leetcode note two-sum')} Edit notes by problem name ${chalk.cyan('$ leetcode note 1 edit')} Edit notes (explicit) - ${chalk.cyan('$ leetcode note 1 view')} View notes in terminal + ${chalk.cyan('$ leetcode note two-sum view')} View notes in terminal + +${chalk.gray('Problem names resolve to the same note file as the problem ID.')} ` ) .action(notesCommand); diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 2af7d32..efe346d 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -9,6 +9,15 @@ export function isFileName(input: string): boolean { return !input.includes('/') && !input.includes('\\') && input.includes('.'); } +const TITLE_SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * A LeetCode problem name in title-slug form, e.g. "two-sum". + */ +export function isTitleSlug(input: string): boolean { + return TITLE_SLUG_REGEX.test(input.trim()); +} + const WORKSPACE_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/; const SNAPSHOT_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9 _-]{0,63}$/; From bc5538b6bb52a691472e4b47d5410464022fb95e Mon Sep 17 00:00:00 2001 From: acid-boi Date: Thu, 10 Sep 2026 16:09:57 +0530 Subject: [PATCH 2/3] test(notes): cover problem name resolution Adds coverage for name lookup, template reuse of the already fetched problem, rejected input, unknown names and unexpected lookup failures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4JwzivBEbeq9u3zAXKUu2 Signed-off-by: acid-boi --- src/__tests__/commands/notes.test.ts | 86 ++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 19 deletions(-) diff --git a/src/__tests__/commands/notes.test.ts b/src/__tests__/commands/notes.test.ts index 4672e8d..14b56a0 100644 --- a/src/__tests__/commands/notes.test.ts +++ b/src/__tests__/commands/notes.test.ts @@ -28,21 +28,26 @@ vi.mock('../../storage/bookmarks.js', () => ({ }, })); -vi.mock('../../api/client.js', () => ({ - leetcodeClient: { - setCredentials: vi.fn(), - checkAuth: vi.fn().mockResolvedValue({ isSignedIn: true, username: 'TestUser' }), - getProblemById: vi.fn().mockResolvedValue({ - questionId: '1', - questionFrontendId: '1', - title: 'Two Sum', - titleSlug: 'two-sum', - difficulty: 'Easy', - status: 'ac', - topicTags: [{ name: 'Array' }], - }), - }, -})); +vi.mock('../../api/client.js', () => { + const problem = { + questionId: '1', + questionFrontendId: '1', + title: 'Two Sum', + titleSlug: 'two-sum', + difficulty: 'Easy', + status: 'ac', + topicTags: [{ name: 'Array' }], + }; + + return { + leetcodeClient: { + setCredentials: vi.fn(), + checkAuth: vi.fn().mockResolvedValue({ isSignedIn: true, username: 'TestUser' }), + getProblemById: vi.fn().mockResolvedValue(problem), + getProblem: vi.fn().mockResolvedValue(problem), + }, + }; +}); vi.mock('fs/promises', () => ({ readFile: vi.fn().mockResolvedValue('# Notes for Two Sum\n\nThis is a test note.'), @@ -71,8 +76,10 @@ vi.mock('ora', () => ({ import { notesCommand } from '../../commands/notes.js'; import { bookmarkCommand } from '../../commands/bookmark.js'; import { bookmarks } from '../../storage/bookmarks.js'; +import { leetcodeClient } from '../../api/client.js'; import { existsSync } from 'fs'; -import { readFile } from 'fs/promises'; +import { readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; describe('Notes and Bookmark Commands', () => { beforeEach(() => { @@ -96,10 +103,51 @@ describe('Notes and Bookmark Commands', () => { expect(openInEditor).toHaveBeenCalled(); }); - it('should handle invalid problem ID', async () => { - await notesCommand('invalid', 'view'); + it('should handle invalid problem ID or name', async () => { + await notesCommand('Two Sum', 'view'); + + expect(outputContains('Invalid problem ID or name')).toBe(true); + expect(leetcodeClient.getProblem).not.toHaveBeenCalled(); + }); + + it('should resolve a problem name to its note file', async () => { + vi.mocked(existsSync).mockReturnValue(true); + + await notesCommand('two-sum', 'view'); + + expect(leetcodeClient.getProblem).toHaveBeenCalledWith('two-sum'); + expect(readFile).toHaveBeenCalledWith(join('/tmp/leetcode', '.notes', '1.md'), 'utf-8'); + }); + + it('should reuse the resolved problem for the note template', async () => { + // Note file does not exist yet, but the notes directory does. + vi.mocked(existsSync).mockImplementation((path) => !String(path).endsWith('1.md')); + + await notesCommand('two-sum', 'edit'); + + expect(leetcodeClient.getProblem).toHaveBeenCalledTimes(1); + expect(leetcodeClient.getProblemById).not.toHaveBeenCalled(); + expect(vi.mocked(writeFile).mock.calls[0][1]).toContain('# 1. Two Sum'); + }); + + it('should report a problem name that does not exist', async () => { + vi.mocked(leetcodeClient.getProblem).mockRejectedValueOnce( + new Error('Invalid input: expected object, received null') + ); + + await notesCommand('not-a-real-problem', 'view'); + + expect(readFile).not.toHaveBeenCalled(); + expect(outputContains('Check the problem name')).toBe(true); + }); + + it('should surface an unexpected lookup failure', async () => { + vi.mocked(leetcodeClient.getProblem).mockRejectedValueOnce(new Error('connect ETIMEDOUT')); + + await notesCommand('two-sum', 'view'); - expect(outputContains('Invalid problem ID')).toBe(true); + expect(readFile).not.toHaveBeenCalled(); + expect(outputContains('connect ETIMEDOUT')).toBe(true); }); }); From e36cf03bbfa9a9113fbae530a52daa0525faa9e1 Mon Sep 17 00:00:00 2001 From: acid-boi Date: Thu, 10 Sep 2026 16:10:03 +0530 Subject: [PATCH 3/3] docs(notes): document taking a problem name in the note command Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T4JwzivBEbeq9u3zAXKUu2 Signed-off-by: acid-boi --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2c365af..3e5dc0d 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ The CLI keeps command semantics the same and applies site-specific GraphQL queri | `pick-batch ` | Pick multiple problems | | `reset ` | Reset solution file to original stub | | `bookmark ` | Manage problem bookmarks | -| `note ` | Manage problem notes | +| `note ` | Manage problem notes | | `daily` | Show today's challenge | | `random` | Get a random problem | | `test ` | Test solution against sample cases | @@ -125,7 +125,7 @@ The CLI keeps command semantics the same and applies site-specific GraphQL queri | `snapshot ` | Save and restore solution versions | | `diff ` | Compare solution with past submissions | | `collab ` | Collaborative coding with a partner | -| `contest [slug]` | Browse and pick from contest problems | +| `contest [slug]` | Browse and pick from contest problems | | `workspace ` | Manage workspaces for different contexts | | `config` | View or set configuration | | `sync` | Sync solutions to Git repository | @@ -262,8 +262,9 @@ leetcode pick-batch 1 2 3 -l python3 leetcode bookmark add 1 leetcode bookmark list -# Keep personal notes +# Keep personal notes (by problem ID or name) leetcode note 1 edit +leetcode note two-sum edit ``` ### User Statistics