diff --git a/src/test-utils/test-defaults.ts b/src/test-utils/test-defaults.ts index 9536491c..a185d22e 100644 --- a/src/test-utils/test-defaults.ts +++ b/src/test-utils/test-defaults.ts @@ -95,6 +95,7 @@ export const DEFAULT_TASK: Task = { noteCount: DEFAULT_NOTE_COUNT, dayOrder: DEFAULT_ORDER, isCollapsed: DEFAULT_IS_COLLAPSED, + isUncompletable: false, url: DEFAULT_TASK_URL, } @@ -127,6 +128,7 @@ export const TASK_WITH_OPTIONALS_AS_NULL: Task = { description: DEFAULT_TASK_DESCRIPTION, dayOrder: DEFAULT_ORDER, isCollapsed: DEFAULT_IS_COLLAPSED, + isUncompletable: false, noteCount: DEFAULT_NOTE_COUNT, url: DEFAULT_TASK_URL, } diff --git a/src/todoist-api.tasks.test.ts b/src/todoist-api.tasks.test.ts index 52ee29b2..e496cc2e 100644 --- a/src/todoist-api.tasks.test.ts +++ b/src/todoist-api.tasks.test.ts @@ -55,6 +55,81 @@ describe('TodoistApi task endpoints', () => { expect(task).toEqual(DEFAULT_TASK) }) + + test('adds uncompletable prefix when isUncompletable is true', async () => { + const expectedTask = { + ...DEFAULT_TASK, + content: '* This is an uncompletable task', + isUncompletable: true, + } + + server.use( + http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}`, async ({ request }) => { + const body = (await request.json()) as any + expect(body.content).toBe('* This is an uncompletable task') + return HttpResponse.json(expectedTask, { status: 200 }) + }), + ) + const api = getTarget() + + const task = await api.addTask({ + content: 'This is an uncompletable task', + isUncompletable: true, + }) + + expect(task.content).toBe('* This is an uncompletable task') + expect(task.isUncompletable).toBe(true) + }) + + test('preserves existing prefix when isUncompletable is false', async () => { + const expectedTask = { + ...DEFAULT_TASK, + content: '* Already has prefix', + isUncompletable: true, + } + + server.use( + http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}`, async ({ request }) => { + const body = (await request.json()) as any + expect(body.content).toBe('* Already has prefix') + return HttpResponse.json(expectedTask, { status: 200 }) + }), + ) + const api = getTarget() + + const task = await api.addTask({ + content: '* Already has prefix', + isUncompletable: false, + }) + + expect(task.content).toBe('* Already has prefix') + expect(task.isUncompletable).toBe(true) + }) + + test('does not add prefix when isUncompletable is false', async () => { + const expectedTask = { + ...DEFAULT_TASK, + content: 'Regular completable task', + isUncompletable: false, + } + + server.use( + http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}`, async ({ request }) => { + const body = (await request.json()) as any + expect(body.content).toBe('Regular completable task') + return HttpResponse.json(expectedTask, { status: 200 }) + }), + ) + const api = getTarget() + + const task = await api.addTask({ + content: 'Regular completable task', + isUncompletable: false, + }) + + expect(task.content).toBe('Regular completable task') + expect(task.isUncompletable).toBe(false) + }) }) describe('updateTask', () => { @@ -81,6 +156,55 @@ describe('TodoistApi task endpoints', () => { expect(response).toEqual(returnedTask) }) + + test('processes content with isUncompletable when both are provided', async () => { + const returnedTask = { + ...DEFAULT_TASK, + content: '* Updated uncompletable task', + isUncompletable: true, + url: getTaskUrl(DEFAULT_TASK_ID, '* Updated uncompletable task'), + } + + server.use( + http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}/123`, async ({ request }) => { + const body = (await request.json()) as any + expect(body.content).toBe('* Updated uncompletable task') + return HttpResponse.json(returnedTask, { status: 200 }) + }), + ) + const api = getTarget() + + const response = await api.updateTask('123', { + content: 'Updated uncompletable task', + isUncompletable: true, + }) + + expect(response.content).toBe('* Updated uncompletable task') + expect(response.isUncompletable).toBe(true) + }) + + test('does not process content when only isUncompletable is provided', async () => { + const returnedTask = { + ...DEFAULT_TASK, + isUncompletable: false, + } + + server.use( + http.post(`${getSyncBaseUri()}${ENDPOINT_REST_TASKS}/123`, async ({ request }) => { + const body = (await request.json()) as any + expect(body.content).toBeUndefined() + expect(body.is_uncompletable).toBe(false) // Note: snake_case conversion + return HttpResponse.json(returnedTask, { status: 200 }) + }), + ) + const api = getTarget() + + const response = await api.updateTask('123', { + isUncompletable: false, + }) + + expect(response.isUncompletable).toBe(false) + }) }) describe('closeTask', () => { @@ -152,6 +276,56 @@ describe('TodoistApi task endpoints', () => { const task = await api.quickAddTask(DEFAULT_QUICK_ADD_ARGS) expect(task).toEqual(DEFAULT_TASK) }) + + test('adds uncompletable prefix when isUncompletable is true', async () => { + const expectedTask = { + ...DEFAULT_TASK, + content: '* Quick uncompletable task', + isUncompletable: true, + } + + server.use( + http.post(`${getSyncBaseUri()}${ENDPOINT_SYNC_QUICK_ADD}`, async ({ request }) => { + const body = (await request.json()) as any + expect(body.text).toBe('* Quick uncompletable task') + return HttpResponse.json(expectedTask, { status: 200 }) + }), + ) + const api = getTarget() + + const task = await api.quickAddTask({ + text: 'Quick uncompletable task', + isUncompletable: true, + }) + + expect(task.content).toBe('* Quick uncompletable task') + expect(task.isUncompletable).toBe(true) + }) + + test('preserves existing prefix even when isUncompletable is false', async () => { + const expectedTask = { + ...DEFAULT_TASK, + content: '* Already prefixed quick task', + isUncompletable: true, + } + + server.use( + http.post(`${getSyncBaseUri()}${ENDPOINT_SYNC_QUICK_ADD}`, async ({ request }) => { + const body = (await request.json()) as any + expect(body.text).toBe('* Already prefixed quick task') + return HttpResponse.json(expectedTask, { status: 200 }) + }), + ) + const api = getTarget() + + const task = await api.quickAddTask({ + text: '* Already prefixed quick task', + isUncompletable: false, + }) + + expect(task.content).toBe('* Already prefixed quick task') + expect(task.isUncompletable).toBe(true) + }) }) describe('getTask', () => { diff --git a/src/todoist-api.ts b/src/todoist-api.ts index 5369ce93..d8c837ad 100644 --- a/src/todoist-api.ts +++ b/src/todoist-api.ts @@ -133,6 +133,7 @@ import { import { formatDateToYYYYMMDD } from './utils/url-helpers' import { uploadMultipartFile } from './utils/multipart-upload' import { normalizeObjectTypeForApi, denormalizeObjectTypeFromApi } from './utils/activity-helpers' +import { processTaskContent } from './utils/uncompletable-helpers' import { z } from 'zod' import { v4 as uuidv4 } from 'uuid' @@ -395,13 +396,19 @@ export class TodoistApi { * @returns A promise that resolves to the created task. */ async addTask(args: AddTaskArgs, requestId?: string): Promise { + // Process content based on isUncompletable flag + const processedArgs = { + ...args, + content: processTaskContent(args.content, args.isUncompletable), + } + const response = await request({ httpMethod: 'POST', baseUri: this.syncApiBase, relativePath: ENDPOINT_REST_TASKS, apiToken: this.authToken, customFetch: this.customFetch, - payload: args, + payload: processedArgs, requestId: requestId, }) @@ -415,13 +422,19 @@ export class TodoistApi { * @returns A promise that resolves to the created task. */ async quickAddTask(args: QuickAddTaskArgs): Promise { + // Process text based on isUncompletable flag + const processedArgs = { + ...args, + text: processTaskContent(args.text, args.isUncompletable), + } + const response = await request({ httpMethod: 'POST', baseUri: this.syncApiBase, relativePath: ENDPOINT_SYNC_QUICK_ADD, apiToken: this.authToken, customFetch: this.customFetch, - payload: args, + payload: processedArgs, }) return validateTask(response.data) @@ -437,13 +450,20 @@ export class TodoistApi { */ async updateTask(id: string, args: UpdateTaskArgs, requestId?: string): Promise { z.string().parse(id) + + // Process content if both content and isUncompletable are provided + const processedArgs = + args.content && args.isUncompletable !== undefined + ? { ...args, content: processTaskContent(args.content, args.isUncompletable) } + : args + const response = await request({ httpMethod: 'POST', baseUri: this.syncApiBase, relativePath: generatePath(ENDPOINT_REST_TASKS, id), apiToken: this.authToken, customFetch: this.customFetch, - payload: args, + payload: processedArgs, requestId: requestId, }) diff --git a/src/types/entities.ts b/src/types/entities.ts index 91a11976..392cd396 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { getProjectUrl, getTaskUrl, getSectionUrl } from '../utils/url-helpers' +import { hasUncompletablePrefix } from '../utils/uncompletable-helpers' export const DueDateSchema = z .object({ @@ -63,10 +64,14 @@ export const TaskSchema = z noteCount: z.number().int(), dayOrder: z.number().int(), isCollapsed: z.boolean(), + isUncompletable: z.boolean().default(false), }) .transform((data) => { + // Auto-detect uncompletable status from content prefix + const isUncompletable = hasUncompletablePrefix(data.content) return { ...data, + isUncompletable, url: getTaskUrl(data.id, data.content), } }) diff --git a/src/types/requests.ts b/src/types/requests.ts index 3a15cc35..2d9836ea 100644 --- a/src/types/requests.ts +++ b/src/types/requests.ts @@ -32,6 +32,7 @@ export type AddTaskArgs = { dueLang?: string deadlineLang?: string deadlineDate?: string + isUncompletable?: boolean } & RequireOneOrNone<{ dueDate?: string dueDatetime?: string @@ -141,6 +142,7 @@ export type UpdateTaskArgs = { assigneeId?: string | null deadlineDate?: string | null deadlineLang?: string | null + isUncompletable?: boolean } & RequireOneOrNone<{ dueDate?: string dueDatetime?: string @@ -160,6 +162,7 @@ export type QuickAddTaskArgs = { reminder?: string autoReminder?: boolean meta?: boolean + isUncompletable?: boolean } /** diff --git a/src/utils/uncompletable-helpers.test.ts b/src/utils/uncompletable-helpers.test.ts new file mode 100644 index 00000000..7837f2b9 --- /dev/null +++ b/src/utils/uncompletable-helpers.test.ts @@ -0,0 +1,200 @@ +import { + addUncompletablePrefix, + removeUncompletablePrefix, + hasUncompletablePrefix, + processTaskContent, +} from './uncompletable-helpers' + +describe('uncompletable-helpers', () => { + describe('addUncompletablePrefix', () => { + test('adds prefix to content without prefix', () => { + expect(addUncompletablePrefix('Task content')).toBe('* Task content') + }) + + test('does not add prefix if already present', () => { + expect(addUncompletablePrefix('* Already prefixed')).toBe('* Already prefixed') + }) + + test('handles empty string', () => { + expect(addUncompletablePrefix('')).toBe('* ') + }) + + test('handles content with just asterisk (no space)', () => { + expect(addUncompletablePrefix('*No space')).toBe('* *No space') + }) + + test('handles content with multiple asterisks', () => { + expect(addUncompletablePrefix('** Bold text')).toBe('* ** Bold text') + }) + }) + + describe('removeUncompletablePrefix', () => { + test('removes prefix from content with prefix', () => { + expect(removeUncompletablePrefix('* Task content')).toBe('Task content') + }) + + test('does not modify content without prefix', () => { + expect(removeUncompletablePrefix('Regular task')).toBe('Regular task') + }) + + test('handles content with just prefix', () => { + expect(removeUncompletablePrefix('* ')).toBe('') + }) + + test('does not remove asterisk without space', () => { + expect(removeUncompletablePrefix('*No space')).toBe('*No space') + }) + + test('handles content with multiple prefixes', () => { + expect(removeUncompletablePrefix('* * Double prefix')).toBe('* Double prefix') + }) + }) + + describe('hasUncompletablePrefix', () => { + test('returns true for content with prefix', () => { + expect(hasUncompletablePrefix('* Task content')).toBe(true) + }) + + test('returns false for content without prefix', () => { + expect(hasUncompletablePrefix('Regular task')).toBe(false) + }) + + test('returns false for asterisk without space', () => { + expect(hasUncompletablePrefix('*No space')).toBe(false) + }) + + test('returns true for just the prefix', () => { + expect(hasUncompletablePrefix('* ')).toBe(true) + }) + + test('returns false for empty string', () => { + expect(hasUncompletablePrefix('')).toBe(false) + }) + }) + + describe('processTaskContent', () => { + describe('content prefix takes precedence', () => { + test('preserves existing prefix even when isUncompletable is false', () => { + expect(processTaskContent('* Existing prefix', false)).toBe('* Existing prefix') + }) + + test('preserves existing prefix when isUncompletable is true', () => { + expect(processTaskContent('* Existing prefix', true)).toBe('* Existing prefix') + }) + + test('preserves existing prefix when isUncompletable is undefined', () => { + expect(processTaskContent('* Existing prefix')).toBe('* Existing prefix') + }) + }) + + describe('adds prefix when requested and not present', () => { + test('adds prefix when isUncompletable is true', () => { + expect(processTaskContent('Regular task', true)).toBe('* Regular task') + }) + + test('does not add prefix when isUncompletable is false', () => { + expect(processTaskContent('Regular task', false)).toBe('Regular task') + }) + + test('does not add prefix when isUncompletable is undefined', () => { + expect(processTaskContent('Regular task')).toBe('Regular task') + }) + }) + + describe('edge cases', () => { + test('handles empty string with isUncompletable true', () => { + expect(processTaskContent('', true)).toBe('* ') + }) + + test('handles empty string with isUncompletable false', () => { + expect(processTaskContent('', false)).toBe('') + }) + + test('handles content with asterisk but no space', () => { + expect(processTaskContent('*Bold text', true)).toBe('* *Bold text') + }) + + test('handles content with multiple asterisks', () => { + expect(processTaskContent('**Important task**', true)).toBe('* **Important task**') + }) + + test('handles content starting with space', () => { + expect(processTaskContent(' Indented task', true)).toBe('* Indented task') + }) + }) + }) + + describe('integration test cases', () => { + const testCases = [ + // Content prefix takes precedence + { + input: '* Task content', + isUncompletable: false, + expected: '* Task content', + description: 'prefix wins over false flag', + }, + { + input: '* Task content', + isUncompletable: true, + expected: '* Task content', + description: 'prefix preserved with true flag', + }, + { + input: '* Task content', + isUncompletable: undefined, + expected: '* Task content', + description: 'prefix preserved with undefined flag', + }, + + // Add prefix when requested + { + input: 'Task content', + isUncompletable: true, + expected: '* Task content', + description: 'adds prefix when requested', + }, + { + input: 'Task content', + isUncompletable: false, + expected: 'Task content', + description: 'no prefix when false', + }, + { + input: 'Task content', + isUncompletable: undefined, + expected: 'Task content', + description: 'no prefix when undefined', + }, + + // Edge cases + { + input: '', + isUncompletable: true, + expected: '* ', + description: 'empty string with true', + }, + { + input: '', + isUncompletable: false, + expected: '', + description: 'empty string with false', + }, + { + input: '*No space', + isUncompletable: true, + expected: '* *No space', + description: 'asterisk without space', + }, + { + input: '**Bold**', + isUncompletable: true, + expected: '* **Bold**', + description: 'formatting preserved', + }, + ] + + test.each(testCases)('$description', ({ input, isUncompletable, expected }) => { + expect(processTaskContent(input, isUncompletable)).toBe(expected) + }) + }) +}) diff --git a/src/utils/uncompletable-helpers.ts b/src/utils/uncompletable-helpers.ts new file mode 100644 index 00000000..4a019572 --- /dev/null +++ b/src/utils/uncompletable-helpers.ts @@ -0,0 +1,60 @@ +const UNCOMPLETABLE_PREFIX = '* ' + +/** + * Adds the uncompletable prefix (* ) to task content if not already present + * @param content - The task content + * @returns Content with uncompletable prefix added + */ +export function addUncompletablePrefix(content: string): string { + if (content.startsWith(UNCOMPLETABLE_PREFIX)) { + return content + } + return UNCOMPLETABLE_PREFIX + content +} + +/** + * Removes the uncompletable prefix (* ) from task content if present + * @param content - The task content + * @returns Content with uncompletable prefix removed + */ +export function removeUncompletablePrefix(content: string): string { + if (content.startsWith(UNCOMPLETABLE_PREFIX)) { + return content.slice(UNCOMPLETABLE_PREFIX.length) + } + return content +} + +/** + * Checks if task content has the uncompletable prefix (* ) + * @param content - The task content + * @returns True if content starts with uncompletable prefix + */ +export function hasUncompletablePrefix(content: string): boolean { + return content.startsWith(UNCOMPLETABLE_PREFIX) +} + +/** + * Processes task content based on isUncompletable flag, with content prefix taking precedence + * @param content - The original task content + * @param isUncompletable - Optional flag to make task uncompletable + * @returns Processed content + * + * Logic: + * - If content already has * prefix, task is uncompletable regardless of flag + * - If content doesn't have * prefix and isUncompletable is true, add the prefix + * - If isUncompletable is undefined or false (and no prefix), leave content unchanged + */ +export function processTaskContent(content: string, isUncompletable?: boolean): string { + // Content prefix takes precedence - if already has prefix, keep it + if (hasUncompletablePrefix(content)) { + return content + } + + // If content doesn't have prefix and user wants uncompletable, add it + if (isUncompletable === true) { + return addUncompletablePrefix(content) + } + + // Otherwise, leave content unchanged + return content +}