diff --git a/skills/todoist-cli/SKILL.md b/skills/todoist-cli/SKILL.md index 7e241119..e1798f70 100644 --- a/skills/todoist-cli/SKILL.md +++ b/skills/todoist-cli/SKILL.md @@ -259,6 +259,8 @@ td section delete id:123 --yes td section browse id:123 ``` +Saved filters can contain multiple comma-separated queries, each displayed as a separate filter section. `td filter view` preserves those sections and applies `--limit` to each one. Under `--json`, multi-section filters return `{ sections: [{ query, results, nextCursor }] }`; under `--ndjson`, each line is one section with the same fields. Because each section has its own pagination cursor, use `--all` instead of `--cursor` for multi-section filters. + Shared labels can appear in `td label list` and `td label view`, but standard update and delete actions only work for labels with IDs. Use `td label rename-shared` and `td label remove-shared` for shared labels. ### Comments, Attachments, Notifications, And Reminders diff --git a/src/commands/filter/filter.test.ts b/src/commands/filter/filter.test.ts index 83a3fd34..e67cc5e6 100644 --- a/src/commands/filter/filter.test.ts +++ b/src/commands/filter/filter.test.ts @@ -14,9 +14,10 @@ vi.mock('../../lib/api/filters.js', () => ({ import { addFilter, deleteFilter, fetchFilters, updateFilter } from '../../lib/api/filters.js' import { setupApiMock } from '../../test-support/api-mock.js' -import { makeFilter } from '../../test-support/fixtures.js' +import { fixtures, makeFilter } from '../../test-support/fixtures.js' import { type MockApi } from '../../test-support/mock-api.js' import { registerFilterCommand } from './index.js' +import { splitFilterQueries } from './view.js' const mockFetchFilters = vi.mocked(fetchFilters) const mockAddFilter = vi.mocked(addFilter) @@ -27,6 +28,24 @@ function createProgram() { return createTestProgram(registerFilterCommand) } +describe('splitFilterQueries', () => { + it('ignores empty filter sections', () => { + expect(splitFilterQueries('today,, , tomorrow')).toEqual(['today', 'tomorrow']) + }) + + it('only splits commas preceded by an even number of backslashes', () => { + expect(splitFilterQueries(String.raw`#Research\, Inc, today`)).toEqual([ + String.raw`#Research\, Inc`, + 'today', + ]) + expect(splitFilterQueries(String.raw`#Research\\, Inc, today`)).toEqual([ + String.raw`#Research\\`, + 'Inc', + 'today', + ]) + }) +}) + describe('filter list', () => { beforeEach(() => { vi.clearAllMocks() @@ -490,6 +509,227 @@ describe('filter show', () => { expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Work task 1')) }) + it('shows comma-separated filter sections', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + + mockFetchFilters.mockResolvedValue([ + makeFilter({ + id: 'filter-1', + name: 'Dashboard', + query: 'due today, due tomorrow, no date', + }), + ]) + + mockApi.getTasksByFilter + .mockResolvedValueOnce({ + results: [ + { + ...fixtures.tasks.basic, + id: 'task-today', + content: 'Due today', + }, + ], + nextCursor: null, + }) + .mockResolvedValueOnce({ + results: [ + { + ...fixtures.tasks.basic, + id: 'task-tomorrow', + content: 'Due tomorrow', + }, + ], + nextCursor: null, + }) + .mockResolvedValueOnce({ results: [], nextCursor: null }) + + mockApi.getProjects.mockResolvedValue({ + results: [{ id: 'proj-1', name: 'Work Project' }], + nextCursor: null, + }) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Dashboard']) + + expect(mockApi.getTasksByFilter).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ query: 'due today' }), + ) + expect(mockApi.getTasksByFilter).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ query: 'due tomorrow' }), + ) + expect(mockApi.getTasksByFilter).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ query: 'no date' }), + ) + + const output = consoleSpy.mock.calls.map(([line]) => String(line)).join('\n') + expect(output).toContain('--- due today ---') + expect(output).toContain('Due today') + expect(output).toContain('--- due tomorrow ---') + expect(output).toContain('Due tomorrow') + expect(output).toContain('--- no date ---') + expect(output).toContain('No tasks match this section.') + }) + + it('does not load projects when every filter section is empty', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + + mockFetchFilters.mockResolvedValue([ + makeFilter({ id: 'filter-1', name: 'Dashboard', query: 'today, tomorrow' }), + ]) + mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null }) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Dashboard']) + + expect(mockApi.getProjects).not.toHaveBeenCalled() + const output = consoleSpy.mock.calls.map(([line]) => String(line)).join('\n') + expect(output).toContain('--- today ---') + expect(output).toContain('--- tomorrow ---') + expect(output.match(/No tasks match this section\./g)).toHaveLength(2) + }) + + it('caps concurrent filter section requests while keeping them parallel', async () => { + const program = createProgram() + captureConsole() + const queries = Array.from({ length: 7 }, (_, index) => `query-${index + 1}`) + let activeRequests = 0 + let maxActiveRequests = 0 + + mockFetchFilters.mockResolvedValue([ + makeFilter({ id: 'filter-1', name: 'Dashboard', query: queries.join(',') }), + ]) + mockApi.getTasksByFilter.mockImplementation(async () => { + activeRequests++ + maxActiveRequests = Math.max(maxActiveRequests, activeRequests) + await Promise.resolve() + activeRequests-- + return { results: [], nextCursor: null } + }) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Dashboard', '--json']) + + expect(mockApi.getTasksByFilter).toHaveBeenCalledTimes(queries.length) + expect(maxActiveRequests).toBeGreaterThan(1) + expect(maxActiveRequests).toBeLessThan(queries.length) + }) + + it('groups comma-separated filter sections in JSON output', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + + mockFetchFilters.mockResolvedValue([ + makeFilter({ id: 'filter-1', name: 'Dashboard', query: 'today, p1' }), + ]) + + const matchingTask = { + ...fixtures.tasks.basic, + id: 'task-1', + content: 'Urgent today', + priority: 4, + } + mockApi.getTasksByFilter + .mockResolvedValueOnce({ results: [matchingTask], nextCursor: null }) + .mockResolvedValueOnce({ results: [matchingTask], nextCursor: 'next-p1' }) + + await program.parseAsync([ + 'node', + 'td', + 'filter', + 'show', + 'Dashboard', + '--json', + '--limit', + '1', + ]) + + const parsed = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(parsed).toEqual({ + sections: [ + { + query: 'today', + results: [expect.objectContaining({ id: 'task-1' })], + nextCursor: null, + }, + { + query: 'p1', + results: [expect.objectContaining({ id: 'task-1' })], + nextCursor: 'next-p1', + }, + ], + }) + }) + + it('outputs one NDJSON record per filter section', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + + mockFetchFilters.mockResolvedValue([ + makeFilter({ id: 'filter-1', name: 'Dashboard', query: 'today, tomorrow' }), + ]) + mockApi.getTasksByFilter + .mockResolvedValueOnce({ results: [], nextCursor: null }) + .mockResolvedValueOnce({ results: [], nextCursor: null }) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Dashboard', '--ndjson']) + + const records = consoleSpy.mock.calls[0][0] + .split('\n') + .map((line: string) => JSON.parse(line)) + expect(records).toEqual([ + { query: 'today', results: [], nextCursor: null }, + { query: 'tomorrow', results: [], nextCursor: null }, + ]) + }) + + it('does not split escaped commas in filter queries', async () => { + const program = createProgram() + captureConsole() + + mockFetchFilters.mockResolvedValue([ + makeFilter({ + id: 'filter-1', + name: 'Dashboard', + query: '#Research\\, Inc, today', + }), + ]) + mockApi.getTasksByFilter.mockResolvedValue({ results: [], nextCursor: null }) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Dashboard', '--json']) + + expect(mockApi.getTasksByFilter).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ query: '#Research\\, Inc' }), + ) + expect(mockApi.getTasksByFilter).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ query: 'today' }), + ) + }) + + it('rejects --cursor for filters with multiple sections', async () => { + const program = createProgram() + + mockFetchFilters.mockResolvedValue([ + makeFilter({ id: 'filter-1', name: 'Dashboard', query: 'today, tomorrow' }), + ]) + + await expect( + program.parseAsync([ + 'node', + 'td', + 'filter', + 'show', + 'Dashboard', + '--cursor', + 'next-page', + ]), + ).rejects.toHaveProperty('code', 'INVALID_OPTIONS') + expect(mockApi.getTasksByFilter).not.toHaveBeenCalled() + }) + it('shows "No tasks match this filter" when empty', async () => { const program = createProgram() const consoleSpy = captureConsole() diff --git a/src/commands/filter/index.ts b/src/commands/filter/index.ts index b738cc4f..fb1c408e 100644 --- a/src/commands/filter/index.ts +++ b/src/commands/filter/index.ts @@ -72,7 +72,7 @@ export function registerFilterCommand(program: Command): void { .command('view [ref]', { isDefault: true }) .alias('show') .description('Show tasks matching a filter') - .option('--limit ', 'Limit number of results (default: 300)') + .option('--limit ', 'Limit results per filter section (default: 300)') .option('--cursor ', CURSOR_DESCRIPTION) .option('--all', 'Fetch all results (no limit)') .option('--json', 'Output as JSON') diff --git a/src/commands/filter/view.ts b/src/commands/filter/view.ts index e3cd9d15..1af68b38 100644 --- a/src/commands/filter/view.ts +++ b/src/commands/filter/view.ts @@ -1,18 +1,103 @@ import chalk from 'chalk' -import { getApi, type Project } from '../../lib/api/core.js' +import { getApi, type Project, type Task } from '../../lib/api/core.js' import { CollaboratorCache, formatAssignee } from '../../lib/collaborators.js' import { CliError } from '../../lib/errors.js' import type { PaginatedViewOptions } from '../../lib/options.js' import { formatNextCursorFooter, + formatJson, + formatNdjson, formatPaginatedJson, formatPaginatedNdjson, formatTaskRow, + processJsonItem, } from '../../lib/output.js' import { LIMITS, paginate } from '../../lib/pagination.js' import { filterUrl } from '../../lib/urls.js' import { resolveFilterRef } from './helpers.js' +interface FilterSection { + query: string + results: Task[] + nextCursor: string | null +} + +const FILTER_SECTION_CONCURRENCY = 6 + +/** + * Split Todoist's multi-list filter syntax without reimplementing its query parser. + * Todoist defines each unescaped comma as a list separator and uses backslashes + * to escape special characters. Preserve those escapes for the API subqueries. + */ +export function splitFilterQueries(query: string): string[] { + const sections: string[] = [] + let current = '' + let escaped = false + + for (const character of query) { + if (character === ',' && !escaped) { + const section = current.trim() + if (section) sections.push(section) + current = '' + continue + } + + current += character + escaped = character === '\\' ? !escaped : false + } + + const section = current.trim() + if (section) sections.push(section) + + return sections.length > 0 ? sections : [query] +} + +function formatFilterSectionsJson( + sections: FilterSection[], + full = false, + showUrls = false, +): string { + return formatJson({ sections: processFilterSections(sections, full, showUrls) }) +} + +function formatFilterSectionsNdjson( + sections: FilterSection[], + full = false, + showUrls = false, +): string { + return formatNdjson(processFilterSections(sections, full, showUrls)) +} + +function processFilterSections(sections: FilterSection[], full: boolean, showUrls: boolean) { + return sections.map((section) => ({ + query: section.query, + results: section.results.map((task) => processJsonItem(task, 'task', full, showUrls)), + nextCursor: section.nextCursor, + })) +} + +async function mapFilterSections( + queries: string[], + loadSection: (query: string) => Promise, +): Promise { + const sections: FilterSection[] = [] + let nextIndex = 0 + + // Workers claim indexes before awaiting, which caps request bursts while keeping + // results in saved-filter order even when later sections finish first. + const workers = Array.from( + { length: Math.min(FILTER_SECTION_CONCURRENCY, queries.length) }, + async () => { + while (nextIndex < queries.length) { + const index = nextIndex++ + sections[index] = await loadSection(queries[index]) + } + }, + ) + await Promise.all(workers) + return sections +} + export async function showFilter(nameOrId: string, options: PaginatedViewOptions): Promise { const filter = await resolveFilterRef(nameOrId) const api = await getApi() @@ -23,21 +108,34 @@ export async function showFilter(nameOrId: string, options: PaginatedViewOptions ? parseInt(options.limit, 10) : LIMITS.tasks - let tasks: Awaited>['results'] - let nextCursor: string | null + const queries = splitFilterQueries(filter.query) + const hasMultipleSections = queries.length > 1 - try { - const result = await paginate( - (cursor, limit) => - api.getTasksByFilter({ - query: filter.query, - cursor: cursor ?? undefined, - limit, - }), - { limit: targetLimit, startCursor: options.cursor }, + if (hasMultipleSections && options.cursor) { + throw new CliError( + 'INVALID_OPTIONS', + 'Cannot use --cursor with a filter that has multiple sections.', + ['Each filter section has its own cursor; use --all or query one section directly'], ) - tasks = result.results - nextCursor = result.nextCursor + } + + let sections: FilterSection[] + + try { + // Section cursors are independent; pages within one section remain serial. + // Any section failure rejects the view so partial output never looks complete. + sections = await mapFilterSections(queries, async (query) => { + const result = await paginate( + (cursor, limit) => + api.getTasksByFilter({ + query, + cursor: cursor ?? undefined, + limit, + }), + { limit: targetLimit, startCursor: options.cursor }, + ) + return { query, ...result } + }) } catch (err) { const message = err instanceof Error ? err.message : String(err) if (message.includes('400')) { @@ -51,9 +149,14 @@ export async function showFilter(nameOrId: string, options: PaginatedViewOptions } if (options.json) { + if (hasMultipleSections) { + console.log(formatFilterSectionsJson(sections, options.full, options.showUrls)) + return + } + const [section] = sections console.log( formatPaginatedJson( - { results: tasks, nextCursor }, + { results: section.results, nextCursor: section.nextCursor }, 'task', options.full, options.showUrls, @@ -63,9 +166,14 @@ export async function showFilter(nameOrId: string, options: PaginatedViewOptions } if (options.ndjson) { + if (hasMultipleSections) { + console.log(formatFilterSectionsNdjson(sections, options.full, options.showUrls)) + return + } + const [section] = sections console.log( formatPaginatedNdjson( - { results: tasks, nextCursor }, + { results: section.results, nextCursor: section.nextCursor }, 'task', options.full, options.showUrls, @@ -74,42 +182,65 @@ export async function showFilter(nameOrId: string, options: PaginatedViewOptions return } + // Do not deduplicate: Todoist can intentionally show one task in multiple lists. + const tasks = sections.flatMap((section) => section.results) + console.log(chalk.bold(`${filter.name}`)) console.log(chalk.dim(`Query: ${filter.query}`)) console.log(chalk.dim(`URL: ${filterUrl(filter.id)}`)) console.log('') if (tasks.length === 0) { - console.log('No tasks match this filter.') - console.log(formatNextCursorFooter(nextCursor)) - return + if (!hasMultipleSections) { + console.log('No tasks match this filter.') + console.log(formatNextCursorFooter(sections[0].nextCursor)) + return + } } - const { results: projects } = await api.getProjects() const projectMap = new Map() - for (const p of projects) { - projectMap.set(p.id, p) + const collaboratorCache = new CollaboratorCache() + if (tasks.length > 0) { + const { results: projects } = await api.getProjects() + for (const project of projects) projectMap.set(project.id, project) + await collaboratorCache.preload(api, tasks, projectMap) } - const collaboratorCache = new CollaboratorCache() - await collaboratorCache.preload(api, tasks, projectMap) - - for (const task of tasks) { - const assignee = formatAssignee({ - userId: task.responsibleUid, - projectId: task.projectId, - projects: projectMap, - cache: collaboratorCache, - }) - console.log( - await formatTaskRow({ - task, - projectName: projectMap.get(task.projectId)?.name, - assignee: assignee ?? undefined, - showUrl: options.showUrls, - }), - ) - console.log('') + for (const [index, section] of sections.entries()) { + if (hasMultipleSections) { + console.log(chalk.bold(`--- ${section.query} ---`)) + } + + if (section.results.length === 0) { + console.log('No tasks match this section.') + } else { + for (const task of section.results) { + const assignee = formatAssignee({ + userId: task.responsibleUid, + projectId: task.projectId, + projects: projectMap, + cache: collaboratorCache, + }) + console.log( + await formatTaskRow({ + task, + projectName: projectMap.get(task.projectId)?.name, + assignee: assignee ?? undefined, + showUrl: options.showUrls, + }), + ) + console.log('') + } + } + + const footer = formatNextCursorFooter(section.nextCursor) + if (footer) console.log(footer) + if ( + hasMultipleSections && + index < sections.length - 1 && + (section.results.length === 0 || footer) + ) { + console.log('') + } } - console.log(formatNextCursorFooter(nextCursor)) } diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index bf9dcb69..00aa6fde 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -256,6 +256,8 @@ td section delete id:123 --yes td section browse id:123 \`\`\` +Saved filters can contain multiple comma-separated queries, each displayed as a separate filter section. \`td filter view\` preserves those sections and applies \`--limit\` to each one. Under \`--json\`, multi-section filters return \`{ sections: [{ query, results, nextCursor }] }\`; under \`--ndjson\`, each line is one section with the same fields. Because each section has its own pagination cursor, use \`--all\` instead of \`--cursor\` for multi-section filters. + Shared labels can appear in \`td label list\` and \`td label view\`, but standard update and delete actions only work for labels with IDs. Use \`td label rename-shared\` and \`td label remove-shared\` for shared labels. ### Comments, Attachments, Notifications, And Reminders