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 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); }); }); 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}$/;