Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ The CLI keeps command semantics the same and applies site-specific GraphQL queri
| `pick-batch <ids>` | Pick multiple problems |
| `reset <id>` | Reset solution file to original stub |
| `bookmark <action>` | Manage problem bookmarks |
| `note <id>` | Manage problem notes |
| `note <id\|name>` | Manage problem notes |
| `daily` | Show today's challenge |
| `random` | Get a random problem |
| `test <id\|file>` | Test solution against sample cases |
Expand All @@ -125,7 +125,7 @@ The CLI keeps command semantics the same and applies site-specific GraphQL queri
| `snapshot <cmd>` | Save and restore solution versions |
| `diff <id>` | Compare solution with past submissions |
| `collab <cmd>` | Collaborative coding with a partner |
| `contest [slug]` | Browse and pick from contest problems |
| `contest [slug]` | Browse and pick from contest problems |
| `workspace <cmd>` | Manage workspaces for different contexts |
| `config` | View or set configuration |
| `sync` | Sync solutions to Git repository |
Expand Down Expand Up @@ -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
Expand Down
86 changes: 67 additions & 19 deletions src/__tests__/commands/notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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);
});
});

Expand Down
86 changes: 72 additions & 14 deletions src/commands/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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');
Expand All @@ -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<ResolvedProblem | null> {
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;
}
}

Expand Down Expand Up @@ -65,10 +119,11 @@ async function viewNote(notePath: string, problemId: string): Promise<void> {
async function editNote(
notePath: string,
problemId: string,
problem: ProblemDetail | null,
options: { silent?: boolean } = {}
): Promise<void> {
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}`));
}
Expand All @@ -77,13 +132,16 @@ async function editNote(
await openInEditor(notePath);
}

async function generateNoteTemplate(problemId: string): Promise<string> {
async function generateNoteTemplate(
problemId: string,
fetchedProblem: ProblemDetail | null = null
): Promise<string> {
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`;
Expand Down
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ ${chalk.yellow('Examples:')}
.action(bookmarkCommand);

program
.command('note <id> [action]')
.command('note <id|name> [action]')
.description('View or edit notes for a problem')
.addHelpText(
'after',
Expand All @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}$/;

Expand Down
Loading