@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,27 @@
1
+ /** Maps Todoist API color names to terminal-compatible color strings. */
2
+ export const todoistColorMap: Record<string, string> = {
3
+ berry_red: "red",
4
+ red: "red",
5
+ orange: "yellow",
6
+ yellow: "yellow",
7
+ olive_green: "green",
8
+ lime_green: "green",
9
+ green: "green",
10
+ mint_green: "green",
11
+ teal: "cyan",
12
+ sky_blue: "cyan",
13
+ light_blue: "blue",
14
+ blue: "blue",
15
+ grape: "magenta",
16
+ violet: "magenta",
17
+ lavender: "magenta",
18
+ magenta: "magenta",
19
+ salmon: "red",
20
+ charcoal: "gray",
21
+ grey: "gray",
22
+ taupe: "gray",
23
+ };
24
+
25
+ export function mapTodoistColor(color: string): string {
26
+ return todoistColorMap[color] ?? "cyan";
27
+ }
@@ -0,0 +1,54 @@
1
+ const SHORT_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
2
+ const FULL_MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
3
+
4
+ function getTodayString(): string {
5
+ const today = new Date();
6
+ return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
7
+ }
8
+
9
+ export function formatDeadlineShort(dateStr: string): string {
10
+ const parts = dateStr.split("-").map(Number);
11
+ const m = parts[1] ?? 1;
12
+ const d = parts[2] ?? 1;
13
+ return `${SHORT_MONTHS[m - 1]} ${d}`;
14
+ }
15
+
16
+ export function formatDeadlineLong(dateStr: string): string {
17
+ const parts = dateStr.split("-").map(Number);
18
+ const y = parts[0] ?? 2025;
19
+ const m = parts[1] ?? 1;
20
+ const d = parts[2] ?? 1;
21
+ return `${FULL_MONTHS[m - 1]} ${d}, ${y}`;
22
+ }
23
+
24
+ export function isDeadlineUrgent(dateStr: string): boolean {
25
+ const todayStr = getTodayString();
26
+ const deadline = new Date(dateStr + "T00:00:00");
27
+ const now = new Date(todayStr + "T00:00:00");
28
+ const diffDays = (deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
29
+ return diffDays <= 3;
30
+ }
31
+
32
+ export function isDeadlineOverdue(dateStr: string): boolean {
33
+ return dateStr < getTodayString();
34
+ }
35
+
36
+ export function formatRelativeDue(dateStr: string): { text: string; color: string } {
37
+ const todayStr = getTodayString();
38
+ const todayDate = new Date(todayStr + "T00:00:00");
39
+ const dueDate = new Date(dateStr.slice(0, 10) + "T00:00:00");
40
+ const diffDays = Math.round((dueDate.getTime() - todayDate.getTime()) / (1000 * 60 * 60 * 24));
41
+
42
+ if (diffDays < 0) return { text: "overdue", color: "red" };
43
+ if (diffDays === 0) return { text: "today", color: "green" };
44
+ if (diffDays === 1) return { text: "tomorrow", color: "yellow" };
45
+
46
+ const m = dueDate.getMonth();
47
+ const d = dueDate.getDate();
48
+ return { text: `${SHORT_MONTHS[m]} ${d}`, color: "cyan" };
49
+ }
50
+
51
+ export function formatCreatedAt(isoString: string): string {
52
+ const d = new Date(isoString);
53
+ return `${FULL_MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()} at ${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`;
54
+ }
@@ -0,0 +1,159 @@
1
+ import chalk from "chalk";
2
+
3
+ export const EXIT_OK = 0;
4
+ export const EXIT_USAGE = 2;
5
+ export const EXIT_AUTH = 3;
6
+ export const EXIT_NETWORK = 4;
7
+ export const EXIT_NOT_FOUND = 5;
8
+
9
+ export enum ErrorCode {
10
+ AUTH_FAILED = "AUTH_FAILED",
11
+ RATE_LIMITED = "RATE_LIMITED",
12
+ NOT_FOUND = "NOT_FOUND",
13
+ VALIDATION = "VALIDATION",
14
+ NETWORK = "NETWORK",
15
+ UNKNOWN = "UNKNOWN",
16
+ }
17
+
18
+ let DEBUG = false;
19
+
20
+ export function setDebug(value: boolean): void {
21
+ DEBUG = value;
22
+ }
23
+
24
+ export function isDebug(): boolean {
25
+ return DEBUG;
26
+ }
27
+
28
+ export function debug(...args: unknown[]): void {
29
+ if (DEBUG) console.error("[debug]", ...args);
30
+ }
31
+
32
+ export class CliError extends Error {
33
+ code: number;
34
+ errorCode: ErrorCode;
35
+ suggestion?: string;
36
+ helpUrl?: string;
37
+
38
+ constructor(message: string, opts: { code: number; errorCode?: ErrorCode; suggestion?: string; helpUrl?: string }) {
39
+ super(message);
40
+ this.name = "CliError";
41
+ this.code = opts.code;
42
+ this.errorCode = opts.errorCode ?? ErrorCode.UNKNOWN;
43
+ this.suggestion = opts.suggestion;
44
+ this.helpUrl = opts.helpUrl;
45
+ }
46
+ }
47
+
48
+ export function wrapApiError(err: unknown): CliError {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+
51
+ debug("wrapApiError called with:", message);
52
+
53
+ // Check for HTTP status codes in the error message
54
+ const statusMatch = message.match(/\b(4\d{2}|5\d{2})\b/);
55
+ const statusCode = statusMatch ? parseInt(statusMatch[1]!, 10) : null;
56
+
57
+ if (message.includes("Authentication failed") || message.includes("401") || message.includes("403") || statusCode === 401 || statusCode === 403) {
58
+ return new CliError("Authentication failed. Run `todoist auth` to set your API token.", {
59
+ code: EXIT_AUTH,
60
+ errorCode: ErrorCode.AUTH_FAILED,
61
+ suggestion: "Run `todoist auth` to set a valid API token.",
62
+ });
63
+ }
64
+
65
+ if (message.includes("Rate limit") || message.includes("429") || statusCode === 429) {
66
+ return new CliError("Rate limit exceeded. Please wait a moment and try again.", {
67
+ code: EXIT_NETWORK,
68
+ errorCode: ErrorCode.RATE_LIMITED,
69
+ suggestion: "Wait a moment and try again. Todoist allows ~450 requests per 15 minutes.",
70
+ });
71
+ }
72
+
73
+ if (message.includes("404") || message.includes("not found") || statusCode === 404) {
74
+ return new CliError("Resource not found. The task/project may have been deleted.", {
75
+ code: EXIT_NOT_FOUND,
76
+ errorCode: ErrorCode.NOT_FOUND,
77
+ suggestion: "Check the ID and try again. The resource may have been deleted or moved.",
78
+ });
79
+ }
80
+
81
+ if (statusCode && statusCode >= 500) {
82
+ return new CliError("Todoist API is experiencing issues. Try again later.", {
83
+ code: EXIT_NETWORK,
84
+ errorCode: ErrorCode.NETWORK,
85
+ suggestion: "The Todoist servers are having problems. Check https://status.todoist.com for updates.",
86
+ });
87
+ }
88
+
89
+ if (message.includes("fetch") || message.includes("ECONNREFUSED") || message.includes("ENOTFOUND") || message.includes("ETIMEDOUT")) {
90
+ return new CliError("Network error — could not reach the Todoist API.", {
91
+ code: EXIT_NETWORK,
92
+ errorCode: ErrorCode.NETWORK,
93
+ suggestion: "Check your internet connection and try again.",
94
+ });
95
+ }
96
+
97
+ return new CliError(message, { code: 1, errorCode: ErrorCode.UNKNOWN });
98
+ }
99
+
100
+ export function formatCliError(err: CliError): string {
101
+ let out = chalk.red(`Error: ${err.message}`);
102
+ if (DEBUG) {
103
+ out += `\n${chalk.dim(`[${err.errorCode}] exit code: ${err.code}`)}`;
104
+ if (err.stack) {
105
+ out += `\n${chalk.dim(err.stack)}`;
106
+ }
107
+ }
108
+ if (err.suggestion) {
109
+ out += `\n${chalk.yellow("Hint:")} ${err.suggestion}`;
110
+ }
111
+ if (err.helpUrl) {
112
+ out += `\n${chalk.dim("More info:")} ${err.helpUrl}`;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function levenshtein(a: string, b: string): number {
118
+ const m = a.length;
119
+ const n = b.length;
120
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0) as number[]);
121
+
122
+ for (let i = 0; i <= m; i++) dp[i]![0] = i;
123
+ for (let j = 0; j <= n; j++) dp[0]![j] = j;
124
+
125
+ for (let i = 1; i <= m; i++) {
126
+ for (let j = 1; j <= n; j++) {
127
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
128
+ dp[i]![j] = Math.min(
129
+ dp[i - 1]![j]! + 1,
130
+ dp[i]![j - 1]! + 1,
131
+ dp[i - 1]![j - 1]! + cost,
132
+ );
133
+ }
134
+ }
135
+
136
+ return dp[m]![n]!;
137
+ }
138
+
139
+ export function didYouMean(input: string, candidates: string[]): string | null {
140
+ let bestMatch: string | null = null;
141
+ let bestDist = Infinity;
142
+
143
+ for (const c of candidates) {
144
+ const dist = levenshtein(input.toLowerCase(), c.toLowerCase());
145
+ if (dist < bestDist && dist <= 3) {
146
+ bestDist = dist;
147
+ bestMatch = c;
148
+ }
149
+ }
150
+
151
+ return bestMatch;
152
+ }
153
+
154
+ export function handleError(err: unknown): never {
155
+ const cliErr = err instanceof CliError ? err : wrapApiError(err);
156
+ debug("handleError:", cliErr.errorCode, cliErr.message);
157
+ console.error(formatCliError(cliErr));
158
+ process.exit(cliErr.code);
159
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Wrappable process.exit for testability.
3
+ * In tests, override `cliExit` to throw instead of exiting.
4
+ */
5
+ export let cliExit: (code?: number) => never = (code = 0) => {
6
+ process.exit(code);
7
+ };
8
+
9
+ export function setCLIExit(fn: (code?: number) => never): void {
10
+ cliExit = fn;
11
+ }
@@ -0,0 +1,46 @@
1
+ import chalk from "chalk";
2
+ import type { Priority } from "../api/types.ts";
3
+
4
+ export const ID_WIDTH = 18;
5
+ export const PRI_WIDTH = 6;
6
+ export const LABEL_WIDTH = 10;
7
+
8
+ export function getContentWidth(): number {
9
+ const cols = process.stdout.columns || 80;
10
+ // Fixed columns: ID + Pri + Due + Labels + 4 spaces between columns
11
+ const fixed = ID_WIDTH + 1 + PRI_WIDTH + 1 + 1 + LABEL_WIDTH;
12
+ const remaining = cols - fixed;
13
+ // Split remaining between content and due (roughly 3:1)
14
+ return Math.max(20, Math.floor(remaining * 0.7));
15
+ }
16
+
17
+ export function getDueWidth(): number {
18
+ const cols = process.stdout.columns || 80;
19
+ const fixed = ID_WIDTH + 1 + PRI_WIDTH + 1 + 1 + LABEL_WIDTH;
20
+ const remaining = cols - fixed;
21
+ const contentWidth = Math.max(20, Math.floor(remaining * 0.7));
22
+ return Math.max(10, remaining - contentWidth);
23
+ }
24
+
25
+ export function padEnd(str: string, len: number): string {
26
+ const stripped = str.replace(/\x1b\[[0-9;]*m/g, "");
27
+ const pad = Math.max(0, len - stripped.length);
28
+ return str + " ".repeat(pad);
29
+ }
30
+
31
+ export function priorityColor(p: Priority): (text: string) => string {
32
+ switch (p) {
33
+ case 1: return chalk.white;
34
+ case 2: return chalk.blue;
35
+ case 3: return chalk.yellow;
36
+ case 4: return chalk.red;
37
+ }
38
+ }
39
+
40
+ export function priorityLabel(p: Priority): string {
41
+ return priorityColor(p)(`p${p}`);
42
+ }
43
+
44
+ export function truncate(str: string, maxLen: number): string {
45
+ return str.length > maxLen ? str.slice(0, maxLen - 1) + "..." : str;
46
+ }
@@ -0,0 +1,9 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export function openUrl(url: string): void {
4
+ const platform = process.platform;
5
+ if (platform === "darwin") spawn("open", [url], { stdio: "ignore", detached: true }).unref();
6
+ else if (platform === "linux") spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
7
+ else if (platform === "win32") spawn("cmd", ["/c", "start", url], { stdio: "ignore", detached: true }).unref();
8
+ else throw new Error(`Unsupported platform: ${platform}`);
9
+ }
@@ -0,0 +1,29 @@
1
+ import type { Task } from "../api/types.ts";
2
+
3
+ function escapeCsvField(val: string, delimiter: string): string {
4
+ if (val.includes(delimiter) || val.includes('"') || val.includes("\n")) {
5
+ return `"${val.replace(/"/g, '""')}"`;
6
+ }
7
+ return val;
8
+ }
9
+
10
+ export function formatTasksDelimited(tasks: Task[], delimiter: string): string {
11
+ const headers = ["id", "content", "priority", "due_date", "deadline", "project_id", "labels", "description"];
12
+ const lines: string[] = [headers.join(delimiter)];
13
+
14
+ for (const t of tasks) {
15
+ const row = [
16
+ t.id,
17
+ escapeCsvField(t.content, delimiter),
18
+ String(t.priority),
19
+ t.due?.date ?? "",
20
+ t.deadline?.date ?? "",
21
+ t.project_id,
22
+ t.labels.join(";"),
23
+ escapeCsvField(t.description || "", delimiter),
24
+ ];
25
+ lines.push(row.join(delimiter));
26
+ }
27
+
28
+ return lines.join("\n");
29
+ }
@@ -0,0 +1,202 @@
1
+ import type { Priority } from "../api/types.ts";
2
+ import { getProjects } from "../api/projects.ts";
3
+
4
+ export interface QuickAddResult {
5
+ content: string;
6
+ description?: string;
7
+ priority?: Priority;
8
+ labels: string[];
9
+ due_string?: string;
10
+ project_name?: string;
11
+ project_id?: string;
12
+ section_name?: string;
13
+ deadline?: string; // YYYY-MM-DD
14
+ }
15
+
16
+ const PRIORITY_RE = /\bp([1-4])\b/;
17
+ const PROJECT_RE = /#"([^"]+)"|#'([^']+)'|#(\S+)/;
18
+ const LABEL_RE = /@(\S+)/g;
19
+ const SECTION_RE = /\/\/(\S+)/;
20
+ const DEADLINE_RE = /\{(\d{4}-\d{2}-\d{2})\}/;
21
+
22
+ // Description syntax: "// description text" (double slash + space separates description from content)
23
+ const DESCRIPTION_RE = /\s+\/\/\s+(.*)/;
24
+
25
+ // Recurrence syntax: !daily, !weekly, !monthly, !yearly, !weekdays, !every <text>
26
+ const RECURRENCE_SIMPLE_RE = /!(daily|weekly|monthly|yearly|weekdays)\b/;
27
+ const RECURRENCE_EVERY_RE = /!every\s+([^#@!{}\\/]+?)(?=\s+[#@!{p]|\s+\/\/|\s*$)/;
28
+
29
+ // Relative date patterns: +Nd for days, +Nw for weeks
30
+ const RELATIVE_DAYS_RE = /\+(\d+)d\b/;
31
+ const RELATIVE_WEEKS_RE = /\+(\d+)w\b/;
32
+
33
+ const DATE_KEYWORDS = [
34
+ "today",
35
+ "tomorrow",
36
+ "yesterday",
37
+ "monday",
38
+ "tuesday",
39
+ "wednesday",
40
+ "thursday",
41
+ "friday",
42
+ "saturday",
43
+ "sunday",
44
+ "next week",
45
+ "next month",
46
+ "every day",
47
+ "every week",
48
+ "every month",
49
+ ];
50
+
51
+ function extractDatePhrase(text: string): { date: string; remaining: string } | null {
52
+ const lower = text.toLowerCase();
53
+ for (const keyword of DATE_KEYWORDS) {
54
+ const idx = lower.indexOf(keyword);
55
+ if (idx !== -1) {
56
+ const remaining = text.slice(0, idx) + text.slice(idx + keyword.length);
57
+ return { date: keyword, remaining: remaining.replace(/\s+/g, " ").trim() };
58
+ }
59
+ }
60
+
61
+ // Match patterns like "Jan 15", "2026-02-10", "Feb 3rd"
62
+ const datePatternRe = /\b(\d{4}-\d{2}-\d{2}|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}(?:st|nd|rd|th)?)\b/i;
63
+ const match = datePatternRe.exec(text);
64
+ if (match) {
65
+ const remaining = text.slice(0, match.index) + text.slice(match.index + match[0].length);
66
+ return { date: match[0], remaining: remaining.replace(/\s+/g, " ").trim() };
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ export function parseQuickAdd(input: string): QuickAddResult {
73
+ let text = input;
74
+ const labels: string[] = [];
75
+
76
+ // Extract description first: "// description text" (double slash + space)
77
+ // Must happen before section extraction since //SectionName (no space) is different
78
+ let description: string | undefined;
79
+ const descMatch = DESCRIPTION_RE.exec(text);
80
+ if (descMatch) {
81
+ description = descMatch[1]!.trim();
82
+ text = text.slice(0, descMatch.index).trim();
83
+ }
84
+
85
+ // Extract priority
86
+ let priority: Priority | undefined;
87
+ const priMatch = PRIORITY_RE.exec(text);
88
+ if (priMatch) {
89
+ priority = parseInt(priMatch[1]!, 10) as Priority;
90
+ text = text.replace(priMatch[0], "").trim();
91
+ }
92
+
93
+ // Extract deadline {YYYY-MM-DD} before project/section to avoid conflicts
94
+ let deadline: string | undefined;
95
+ const deadlineMatch = DEADLINE_RE.exec(text);
96
+ if (deadlineMatch) {
97
+ deadline = deadlineMatch[1]!;
98
+ text = text.replace(deadlineMatch[0], "").trim();
99
+ }
100
+
101
+ // Extract recurrence syntax: !daily, !weekly, !every <text>
102
+ let dueString: string | undefined;
103
+ const recurrenceSimpleMatch = RECURRENCE_SIMPLE_RE.exec(text);
104
+ const recurrenceEveryMatch = RECURRENCE_EVERY_RE.exec(text);
105
+
106
+ if (recurrenceEveryMatch) {
107
+ // !every takes precedence when it comes before simple recurrence
108
+ dueString = "every " + recurrenceEveryMatch[1]!.trim();
109
+ text = text.replace(recurrenceEveryMatch[0], "").trim();
110
+ } else if (recurrenceSimpleMatch) {
111
+ const keyword = recurrenceSimpleMatch[1]!;
112
+ // Map shorthand to Todoist-compatible due_string
113
+ const recurrenceMap: Record<string, string> = {
114
+ daily: "every day",
115
+ weekly: "every week",
116
+ monthly: "every month",
117
+ yearly: "every year",
118
+ weekdays: "every weekday",
119
+ };
120
+ dueString = recurrenceMap[keyword] ?? keyword;
121
+ text = text.replace(recurrenceSimpleMatch[0], "").trim();
122
+ }
123
+
124
+ // Extract relative date patterns: +Nd, +Nw
125
+ if (!dueString) {
126
+ const relativeDaysMatch = RELATIVE_DAYS_RE.exec(text);
127
+ if (relativeDaysMatch) {
128
+ const n = parseInt(relativeDaysMatch[1]!, 10);
129
+ dueString = n === 1 ? "in 1 day" : `in ${n} days`;
130
+ text = text.replace(relativeDaysMatch[0], "").trim();
131
+ } else {
132
+ const relativeWeeksMatch = RELATIVE_WEEKS_RE.exec(text);
133
+ if (relativeWeeksMatch) {
134
+ const n = parseInt(relativeWeeksMatch[1]!, 10);
135
+ dueString = n === 1 ? "in 1 week" : `in ${n} weeks`;
136
+ text = text.replace(relativeWeeksMatch[0], "").trim();
137
+ }
138
+ }
139
+ }
140
+
141
+ // Extract section (//SectionName) before project (#Name) to avoid conflicts
142
+ let sectionName: string | undefined;
143
+ const sectionMatch = SECTION_RE.exec(text);
144
+ if (sectionMatch) {
145
+ sectionName = sectionMatch[1]!;
146
+ text = text.replace(sectionMatch[0], "").trim();
147
+ }
148
+
149
+ // Extract project (supports #Name, #"Multi Word", #'Multi Word')
150
+ let projectName: string | undefined;
151
+ const projectMatch = PROJECT_RE.exec(text);
152
+ if (projectMatch) {
153
+ projectName = projectMatch[1] ?? projectMatch[2] ?? projectMatch[3]!;
154
+ text = text.replace(projectMatch[0], "").trim();
155
+ }
156
+
157
+ // Extract labels
158
+ LABEL_RE.lastIndex = 0;
159
+ let labelMatch: RegExpExecArray | null;
160
+ while ((labelMatch = LABEL_RE.exec(text)) !== null) {
161
+ labels.push(labelMatch[1]!);
162
+ }
163
+ text = text.replace(LABEL_RE, "").trim();
164
+
165
+ // Extract date keywords (only if no recurrence/relative date was already found)
166
+ if (!dueString) {
167
+ const dateResult = extractDatePhrase(text);
168
+ if (dateResult) {
169
+ dueString = dateResult.date;
170
+ text = dateResult.remaining;
171
+ }
172
+ }
173
+
174
+ // Clean up extra whitespace
175
+ text = text.replace(/\s+/g, " ").trim();
176
+
177
+ return {
178
+ content: text,
179
+ description,
180
+ priority,
181
+ labels,
182
+ due_string: dueString,
183
+ project_name: projectName,
184
+ section_name: sectionName,
185
+ deadline,
186
+ };
187
+ }
188
+
189
+ export async function resolveProjectName(name: string): Promise<string | undefined> {
190
+ const projects = await getProjects();
191
+ const lower = name.toLowerCase();
192
+ const found = projects.find((p) => p.name.toLowerCase() === lower);
193
+ return found?.id;
194
+ }
195
+
196
+ export async function resolveSectionName(name: string, projectId?: string): Promise<string | undefined> {
197
+ const { getSections } = await import("../api/sections.ts");
198
+ const sections = await getSections(projectId);
199
+ const lower = name.toLowerCase();
200
+ const found = sections.find((s) => s.name.toLowerCase() === lower);
201
+ return found?.id;
202
+ }