diff --git a/CODEBASE.md b/CODEBASE.md index 7f488dc..ac895e4 100644 --- a/CODEBASE.md +++ b/CODEBASE.md @@ -169,6 +169,10 @@ New subcommand? Copy a sibling in the target group, wire it in that group's `commentUrl`, `filterUrl` - **`task-list.ts`** — `fetchProjects`, `filterByWorkspaceOrPersonal`, `parsePriority`, `PRIORITY_CHOICES` (`"p1"`–`"p4"`; internally p1→4, p4→1) +- **`task-sort.ts`** — the CLI half of task ordering: `TASK_SORT_FIELDS` + (the `--sort` vocabulary), `taskSortFromViewOptions`, `buildProjectOrder`, + `queryUsesDates`, and a `sortTasks` wrapper. The comparators live in the + SDK; the API returns storage order, so every list view sorts locally. - **`pagination.ts`** — `paginate()`, `LIMITS` (tasks: 300, projects: 50, …) - **`completion.ts`** — `parseCompLine`, `getCompletions`, `withCaseInsensitiveChoices`, `withUnvalidatedChoices` (Commander tree-walker) diff --git a/package-lock.json b/package-lock.json index d0494ec..8656010 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@doist/cli-core": "1.2.0", - "@doist/todoist-sdk": "14.0.2", + "@doist/todoist-sdk": "14.1.0", "@napi-rs/keyring": "1.3.0", "@pnpm/tabtab": "0.5.4", "chalk": "6.0.0", @@ -204,9 +204,9 @@ } }, "node_modules/@doist/todoist-sdk": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/@doist/todoist-sdk/-/todoist-sdk-14.0.2.tgz", - "integrity": "sha512-ppV2TUT8QDsiwC896pUxqTwdflSU/C8R3Vutv1ef4RAmY3Ch+0RlueZKOf+TGkwIGi88iWw4cxB+Ti1G3pAG0w==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@doist/todoist-sdk/-/todoist-sdk-14.1.0.tgz", + "integrity": "sha512-4hNZe6Hx4/oLYQbFokWMMaQd3Q6pXnUpbVq64PmYby4l466wxLarvTqB9g7J+TNdt5f8qfYWHjXOhaBucHblgA==", "license": "MIT", "dependencies": { "camelcase": "6.3.0", @@ -229,6 +229,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -240,6 +241,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -250,6 +252,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1060,6 +1063,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1368,9 +1372,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1388,9 +1389,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1408,9 +1406,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1428,9 +1423,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1448,9 +1440,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1468,9 +1457,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1488,9 +1474,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1508,9 +1491,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1715,9 +1695,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1735,9 +1712,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1755,9 +1729,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1775,9 +1746,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1795,9 +1763,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1815,9 +1780,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1835,9 +1797,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1855,9 +1814,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2002,6 +1958,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2018,6 +1975,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2034,6 +1992,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2050,6 +2009,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2066,6 +2026,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2082,6 +2043,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2098,6 +2060,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2114,6 +2077,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2130,6 +2094,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2146,6 +2111,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2162,6 +2128,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2178,6 +2145,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2194,6 +2162,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2212,6 +2181,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2228,6 +2198,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2886,6 +2857,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2927,7 +2899,7 @@ "version": "25.9.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -4958,6 +4930,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5966,6 +5939,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5986,6 +5960,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6006,6 +5981,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6026,6 +6002,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6046,6 +6023,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6066,6 +6044,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6086,6 +6065,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6106,6 +6086,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6126,6 +6107,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6146,6 +6128,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6166,6 +6149,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -10631,6 +10615,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -10730,7 +10715,7 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { diff --git a/package.json b/package.json index f286974..e777bf4 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ ], "dependencies": { "@doist/cli-core": "1.2.0", - "@doist/todoist-sdk": "14.0.2", + "@doist/todoist-sdk": "14.1.0", "@napi-rs/keyring": "1.3.0", "@pnpm/tabtab": "0.5.4", "chalk": "6.0.0", diff --git a/skills/todoist-cli/SKILL.md b/skills/todoist-cli/SKILL.md index 3439943..10dcb69 100644 --- a/skills/todoist-cli/SKILL.md +++ b/skills/todoist-cli/SKILL.md @@ -235,6 +235,8 @@ td label remove-shared "oldname" --yes td filter list td filter view "Urgent work" +td filter view "Urgent work" --sort priority --sort-order desc # override the view's sorting +td filter view "Urgent work" --sort none # keep the raw API order td filter create --name "Urgent work" --query "p1 & #Work" td filter update "Urgent work" --query "p1 & #Work & today" td filter delete "Urgent work" --yes @@ -261,6 +263,8 @@ 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. +`td filter view` orders tasks the way the Todoist apps do: it applies the sorting saved on that filter's view, and falls back to Todoist's default hierarchy (priority, then date, then deadline, then project and task order; date first for filters that query dates). `--sort` overrides it with `default`, `priority`, `date`, `deadline`, `date-added`, `name`, `project`, `assignee`, `workspace`, or `none` for the raw API order, and `--sort-order asc|desc` sets the direction of whichever field is in play (`default` and `none` have no direction). Sorting is applied to the tasks that were fetched, so pair it with `--all` when a filter has more results than the limit. + 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 e67cc5e..d53ef69 100644 --- a/src/commands/filter/filter.test.ts +++ b/src/commands/filter/filter.test.ts @@ -3,6 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('../../lib/api/core.js', () => ({ getApi: vi.fn(), + getAccountTimezone: vi.fn(async () => 'US/Pacific'), +})) + +vi.mock('../../lib/api/workspaces.js', () => ({ + fetchWorkspaces: vi.fn(async () => []), })) vi.mock('../../lib/api/filters.js', () => ({ @@ -12,6 +17,8 @@ vi.mock('../../lib/api/filters.js', () => ({ deleteFilter: vi.fn(), })) +import type { ViewOptions as SavedViewOptions } from '@doist/todoist-sdk' +import { getAccountTimezone } from '../../lib/api/core.js' import { addFilter, deleteFilter, fetchFilters, updateFilter } from '../../lib/api/filters.js' import { setupApiMock } from '../../test-support/api-mock.js' import { fixtures, makeFilter } from '../../test-support/fixtures.js' @@ -20,6 +27,7 @@ import { registerFilterCommand } from './index.js' import { splitFilterQueries } from './view.js' const mockFetchFilters = vi.mocked(fetchFilters) +const mockAccountTimezone = vi.mocked(getAccountTimezone) const mockAddFilter = vi.mocked(addFilter) const mockUpdateFilter = vi.mocked(updateFilter) const mockDeleteFilter = vi.mocked(deleteFilter) @@ -643,6 +651,8 @@ describe('filter show', () => { '--json', '--limit', '1', + '--sort', + 'none', ]) const parsed = JSON.parse(consoleSpy.mock.calls[0][0]) @@ -855,6 +865,367 @@ describe('filter show', () => { }) }) +describe('filter show sorting', () => { + let mockApi: MockApi + + const tasks = [ + { ...fixtures.tasks.basic, id: 'task-p4', content: 'Low', priority: 1 }, + { ...fixtures.tasks.basic, id: 'task-p1', content: 'Urgent', priority: 4 }, + { ...fixtures.tasks.basic, id: 'task-p3', content: 'Medium', priority: 2 }, + ] + + function makeViewOptions(overrides: Partial): SavedViewOptions { + return { + viewType: 'FILTER', + objectId: 'filter-1', + sortedBy: null, + sortOrder: null, + groupedBy: null, + viewMode: 'LIST', + isDeleted: false, + ...overrides, + } as SavedViewOptions + } + + beforeEach(() => { + vi.clearAllMocks() + mockApi = setupApiMock() + mockFetchFilters.mockResolvedValue([ + makeFilter({ id: 'filter-1', name: 'Work', query: '##work & p4 & !subtask' }), + ]) + mockApi.getTasksByFilter.mockResolvedValue({ results: tasks, nextCursor: null }) + mockApi.getProjects.mockResolvedValue({ + results: [{ id: 'proj-1', name: 'Work Project' }], + nextCursor: null, + }) + }) + + function orderedIds(consoleSpy: ReturnType): string[] { + const output = consoleSpy.mock.calls.map(([line]) => String(line)).join('\n') + return tasks.map((task) => task.id).sort((a, b) => output.indexOf(a) - output.indexOf(b)) + } + + it('applies the Todoist default ordering when the view has no saved sort', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + mockApi.getViewOptions.mockResolvedValue([makeViewOptions({})]) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Work', '--show-urls']) + + expect(orderedIds(consoleSpy)).toEqual(['task-p1', 'task-p3', 'task-p4']) + }) + + it('applies the sorting saved on the view in Todoist', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + mockApi.getViewOptions.mockResolvedValue([ + makeViewOptions({ sortedBy: 'ALPHABETICALLY', sortOrder: 'ASC' }), + ]) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Work', '--show-urls']) + + // Low, Medium, Urgent + expect(orderedIds(consoleSpy)).toEqual(['task-p4', 'task-p3', 'task-p1']) + const output = consoleSpy.mock.calls.map(([line]) => String(line)).join('\n') + expect(output).toContain('Sort: Name (A-Z)') + }) + + it('reads the sorting saved on a workspace filter view', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + mockApi.getViewOptions.mockResolvedValue([ + makeViewOptions({ + viewType: 'WORKSPACE_FILTER', + sortedBy: 'PRIORITY', + sortOrder: 'ASC', + }), + ]) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Work', '--show-urls']) + + expect(orderedIds(consoleSpy)).toEqual(['task-p4', 'task-p3', 'task-p1']) + }) + + it('ignores view options saved for a different view', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + mockApi.getViewOptions.mockResolvedValue([ + makeViewOptions({ objectId: 'filter-2', sortedBy: 'ALPHABETICALLY' }), + makeViewOptions({ + viewType: 'PROJECT', + objectId: 'filter-1', + sortedBy: 'ALPHABETICALLY', + }), + ]) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Work', '--show-urls']) + + expect(orderedIds(consoleSpy)).toEqual(['task-p1', 'task-p3', 'task-p4']) + }) + + it('lets --sort override the saved sorting', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + mockApi.getViewOptions.mockResolvedValue([makeViewOptions({ sortedBy: 'PRIORITY' })]) + + await program.parseAsync([ + 'node', + 'td', + 'filter', + 'show', + 'Work', + '--sort', + 'name', + '--show-urls', + ]) + + expect(orderedIds(consoleSpy)).toEqual(['task-p4', 'task-p3', 'task-p1']) + expect(mockApi.getViewOptions).not.toHaveBeenCalled() + }) + + it('lets --sort-order flip the saved sorting', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + mockApi.getViewOptions.mockResolvedValue([makeViewOptions({ sortedBy: 'PRIORITY' })]) + + await program.parseAsync([ + 'node', + 'td', + 'filter', + 'show', + 'Work', + '--sort-order', + 'asc', + '--show-urls', + ]) + + expect(orderedIds(consoleSpy)).toEqual(['task-p4', 'task-p3', 'task-p1']) + }) + + it('keeps the API order with --sort none', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + + await program.parseAsync([ + 'node', + 'td', + 'filter', + 'show', + 'Work', + '--sort', + 'none', + '--show-urls', + ]) + + expect(orderedIds(consoleSpy)).toEqual(['task-p4', 'task-p1', 'task-p3']) + }) + + it('sorts JSON output the same way', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Work', '--json']) + + const parsed = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(parsed.results.map((task: { id: string }) => task.id)).toEqual([ + 'task-p1', + 'task-p3', + 'task-p4', + ]) + }) + + it('sorts each comma-separated section on its own', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + mockFetchFilters.mockResolvedValue([ + makeFilter({ id: 'filter-1', name: 'Dashboard', query: 'today, @work' }), + ]) + const dueSoon = { + ...fixtures.tasks.basic, + id: 'task-soon', + content: 'Soon', + priority: 1, + due: { date: '2026-02-01', string: 'Feb 1', isRecurring: false }, + } + const dueLater = { + ...fixtures.tasks.basic, + id: 'task-later', + content: 'Later', + priority: 4, + due: { date: '2026-02-09', string: 'Feb 9', isRecurring: false }, + } + mockApi.getTasksByFilter + .mockResolvedValueOnce({ results: [dueLater, dueSoon], nextCursor: null }) + .mockResolvedValueOnce({ results: [dueSoon, dueLater], nextCursor: null }) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Dashboard', '--json']) + + const parsed = JSON.parse(consoleSpy.mock.calls[0][0]) + // "today" is date-driven, so date wins; "@work" is not, so priority wins. + expect(parsed.sections[0].results.map((task: { id: string }) => task.id)).toEqual([ + 'task-soon', + 'task-later', + ]) + expect(parsed.sections[1].results.map((task: { id: string }) => task.id)).toEqual([ + 'task-later', + 'task-soon', + ]) + }) + + it('fetches projects in JSON mode so the order matches the pretty output', async () => { + const program = createProgram() + captureConsole() + + // Project order is the tie-break under every sort, so a name sort + // needs the project list as much as the default hierarchy does. + await program.parseAsync([ + 'node', + 'td', + 'filter', + 'show', + 'Work', + '--json', + '--sort', + 'name', + ]) + + expect(mockApi.getProjects).toHaveBeenCalled() + }) + + it('rejects --cursor while a sort is active', async () => { + const program = createProgram() + captureConsole() + + await expect( + program.parseAsync([ + 'node', + 'td', + 'filter', + 'show', + 'Work', + '--cursor', + 'abc', + '--sort', + 'name', + ]), + ).rejects.toHaveProperty('code', 'INVALID_OPTIONS') + }) + + it('allows --cursor once sorting is off', async () => { + const program = createProgram() + captureConsole() + + await program.parseAsync([ + 'node', + 'td', + 'filter', + 'show', + 'Work', + '--cursor', + 'abc', + '--sort', + 'none', + ]) + + expect(mockApi.getTasksByFilter).toHaveBeenCalledWith( + expect.objectContaining({ cursor: 'abc' }), + ) + }) + + it('sorts the complete result set before applying the output limit', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + mockApi.getTasksByFilter + .mockResolvedValueOnce({ + results: [ + { + ...fixtures.tasks.basic, + id: 'task-low', + content: 'Low priority', + priority: 1, + }, + ], + nextCursor: 'more', + }) + .mockResolvedValueOnce({ + results: [ + { + ...fixtures.tasks.basic, + id: 'task-urgent', + content: 'Urgent', + priority: 4, + }, + ], + nextCursor: null, + }) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Work', '--json', '--limit', '1']) + + const parsed = JSON.parse(consoleSpy.mock.calls[0][0]) + expect(parsed.results.map((task: { id: string }) => task.id)).toEqual(['task-urgent']) + expect(parsed.nextCursor).toBeNull() + expect(mockApi.getTasksByFilter).toHaveBeenCalledTimes(2) + }) + + it('sorts with the account timezone rather than the machine one', async () => { + const program = createProgram() + captureConsole() + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Work', '--json']) + + expect(mockAccountTimezone).toHaveBeenCalled() + }) + + it('does not look up the timezone when nothing is sorted', async () => { + const program = createProgram() + captureConsole() + + await program.parseAsync([ + 'node', + 'td', + 'filter', + 'show', + 'Work', + '--json', + '--sort', + 'none', + ]) + + expect(mockAccountTimezone).not.toHaveBeenCalled() + }) + + it('skips the project fetch when nothing is sorted', async () => { + const program = createProgram() + captureConsole() + + await program.parseAsync([ + 'node', + 'td', + 'filter', + 'show', + 'Work', + '--json', + '--sort', + 'none', + ]) + + expect(mockApi.getProjects).not.toHaveBeenCalled() + }) + + it('still renders when the saved view options cannot be read', async () => { + const program = createProgram() + const consoleSpy = captureConsole() + mockApi.getViewOptions.mockRejectedValue(new Error('boom')) + + await program.parseAsync(['node', 'td', 'filter', 'show', 'Work', '--show-urls']) + + expect(orderedIds(consoleSpy)).toEqual(['task-p1', 'task-p3', 'task-p4']) + const output = consoleSpy.mock.calls.map(([line]) => String(line)).join('\n') + // The list still renders, but it doesn't claim to be the view's sorting. + expect(output).toContain('saved view options unavailable') + }) +}) + describe('filter view (alias)', () => { let mockApi: MockApi diff --git a/src/commands/filter/index.ts b/src/commands/filter/index.ts index fb1c408..be887fc 100644 --- a/src/commands/filter/index.ts +++ b/src/commands/filter/index.ts @@ -1,5 +1,7 @@ -import { Command } from 'commander' +import { Command, Option } from 'commander' +import { withCaseInsensitiveChoices } from '../../lib/completion.js' import { CURSOR_DESCRIPTION } from '../../lib/constants.js' +import { TASK_SORT_DIRECTIONS, TASK_SORT_FIELDS } from '../../lib/task-sort.js' import { browseFilter } from './browse.js' import { createFilter } from './create.js' import { deleteFilterCmd } from './delete.js' @@ -72,6 +74,21 @@ export function registerFilterCommand(program: Command): void { .command('view [ref]', { isDefault: true }) .alias('show') .description('Show tasks matching a filter') + .addOption( + withCaseInsensitiveChoices( + new Option('--sort ', "Sort tasks (default: the view's sorting in Todoist)"), + [...TASK_SORT_FIELDS], + ), + ) + .addOption( + withCaseInsensitiveChoices( + new Option( + '--sort-order ', + 'Sort direction (the default and none sorts have no direction)', + ), + [...TASK_SORT_DIRECTIONS], + ), + ) .option('--limit ', 'Limit results per filter section (default: 300)') .option('--cursor ', CURSOR_DESCRIPTION) .option('--all', 'Fetch all results (no limit)') diff --git a/src/commands/filter/view.ts b/src/commands/filter/view.ts index 1af68b3..068c2d4 100644 --- a/src/commands/filter/view.ts +++ b/src/commands/filter/view.ts @@ -1,7 +1,15 @@ +import { + findViewOptions, + isWorkspaceProject, + type TodoistApi, + type ViewOptions as SavedViewOptions, +} from '@doist/todoist-sdk' import chalk from 'chalk' -import { getApi, type Project, type Task } from '../../lib/api/core.js' +import { getAccountTimezone, getApi, type Project, type Task } from '../../lib/api/core.js' +import { fetchWorkspaces } from '../../lib/api/workspaces.js' import { CollaboratorCache, formatAssignee } from '../../lib/collaborators.js' import { CliError } from '../../lib/errors.js' +import { getLogger } from '../../lib/logger.js' import type { PaginatedViewOptions } from '../../lib/options.js' import { formatNextCursorFooter, @@ -13,9 +21,30 @@ import { processJsonItem, } from '../../lib/output.js' import { LIMITS, paginate } from '../../lib/pagination.js' +import { fetchProjects } from '../../lib/task-list.js' +import { + buildProjectOrder, + defaultDirectionFor, + formatTaskSort, + parseTaskSortDirection, + parseTaskSortField, + queryUsesDates, + sortNeedsCollaborators, + sortNeedsProjects, + sortTasks, + type TaskSort, + type TaskSortDirection, + type TaskSortField, + taskSortFromViewOptions, +} from '../../lib/task-sort.js' import { filterUrl } from '../../lib/urls.js' import { resolveFilterRef } from './helpers.js' +export interface FilterViewOptions extends PaginatedViewOptions { + sort?: string + sortOrder?: string +} + interface FilterSection { query: string results: Task[] @@ -98,10 +127,92 @@ async function mapFilterSections( return sections } -export async function showFilter(nameOrId: string, options: PaginatedViewOptions): Promise { - const filter = await resolveFilterRef(nameOrId) +/** + * Flag beats saved view, saved view beats the Todoist default. `--sort-order` + * on its own re-points the direction of whichever field is already in play. + */ +function resolveSort( + filterId: string, + requested: { field?: TaskSortField; direction?: TaskSortDirection }, + viewOptions: SavedViewOptions[], +): TaskSort { + if (requested.field) { + return { + field: requested.field, + direction: requested.direction ?? defaultDirectionFor(requested.field), + } + } + + const saved = taskSortFromViewOptions( + findViewOptions(viewOptions, { + viewTypes: ['FILTER', 'WORKSPACE_FILTER'], + objectId: filterId, + }), + ) + return requested.direction ? { field: saved.field, direction: requested.direction } : saved +} + +/** + * A view-options failure shouldn't cost you the list, the way a collaborator + * lookup failure doesn't cost you a task's name. It does change what the + * ordering means though, so the caller says so in the header rather than + * passing off the default hierarchy as the view's own sorting. + */ +async function loadViewOptions( + api: TodoistApi, +): Promise<{ viewOptions: SavedViewOptions[]; unavailable: boolean }> { + try { + return { viewOptions: await api.getViewOptions(), unavailable: false } + } catch (error) { + getLogger().detail('failed to load saved view options', { + error: error instanceof Error ? error.message : String(error), + }) + return { viewOptions: [], unavailable: true } + } +} + +/** + * Workspace names order the workspace buckets the way the sidebar and + * `td project list` do. Only worth a request when more than one workspace is + * represented, since a single bucket has nothing to sort against. + */ +async function loadWorkspaceNames( + projects: Map, +): Promise | undefined> { + const workspaceIds = new Set() + for (const project of projects.values()) { + if (isWorkspaceProject(project)) workspaceIds.add(project.workspaceId) + } + if (workspaceIds.size < 2) return undefined + + try { + const workspaces = await fetchWorkspaces() + return new Map(workspaces.map((workspace) => [workspace.id, workspace.name])) + } catch (error) { + getLogger().detail('failed to load workspaces for sort order', { + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } +} + +export async function showFilter(nameOrId: string, options: FilterViewOptions): Promise { + const requested = { + field: options.sort ? parseTaskSortField(options.sort) : undefined, + direction: options.sortOrder ? parseTaskSortDirection(options.sortOrder) : undefined, + } const api = await getApi() + // An explicit --sort makes the saved view options moot, so only pay for + // them when they can still change the order. + const [filter, saved] = await Promise.all([ + resolveFilterRef(nameOrId), + requested.field + ? Promise.resolve({ viewOptions: [] as SavedViewOptions[], unavailable: false }) + : loadViewOptions(api), + ]) + const sort = resolveSort(filter.id, requested, saved.viewOptions) + const targetLimit = options.all ? Number.MAX_SAFE_INTEGER : options.limit @@ -119,7 +230,22 @@ export async function showFilter(nameOrId: string, options: PaginatedViewOptions ) } + // Sorting happens here, over whatever came back. A cursor hands us a slice + // of the middle of the API's own order, so sorting it would produce a page + // that belongs to no ordering at all. + if (options.cursor && sort.field !== 'none') { + throw new CliError( + 'INVALID_OPTIONS', + 'Cannot use --cursor while the results are being sorted.', + [ + 'Use --all to sort the whole filter', + 'Or pass --sort none to page through the API order', + ], + ) + } + let sections: FilterSection[] + const fetchLimit = sort.field === 'none' ? targetLimit : Number.MAX_SAFE_INTEGER try { // Section cursors are independent; pages within one section remain serial. @@ -132,7 +258,7 @@ export async function showFilter(nameOrId: string, options: PaginatedViewOptions cursor: cursor ?? undefined, limit, }), - { limit: targetLimit, startCursor: options.cursor }, + { limit: fetchLimit, startCursor: options.cursor }, ) return { query, ...result } }) @@ -148,6 +274,50 @@ export async function showFilter(nameOrId: string, options: PaginatedViewOptions throw err } + // Do not deduplicate: Todoist can intentionally show one task in multiple lists. + const tasks = sections.flatMap((section) => section.results) + const isPretty = !options.json && !options.ndjson + + // Rendering needs the projects and collaborators for every task; sorting + // needs them, plus the account timezone, only for some fields. Fetch when + // either side asks, never when there is nothing to order or draw. + const needsProjects = tasks.length > 0 && (isPretty || sortNeedsProjects(sort.field)) + const sorting = tasks.length > 0 && sort.field !== 'none' + const [projectMap, timezone] = await Promise.all([ + needsProjects ? fetchProjects(api) : Promise.resolve(new Map()), + sorting ? getAccountTimezone() : Promise.resolve(undefined), + ]) + const collaboratorCache = new CollaboratorCache() + if (tasks.length > 0 && (isPretty || sortNeedsCollaborators(sort.field))) { + await collaboratorCache.preload(api, tasks, projectMap) + } + + // Each section is its own list in the Todoist apps, sorted on its own. + if (sorting) { + const projectOrder = buildProjectOrder(projectMap.values(), { + workspaceNames: await loadWorkspaceNames(projectMap), + }) + sections = sections.map((section) => ({ + ...section, + results: sortTasks(section.results, sort, { + ...projectOrder, + timezone, + dateDriven: queryUsesDates(section.query), + assigneeName: (task) => + task.responsibleUid + ? (collaboratorCache.getUserName({ + userId: task.responsibleUid, + projectId: task.projectId, + projects: projectMap, + }) ?? task.responsibleUid) + : null, + }).slice(0, targetLimit), + // An API cursor belongs to the API's order, not this local one. + // The complete result set is already loaded before sorting. + nextCursor: null, + })) + } + if (options.json) { if (hasMultipleSections) { console.log(formatFilterSectionsJson(sections, options.full, options.showUrls)) @@ -182,12 +352,11 @@ 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)}`)) + const sortNote = saved.unavailable ? ' (saved view options unavailable)' : '' + console.log(chalk.dim(`Sort: ${formatTaskSort(sort)}${sortNote}`)) console.log('') if (tasks.length === 0) { @@ -198,14 +367,6 @@ export async function showFilter(nameOrId: string, options: PaginatedViewOptions } } - const projectMap = new Map() - 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) - } - for (const [index, section] of sections.entries()) { if (hasMultipleSections) { console.log(chalk.bold(`--- ${section.query} ---`)) diff --git a/src/commands/view.test.ts b/src/commands/view.test.ts index f1912a1..03e8b3a 100644 --- a/src/commands/view.test.ts +++ b/src/commands/view.test.ts @@ -278,6 +278,8 @@ describe('view command', () => { '--limit', '1', '--ndjson', + '--sort', + 'none', ]) expect(mockApi.getTasksByFilter).toHaveBeenCalledWith( diff --git a/src/lib/api/core.ts b/src/lib/api/core.ts index 9c6aba5..a7097bd 100644 --- a/src/lib/api/core.ts +++ b/src/lib/api/core.ts @@ -350,6 +350,33 @@ export async function getCurrentUserId(): Promise { export function clearCurrentUserCache(): void { currentUserIdCache = null + accountTimezoneCache = null +} + +let accountTimezoneCache: string | null = null + +/** + * The IANA timezone on the Todoist account, which is what the apps resolve a + * floating due time in. It can differ from the machine clock inside a + * container, over SSH, or when someone sets their timezone in Todoist and + * travels, so ordering timed tasks by the local clock reverses pairs around + * midnight. Falls back to the machine on a failed lookup, and caches for the + * life of the process. + */ +export async function getAccountTimezone(): Promise { + if (accountTimezoneCache) return accountTimezoneCache + try { + const api = await getApi() + const user = await api.getUser() + accountTimezoneCache = user.tzInfo?.timezone || localTimezone() + } catch { + accountTimezoneCache = localTimezone() + } + return accountTimezoneCache ?? undefined +} + +function localTimezone(): string | null { + return Intl.DateTimeFormat().resolvedOptions().timeZone || null } export async function completeTaskForever(taskId: string): Promise { diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index 6cc35a5..e4626dd 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -232,6 +232,8 @@ td label remove-shared "oldname" --yes td filter list td filter view "Urgent work" +td filter view "Urgent work" --sort priority --sort-order desc # override the view's sorting +td filter view "Urgent work" --sort none # keep the raw API order td filter create --name "Urgent work" --query "p1 & #Work" td filter update "Urgent work" --query "p1 & #Work & today" td filter delete "Urgent work" --yes @@ -258,6 +260,8 @@ 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. +\`td filter view\` orders tasks the way the Todoist apps do: it applies the sorting saved on that filter's view, and falls back to Todoist's default hierarchy (priority, then date, then deadline, then project and task order; date first for filters that query dates). \`--sort\` overrides it with \`default\`, \`priority\`, \`date\`, \`deadline\`, \`date-added\`, \`name\`, \`project\`, \`assignee\`, \`workspace\`, or \`none\` for the raw API order, and \`--sort-order asc|desc\` sets the direction of whichever field is in play (\`default\` and \`none\` have no direction). Sorting is applied to the tasks that were fetched, so pair it with \`--all\` when a filter has more results than the limit. + 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/lib/task-list.ts b/src/lib/task-list.ts index 2ad93b7..e6f8a85 100644 --- a/src/lib/task-list.ts +++ b/src/lib/task-list.ts @@ -13,8 +13,19 @@ import { import { LIMITS, paginate } from './pagination.js' import { resolveWorkspaceRef } from './refs.js' +/** + * Every project the account can see, keyed by id. + * + * Pages to the end rather than stopping at the first response: callers use + * this map to name a task's project and to place it in sidebar order, and a + * project missing from the map loses both. Accounts under one page still cost + * a single request. + */ export async function fetchProjects(api: TodoistApi): Promise> { - const { results: allProjects } = await api.getProjects() + const { results: allProjects } = await paginate( + (cursor, limit) => api.getProjects({ cursor: cursor ?? undefined, limit }), + { limit: Number.MAX_SAFE_INTEGER }, + ) return new Map(allProjects.map((p) => [p.id, p])) } diff --git a/src/lib/task-sort.test.ts b/src/lib/task-sort.test.ts new file mode 100644 index 0000000..c6f07ac --- /dev/null +++ b/src/lib/task-sort.test.ts @@ -0,0 +1,316 @@ +import type { ViewOptions as SavedViewOptions } from '@doist/todoist-sdk' +import { describe, expect, it } from 'vitest' +import type { Project, Task } from './api/core.js' +import { CliError } from './errors.js' +import { + buildProjectOrder, + defaultDirectionFor, + formatTaskSort, + parseTaskSortDirection, + parseTaskSortField, + queryUsesDates, + sortTasks, + taskSortFromViewOptions, +} from './task-sort.js' + +function makeTask(overrides: Partial & { id: string }): Task { + return { + content: overrides.id, + priority: 1, + projectId: 'proj-1', + childOrder: 0, + due: null, + deadline: null, + addedAt: new Date('2026-01-01T00:00:00Z'), + responsibleUid: null, + labels: [], + ...overrides, + } as Task +} + +function makeProject(overrides: Partial & { id: string }): Project { + return { + name: overrides.id, + childOrder: 0, + defaultOrder: 0, + parentId: null, + inboxProject: false, + ...overrides, + } as Project +} + +function ids(tasks: Task[]): string[] { + return tasks.map((task) => task.id) +} + +describe('sortTasks', () => { + // The comparators live in the SDK and are tested there. What matters here + // is the handoff: that each CLI concept reaches it as the right argument. + + it('asks for the priority-first hierarchy by default', () => { + const tasks = [ + makeTask({ id: 'p4' }), + makeTask({ id: 'p1', priority: 4 }), + makeTask({ id: 'p3', priority: 2 }), + ] + + expect(ids(sortTasks(tasks, { field: 'default', direction: 'asc' }))).toEqual([ + 'p1', + 'p3', + 'p4', + ]) + }) + + it('switches to the date-first hierarchy for a date-driven list', () => { + const tasks = [ + makeTask({ + id: 'p1-later', + priority: 4, + due: { date: '2026-02-02', string: '', isRecurring: false }, + }), + makeTask({ + id: 'p4-sooner', + due: { date: '2026-02-01', string: '', isRecurring: false }, + }), + ] + + expect( + ids(sortTasks(tasks, { field: 'default', direction: 'asc' }, { dateDriven: true })), + ).toEqual(['p4-sooner', 'p1-later']) + }) + + it('passes a named sort through with its direction', () => { + const tasks = [ + makeTask({ id: 'p3', priority: 2 }), + makeTask({ id: 'p1', priority: 4 }), + makeTask({ id: 'p4' }), + ] + + expect(ids(sortTasks(tasks, { field: 'priority', direction: 'desc' }))).toEqual([ + 'p1', + 'p3', + 'p4', + ]) + expect(ids(sortTasks(tasks, { field: 'priority', direction: 'asc' }))).toEqual([ + 'p4', + 'p3', + 'p1', + ]) + }) + + it('hands over the project and workspace maps it built', () => { + const order = buildProjectOrder([ + makeProject({ id: 'personal-1', childOrder: 0 }), + makeProject({ id: 'ws-a-1', workspaceId: 'ws-a', childOrder: 0 }), + makeProject({ id: 'ws-b-1', workspaceId: 'ws-b', childOrder: 0 }), + ]) + const tasks = [ + makeTask({ id: 'in-ws-b', projectId: 'ws-b-1' }), + makeTask({ id: 'in-personal', projectId: 'personal-1' }), + makeTask({ id: 'in-ws-a', projectId: 'ws-a-1' }), + ] + + // Project order is the default hierarchy's fourth criterion, and the + // workspace buckets are a sort field of their own. + expect(ids(sortTasks(tasks, { field: 'default', direction: 'asc' }, order))).toEqual([ + 'in-personal', + 'in-ws-a', + 'in-ws-b', + ]) + expect(ids(sortTasks(tasks, { field: 'workspace', direction: 'desc' }, order))).toEqual([ + 'in-ws-b', + 'in-ws-a', + 'in-personal', + ]) + }) + + it('resolves assignee names through the callback it is given', () => { + const tasks = [ + makeTask({ id: 'unassigned' }), + makeTask({ id: 'zoe', responsibleUid: 'user-z' }), + makeTask({ id: 'ana', responsibleUid: 'user-a' }), + ] + const names: Record = { 'user-a': 'Ana', 'user-z': 'Zoe' } + + expect( + ids( + sortTasks( + tasks, + { field: 'assignee', direction: 'asc' }, + { + assigneeName: (task) => + task.responsibleUid ? names[task.responsibleUid] : null, + }, + ), + ), + ).toEqual(['ana', 'zoe', 'unassigned']) + }) + + it('leaves the API order alone for "none", and never mutates the input', () => { + const tasks = [ + makeTask({ id: 'second', priority: 1 }), + makeTask({ id: 'first', priority: 4 }), + ] + const unsorted = sortTasks(tasks, { field: 'none', direction: 'asc' }) + + expect(ids(unsorted)).toEqual(['second', 'first']) + expect(unsorted).not.toBe(tasks) + + sortTasks(tasks, { field: 'default', direction: 'asc' }) + expect(ids(tasks)).toEqual(['second', 'first']) + }) +}) + +describe('buildProjectOrder', () => { + it('orders workspace projects by defaultOrder, not childOrder', () => { + // Workspace projects leave childOrder at 0 and carry their sidebar + // position in defaultOrder. + const order = buildProjectOrder([ + makeProject({ id: 'third', workspaceId: 'ws-1', childOrder: 0, defaultOrder: 2 }), + makeProject({ id: 'first', workspaceId: 'ws-1', childOrder: 0, defaultOrder: 0 }), + makeProject({ id: 'second', workspaceId: 'ws-1', childOrder: 0, defaultOrder: 1 }), + ]) + + const byPosition = [...order.projectOrder.entries()] + .sort((a, b) => a[1] - b[1]) + .map(([id]) => id) + expect(byPosition).toEqual(['first', 'second', 'third']) + }) + + it('orders the workspaces themselves by name when it has them', () => { + const projects = [ + makeProject({ id: 'in-zebra', workspaceId: 'ws-z' }), + makeProject({ id: 'in-acme', workspaceId: 'ws-a' }), + ] + const names = new Map([ + ['ws-z', 'Acme'], + ['ws-a', 'Zebra'], + ]) + + // Without names the buckets fall back to id order, so ws-a leads. + expect(buildProjectOrder(projects).workspaceOrder.get('in-acme')).toBe(1) + // With them, "Acme" leads even though its id sorts last. + expect( + buildProjectOrder(projects, { workspaceNames: names }).workspaceOrder.get('in-zebra'), + ).toBe(1) + }) + + it('lays projects out as Inbox, personal tree, then workspaces', () => { + const order = buildProjectOrder([ + makeProject({ id: 'ws-b', workspaceId: 'ws-2', childOrder: 0 }), + makeProject({ id: 'child', parentId: 'personal', childOrder: 0 }), + makeProject({ id: 'ws-a', workspaceId: 'ws-1', childOrder: 0 }), + makeProject({ id: 'personal', childOrder: 5 }), + makeProject({ id: 'inbox', inboxProject: true, childOrder: 9 }), + ]) + + const byPosition = [...order.projectOrder.entries()] + .sort((a, b) => a[1] - b[1]) + .map(([id]) => id) + + expect(byPosition).toEqual(['inbox', 'personal', 'child', 'ws-a', 'ws-b']) + expect(order.workspaceOrder.get('personal')).toBe(0) + expect(order.workspaceOrder.get('ws-a')).toBe(1) + expect(order.workspaceOrder.get('ws-b')).toBe(2) + }) +}) + +describe('taskSortFromViewOptions', () => { + function makeViewOptions(overrides: Partial): SavedViewOptions { + return { + viewType: 'FILTER', + objectId: 'filter-1', + sortedBy: null, + sortOrder: null, + groupedBy: null, + viewMode: 'LIST', + isDeleted: false, + ...overrides, + } as SavedViewOptions + } + + it('reads the saved sorting', () => { + expect( + taskSortFromViewOptions(makeViewOptions({ sortedBy: 'PRIORITY', sortOrder: 'DESC' })), + ).toEqual({ field: 'priority', direction: 'desc' }) + expect( + taskSortFromViewOptions(makeViewOptions({ sortedBy: 'DUE_DATE', sortOrder: 'ASC' })), + ).toEqual({ field: 'date', direction: 'asc' }) + }) + + it('treats a missing view, a null sort, and MANUAL as the Todoist default', () => { + expect(taskSortFromViewOptions(undefined).field).toBe('default') + expect(taskSortFromViewOptions(makeViewOptions({})).field).toBe('default') + expect(taskSortFromViewOptions(makeViewOptions({ sortedBy: 'MANUAL' })).field).toBe( + 'default', + ) + }) + + it('falls back to the per-field direction when the view has none', () => { + expect(taskSortFromViewOptions(makeViewOptions({ sortedBy: 'PRIORITY' })).direction).toBe( + 'desc', + ) + expect(taskSortFromViewOptions(makeViewOptions({ sortedBy: 'DUE_DATE' })).direction).toBe( + 'asc', + ) + }) +}) + +describe('queryUsesDates', () => { + it.each([ + 'today', + 'due before: next week', + 'overdue | today', + '@work & 7 days', + 'no date', + 'deadline: today', + ])('treats %s as date-driven', (query) => { + expect(queryUsesDates(query)).toBe(true) + }) + + it.each(['##work & p4 & !subtask', '@waiting', '#Marketing & p1', 'search: invoice'])( + 'treats %s as priority-driven', + (query) => { + expect(queryUsesDates(query)).toBe(false) + }, + ) + + it('ignores date words inside project and label names', () => { + expect(queryUsesDates('#May Launch')).toBe(false) + expect(queryUsesDates('@monday-meeting & p1')).toBe(false) + // A name runs to the operator, so "date" here belongs to the project. + expect(queryUsesDates('#due date')).toBe(false) + expect(queryUsesDates('#due date & p1')).toBe(false) + expect(queryUsesDates('#due date & today')).toBe(true) + }) + + it('ignores date words inside a search term', () => { + expect(queryUsesDates('search: due diligence')).toBe(false) + expect(queryUsesDates('search: today notes & p1')).toBe(false) + // The search operand ends at the operator, so a real date query still counts. + expect(queryUsesDates('search: invoice & today')).toBe(true) + }) +}) + +describe('sort option parsing', () => { + it('accepts known fields and directions case-insensitively', () => { + expect(parseTaskSortField('Priority')).toBe('priority') + expect(parseTaskSortField(' date-added ')).toBe('date-added') + expect(parseTaskSortDirection('DESC')).toBe('desc') + }) + + it('rejects unknown values with a CliError', () => { + expect(() => parseTaskSortField('due')).toThrow(CliError) + expect(() => parseTaskSortDirection('descending')).toThrow(CliError) + }) + + it('defaults priority to descending and everything else to ascending', () => { + expect(defaultDirectionFor('priority')).toBe('desc') + expect(defaultDirectionFor('date')).toBe('asc') + }) + + it('describes the applied sort', () => { + expect(formatTaskSort({ field: 'default', direction: 'asc' })).toBe('Todoist default') + expect(formatTaskSort({ field: 'priority', direction: 'desc' })).toBe('Priority (p1 first)') + }) +}) diff --git a/src/lib/task-sort.ts b/src/lib/task-sort.ts new file mode 100644 index 0000000..b262ae5 --- /dev/null +++ b/src/lib/task-sort.ts @@ -0,0 +1,348 @@ +import { isWorkspaceProject, sortTasks as sortTasksBySdk } from '@doist/todoist-sdk' +import type { SortedBy, SortOrder, TaskSortContext, ViewOptions } from '@doist/todoist-sdk' +import type { Project, Task } from './api/core.js' +import { CliError } from './errors.js' + +/** + * The CLI side of task ordering. + * + * The comparators themselves live in the SDK (`sortTasks`), which is where + * every Todoist client can share them. What stays here is the vocabulary the + * `--sort` flag speaks, the mapping from a saved view to that vocabulary, the + * sidebar layout the SDK wants as a lookup, and the guess at whether a filter + * query is date-driven, which the SDK asks the caller to decide. + * + * @see https://www.todoist.com/help/articles/default-sorting-order-for-todoist-tasks-mqmgerY7 + */ + +export const TASK_SORT_FIELDS = [ + 'default', + 'priority', + 'date', + 'deadline', + 'date-added', + 'name', + 'project', + 'assignee', + 'workspace', + 'none', +] as const + +export type TaskSortField = (typeof TASK_SORT_FIELDS)[number] + +export const TASK_SORT_DIRECTIONS = ['asc', 'desc'] as const + +export type TaskSortDirection = (typeof TASK_SORT_DIRECTIONS)[number] + +export interface TaskSort { + field: TaskSortField + direction: TaskSortDirection +} + +export const DEFAULT_TASK_SORT: TaskSort = { field: 'default', direction: 'asc' } + +/** Saved `sorted_by` values → the vocabulary `--sort` speaks. */ +const FIELD_BY_SORTED_BY: Record = { + MANUAL: 'default', + ALPHABETICALLY: 'name', + ASSIGNEE: 'assignee', + DUE_DATE: 'date', + DEADLINE: 'deadline', + ADDED_DATE: 'date-added', + PRIORITY: 'priority', + PROJECT: 'project', + WORKSPACE: 'workspace', +} + +/** And back again for the SDK call. `default` maps to MANUAL; only `none` is absent. */ +const SORTED_BY_FIELD: Partial> = Object.fromEntries( + Object.entries(FIELD_BY_SORTED_BY).map(([sortedBy, field]) => [field, sortedBy]), +) + +const FIELD_LABELS: Record = { + default: { asc: 'Todoist default', desc: 'Todoist default' }, + priority: { asc: 'Priority (p4 first)', desc: 'Priority (p1 first)' }, + date: { asc: 'Due date (earliest first)', desc: 'Due date (latest first)' }, + deadline: { asc: 'Deadline (earliest first)', desc: 'Deadline (latest first)' }, + 'date-added': { asc: 'Date added (oldest first)', desc: 'Date added (newest first)' }, + name: { asc: 'Name (A-Z)', desc: 'Name (Z-A)' }, + project: { asc: 'Project order', desc: 'Project order (reversed)' }, + assignee: { asc: 'Assignee (A-Z)', desc: 'Assignee (Z-A)' }, + workspace: { asc: 'Workspace order', desc: 'Workspace order (reversed)' }, + none: { asc: 'None (API order)', desc: 'None (API order)' }, +} + +/** + * Todoist sorts ascending everywhere except priority, which reads p1 to p4 and + * is stored as descending. + */ +export function defaultDirectionFor(field: TaskSortField): TaskSortDirection { + return field === 'priority' ? 'desc' : 'asc' +} + +/** + * The `--sort` flag is registered with Commander choices, so a bad value never + * reaches here from the command line. This guards the exported helper for any + * other caller, and keeps the error a `CliError` rather than a cast. + */ +export function parseTaskSortField(value: string): TaskSortField { + const normalized = value.trim().toLowerCase() + const match = TASK_SORT_FIELDS.find((field) => field === normalized) + if (match) return match + throw new CliError('INVALID_SORT', `Invalid sort field "${value}".`, [ + `Valid fields: ${TASK_SORT_FIELDS.join(', ')}`, + ]) +} + +export function parseTaskSortDirection(value: string): TaskSortDirection { + const normalized = value.trim().toLowerCase() + const match = TASK_SORT_DIRECTIONS.find((direction) => direction === normalized) + if (match) return match + throw new CliError('INVALID_SORT_ORDER', `Invalid sort order "${value}".`, [ + `Valid orders: ${TASK_SORT_DIRECTIONS.join(', ')}`, + ]) +} + +/** The sorting a saved view applies, or the Todoist default when it has none. */ +export function taskSortFromViewOptions(viewOptions?: ViewOptions): TaskSort { + const sortedBy = viewOptions?.sortedBy + const field = sortedBy ? (FIELD_BY_SORTED_BY[sortedBy] ?? 'default') : 'default' + if (field === 'default') return DEFAULT_TASK_SORT + + return { field, direction: directionFromSortOrder(viewOptions?.sortOrder, field) } +} + +function directionFromSortOrder( + sortOrder: SortOrder | null | undefined, + field: TaskSortField, +): TaskSortDirection { + if (sortOrder === 'ASC') return 'asc' + if (sortOrder === 'DESC') return 'desc' + return defaultDirectionFor(field) +} + +export function formatTaskSort(sort: TaskSort): string { + return FIELD_LABELS[sort.field][sort.direction] +} + +/** + * Every sort but `none` needs the project list. Project order is the fourth + * criterion of the default hierarchy, and the default hierarchy is the + * tie-break under every named sort, so skipping the fetch for, say, a name + * sort would order equal names differently from the same sort in another + * output mode. Assignee sorting needs it too, to resolve collaborators. + */ +export function sortNeedsProjects(field: TaskSortField): boolean { + return field !== 'none' +} + +/** Only assignee sorting needs collaborator names resolved. */ +export function sortNeedsCollaborators(field: TaskSortField): boolean { + return field === 'assignee' +} + +export interface ProjectOrder { + /** Project id → position in the sidebar. */ + projectOrder: Map + /** Project id → workspace bucket (0 is personal). */ + workspaceOrder: Map +} + +export interface TaskOrderContext extends Partial { + /** Assignee display name, used by assignee sorting. Unassigned sorts last. */ + assigneeName?: (task: Task) => string | null + /** + * IANA timezone the SDK resolves floating due times in. Pass the Todoist + * account's, not the machine's: the two differ often enough to reverse + * pairs of timed tasks around midnight. + */ + timezone?: string + /** + * True when the list is driven by dates: Today, Upcoming, and filters + * whose query mentions dates lead with date instead of priority. + */ + dateDriven?: boolean +} + +/** + * Lay projects out in sidebar order: Inbox, the personal tree, then each + * workspace. + * + * Personal projects nest, so they walk their tree by `childOrder`. Workspace + * projects don't: they carry `defaultOrder` for their position and leave + * `childOrder` at 0, so ordering them by `childOrder` produces no order at + * all. Pass `workspaceNames` to sort the workspaces themselves the way the + * sidebar and `td project list` do, by name; without it they fall back to id, + * which is stable but arbitrary. + */ +export function buildProjectOrder( + projects: Iterable, + { workspaceNames }: { workspaceNames?: Map } = {}, +): ProjectOrder { + const personal: Project[] = [] + const byWorkspace = new Map() + + for (const project of projects) { + if (isWorkspaceProject(project)) { + const bucket = byWorkspace.get(project.workspaceId) ?? [] + bucket.push(project) + byWorkspace.set(project.workspaceId, bucket) + } else { + personal.push(project) + } + } + + const orderedWorkspaceIds = [...byWorkspace.keys()].sort((a, b) => { + const nameA = workspaceNames?.get(a) + const nameB = workspaceNames?.get(b) + if (nameA && nameB) return compareText(nameA, nameB) + return compareText(a, b) + }) + + const buckets: Project[][] = [ + orderPersonalProjects(personal), + ...orderedWorkspaceIds.map((id) => orderWorkspaceProjects(byWorkspace.get(id) ?? [])), + ] + + const projectOrder = new Map() + const workspaceOrder = new Map() + let position = 0 + + for (const [bucket, projectsInBucket] of buckets.entries()) { + for (const project of projectsInBucket) { + projectOrder.set(project.id, position++) + workspaceOrder.set(project.id, bucket) + } + } + + return { projectOrder, workspaceOrder } +} + +function orderPersonalProjects(projects: Project[]): Project[] { + const inbox = projects.filter(isInboxProject) + const rest = orderProjectTree(projects.filter((project) => !isInboxProject(project))) + return [...inbox, ...rest] +} + +function isInboxProject(project: Project): boolean { + return 'inboxProject' in project && project.inboxProject === true +} + +/** + * `defaultOrder` is the workspace's own sequence for its projects, folders + * included, which is what the sidebar draws. + */ +function orderWorkspaceProjects(projects: Project[]): Project[] { + return [...projects].sort( + (a, b) => compare(a.defaultOrder, b.defaultOrder) || compareText(a.name, b.name), + ) +} + +/** Depth-first walk of the personal project tree, siblings in `childOrder` order. */ +function orderProjectTree(projects: Project[]): Project[] { + const ids = new Set(projects.map((project) => project.id)) + const byParent = new Map() + + for (const project of projects) { + const parent = isWorkspaceProject(project) ? null : project.parentId + const parentId = parent && ids.has(parent) ? parent : null + const siblings = byParent.get(parentId) ?? [] + siblings.push(project) + byParent.set(parentId, siblings) + } + + for (const siblings of byParent.values()) { + siblings.sort((a, b) => compare(a.childOrder, b.childOrder) || compareText(a.name, b.name)) + } + + const ordered: Project[] = [] + const visited = new Set() + + function visit(parentId: string | null): void { + for (const project of byParent.get(parentId) ?? []) { + if (visited.has(project.id)) continue + visited.add(project.id) + ordered.push(project) + visit(project.id) + } + } + + visit(null) + return ordered +} + +function compare(a: number, b: number): number { + if (a === b) return 0 + return a < b ? -1 : 1 +} + +function compareText(a: string, b: string): number { + return a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }) +} + +/** + * Sort a task list the way a Todoist client would, by handing the SDK the + * pieces only the CLI can know: which hierarchy this list falls back to, where + * each project sits in the sidebar, how to name an assignee, and which + * timezone a floating due time belongs to. + */ +export function sortTasks(tasks: Task[], sort: TaskSort, context: TaskOrderContext = {}): Task[] { + if (sort.field === 'none') return [...tasks] + + return sortTasksBySdk( + tasks, + { + sortedBy: SORTED_BY_FIELD[sort.field] ?? null, + sortOrder: sort.direction === 'desc' ? 'DESC' : 'ASC', + defaultOrder: context.dateDriven ? 'DATE_FIRST' : 'PRIORITY_FIRST', + }, + { + projectOrder: context.projectOrder, + workspaceOrder: context.workspaceOrder, + assigneeName: context.assigneeName, + timezone: context.timezone, + } satisfies TaskSortContext, + ) +} + +/** + * Tokens that make a filter query date-driven, which switches the default + * ordering from priority-first to date-first. + * + * Known limitation: these are the English keywords. The backend parses a saved + * query in the account's language when the request carries no `lang`, so a + * filter written as "hoy" or "heute" reads as priority-first here. Classifying + * properly means the query parser, which is Filterist's job rather than a + * regex's, so the fix belongs in the SDK. Until then the cost is one of two + * default hierarchies, not a wrong result set. + */ +const DATE_QUERY_PATTERN = new RegExp( + [ + String.raw`\b(?:today|tomorrow|yesterday|overdue|due|dated?|datetime|deadlines?|recurring)\b`, + String.raw`\b(?:mon|tue|wed|thu|fri|sat|sun)\b`, + String.raw`\b(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b`, + String.raw`\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b`, + String.raw`\b(?:before|after)\s*:`, + String.raw`\b\d+\s*(?:days?|hours?|weeks?|months?)\b`, + String.raw`\b(?:next|last)\s+(?:week|month|year|\d+)`, + ].join('|'), + 'i', +) + +/** + * Names and free-text searches can contain date words ("#May launch", + * "#due date", "search: due diligence"), so those operands are dropped before + * the query is inspected. Todoist ends a name at an operator rather than at a + * space, so these run to the next one and take the whole name with them. + */ +function stripNamedRefs(query: string): string { + return query + .replace(/"[^"]*"/g, ' ') + .replace(/'[^']*'/g, ' ') + .replace(/\bsearch\s*:[^&|(),]*/gi, ' ') + .replace(/[#@/]{1,2}[^&|(),]*/g, ' ') +} + +export function queryUsesDates(query: string): boolean { + return DATE_QUERY_PATTERN.test(stripNamedRefs(query)) +} diff --git a/src/test-support/mock-api.ts b/src/test-support/mock-api.ts index 6be92f7..6e32633 100644 --- a/src/test-support/mock-api.ts +++ b/src/test-support/mock-api.ts @@ -28,6 +28,10 @@ export function createMockApi(overrides: Partial = {}): MockApi { .fn() .mockResolvedValue({ items: [], nextCursor: null }), searchCompletedTasks: vi.fn().mockResolvedValue({ items: [], nextCursor: null }), + // View options + getViewOptions: vi.fn().mockResolvedValue([]), + setViewOptions: vi.fn(), + deleteViewOptions: vi.fn(), // Projects getProjects: vi.fn().mockResolvedValue({ results: [], nextCursor: null }), getProject: vi.fn(),