@md2do/todoist 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { TodoistApi } from '@doist/todoist-api-typescript';
2
+ import type { Task, Project, Label } from '@doist/todoist-api-typescript';
3
+ import type { TodoistTaskParams } from './mapper.js';
4
+
5
+ /**
6
+ * Configuration for Todoist client
7
+ */
8
+ export interface TodoistClientConfig {
9
+ apiToken: string;
10
+ }
11
+
12
+ /**
13
+ * Wrapper around Todoist API with error handling and convenience methods
14
+ */
15
+ export class TodoistClient {
16
+ private api: TodoistApi;
17
+
18
+ constructor(config: TodoistClientConfig) {
19
+ this.api = new TodoistApi(config.apiToken);
20
+ }
21
+
22
+ /**
23
+ * Get all active tasks
24
+ */
25
+ async getTasks(options?: {
26
+ projectId?: string;
27
+ labelId?: string;
28
+ }): Promise<Task[]> {
29
+ try {
30
+ return await this.api.getTasks(options);
31
+ } catch (error) {
32
+ throw new Error(
33
+ `Failed to get tasks: ${error instanceof Error ? error.message : String(error)}`,
34
+ );
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Get a specific task by ID
40
+ */
41
+ async getTask(taskId: string): Promise<Task> {
42
+ try {
43
+ return await this.api.getTask(taskId);
44
+ } catch (error) {
45
+ throw new Error(
46
+ `Failed to get task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
47
+ );
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Create a new task
53
+ */
54
+ async createTask(
55
+ params: TodoistTaskParams | Record<string, unknown>,
56
+ ): Promise<Task> {
57
+ try {
58
+ return await this.api.addTask(params as TodoistTaskParams);
59
+ } catch (error) {
60
+ throw new Error(
61
+ `Failed to create task: ${error instanceof Error ? error.message : String(error)}`,
62
+ );
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Update an existing task
68
+ */
69
+ async updateTask(
70
+ taskId: string,
71
+ params: Partial<TodoistTaskParams>,
72
+ ): Promise<Task> {
73
+ try {
74
+ return await this.api.updateTask(taskId, params);
75
+ } catch (error) {
76
+ throw new Error(
77
+ `Failed to update task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
78
+ );
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Complete a task
84
+ */
85
+ async completeTask(taskId: string): Promise<boolean> {
86
+ try {
87
+ return await this.api.closeTask(taskId);
88
+ } catch (error) {
89
+ throw new Error(
90
+ `Failed to complete task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
91
+ );
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Reopen a completed task
97
+ */
98
+ async reopenTask(taskId: string): Promise<boolean> {
99
+ try {
100
+ return await this.api.reopenTask(taskId);
101
+ } catch (error) {
102
+ throw new Error(
103
+ `Failed to reopen task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
104
+ );
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Delete a task
110
+ */
111
+ async deleteTask(taskId: string): Promise<boolean> {
112
+ try {
113
+ return await this.api.deleteTask(taskId);
114
+ } catch (error) {
115
+ throw new Error(
116
+ `Failed to delete task ${taskId}: ${error instanceof Error ? error.message : String(error)}`,
117
+ );
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Get all projects
123
+ */
124
+ async getProjects(): Promise<Project[]> {
125
+ try {
126
+ return await this.api.getProjects();
127
+ } catch (error) {
128
+ throw new Error(
129
+ `Failed to get projects: ${error instanceof Error ? error.message : String(error)}`,
130
+ );
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Get a specific project by ID
136
+ */
137
+ async getProject(projectId: string): Promise<Project> {
138
+ try {
139
+ return await this.api.getProject(projectId);
140
+ } catch (error) {
141
+ throw new Error(
142
+ `Failed to get project ${projectId}: ${error instanceof Error ? error.message : String(error)}`,
143
+ );
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Find project by name
149
+ */
150
+ async findProjectByName(name: string): Promise<Project | null> {
151
+ const projects = await this.getProjects();
152
+ return (
153
+ projects.find((p) => p.name.toLowerCase() === name.toLowerCase()) ?? null
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Get all labels
159
+ */
160
+ async getLabels(): Promise<Label[]> {
161
+ try {
162
+ return await this.api.getLabels();
163
+ } catch (error) {
164
+ throw new Error(
165
+ `Failed to get labels: ${error instanceof Error ? error.message : String(error)}`,
166
+ );
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Create a label if it doesn't exist
172
+ */
173
+ async ensureLabel(name: string): Promise<Label> {
174
+ const labels = await this.getLabels();
175
+ const existing = labels.find(
176
+ (l) => l.name.toLowerCase() === name.toLowerCase(),
177
+ );
178
+
179
+ if (existing) {
180
+ return existing;
181
+ }
182
+
183
+ try {
184
+ return await this.api.addLabel({ name });
185
+ } catch (error) {
186
+ throw new Error(
187
+ `Failed to create label ${name}: ${error instanceof Error ? error.message : String(error)}`,
188
+ );
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Test the API connection
194
+ */
195
+ async test(): Promise<boolean> {
196
+ try {
197
+ await this.getProjects();
198
+ return true;
199
+ } catch {
200
+ return false;
201
+ }
202
+ }
203
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { TodoistClient } from './client.js';
2
+ export type { TodoistClientConfig } from './client.js';
3
+ export {
4
+ md2doToTodoistPriority,
5
+ todoistToMd2doPriority,
6
+ extractTaskContent,
7
+ formatTaskContent,
8
+ md2doToTodoist,
9
+ todoistToMd2do,
10
+ } from './mapper.js';
11
+ export type { TodoistTaskParams, Md2doTaskUpdate } from './mapper.js';
package/src/mapper.ts ADDED
@@ -0,0 +1,225 @@
1
+ import type { Task } from '@md2do/core';
2
+ import type { Task as TodoistTask } from '@doist/todoist-api-typescript';
3
+
4
+ /**
5
+ * Priority mapping between md2do and Todoist
6
+ * md2do: urgent (!!!) / high (!!) / normal (!) / low (none)
7
+ * Todoist: 4 / 3 / 2 / 1
8
+ */
9
+ export function md2doToTodoistPriority(priority?: string): number {
10
+ switch (priority) {
11
+ case 'urgent':
12
+ return 4;
13
+ case 'high':
14
+ return 3;
15
+ case 'normal':
16
+ return 2;
17
+ case 'low':
18
+ default:
19
+ return 1;
20
+ }
21
+ }
22
+
23
+ export function todoistToMd2doPriority(priority: number): string | undefined {
24
+ switch (priority) {
25
+ case 4:
26
+ return 'urgent';
27
+ case 3:
28
+ return 'high';
29
+ case 2:
30
+ return 'normal';
31
+ case 1:
32
+ return 'low';
33
+ default:
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Extract task content without metadata for Todoist
40
+ * Removes: assignee, priority markers, tags, dates, todoist ID
41
+ */
42
+ export function extractTaskContent(text: string): string {
43
+ return (
44
+ text
45
+ // Remove assignee @username
46
+ .replace(/@\w+/g, '')
47
+ // Remove priority markers
48
+ .replace(/!+/g, '')
49
+ // Remove tags
50
+ .replace(/#\w+/g, '')
51
+ // Remove dates in parentheses
52
+ .replace(/\(\d{4}-\d{2}-\d{2}\)/g, '')
53
+ // Remove Todoist ID
54
+ .replace(/\[todoist:\s*\d+\]/gi, '')
55
+ // Clean up extra whitespace
56
+ .replace(/\s+/g, ' ')
57
+ .trim()
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Format Todoist task content for markdown
63
+ * Adds back assignee, priority, tags, and due date
64
+ */
65
+ export function formatTaskContent(
66
+ content: string,
67
+ options: {
68
+ assignee?: string;
69
+ priority?: string;
70
+ tags?: string[];
71
+ due?: Date;
72
+ todoistId?: string;
73
+ },
74
+ ): string {
75
+ let result = content;
76
+
77
+ // Add assignee
78
+ if (options.assignee) {
79
+ result += ` @${options.assignee}`;
80
+ }
81
+
82
+ // Add priority markers
83
+ if (options.priority === 'urgent') {
84
+ result += ' !!!';
85
+ } else if (options.priority === 'high') {
86
+ result += ' !!';
87
+ } else if (options.priority === 'normal') {
88
+ result += ' !';
89
+ }
90
+
91
+ // Add tags
92
+ if (options.tags && options.tags.length > 0) {
93
+ result += ' ' + options.tags.map((tag) => `#${tag}`).join(' ');
94
+ }
95
+
96
+ // Add due date
97
+ if (options.due) {
98
+ // Format date in UTC to avoid timezone issues
99
+ const year = options.due.getUTCFullYear();
100
+ const month = String(options.due.getUTCMonth() + 1).padStart(2, '0');
101
+ const day = String(options.due.getUTCDate()).padStart(2, '0');
102
+ result += ` (${year}-${month}-${day})`;
103
+ }
104
+
105
+ // Add Todoist ID
106
+ if (options.todoistId) {
107
+ result += ` [todoist:${options.todoistId}]`;
108
+ }
109
+
110
+ return result;
111
+ }
112
+
113
+ /**
114
+ * Convert md2do task to Todoist task creation parameters
115
+ */
116
+ export interface TodoistTaskParams {
117
+ content: string;
118
+ priority: number;
119
+ labels?: string[];
120
+ due_date?: string;
121
+ due_string?: string;
122
+ project_id?: string;
123
+ }
124
+
125
+ export function md2doToTodoist(
126
+ task: Task,
127
+ projectId?: string,
128
+ ): TodoistTaskParams {
129
+ const params: TodoistTaskParams = {
130
+ content: extractTaskContent(task.text),
131
+ priority: md2doToTodoistPriority(task.priority),
132
+ };
133
+
134
+ // Add labels from tags
135
+ if (task.tags && task.tags.length > 0) {
136
+ params.labels = task.tags;
137
+ }
138
+
139
+ // Add due date
140
+ if (task.dueDate) {
141
+ // Format date in UTC to avoid timezone issues
142
+ const year = task.dueDate.getUTCFullYear();
143
+ const month = String(task.dueDate.getUTCMonth() + 1).padStart(2, '0');
144
+ const day = String(task.dueDate.getUTCDate()).padStart(2, '0');
145
+ params.due_date = `${year}-${month}-${day}`;
146
+ }
147
+
148
+ // Add project ID
149
+ if (projectId) {
150
+ params.project_id = projectId;
151
+ }
152
+
153
+ return params;
154
+ }
155
+
156
+ /**
157
+ * Convert Todoist task to md2do task update data
158
+ */
159
+ export interface Md2doTaskUpdate {
160
+ text: string;
161
+ completed: boolean;
162
+ todoistId: string;
163
+ priority?: string;
164
+ tags?: string[];
165
+ due?: Date;
166
+ }
167
+
168
+ export function todoistToMd2do(
169
+ todoistTask: TodoistTask,
170
+ assignee?: string,
171
+ ): Md2doTaskUpdate {
172
+ const priority = todoistToMd2doPriority(todoistTask.priority);
173
+ // Parse date string as UTC to avoid timezone issues
174
+ const due = todoistTask.due?.date
175
+ ? new Date(`${todoistTask.due.date}T00:00:00.000Z`)
176
+ : undefined;
177
+
178
+ // Build format options with only defined values
179
+ const formatOptions: {
180
+ assignee?: string;
181
+ priority?: string;
182
+ tags?: string[];
183
+ due?: Date;
184
+ todoistId?: string;
185
+ } = {
186
+ todoistId: todoistTask.id,
187
+ };
188
+
189
+ if (assignee !== undefined) {
190
+ formatOptions.assignee = assignee;
191
+ }
192
+
193
+ if (priority !== undefined) {
194
+ formatOptions.priority = priority;
195
+ }
196
+
197
+ if (todoistTask.labels.length > 0) {
198
+ formatOptions.tags = todoistTask.labels;
199
+ }
200
+
201
+ if (due !== undefined) {
202
+ formatOptions.due = due;
203
+ }
204
+
205
+ const update: Md2doTaskUpdate = {
206
+ text: formatTaskContent(todoistTask.content, formatOptions),
207
+ completed: todoistTask.isCompleted ?? false,
208
+ todoistId: todoistTask.id,
209
+ };
210
+
211
+ // Add optional properties only if they have values
212
+ if (priority !== undefined) {
213
+ update.priority = priority;
214
+ }
215
+
216
+ if (todoistTask.labels.length > 0) {
217
+ update.tags = todoistTask.labels;
218
+ }
219
+
220
+ if (due !== undefined) {
221
+ update.due = due;
222
+ }
223
+
224
+ return update;
225
+ }
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ md2doToTodoistPriority,
4
+ todoistToMd2doPriority,
5
+ extractTaskContent,
6
+ formatTaskContent,
7
+ md2doToTodoist,
8
+ todoistToMd2do,
9
+ } from '../src/mapper.js';
10
+ import type { Task } from '@md2do/core';
11
+ import type { Task as TodoistTask } from '@doist/todoist-api-typescript';
12
+
13
+ describe('md2doToTodoistPriority', () => {
14
+ it('should map urgent to 4', () => {
15
+ expect(md2doToTodoistPriority('urgent')).toBe(4);
16
+ });
17
+
18
+ it('should map high to 3', () => {
19
+ expect(md2doToTodoistPriority('high')).toBe(3);
20
+ });
21
+
22
+ it('should map normal to 2', () => {
23
+ expect(md2doToTodoistPriority('normal')).toBe(2);
24
+ });
25
+
26
+ it('should map low to 1', () => {
27
+ expect(md2doToTodoistPriority('low')).toBe(1);
28
+ });
29
+
30
+ it('should default to 1 for undefined', () => {
31
+ expect(md2doToTodoistPriority(undefined)).toBe(1);
32
+ });
33
+ });
34
+
35
+ describe('todoistToMd2doPriority', () => {
36
+ it('should map 4 to urgent', () => {
37
+ expect(todoistToMd2doPriority(4)).toBe('urgent');
38
+ });
39
+
40
+ it('should map 3 to high', () => {
41
+ expect(todoistToMd2doPriority(3)).toBe('high');
42
+ });
43
+
44
+ it('should map 2 to normal', () => {
45
+ expect(todoistToMd2doPriority(2)).toBe('normal');
46
+ });
47
+
48
+ it('should map 1 to low', () => {
49
+ expect(todoistToMd2doPriority(1)).toBe('low');
50
+ });
51
+
52
+ it('should return undefined for invalid priority', () => {
53
+ expect(todoistToMd2doPriority(0)).toBeUndefined();
54
+ });
55
+ });
56
+
57
+ describe('extractTaskContent', () => {
58
+ it('should remove assignee', () => {
59
+ const text = 'Fix bug @nick';
60
+ expect(extractTaskContent(text)).toBe('Fix bug');
61
+ });
62
+
63
+ it('should remove priority markers', () => {
64
+ const text = 'Fix bug !!!';
65
+ expect(extractTaskContent(text)).toBe('Fix bug');
66
+ });
67
+
68
+ it('should remove tags', () => {
69
+ const text = 'Fix bug #backend #urgent';
70
+ expect(extractTaskContent(text)).toBe('Fix bug');
71
+ });
72
+
73
+ it('should remove dates', () => {
74
+ const text = 'Fix bug (2026-01-20)';
75
+ expect(extractTaskContent(text)).toBe('Fix bug');
76
+ });
77
+
78
+ it('should remove Todoist ID', () => {
79
+ const text = 'Fix bug [todoist:123456]';
80
+ expect(extractTaskContent(text)).toBe('Fix bug');
81
+ });
82
+
83
+ it('should remove all metadata', () => {
84
+ const text = 'Fix bug @nick !!! #backend (2026-01-20) [todoist:123456]';
85
+ expect(extractTaskContent(text)).toBe('Fix bug');
86
+ });
87
+
88
+ it('should handle multiple spaces', () => {
89
+ const text = 'Fix bug @nick !!! #backend';
90
+ expect(extractTaskContent(text)).toBe('Fix bug');
91
+ });
92
+ });
93
+
94
+ describe('formatTaskContent', () => {
95
+ it('should format content with assignee', () => {
96
+ const result = formatTaskContent('Fix bug', { assignee: 'nick' });
97
+ expect(result).toBe('Fix bug @nick');
98
+ });
99
+
100
+ it('should format content with urgent priority', () => {
101
+ const result = formatTaskContent('Fix bug', { priority: 'urgent' });
102
+ expect(result).toBe('Fix bug !!!');
103
+ });
104
+
105
+ it('should format content with high priority', () => {
106
+ const result = formatTaskContent('Fix bug', { priority: 'high' });
107
+ expect(result).toBe('Fix bug !!');
108
+ });
109
+
110
+ it('should format content with normal priority', () => {
111
+ const result = formatTaskContent('Fix bug', { priority: 'normal' });
112
+ expect(result).toBe('Fix bug !');
113
+ });
114
+
115
+ it('should format content with tags', () => {
116
+ const result = formatTaskContent('Fix bug', {
117
+ tags: ['backend', 'urgent'],
118
+ });
119
+ expect(result).toBe('Fix bug #backend #urgent');
120
+ });
121
+
122
+ it('should format content with due date', () => {
123
+ const result = formatTaskContent('Fix bug', {
124
+ due: new Date('2026-01-20T00:00:00.000Z'),
125
+ });
126
+ expect(result).toBe('Fix bug (2026-01-20)');
127
+ });
128
+
129
+ it('should format content with Todoist ID', () => {
130
+ const result = formatTaskContent('Fix bug', { todoistId: '123456' });
131
+ expect(result).toBe('Fix bug [todoist:123456]');
132
+ });
133
+
134
+ it('should format content with all metadata', () => {
135
+ const result = formatTaskContent('Fix bug', {
136
+ assignee: 'nick',
137
+ priority: 'urgent',
138
+ tags: ['backend'],
139
+ due: new Date('2026-01-20T00:00:00.000Z'),
140
+ todoistId: '123456',
141
+ });
142
+ expect(result).toBe(
143
+ 'Fix bug @nick !!! #backend (2026-01-20) [todoist:123456]',
144
+ );
145
+ });
146
+ });
147
+
148
+ describe('md2doToTodoist', () => {
149
+ it('should convert md2do task to Todoist params', () => {
150
+ const task: Task = {
151
+ id: 'test-id',
152
+ text: 'Fix bug @nick !!! #backend (2026-01-20)',
153
+ completed: false,
154
+ file: 'test.md',
155
+ line: 1,
156
+ tags: ['backend'],
157
+ assignee: 'nick',
158
+ priority: 'urgent',
159
+ dueDate: new Date('2026-01-20T00:00:00.000Z'),
160
+ };
161
+
162
+ const params = md2doToTodoist(task);
163
+
164
+ expect(params).toEqual({
165
+ content: 'Fix bug',
166
+ priority: 4,
167
+ labels: ['backend'],
168
+ due_date: '2026-01-20',
169
+ });
170
+ });
171
+
172
+ it('should include project ID if provided', () => {
173
+ const task: Task = {
174
+ id: 'test-id',
175
+ text: 'Fix bug',
176
+ completed: false,
177
+ file: 'test.md',
178
+ line: 1,
179
+ tags: [],
180
+ };
181
+
182
+ const params = md2doToTodoist(task, 'project-123');
183
+
184
+ expect(params.project_id).toBe('project-123');
185
+ });
186
+
187
+ it('should handle task without optional fields', () => {
188
+ const task: Task = {
189
+ id: 'test-id',
190
+ text: 'Fix bug',
191
+ completed: false,
192
+ file: 'test.md',
193
+ line: 1,
194
+ tags: [],
195
+ };
196
+
197
+ const params = md2doToTodoist(task);
198
+
199
+ expect(params).toEqual({
200
+ content: 'Fix bug',
201
+ priority: 1,
202
+ });
203
+ });
204
+ });
205
+
206
+ describe('todoistToMd2do', () => {
207
+ it('should convert Todoist task to md2do update', () => {
208
+ const todoistTask: TodoistTask = {
209
+ id: '123456',
210
+ order: 1,
211
+ content: 'Fix bug',
212
+ description: '',
213
+ priority: 4,
214
+ labels: ['backend'],
215
+ due: { date: '2026-01-20', isRecurring: false, string: '2026-01-20' },
216
+ isCompleted: false,
217
+ createdAt: '2026-01-18T10:00:00Z',
218
+ creatorId: 'user-id',
219
+ projectId: 'project-id',
220
+ commentCount: 0,
221
+ url: 'https://todoist.com/app/task/123456',
222
+ };
223
+
224
+ const update = todoistToMd2do(todoistTask, 'nick');
225
+
226
+ expect(update).toEqual({
227
+ text: 'Fix bug @nick !!! #backend (2026-01-20) [todoist:123456]',
228
+ completed: false,
229
+ priority: 'urgent',
230
+ tags: ['backend'],
231
+ due: new Date('2026-01-20T00:00:00.000Z'),
232
+ todoistId: '123456',
233
+ });
234
+ });
235
+
236
+ it('should handle completed task', () => {
237
+ const todoistTask: TodoistTask = {
238
+ id: '123456',
239
+ order: 1,
240
+ content: 'Fix bug',
241
+ description: '',
242
+ priority: 1,
243
+ labels: [],
244
+ isCompleted: true,
245
+ createdAt: '2026-01-18T10:00:00Z',
246
+ creatorId: 'user-id',
247
+ projectId: 'project-id',
248
+ commentCount: 0,
249
+ url: 'https://todoist.com/app/task/123456',
250
+ };
251
+
252
+ const update = todoistToMd2do(todoistTask);
253
+
254
+ expect(update.completed).toBe(true);
255
+ expect(update.text).toBe('Fix bug [todoist:123456]');
256
+ });
257
+
258
+ it('should handle task without optional fields', () => {
259
+ const todoistTask: TodoistTask = {
260
+ id: '123456',
261
+ order: 1,
262
+ content: 'Fix bug',
263
+ description: '',
264
+ priority: 1,
265
+ labels: [],
266
+ isCompleted: false,
267
+ createdAt: '2026-01-18T10:00:00Z',
268
+ creatorId: 'user-id',
269
+ projectId: 'project-id',
270
+ commentCount: 0,
271
+ url: 'https://todoist.com/app/task/123456',
272
+ };
273
+
274
+ const update = todoistToMd2do(todoistTask);
275
+
276
+ expect(update).toEqual({
277
+ text: 'Fix bug [todoist:123456]',
278
+ completed: false,
279
+ priority: 'low',
280
+ todoistId: '123456',
281
+ });
282
+ });
283
+ });