@mindtnv/todoist-cli 0.4.0 → 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 (84) hide show
  1. package/package.json +6 -6
  2. package/src/api/activity.ts +8 -0
  3. package/src/api/client.ts +214 -0
  4. package/src/api/comments.ts +18 -0
  5. package/src/api/completed.ts +15 -0
  6. package/src/api/labels.ts +18 -0
  7. package/src/api/projects.ts +22 -0
  8. package/src/api/sections.ts +20 -0
  9. package/src/api/stats.ts +38 -0
  10. package/src/api/tasks.ts +34 -0
  11. package/src/api/types.ts +202 -0
  12. package/src/cli/auth.ts +40 -0
  13. package/src/cli/commands/task/add.ts +328 -0
  14. package/src/cli/commands/task/complete.ts +62 -0
  15. package/src/cli/commands/task/delete.ts +62 -0
  16. package/src/cli/commands/task/helpers.ts +289 -0
  17. package/src/cli/commands/task/index.ts +27 -0
  18. package/src/cli/commands/task/list.ts +151 -0
  19. package/src/cli/commands/task/move.ts +49 -0
  20. package/src/cli/commands/task/reopen.ts +43 -0
  21. package/src/cli/commands/task/show.ts +115 -0
  22. package/src/cli/commands/task/update.ts +122 -0
  23. package/src/cli/comment.ts +83 -0
  24. package/src/cli/completed.ts +87 -0
  25. package/src/cli/completion.ts +360 -0
  26. package/src/cli/filter.ts +115 -0
  27. package/src/cli/index.ts +638 -0
  28. package/src/cli/label.ts +120 -0
  29. package/src/cli/log.ts +57 -0
  30. package/src/cli/matrix.ts +100 -0
  31. package/src/cli/plugin-loader.ts +38 -0
  32. package/src/cli/plugin.ts +289 -0
  33. package/src/cli/project.ts +172 -0
  34. package/src/cli/review.ts +116 -0
  35. package/src/cli/section.ts +98 -0
  36. package/src/cli/stats.ts +62 -0
  37. package/src/cli/template.ts +89 -0
  38. package/src/config/index.ts +229 -0
  39. package/src/plugins/api-proxy.ts +70 -0
  40. package/src/plugins/extension-registry.ts +53 -0
  41. package/src/plugins/hook-registry.ts +36 -0
  42. package/src/plugins/loader.ts +200 -0
  43. package/src/plugins/marketplace-types.ts +55 -0
  44. package/src/plugins/marketplace.ts +576 -0
  45. package/src/plugins/palette-registry.ts +21 -0
  46. package/src/plugins/storage.ts +101 -0
  47. package/src/plugins/types.ts +226 -0
  48. package/src/plugins/view-registry.ts +19 -0
  49. package/src/ui/App.tsx +234 -0
  50. package/src/ui/components/Breadcrumb.tsx +18 -0
  51. package/src/ui/components/CommandPalette.tsx +237 -0
  52. package/src/ui/components/ConfirmDialog.tsx +28 -0
  53. package/src/ui/components/EditTaskModal.tsx +484 -0
  54. package/src/ui/components/HelpOverlay.tsx +195 -0
  55. package/src/ui/components/InputPrompt.tsx +109 -0
  56. package/src/ui/components/LabelPicker.tsx +110 -0
  57. package/src/ui/components/ModalManager.tsx +275 -0
  58. package/src/ui/components/ProjectPicker.tsx +95 -0
  59. package/src/ui/components/Sidebar.tsx +282 -0
  60. package/src/ui/components/SortMenu.tsx +77 -0
  61. package/src/ui/components/StatusBar.tsx +67 -0
  62. package/src/ui/components/TaskList.tsx +258 -0
  63. package/src/ui/components/TaskRow.tsx +105 -0
  64. package/src/ui/hooks/useKeyboardHandler.ts +291 -0
  65. package/src/ui/hooks/useStatusMessage.ts +32 -0
  66. package/src/ui/hooks/useTaskOperations.ts +558 -0
  67. package/src/ui/hooks/useUndoSystem.ts +218 -0
  68. package/src/ui/views/ActivityView.tsx +213 -0
  69. package/src/ui/views/CompletedView.tsx +337 -0
  70. package/src/ui/views/StatsView.tsx +178 -0
  71. package/src/ui/views/TaskDetailView.tsx +438 -0
  72. package/src/ui/views/TasksView.tsx +851 -0
  73. package/src/utils/colors.ts +27 -0
  74. package/src/utils/date-format.ts +54 -0
  75. package/src/utils/errors.ts +159 -0
  76. package/src/utils/exit.ts +11 -0
  77. package/src/utils/format.ts +46 -0
  78. package/src/utils/open-url.ts +9 -0
  79. package/src/utils/output.ts +29 -0
  80. package/src/utils/quick-add.ts +202 -0
  81. package/src/utils/resolve.ts +359 -0
  82. package/src/utils/sorting.ts +27 -0
  83. package/src/utils/validation.ts +88 -0
  84. package/dist/index.js +0 -11355
@@ -0,0 +1,105 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import type { Task } from "../../api/types.ts";
4
+ import type { TaskColumnDefinition, PluginContext } from "../../plugins/types.ts";
5
+ import { formatRelativeDue, formatDeadlineShort, isDeadlineUrgent } from "../../utils/date-format.ts";
6
+
7
+ interface TaskRowProps {
8
+ task: Task;
9
+ isSelected: boolean;
10
+ isMarked?: boolean;
11
+ depth?: number;
12
+ searchQuery?: string;
13
+ pluginColumns?: TaskColumnDefinition[];
14
+ pluginColumnContextMap?: Map<string, PluginContext>;
15
+ }
16
+
17
+ function highlightMatch(text: string, query: string): React.ReactNode {
18
+ if (!query) return text;
19
+ const lowerText = text.toLowerCase();
20
+ const lowerQuery = query.toLowerCase();
21
+ const idx = lowerText.indexOf(lowerQuery);
22
+ if (idx === -1) return text;
23
+ const before = text.slice(0, idx);
24
+ const match = text.slice(idx, idx + query.length);
25
+ const after = text.slice(idx + query.length);
26
+ return (
27
+ <>
28
+ {before ? <Text>{before}</Text> : null}
29
+ <Text backgroundColor="yellow" color="black">{match}</Text>
30
+ {after ? <Text>{after}</Text> : null}
31
+ </>
32
+ );
33
+ }
34
+
35
+ const priorityConfig: Record<number, { dot: string; color: string }> = {
36
+ 4: { dot: "\u25CF", color: "red" },
37
+ 3: { dot: "\u25CF", color: "yellow" },
38
+ 2: { dot: "\u25CF", color: "blue" },
39
+ 1: { dot: "\u25CB", color: "gray" },
40
+ };
41
+
42
+ export function TaskRow({ task, isSelected, isMarked = false, depth = 0, searchQuery, pluginColumns, pluginColumnContextMap }: TaskRowProps) {
43
+ const checkbox = task.is_completed ? "\u2611" : "\u2610";
44
+ const prio = priorityConfig[task.priority] ?? { dot: "\u25CB", color: "gray" };
45
+ const dueInfo = task.due ? formatRelativeDue(task.due.date) : null;
46
+ const dueText = dueInfo ? dueInfo.text : "";
47
+ const dueColor = dueInfo ? dueInfo.color : "cyan";
48
+ const recurringIndicator = task.due?.is_recurring ? " \u21BB" : "";
49
+ const labelText = task.labels.length > 0 ? task.labels.map((l) => `@${l}`).join(" ") : "";
50
+ const marker = isMarked ? "\u25cf " : " ";
51
+ const indent = depth > 0 ? " ".repeat(depth) + "\u2514 " : "";
52
+ const deadlineText = task.deadline ? formatDeadlineShort(task.deadline.date) : "";
53
+ const deadlineUrgent = task.deadline ? isDeadlineUrgent(task.deadline.date) : false;
54
+ const commentCount = task.comment_count ?? 0;
55
+ const commentText = commentCount > 0 ? `\u2709 ${commentCount}` : "";
56
+
57
+ // Truncate content to fit terminal width
58
+ const termWidth = process.stdout.columns ?? 80;
59
+ const markerWidth = 2; // "* " or " "
60
+ const indentWidth = indent.length;
61
+ const checkboxPrioWidth = 4; // "X D " (checkbox + space + dot + space)
62
+ const dueWidth = dueText ? dueText.length + 3 : 0; // " [text]"
63
+ const recurringWidth = recurringIndicator ? 2 : 0;
64
+ const deadlineWidth = deadlineText ? deadlineText.length + 3 : 0; // " F date"
65
+ const labelWidth = labelText ? labelText.length + 1 : 0;
66
+ const commentWidth = commentText ? commentText.length + 1 : 0;
67
+ // Sidebar width is dynamic (24-38), estimate from terminal width
68
+ const sidebarWidth = Math.min(38, Math.max(24, Math.floor(termWidth * 0.25)));
69
+ const borderPadding = 4; // task list borders/padding
70
+ const pluginColumnsWidth = pluginColumns?.reduce((w, c) => w + (c.width ?? 8) + 1, 0) ?? 0;
71
+ const overhead = sidebarWidth + borderPadding + markerWidth + indentWidth + checkboxPrioWidth + dueWidth + recurringWidth + deadlineWidth + labelWidth + commentWidth + pluginColumnsWidth + 1;
72
+ const maxContent = Math.max(10, termWidth - overhead);
73
+ const content = task.content.length > maxContent ? task.content.slice(0, maxContent - 1) + "\u2026" : task.content;
74
+
75
+ return (
76
+ <Box>
77
+ <Text
78
+ backgroundColor={isSelected && isMarked ? "magenta" : isSelected ? "blue" : isMarked ? "gray" : undefined}
79
+ color={isSelected || isMarked ? "white" : undefined}
80
+ >
81
+ <Text color={isMarked ? "yellow" : "gray"}>{marker}</Text>
82
+ {indent ? <Text color="gray" dimColor>{indent}</Text> : null}
83
+ <Text>{checkbox} </Text>
84
+ <Text color={prio.color}>{prio.dot}</Text>
85
+ {" "}
86
+ <Text strikethrough={task.is_completed}>{searchQuery ? highlightMatch(content, searchQuery) : content}</Text>
87
+ {dueText ? <Text color={dueColor}>{` [${dueText}]`}</Text> : null}
88
+ {recurringIndicator ? <Text color="cyan">{recurringIndicator}</Text> : null}
89
+ {deadlineText ? <Text color={deadlineUrgent ? "red" : "magenta"} bold={deadlineUrgent}>{` \u2691 ${deadlineText}`}</Text> : null}
90
+ {labelText ? <Text color="magenta">{` ${labelText}`}</Text> : null}
91
+ {commentText ? <Text color="gray">{` ${commentText}`}</Text> : null}
92
+ {pluginColumns?.map(col => {
93
+ const colCtx = pluginColumnContextMap?.get(col.id);
94
+ if (!colCtx) return null;
95
+ const rawText = col.render(task, colCtx) ?? "";
96
+ const colWidth = col.width ?? 8;
97
+ const fixedText = rawText.length > colWidth ? rawText.slice(0, colWidth - 1) + "\u2026" : rawText.padEnd(colWidth);
98
+ const textColor = col.color?.(task) ?? "dim";
99
+ return <Text key={col.id} color={textColor}>{` ${fixedText}`}</Text>;
100
+ })}
101
+ {" "}
102
+ </Text>
103
+ </Box>
104
+ );
105
+ }
@@ -0,0 +1,291 @@
1
+ import { useInput } from "ink";
2
+ import type { Task } from "../../api/types.ts";
3
+ import type { ExtensionRegistry, PluginContext } from "../../plugins/types.ts";
4
+
5
+ type Modal = "none" | "add" | "addSubtask" | "edit" | "delete" | "filter" | "search" | "help" | "sort" | "bulkDelete" | "command" | "due" | "deadline" | "move" | "label" | "editFull" | "createFull" | "rename" | "pluginInput" | "createProject" | "createLabel";
6
+ type Panel = "sidebar" | "tasks";
7
+
8
+ interface UseKeyboardHandlerOptions {
9
+ modal: Modal;
10
+ activePanel: Panel;
11
+ setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
12
+ setModal: (modal: Modal) => void;
13
+ selectedTask: Task | undefined;
14
+ selectedIds: Set<string>;
15
+ setSelectedIds: React.Dispatch<React.SetStateAction<Set<string>>>;
16
+ filteredTasks: Task[];
17
+ taskIndex: number;
18
+ rangeSelectAnchor: number | null;
19
+ setRangeSelectAnchor: React.Dispatch<React.SetStateAction<number | null>>;
20
+ searchQuery: string;
21
+ setSearchQuery: (q: string) => void;
22
+ apiFilteredTasks: Task[] | null;
23
+ setApiFilteredTasks: React.Dispatch<React.SetStateAction<Task[] | null>>;
24
+ setFilterProjectId: React.Dispatch<React.SetStateAction<string | undefined>>;
25
+ setFilterSectionId: React.Dispatch<React.SetStateAction<string | undefined>>;
26
+ setFilterLabel: React.Dispatch<React.SetStateAction<string | undefined>>;
27
+ setFilterView: React.Dispatch<React.SetStateAction<string>>;
28
+ setTaskIndex: React.Dispatch<React.SetStateAction<number>>;
29
+ pendingQuit: boolean;
30
+ setPendingQuit: React.Dispatch<React.SetStateAction<boolean>>;
31
+ pendingQuitTimerRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
32
+ showStatus: (msg: string) => void;
33
+ onQuit: () => void;
34
+ onOpenTask?: (task: Task) => void;
35
+ refreshTasks: () => Promise<void>;
36
+ // Handlers
37
+ handleCompleteTask: () => Promise<void>;
38
+ handleBulkComplete: () => Promise<void>;
39
+ handleSetPriority: (priority: 1 | 2 | 3 | 4) => Promise<void>;
40
+ handleUndo: () => Promise<void>;
41
+ handleRedo: () => Promise<void>;
42
+ handleDuplicateTask: () => Promise<void>;
43
+ handleOpenInBrowser: () => void;
44
+ handleCopyUrl: () => void;
45
+ toggleSelection: () => void;
46
+ handleRangeSelect: () => void;
47
+ // Plugin
48
+ pluginExtensions?: ExtensionRegistry | null;
49
+ pluginKeybindingContextMap?: Map<string, PluginContext>;
50
+ }
51
+
52
+ export function useKeyboardHandler({
53
+ modal,
54
+ activePanel,
55
+ setActivePanel,
56
+ setModal,
57
+ selectedTask,
58
+ selectedIds,
59
+ setSelectedIds,
60
+ filteredTasks,
61
+ rangeSelectAnchor,
62
+ setRangeSelectAnchor,
63
+ searchQuery,
64
+ setSearchQuery,
65
+ apiFilteredTasks,
66
+ setApiFilteredTasks,
67
+ setFilterProjectId,
68
+ setFilterSectionId,
69
+ setFilterLabel,
70
+ setFilterView,
71
+ setTaskIndex,
72
+ pendingQuit,
73
+ setPendingQuit,
74
+ pendingQuitTimerRef,
75
+ showStatus,
76
+ onQuit,
77
+ onOpenTask,
78
+ refreshTasks,
79
+ handleCompleteTask,
80
+ handleBulkComplete,
81
+ handleSetPriority,
82
+ handleUndo,
83
+ handleRedo,
84
+ handleDuplicateTask,
85
+ handleOpenInBrowser,
86
+ handleCopyUrl,
87
+ toggleSelection,
88
+ handleRangeSelect,
89
+ pluginExtensions,
90
+ pluginKeybindingContextMap,
91
+ }: UseKeyboardHandlerOptions) {
92
+ useInput((input, key) => {
93
+ if (modal === "search" || modal === "command") {
94
+ return;
95
+ }
96
+ if (modal !== "none") return;
97
+
98
+ if (key.tab) {
99
+ setActivePanel((p) => (p === "sidebar" ? "tasks" : "sidebar"));
100
+ return;
101
+ }
102
+ if (input === "h" && activePanel === "tasks") {
103
+ setActivePanel("sidebar");
104
+ return;
105
+ }
106
+ if (input === "l" && activePanel === "sidebar") {
107
+ setActivePanel("tasks");
108
+ return;
109
+ }
110
+ if (input === "q") {
111
+ if (selectedIds.size > 0 && !pendingQuit) {
112
+ setPendingQuit(true);
113
+ showStatus("Press q again to quit (selections will be lost)");
114
+ if (pendingQuitTimerRef.current) clearTimeout(pendingQuitTimerRef.current);
115
+ pendingQuitTimerRef.current = setTimeout(() => setPendingQuit(false), 2000);
116
+ return;
117
+ }
118
+ if (pendingQuitTimerRef.current) clearTimeout(pendingQuitTimerRef.current);
119
+ onQuit();
120
+ return;
121
+ }
122
+ if (activePanel === "tasks") {
123
+ if (input !== "q" && pendingQuit) {
124
+ setPendingQuit(false);
125
+ if (pendingQuitTimerRef.current) clearTimeout(pendingQuitTimerRef.current);
126
+ }
127
+ if (key.escape) {
128
+ if (rangeSelectAnchor !== null) {
129
+ setRangeSelectAnchor(null);
130
+ showStatus("");
131
+ return;
132
+ }
133
+ if (selectedIds.size > 0) {
134
+ setSelectedIds(new Set());
135
+ showStatus("");
136
+ return;
137
+ }
138
+ if (searchQuery) {
139
+ setSearchQuery("");
140
+ showStatus("");
141
+ return;
142
+ }
143
+ if (apiFilteredTasks !== null) {
144
+ setApiFilteredTasks(null);
145
+ setFilterView("Inbox");
146
+ setTaskIndex(0);
147
+ showStatus("");
148
+ return;
149
+ }
150
+ return;
151
+ }
152
+ if (key.ctrl && input === "a") {
153
+ const allIds = new Set(filteredTasks.map((t) => t.id));
154
+ setSelectedIds(allIds);
155
+ showStatus(`${allIds.size} tasks selected`);
156
+ return;
157
+ }
158
+ if (key.ctrl && input === "n") {
159
+ setSelectedIds(new Set());
160
+ setRangeSelectAnchor(null);
161
+ showStatus("");
162
+ return;
163
+ }
164
+ if (input === " ") {
165
+ toggleSelection();
166
+ return;
167
+ }
168
+ if (input === "v") {
169
+ handleRangeSelect();
170
+ return;
171
+ }
172
+ if (key.return && selectedTask && onOpenTask) {
173
+ onOpenTask(selectedTask);
174
+ return;
175
+ }
176
+ if (input === "1" || input === "2" || input === "3" || input === "4") {
177
+ handleSetPriority(Number(input) as 1 | 2 | 3 | 4);
178
+ return;
179
+ }
180
+ if (input === "u") {
181
+ handleUndo();
182
+ return;
183
+ }
184
+ if (input === "U") {
185
+ handleRedo();
186
+ return;
187
+ }
188
+ if (input === "Y") {
189
+ handleDuplicateTask();
190
+ return;
191
+ }
192
+ if (input === "a") {
193
+ setModal("add");
194
+ } else if (input === "N") {
195
+ setModal("createFull");
196
+ } else if (input === "A") {
197
+ if (selectedTask) {
198
+ setModal("addSubtask");
199
+ }
200
+ } else if (input === "c") {
201
+ if (selectedIds.size > 0) {
202
+ handleBulkComplete();
203
+ } else if (selectedTask) {
204
+ handleCompleteTask();
205
+ }
206
+ } else if (input === "d") {
207
+ if (selectedIds.size > 0) {
208
+ setModal("bulkDelete");
209
+ } else if (selectedTask) {
210
+ setModal("delete");
211
+ }
212
+ } else if (input === "D") {
213
+ if (selectedTask || selectedIds.size > 0) {
214
+ setModal("deadline");
215
+ }
216
+ } else if (input === "t") {
217
+ if (selectedTask || selectedIds.size > 0) {
218
+ setModal("due");
219
+ }
220
+ } else if (input === "m") {
221
+ if (selectedTask || selectedIds.size > 0) {
222
+ setModal("move");
223
+ }
224
+ } else if (input === "l") {
225
+ if (selectedTask || selectedIds.size > 0) {
226
+ setModal("label");
227
+ }
228
+ } else if (input === "o") {
229
+ handleOpenInBrowser();
230
+ } else if (input === "y") {
231
+ handleCopyUrl();
232
+ } else if (input === "/") {
233
+ setModal("search");
234
+ } else if (input === "f") {
235
+ setModal("filter");
236
+ } else if (input === "s") {
237
+ setModal("sort");
238
+ } else if (input === "?") {
239
+ setModal("help");
240
+ } else if (input === ":") {
241
+ setModal("command");
242
+ } else if (input === "e") {
243
+ if (selectedTask) {
244
+ setModal("editFull");
245
+ }
246
+ } else if (input === "r") {
247
+ if (selectedTask) {
248
+ setModal("rename");
249
+ }
250
+ } else if (input === "R") {
251
+ refreshTasks();
252
+ showStatus("Refreshing...");
253
+ } else if (input === "!") {
254
+ setFilterProjectId(undefined);
255
+ setFilterSectionId(undefined);
256
+ setFilterLabel(undefined);
257
+ setFilterView("Inbox");
258
+ setApiFilteredTasks(null);
259
+ setTaskIndex(0);
260
+ } else if (input === "@") {
261
+ setFilterProjectId(undefined);
262
+ setFilterSectionId(undefined);
263
+ setFilterLabel(undefined);
264
+ setFilterView("Today");
265
+ setApiFilteredTasks(null);
266
+ setTaskIndex(0);
267
+ } else if (input === "#") {
268
+ setFilterProjectId(undefined);
269
+ setFilterSectionId(undefined);
270
+ setFilterLabel(undefined);
271
+ setFilterView("Upcoming");
272
+ setApiFilteredTasks(null);
273
+ setTaskIndex(0);
274
+ } else if (pluginExtensions) {
275
+ const keyStr = key.ctrl ? `ctrl+${input}` : input;
276
+ const binding = pluginExtensions.getKeybindings().find(k => k.key === keyStr);
277
+ if (binding) {
278
+ const ctx = pluginKeybindingContextMap?.get(binding.key);
279
+ if (ctx) {
280
+ binding.action(ctx, selectedTask ?? null).then((result) => {
281
+ if (result?.statusMessage) showStatus(result.statusMessage);
282
+ }).catch((err) => {
283
+ console.warn("[plugin-keybinding]", err);
284
+ showStatus(`Plugin error: ${err instanceof Error ? err.message : "unknown"}`);
285
+ });
286
+ }
287
+ }
288
+ }
289
+ }
290
+ });
291
+ }
@@ -0,0 +1,32 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ interface UseStatusMessageOptions {
4
+ initialMessage?: string;
5
+ autoClearMs?: number;
6
+ onInitialClear?: () => void;
7
+ }
8
+
9
+ export function useStatusMessage({
10
+ initialMessage,
11
+ autoClearMs = 3000,
12
+ onInitialClear,
13
+ }: UseStatusMessageOptions = {}) {
14
+ const [message, setMessage] = useState("");
15
+
16
+ // Pick up initial status from parent (e.g. after detail view action)
17
+ useEffect(() => {
18
+ if (initialMessage) {
19
+ setMessage(initialMessage);
20
+ onInitialClear?.();
21
+ }
22
+ }, [initialMessage, onInitialClear]);
23
+
24
+ // Auto-clear status message
25
+ useEffect(() => {
26
+ if (!message) return;
27
+ const timer = setTimeout(() => setMessage(""), autoClearMs);
28
+ return () => clearTimeout(timer);
29
+ }, [message, autoClearMs]);
30
+
31
+ return { message, show: setMessage, clear: () => setMessage("") };
32
+ }