@mindtnv/todoist-cli 0.3.1 → 0.5.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.
Files changed (85) hide show
  1. package/marketplace.json +16 -0
  2. package/package.json +7 -6
  3. package/src/api/activity.ts +8 -0
  4. package/src/api/client.ts +214 -0
  5. package/src/api/comments.ts +18 -0
  6. package/src/api/completed.ts +15 -0
  7. package/src/api/labels.ts +18 -0
  8. package/src/api/projects.ts +22 -0
  9. package/src/api/sections.ts +20 -0
  10. package/src/api/stats.ts +38 -0
  11. package/src/api/tasks.ts +34 -0
  12. package/src/api/types.ts +202 -0
  13. package/src/cli/auth.ts +40 -0
  14. package/src/cli/commands/task/add.ts +328 -0
  15. package/src/cli/commands/task/complete.ts +62 -0
  16. package/src/cli/commands/task/delete.ts +62 -0
  17. package/src/cli/commands/task/helpers.ts +289 -0
  18. package/src/cli/commands/task/index.ts +27 -0
  19. package/src/cli/commands/task/list.ts +151 -0
  20. package/src/cli/commands/task/move.ts +49 -0
  21. package/src/cli/commands/task/reopen.ts +43 -0
  22. package/src/cli/commands/task/show.ts +115 -0
  23. package/src/cli/commands/task/update.ts +122 -0
  24. package/src/cli/comment.ts +83 -0
  25. package/src/cli/completed.ts +87 -0
  26. package/src/cli/completion.ts +360 -0
  27. package/src/cli/filter.ts +115 -0
  28. package/src/cli/index.ts +638 -0
  29. package/src/cli/label.ts +120 -0
  30. package/src/cli/log.ts +57 -0
  31. package/src/cli/matrix.ts +100 -0
  32. package/src/cli/plugin-loader.ts +38 -0
  33. package/src/cli/plugin.ts +289 -0
  34. package/src/cli/project.ts +172 -0
  35. package/src/cli/review.ts +116 -0
  36. package/src/cli/section.ts +98 -0
  37. package/src/cli/stats.ts +62 -0
  38. package/src/cli/template.ts +89 -0
  39. package/src/config/index.ts +229 -0
  40. package/src/plugins/api-proxy.ts +70 -0
  41. package/src/plugins/extension-registry.ts +53 -0
  42. package/src/plugins/hook-registry.ts +36 -0
  43. package/src/plugins/loader.ts +200 -0
  44. package/src/plugins/marketplace-types.ts +55 -0
  45. package/src/plugins/marketplace.ts +576 -0
  46. package/src/plugins/palette-registry.ts +21 -0
  47. package/src/plugins/storage.ts +101 -0
  48. package/src/plugins/types.ts +226 -0
  49. package/src/plugins/view-registry.ts +19 -0
  50. package/src/ui/App.tsx +234 -0
  51. package/src/ui/components/Breadcrumb.tsx +18 -0
  52. package/src/ui/components/CommandPalette.tsx +237 -0
  53. package/src/ui/components/ConfirmDialog.tsx +28 -0
  54. package/src/ui/components/EditTaskModal.tsx +484 -0
  55. package/src/ui/components/HelpOverlay.tsx +195 -0
  56. package/src/ui/components/InputPrompt.tsx +109 -0
  57. package/src/ui/components/LabelPicker.tsx +110 -0
  58. package/src/ui/components/ModalManager.tsx +275 -0
  59. package/src/ui/components/ProjectPicker.tsx +95 -0
  60. package/src/ui/components/Sidebar.tsx +282 -0
  61. package/src/ui/components/SortMenu.tsx +77 -0
  62. package/src/ui/components/StatusBar.tsx +67 -0
  63. package/src/ui/components/TaskList.tsx +258 -0
  64. package/src/ui/components/TaskRow.tsx +105 -0
  65. package/src/ui/hooks/useKeyboardHandler.ts +291 -0
  66. package/src/ui/hooks/useStatusMessage.ts +32 -0
  67. package/src/ui/hooks/useTaskOperations.ts +558 -0
  68. package/src/ui/hooks/useUndoSystem.ts +218 -0
  69. package/src/ui/views/ActivityView.tsx +213 -0
  70. package/src/ui/views/CompletedView.tsx +337 -0
  71. package/src/ui/views/StatsView.tsx +178 -0
  72. package/src/ui/views/TaskDetailView.tsx +438 -0
  73. package/src/ui/views/TasksView.tsx +851 -0
  74. package/src/utils/colors.ts +27 -0
  75. package/src/utils/date-format.ts +54 -0
  76. package/src/utils/errors.ts +159 -0
  77. package/src/utils/exit.ts +11 -0
  78. package/src/utils/format.ts +46 -0
  79. package/src/utils/open-url.ts +9 -0
  80. package/src/utils/output.ts +29 -0
  81. package/src/utils/quick-add.ts +202 -0
  82. package/src/utils/resolve.ts +359 -0
  83. package/src/utils/sorting.ts +27 -0
  84. package/src/utils/validation.ts +88 -0
  85. package/dist/index.js +0 -10989
@@ -0,0 +1,558 @@
1
+ import { useCallback } from "react";
2
+ import type { Task, CreateTaskParams, UpdateTaskParams } from "../../api/types.ts";
3
+ import { createTask, closeTask, deleteTask, updateTask } from "../../api/tasks.ts";
4
+ import { parseQuickAdd, resolveProjectName } from "../../utils/quick-add.ts";
5
+ import type { UndoAction } from "./useUndoSystem.ts";
6
+ import type { HookRegistry } from "../../plugins/types.ts";
7
+
8
+ interface UseTaskOperationsOptions {
9
+ tasks: Task[];
10
+ onTasksChange: (tasks: Task[]) => void;
11
+ showStatus: (msg: string) => void;
12
+ startUndoTimer: (action: { type: UndoAction["type"]; taskIds: string[]; previousState?: Partial<Task>[] }) => void;
13
+ clearUndo: () => void;
14
+ lastActionRef: React.RefObject<UndoAction | null>;
15
+ refreshTasks: () => Promise<void>;
16
+ selectedTask: Task | undefined;
17
+ selectedIds: Set<string>;
18
+ setSelectedIds: React.Dispatch<React.SetStateAction<Set<string>>>;
19
+ setRangeSelectAnchor: React.Dispatch<React.SetStateAction<number | null>>;
20
+ setTaskIndex: React.Dispatch<React.SetStateAction<number>>;
21
+ setModal: React.Dispatch<React.SetStateAction<string>>;
22
+ filteredTasksLength: number;
23
+ filterProjectId: string | undefined;
24
+ filterView: string;
25
+ projects: Array<{ id: string; name: string }>;
26
+ pluginHooks?: HookRegistry | null;
27
+ }
28
+
29
+ export function useTaskOperations({
30
+ tasks,
31
+ onTasksChange,
32
+ showStatus,
33
+ startUndoTimer,
34
+ clearUndo,
35
+ lastActionRef,
36
+ refreshTasks,
37
+ selectedTask,
38
+ selectedIds,
39
+ setSelectedIds,
40
+ setRangeSelectAnchor,
41
+ setTaskIndex,
42
+ setModal,
43
+ filteredTasksLength,
44
+ filterProjectId,
45
+ filterView,
46
+ projects,
47
+ pluginHooks,
48
+ }: UseTaskOperationsOptions) {
49
+
50
+ const handleCompleteTask = useCallback(async () => {
51
+ if (!selectedTask) return;
52
+ const taskId = selectedTask.id;
53
+ const taskSnapshot = selectedTask;
54
+ const prevTasks = [...tasks];
55
+ onTasksChange(tasks.filter((t) => t.id !== taskId));
56
+ setTaskIndex((i) => Math.min(i, Math.max(filteredTasksLength - 2, 0)));
57
+ startUndoTimer({ type: "complete", taskIds: [taskId] });
58
+ showStatus("Task completed! Press u to undo (10s)");
59
+ try {
60
+ await closeTask(taskId);
61
+ try { await pluginHooks?.emit("task.completed", { task: taskSnapshot }); } catch (err) { console.warn("[plugin-hook]", err); }
62
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
63
+ } catch {
64
+ onTasksChange(prevTasks);
65
+ if (lastActionRef.current) clearTimeout(lastActionRef.current.timer);
66
+ clearUndo();
67
+ showStatus("Failed to complete task");
68
+ }
69
+ }, [selectedTask, tasks, onTasksChange, filteredTasksLength, startUndoTimer, refreshTasks, showStatus, setTaskIndex, lastActionRef, clearUndo, pluginHooks]);
70
+
71
+ const handleDeleteConfirm = useCallback(async () => {
72
+ if (!selectedTask) return;
73
+ setModal("none");
74
+ const taskId = selectedTask.id;
75
+ const taskSnapshot = selectedTask;
76
+ const snapshot: Partial<Task> = {
77
+ content: selectedTask.content,
78
+ description: selectedTask.description,
79
+ priority: selectedTask.priority,
80
+ due: selectedTask.due,
81
+ labels: selectedTask.labels,
82
+ project_id: selectedTask.project_id,
83
+ };
84
+ const prevTasks = [...tasks];
85
+ onTasksChange(tasks.filter((t) => t.id !== taskId));
86
+ setTaskIndex((i) => Math.min(i, Math.max(filteredTasksLength - 2, 0)));
87
+ setRangeSelectAnchor(null);
88
+ startUndoTimer({ type: "delete", taskIds: [taskId], previousState: [snapshot] });
89
+ showStatus("Task deleted! Press u to undo (10s)");
90
+ try {
91
+ await deleteTask(taskId);
92
+ try { await pluginHooks?.emit("task.deleted", { task: taskSnapshot }); } catch (err) { console.warn("[plugin-hook]", err); }
93
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
94
+ } catch {
95
+ onTasksChange(prevTasks);
96
+ if (lastActionRef.current) clearTimeout(lastActionRef.current.timer);
97
+ clearUndo();
98
+ showStatus("Failed to delete task");
99
+ }
100
+ }, [selectedTask, tasks, onTasksChange, filteredTasksLength, startUndoTimer, refreshTasks, showStatus, setModal, setTaskIndex, setRangeSelectAnchor, lastActionRef, clearUndo, pluginHooks]);
101
+
102
+ const handleEditTask = useCallback(
103
+ async (newContent: string) => {
104
+ if (!selectedTask) return;
105
+ setModal("none");
106
+ const taskId = selectedTask.id;
107
+ const prevTasks = [...tasks];
108
+ onTasksChange(tasks.map((t) => (t.id === taskId ? { ...t, content: newContent } : t)));
109
+ showStatus("Task updated!");
110
+ try {
111
+ await updateTask(taskId, { content: newContent });
112
+ try { await pluginHooks?.emit("task.updated", { task: { ...selectedTask, content: newContent }, changes: { content: newContent } }); } catch (err) { console.warn("[plugin-hook]", err); }
113
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
114
+ } catch {
115
+ onTasksChange(prevTasks);
116
+ showStatus("Failed to update task");
117
+ }
118
+ },
119
+ [selectedTask, tasks, onTasksChange, refreshTasks, showStatus, setModal, pluginHooks],
120
+ );
121
+
122
+ const handleRenameTask = useCallback(
123
+ async (newContent: string) => {
124
+ if (!selectedTask) return;
125
+ setModal("none");
126
+ const taskId = selectedTask.id;
127
+ const prevTasks = [...tasks];
128
+ onTasksChange(tasks.map((t) => (t.id === taskId ? { ...t, content: newContent } : t)));
129
+ showStatus("Renamed!");
130
+ try {
131
+ await updateTask(taskId, { content: newContent });
132
+ try { await pluginHooks?.emit("task.updated", { task: { ...selectedTask, content: newContent }, changes: { content: newContent } }); } catch (err) { console.warn("[plugin-hook]", err); }
133
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
134
+ } catch {
135
+ onTasksChange(prevTasks);
136
+ showStatus("Failed to rename task");
137
+ }
138
+ },
139
+ [selectedTask, tasks, onTasksChange, refreshTasks, showStatus, setModal, pluginHooks],
140
+ );
141
+
142
+ const handleBulkComplete = useCallback(async () => {
143
+ if (selectedIds.size === 0) return;
144
+ const ids = Array.from(selectedIds);
145
+ const count = ids.length;
146
+ const prevTasks = [...tasks];
147
+ const idsSet = new Set(ids);
148
+ const completedTasks = tasks.filter((t) => idsSet.has(t.id));
149
+ onTasksChange(tasks.filter((t) => !idsSet.has(t.id)));
150
+ startUndoTimer({ type: "complete", taskIds: ids });
151
+ setSelectedIds(new Set());
152
+ setRangeSelectAnchor(null);
153
+ setTaskIndex(0);
154
+ showStatus(`${count} tasks completed! Press u to undo (10s)`);
155
+ try {
156
+ await Promise.all(ids.map((id) => closeTask(id)));
157
+ if (pluginHooks) {
158
+ for (const t of completedTasks) {
159
+ try { await pluginHooks.emit("task.completed", { task: t }); } catch (err) { console.warn("[plugin-hook]", err); }
160
+ }
161
+ }
162
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
163
+ } catch {
164
+ onTasksChange(prevTasks);
165
+ if (lastActionRef.current) clearTimeout(lastActionRef.current.timer);
166
+ clearUndo();
167
+ showStatus("Failed to complete some tasks");
168
+ }
169
+ }, [selectedIds, tasks, onTasksChange, startUndoTimer, refreshTasks, showStatus, setSelectedIds, setRangeSelectAnchor, setTaskIndex, lastActionRef, clearUndo, pluginHooks]);
170
+
171
+ const handleBulkDeleteConfirm = useCallback(async () => {
172
+ if (selectedIds.size === 0) return;
173
+ setModal("none");
174
+ const ids = Array.from(selectedIds);
175
+ const count = ids.length;
176
+ const idsSet = new Set(ids);
177
+ const deletedTasks = tasks.filter((t) => idsSet.has(t.id));
178
+ const snapshots = ids.map((id) => {
179
+ const t = tasks.find((task) => task.id === id);
180
+ return {
181
+ content: t?.content ?? "Untitled",
182
+ description: t?.description,
183
+ priority: t?.priority,
184
+ due: t?.due,
185
+ labels: t?.labels,
186
+ project_id: t?.project_id,
187
+ } as Partial<Task>;
188
+ });
189
+ const prevTasks = [...tasks];
190
+ onTasksChange(tasks.filter((t) => !idsSet.has(t.id)));
191
+ startUndoTimer({ type: "delete", taskIds: ids, previousState: snapshots });
192
+ setSelectedIds(new Set());
193
+ setRangeSelectAnchor(null);
194
+ setTaskIndex(0);
195
+ showStatus(`${count} tasks deleted! Press u to undo (10s)`);
196
+ try {
197
+ await Promise.all(ids.map((id) => deleteTask(id)));
198
+ if (pluginHooks) {
199
+ for (const t of deletedTasks) {
200
+ try { await pluginHooks.emit("task.deleted", { task: t }); } catch (err) { console.warn("[plugin-hook]", err); }
201
+ }
202
+ }
203
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
204
+ } catch {
205
+ onTasksChange(prevTasks);
206
+ if (lastActionRef.current) clearTimeout(lastActionRef.current.timer);
207
+ clearUndo();
208
+ showStatus("Failed to delete some tasks");
209
+ }
210
+ }, [selectedIds, tasks, onTasksChange, startUndoTimer, refreshTasks, showStatus, setModal, setSelectedIds, setRangeSelectAnchor, setTaskIndex, lastActionRef, clearUndo, pluginHooks]);
211
+
212
+ const handleSetPriority = useCallback(
213
+ async (priority: 1 | 2 | 3 | 4) => {
214
+ const targetIds = selectedIds.size > 0 ? Array.from(selectedIds) : selectedTask ? [selectedTask.id] : [];
215
+ if (targetIds.length === 0) return;
216
+ const previousState = targetIds.map((id) => {
217
+ const t = tasks.find((task) => task.id === id);
218
+ return { priority: t?.priority, id };
219
+ });
220
+ const prevTasks = [...tasks];
221
+ const targetSet = new Set(targetIds);
222
+ onTasksChange(tasks.map((t) => (targetSet.has(t.id) ? { ...t, priority } : t)));
223
+ startUndoTimer({ type: "priority", taskIds: targetIds, previousState });
224
+ if (selectedIds.size > 0) {
225
+ setSelectedIds(new Set());
226
+ setRangeSelectAnchor(null);
227
+ }
228
+ showStatus(`Priority set to p${priority}. Press u to undo (10s)`);
229
+ try {
230
+ await Promise.all(targetIds.map((id) => updateTask(id, { priority })));
231
+ if (pluginHooks) {
232
+ for (const id of targetIds) {
233
+ const t = tasks.find((task) => task.id === id);
234
+ if (t) {
235
+ try { await pluginHooks.emit("task.updated", { task: { ...t, priority }, changes: { priority } }); } catch (err) { console.warn("[plugin-hook]", err); }
236
+ }
237
+ }
238
+ }
239
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
240
+ } catch {
241
+ onTasksChange(prevTasks);
242
+ if (lastActionRef.current) clearTimeout(lastActionRef.current.timer);
243
+ clearUndo();
244
+ showStatus("Failed to set priority");
245
+ }
246
+ },
247
+ [selectedIds, selectedTask, tasks, onTasksChange, startUndoTimer, refreshTasks, showStatus, setSelectedIds, setRangeSelectAnchor, lastActionRef, clearUndo, pluginHooks],
248
+ );
249
+
250
+ const handleSetDueDate = useCallback(
251
+ async (dueString: string) => {
252
+ setModal("none");
253
+ const targetIds = selectedIds.size > 0 ? Array.from(selectedIds) : selectedTask ? [selectedTask.id] : [];
254
+ if (targetIds.length === 0) return;
255
+ const isRemove = dueString.toLowerCase() === "none" || dueString.toLowerCase() === "clear";
256
+ const prevTasks = [...tasks];
257
+ const targetSet = new Set(targetIds);
258
+ onTasksChange(tasks.map((t) => {
259
+ if (!targetSet.has(t.id)) return t;
260
+ if (isRemove) return { ...t, due: null };
261
+ return { ...t, due: { ...t.due, date: t.due?.date ?? "", string: dueString, is_recurring: false } as Task["due"] };
262
+ }));
263
+ if (selectedIds.size > 0) {
264
+ setSelectedIds(new Set());
265
+ setRangeSelectAnchor(null);
266
+ }
267
+ showStatus(isRemove ? "Due date removed!" : `Due date set to "${dueString}"!`);
268
+ try {
269
+ if (isRemove) {
270
+ await Promise.all(targetIds.map((id) => updateTask(id, { due_string: "no date" })));
271
+ } else {
272
+ await Promise.all(targetIds.map((id) => updateTask(id, { due_string: dueString })));
273
+ }
274
+ if (pluginHooks) {
275
+ const changes = isRemove ? { due_string: "no date" } : { due_string: dueString };
276
+ for (const id of targetIds) {
277
+ const t = tasks.find((task) => task.id === id);
278
+ if (t) {
279
+ try { await pluginHooks.emit("task.updated", { task: t, changes }); } catch (err) { console.warn("[plugin-hook]", err); }
280
+ }
281
+ }
282
+ }
283
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
284
+ } catch {
285
+ onTasksChange(prevTasks);
286
+ showStatus("Failed to set due date");
287
+ }
288
+ },
289
+ [selectedIds, selectedTask, tasks, onTasksChange, refreshTasks, showStatus, setModal, setSelectedIds, setRangeSelectAnchor, pluginHooks],
290
+ );
291
+
292
+ const handleSetDeadline = useCallback(
293
+ async (value: string) => {
294
+ setModal("none");
295
+ const targetIds = selectedIds.size > 0 ? Array.from(selectedIds) : selectedTask ? [selectedTask.id] : [];
296
+ if (targetIds.length === 0) return;
297
+ const isRemove = value.toLowerCase() === "none" || value.toLowerCase() === "clear" || value === "";
298
+ if (!isRemove) {
299
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
300
+ showStatus("Invalid date format. Use YYYY-MM-DD.");
301
+ return;
302
+ }
303
+ const parsed = new Date(value + "T00:00:00");
304
+ if (isNaN(parsed.getTime())) {
305
+ showStatus("Invalid date. Check month/day values.");
306
+ return;
307
+ }
308
+ }
309
+ const prevTasks = [...tasks];
310
+ const targetSet = new Set(targetIds);
311
+ onTasksChange(tasks.map((t) => {
312
+ if (!targetSet.has(t.id)) return t;
313
+ return { ...t, deadline: isRemove ? null : { date: value } };
314
+ }));
315
+ if (selectedIds.size > 0) {
316
+ setSelectedIds(new Set());
317
+ setRangeSelectAnchor(null);
318
+ }
319
+ showStatus(isRemove ? "Deadline removed!" : `Deadline set to ${value}!`);
320
+ try {
321
+ await Promise.all(targetIds.map((id) => updateTask(id, { deadline_date: isRemove ? null : value })));
322
+ if (pluginHooks) {
323
+ const changes = { deadline_date: isRemove ? null : value };
324
+ for (const id of targetIds) {
325
+ const t = tasks.find((task) => task.id === id);
326
+ if (t) {
327
+ try { await pluginHooks.emit("task.updated", { task: t, changes }); } catch (err) { console.warn("[plugin-hook]", err); }
328
+ }
329
+ }
330
+ }
331
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
332
+ } catch {
333
+ onTasksChange(prevTasks);
334
+ showStatus("Failed to set deadline");
335
+ }
336
+ },
337
+ [selectedIds, selectedTask, tasks, onTasksChange, refreshTasks, showStatus, setModal, setSelectedIds, setRangeSelectAnchor, pluginHooks],
338
+ );
339
+
340
+ const handleAddTask = useCallback(
341
+ async (input: string) => {
342
+ try {
343
+ const parsed = parseQuickAdd(input);
344
+ const params: CreateTaskParams = { content: parsed.content };
345
+ if (parsed.priority) params.priority = parsed.priority;
346
+ if (parsed.due_string) {
347
+ params.due_string = parsed.due_string;
348
+ } else if (filterView === "Today") {
349
+ params.due_string = "today";
350
+ }
351
+ if (parsed.labels.length > 0) params.labels = parsed.labels;
352
+ if (parsed.project_name) {
353
+ const resolvedId = await resolveProjectName(parsed.project_name);
354
+ if (resolvedId) params.project_id = resolvedId;
355
+ } else if (filterProjectId) {
356
+ params.project_id = filterProjectId;
357
+ }
358
+ const newTask = await createTask(params);
359
+ try { await pluginHooks?.emit("task.created", { task: newTask }); } catch (err) { console.warn("[plugin-hook]", err); }
360
+ showStatus("Task created! Keep typing or Esc to close");
361
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
362
+ } catch {
363
+ showStatus("Failed to create task");
364
+ }
365
+ },
366
+ [refreshTasks, filterProjectId, filterView, showStatus, pluginHooks],
367
+ );
368
+
369
+ const handleCreateTaskFull = useCallback(
370
+ async (params: CreateTaskParams) => {
371
+ setModal("none");
372
+ try {
373
+ const newTask = await createTask(params);
374
+ try { await pluginHooks?.emit("task.created", { task: newTask }); } catch (err) { console.warn("[plugin-hook]", err); }
375
+ showStatus("Task created!");
376
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
377
+ } catch {
378
+ showStatus("Failed to create task");
379
+ }
380
+ },
381
+ [refreshTasks, showStatus, setModal, pluginHooks],
382
+ );
383
+
384
+ const handleAddSubtask = useCallback(
385
+ async (input: string) => {
386
+ setModal("none");
387
+ if (!selectedTask) return;
388
+ try {
389
+ const parsed = parseQuickAdd(input);
390
+ const params: CreateTaskParams = {
391
+ content: parsed.content,
392
+ parent_id: selectedTask.id,
393
+ };
394
+ if (parsed.priority) params.priority = parsed.priority;
395
+ if (parsed.due_string) params.due_string = parsed.due_string;
396
+ if (parsed.labels.length > 0) params.labels = parsed.labels;
397
+ const newTask = await createTask(params);
398
+ try { await pluginHooks?.emit("task.created", { task: newTask }); } catch (err) { console.warn("[plugin-hook]", err); }
399
+ showStatus("Subtask created!");
400
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
401
+ } catch {
402
+ showStatus("Failed to create subtask");
403
+ }
404
+ },
405
+ [selectedTask, refreshTasks, showStatus, setModal, pluginHooks],
406
+ );
407
+
408
+ const handleMoveToProject = useCallback(
409
+ async (projectId: string) => {
410
+ setModal("none");
411
+ const targetIds = selectedIds.size > 0 ? Array.from(selectedIds) : selectedTask ? [selectedTask.id] : [];
412
+ if (targetIds.length === 0) return;
413
+ const previousState = targetIds.map((id) => {
414
+ const t = tasks.find((task) => task.id === id);
415
+ return { project_id: t?.project_id };
416
+ });
417
+ const projectName = projects.find((p) => p.id === projectId)?.name ?? "project";
418
+ const prevTasks = [...tasks];
419
+ const targetSet = new Set(targetIds);
420
+ onTasksChange(tasks.map((t) => (targetSet.has(t.id) ? { ...t, project_id: projectId } : t)));
421
+ startUndoTimer({ type: "move", taskIds: targetIds, previousState });
422
+ if (selectedIds.size > 0) {
423
+ setSelectedIds(new Set());
424
+ setRangeSelectAnchor(null);
425
+ }
426
+ showStatus(`Moved to ${projectName}! Press u to undo (10s)`);
427
+ try {
428
+ await Promise.all(targetIds.map((id) => updateTask(id, { project_id: projectId })));
429
+ if (pluginHooks) {
430
+ for (const id of targetIds) {
431
+ const t = tasks.find((task) => task.id === id);
432
+ if (t) {
433
+ try { await pluginHooks.emit("task.updated", { task: { ...t, project_id: projectId }, changes: { project_id: projectId } }); } catch (err) { console.warn("[plugin-hook]", err); }
434
+ }
435
+ }
436
+ }
437
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
438
+ } catch {
439
+ onTasksChange(prevTasks);
440
+ if (lastActionRef.current) clearTimeout(lastActionRef.current.timer);
441
+ clearUndo();
442
+ showStatus("Failed to move task");
443
+ }
444
+ },
445
+ [selectedIds, selectedTask, tasks, projects, onTasksChange, startUndoTimer, refreshTasks, showStatus, setModal, setSelectedIds, setRangeSelectAnchor, lastActionRef, clearUndo, pluginHooks],
446
+ );
447
+
448
+ const handleLabelsSave = useCallback(
449
+ async (newLabels: string[]) => {
450
+ setModal("none");
451
+ const targetIds = selectedIds.size > 0 ? Array.from(selectedIds) : selectedTask ? [selectedTask.id] : [];
452
+ if (targetIds.length === 0) return;
453
+ const prevTasks = [...tasks];
454
+ const targetSet = new Set(targetIds);
455
+ onTasksChange(tasks.map((t) => (targetSet.has(t.id) ? { ...t, labels: newLabels } : t)));
456
+ if (selectedIds.size > 0) {
457
+ setSelectedIds(new Set());
458
+ setRangeSelectAnchor(null);
459
+ }
460
+ showStatus(`Labels updated for ${targetIds.length} task(s)!`);
461
+ try {
462
+ await Promise.all(targetIds.map((id) => updateTask(id, { labels: newLabels })));
463
+ if (pluginHooks) {
464
+ for (const id of targetIds) {
465
+ const t = tasks.find((task) => task.id === id);
466
+ if (t) {
467
+ try { await pluginHooks.emit("task.updated", { task: { ...t, labels: newLabels }, changes: { labels: newLabels } }); } catch (err) { console.warn("[plugin-hook]", err); }
468
+ }
469
+ }
470
+ }
471
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
472
+ } catch {
473
+ onTasksChange(prevTasks);
474
+ showStatus("Failed to update labels");
475
+ }
476
+ },
477
+ [selectedIds, selectedTask, tasks, onTasksChange, refreshTasks, showStatus, setModal, setSelectedIds, setRangeSelectAnchor, pluginHooks],
478
+ );
479
+
480
+ const handleEditTaskFull = useCallback(
481
+ async (params: UpdateTaskParams) => {
482
+ if (!selectedTask) return;
483
+ setModal("none");
484
+ const taskId = selectedTask.id;
485
+ const prevTasks = [...tasks];
486
+ onTasksChange(tasks.map((t) => {
487
+ if (t.id !== taskId) return t;
488
+ const updated = { ...t };
489
+ if (params.content !== undefined) updated.content = params.content;
490
+ if (params.description !== undefined) updated.description = params.description;
491
+ if (params.priority !== undefined) updated.priority = params.priority;
492
+ if (params.labels !== undefined) updated.labels = params.labels;
493
+ if (params.project_id !== undefined) updated.project_id = params.project_id;
494
+ return updated;
495
+ }));
496
+ showStatus("Task updated!");
497
+ try {
498
+ await updateTask(taskId, params);
499
+ try { await pluginHooks?.emit("task.updated", { task: selectedTask, changes: params }); } catch (err) { console.warn("[plugin-hook]", err); }
500
+ refreshTasks().catch(() => { /* background refresh — failure is non-critical */ });
501
+ } catch {
502
+ onTasksChange(prevTasks);
503
+ showStatus("Failed to update task");
504
+ }
505
+ },
506
+ [selectedTask, tasks, onTasksChange, refreshTasks, showStatus, setModal, pluginHooks],
507
+ );
508
+
509
+ const handleDuplicateTask = useCallback(async () => {
510
+ if (!selectedTask) return;
511
+ try {
512
+ const params: CreateTaskParams = {
513
+ content: `Copy of ${selectedTask.content}`,
514
+ priority: selectedTask.priority,
515
+ labels: selectedTask.labels.length > 0 ? selectedTask.labels : undefined,
516
+ project_id: selectedTask.project_id,
517
+ };
518
+ if (selectedTask.due) {
519
+ params.due_string = selectedTask.due.string;
520
+ }
521
+ if (selectedTask.description) {
522
+ params.description = selectedTask.description;
523
+ }
524
+ if (selectedTask.deadline) {
525
+ params.deadline_date = selectedTask.deadline.date;
526
+ }
527
+ if (selectedTask.section_id) {
528
+ params.section_id = selectedTask.section_id;
529
+ }
530
+ const newTask = await createTask(params);
531
+ onTasksChange([...tasks, newTask]);
532
+ try { await pluginHooks?.emit("task.created", { task: newTask }); } catch (err) { console.warn("[plugin-hook]", err); }
533
+ showStatus("Task duplicated!");
534
+ refreshTasks().catch(() => { /* background refresh -- failure is non-critical */ });
535
+ } catch {
536
+ showStatus("Failed to duplicate task");
537
+ }
538
+ }, [selectedTask, tasks, onTasksChange, refreshTasks, showStatus, pluginHooks]);
539
+
540
+ return {
541
+ handleCompleteTask,
542
+ handleDeleteConfirm,
543
+ handleEditTask,
544
+ handleRenameTask,
545
+ handleBulkComplete,
546
+ handleBulkDeleteConfirm,
547
+ handleSetPriority,
548
+ handleSetDueDate,
549
+ handleSetDeadline,
550
+ handleAddTask,
551
+ handleCreateTaskFull,
552
+ handleAddSubtask,
553
+ handleMoveToProject,
554
+ handleLabelsSave,
555
+ handleEditTaskFull,
556
+ handleDuplicateTask,
557
+ };
558
+ }