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
2 changes: 2 additions & 0 deletions skills/todoist-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
242 changes: 241 additions & 1 deletion src/commands/filter/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src/commands/filter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>', 'Limit number of results (default: 300)')
.option('--limit <n>', 'Limit results per filter section (default: 300)')
.option('--cursor <cursor>', CURSOR_DESCRIPTION)
.option('--all', 'Fetch all results (no limit)')
.option('--json', 'Output as JSON')
Expand Down
Loading
Loading