todo-agent 0.3.3__py3-none-any.whl → 0.3.6__py3-none-any.whl
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.
- todo_agent/_version.py +2 -2
- todo_agent/core/todo_manager.py +299 -171
- todo_agent/infrastructure/inference.py +16 -9
- todo_agent/infrastructure/prompts/system_prompt.txt +124 -17
- todo_agent/infrastructure/todo_shell.py +63 -0
- todo_agent/interface/cli.py +53 -0
- todo_agent/interface/formatters.py +1 -0
- todo_agent/interface/tools.py +54 -10
- todo_agent/main.py +37 -2
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.6.dist-info}/METADATA +1 -1
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.6.dist-info}/RECORD +15 -15
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.6.dist-info}/WHEEL +0 -0
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.6.dist-info}/entry_points.txt +0 -0
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.6.dist-info}/licenses/LICENSE +0 -0
- {todo_agent-0.3.3.dist-info → todo_agent-0.3.6.dist-info}/top_level.txt +0 -0
todo_agent/_version.py
CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
28
28
|
commit_id: COMMIT_ID
|
29
29
|
__commit_id__: COMMIT_ID
|
30
30
|
|
31
|
-
__version__ = version = '0.3.
|
32
|
-
__version_tuple__ = version_tuple = (0, 3,
|
31
|
+
__version__ = version = '0.3.6'
|
32
|
+
__version_tuple__ = version_tuple = (0, 3, 6)
|
33
33
|
|
34
34
|
__commit_id__ = commit_id = None
|
todo_agent/core/todo_manager.py
CHANGED
@@ -12,86 +12,126 @@ class TodoManager:
|
|
12
12
|
def __init__(self, todo_shell: Any) -> None:
|
13
13
|
self.todo_shell = todo_shell
|
14
14
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
15
|
+
# ============================================================================
|
16
|
+
# VALIDATION METHODS
|
17
|
+
# ============================================================================
|
18
|
+
|
19
|
+
def _normalize_empty_to_none(self, value: Optional[str]) -> Optional[str]:
|
20
|
+
"""Convert empty strings to None for consistent handling."""
|
21
|
+
if value is None:
|
22
|
+
return None
|
23
|
+
if isinstance(value, str) and not value.strip():
|
24
|
+
return None
|
25
|
+
return value
|
26
|
+
|
27
|
+
def _validate_date_format(
|
28
|
+
self, date_str: Optional[str], field_name: str = "date"
|
29
|
+
) -> None:
|
30
|
+
"""Validate date format (YYYY-MM-DD)."""
|
31
|
+
if date_str is None or not date_str.strip():
|
32
|
+
return
|
33
|
+
try:
|
34
|
+
datetime.strptime(date_str, "%Y-%m-%d")
|
35
|
+
except ValueError:
|
36
|
+
raise ValueError(
|
37
|
+
f"Invalid {field_name} format '{date_str}'. Must be YYYY-MM-DD."
|
38
|
+
)
|
39
|
+
|
40
|
+
def _validate_priority(self, priority: Optional[str]) -> None:
|
41
|
+
"""Validate priority format (single uppercase letter A-Z)."""
|
42
|
+
if priority is None:
|
43
|
+
return
|
44
|
+
if not (len(priority) == 1 and priority.isalpha() and priority.isupper()):
|
29
45
|
raise ValueError(
|
30
46
|
f"Invalid priority '{priority}'. Must be a single uppercase letter (A-Z)."
|
31
47
|
)
|
32
48
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
49
|
+
def _validate_parent_number(self, parent_number: Optional[int]) -> None:
|
50
|
+
"""Validate parent task number."""
|
51
|
+
if parent_number is not None and (
|
52
|
+
not isinstance(parent_number, int) or parent_number <= 0
|
53
|
+
):
|
54
|
+
raise ValueError(
|
55
|
+
f"Invalid parent_number '{parent_number}'. Must be a positive integer."
|
56
|
+
)
|
40
57
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
raise ValueError(
|
46
|
-
"Context name cannot be empty after removing @ symbol."
|
47
|
-
)
|
58
|
+
def _validate_duration(self, duration: Optional[str]) -> None:
|
59
|
+
"""Validate duration format (e.g., '30m', '2h', '1d')."""
|
60
|
+
if duration is None:
|
61
|
+
return
|
48
62
|
|
49
|
-
if
|
50
|
-
|
51
|
-
try:
|
52
|
-
datetime.strptime(due, "%Y-%m-%d")
|
53
|
-
except ValueError:
|
54
|
-
raise ValueError(
|
55
|
-
f"Invalid due date format '{due}'. Must be YYYY-MM-DD."
|
56
|
-
)
|
63
|
+
if not isinstance(duration, str) or not duration.strip():
|
64
|
+
raise ValueError("Duration must be a non-empty string.")
|
57
65
|
|
58
|
-
if duration
|
59
|
-
|
60
|
-
|
61
|
-
|
66
|
+
if not any(duration.endswith(unit) for unit in ["m", "h", "d"]):
|
67
|
+
raise ValueError(
|
68
|
+
f"Invalid duration format '{duration}'. Must end with m (minutes), h (hours), or d (days)."
|
69
|
+
)
|
62
70
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
f"Invalid duration format '{duration}'. Must end with m (minutes), h (hours), or d (days)."
|
67
|
-
)
|
71
|
+
value = duration[:-1]
|
72
|
+
if not value:
|
73
|
+
raise ValueError("Duration value cannot be empty.")
|
68
74
|
|
69
|
-
|
70
|
-
|
71
|
-
if
|
72
|
-
raise ValueError("Duration value
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
if numeric_value <= 0:
|
78
|
-
raise ValueError("Duration value must be positive.")
|
79
|
-
except ValueError:
|
80
|
-
raise ValueError(
|
81
|
-
f"Invalid duration value '{value}'. Must be a positive number."
|
82
|
-
)
|
75
|
+
try:
|
76
|
+
numeric_value = float(value)
|
77
|
+
if numeric_value <= 0:
|
78
|
+
raise ValueError("Duration value must be positive.")
|
79
|
+
except ValueError:
|
80
|
+
raise ValueError(
|
81
|
+
f"Invalid duration value '{value}'. Must be a positive number."
|
82
|
+
)
|
83
83
|
|
84
|
-
|
84
|
+
def _clean_project_name(self, project: Optional[str]) -> Optional[str]:
|
85
|
+
"""Clean and validate project name."""
|
86
|
+
if project is None:
|
87
|
+
return None
|
88
|
+
if not project.strip():
|
89
|
+
return None
|
90
|
+
clean_project = project.strip().lstrip("+")
|
91
|
+
if not clean_project:
|
92
|
+
raise ValueError("Project name cannot be empty after removing + symbol.")
|
93
|
+
return clean_project
|
94
|
+
|
95
|
+
def _clean_context_name(self, context: Optional[str]) -> Optional[str]:
|
96
|
+
"""Clean and validate context name."""
|
97
|
+
if context is None:
|
98
|
+
return None
|
99
|
+
if not context.strip():
|
100
|
+
return "" # Return empty string for empty input (used by set_context for removal)
|
101
|
+
clean_context = context.strip().lstrip("@")
|
102
|
+
if not clean_context:
|
103
|
+
raise ValueError("Context name cannot be empty after removing @ symbol.")
|
104
|
+
return clean_context
|
105
|
+
|
106
|
+
# ============================================================================
|
107
|
+
# TASK BUILDING AND UTILITY METHODS
|
108
|
+
# ============================================================================
|
109
|
+
|
110
|
+
def _build_task_description(
|
111
|
+
self,
|
112
|
+
description: str,
|
113
|
+
priority: Optional[str] = None,
|
114
|
+
project: Optional[str] = None,
|
115
|
+
context: Optional[str] = None,
|
116
|
+
due: Optional[str] = None,
|
117
|
+
duration: Optional[str] = None,
|
118
|
+
parent_number: Optional[int] = None,
|
119
|
+
) -> str:
|
120
|
+
"""Build full task description with all components."""
|
85
121
|
full_description = description
|
86
122
|
|
87
123
|
if priority:
|
88
124
|
full_description = f"({priority}) {full_description}"
|
89
125
|
|
90
126
|
if project:
|
91
|
-
|
127
|
+
clean_project = self._clean_project_name(project)
|
128
|
+
if clean_project: # Only add if not None/empty after cleaning
|
129
|
+
full_description = f"{full_description} +{clean_project}"
|
92
130
|
|
93
131
|
if context:
|
94
|
-
|
132
|
+
clean_context = self._clean_context_name(context)
|
133
|
+
if clean_context: # Only add if not None/empty after cleaning
|
134
|
+
full_description = f"{full_description} @{clean_context}"
|
95
135
|
|
96
136
|
if due:
|
97
137
|
full_description = f"{full_description} due:{due}"
|
@@ -99,6 +139,86 @@ class TodoManager:
|
|
99
139
|
if duration:
|
100
140
|
full_description = f"{full_description} duration:{duration}"
|
101
141
|
|
142
|
+
if parent_number:
|
143
|
+
full_description = f"{full_description} parent:{parent_number}"
|
144
|
+
|
145
|
+
return full_description
|
146
|
+
|
147
|
+
def _format_task_operation_response(
|
148
|
+
self, operation: str, task_number: int, result: str, additional_info: str = ""
|
149
|
+
) -> str:
|
150
|
+
"""Format consistent task operation response messages."""
|
151
|
+
# Handle special cases for backward compatibility with tests
|
152
|
+
if operation == "Set due date" and additional_info:
|
153
|
+
return f"Set due date {additional_info} for task {task_number}: {result}"
|
154
|
+
elif operation == "Set context" and additional_info:
|
155
|
+
return f"Set context {additional_info} for task {task_number}: {result}"
|
156
|
+
elif operation == "Set priority" and additional_info:
|
157
|
+
return f"Set priority {additional_info} for task {task_number}: {result}"
|
158
|
+
else:
|
159
|
+
base_message = f"{operation} task {task_number}"
|
160
|
+
if additional_info:
|
161
|
+
base_message += f" ({additional_info})"
|
162
|
+
return f"{base_message}: {result}"
|
163
|
+
|
164
|
+
def _handle_empty_result(self, result: str, empty_message: str) -> str:
|
165
|
+
"""Handle empty results with consistent messaging."""
|
166
|
+
return empty_message if not result.strip() else result
|
167
|
+
|
168
|
+
def _find_task_number_by_description(self, description: str) -> int:
|
169
|
+
"""Find task number by description in the current task list."""
|
170
|
+
tasks = self.todo_shell.list_tasks()
|
171
|
+
task_lines = [line.strip() for line in tasks.split("\n") if line.strip()]
|
172
|
+
|
173
|
+
if not task_lines:
|
174
|
+
raise RuntimeError("Failed to add task - no tasks found after addition")
|
175
|
+
|
176
|
+
import re
|
177
|
+
|
178
|
+
for line in reversed(task_lines):
|
179
|
+
if description in line:
|
180
|
+
match = re.match(r"^(\d+)", line)
|
181
|
+
if match:
|
182
|
+
return int(match.group(1))
|
183
|
+
|
184
|
+
raise RuntimeError(
|
185
|
+
f"Could not find task with description '{description}' after adding it. "
|
186
|
+
f"This indicates a serious issue with task matching."
|
187
|
+
)
|
188
|
+
|
189
|
+
# ============================================================================
|
190
|
+
# CORE CRUD OPERATIONS
|
191
|
+
# ============================================================================
|
192
|
+
|
193
|
+
def add_task(
|
194
|
+
self,
|
195
|
+
description: str,
|
196
|
+
priority: Optional[str] = None,
|
197
|
+
project: Optional[str] = None,
|
198
|
+
context: Optional[str] = None,
|
199
|
+
due: Optional[str] = None,
|
200
|
+
duration: Optional[str] = None,
|
201
|
+
parent_number: Optional[int] = None,
|
202
|
+
) -> str:
|
203
|
+
"""Add new task with explicit project/context parameters."""
|
204
|
+
# Normalize empty strings to None for optional parameters
|
205
|
+
priority = self._normalize_empty_to_none(priority)
|
206
|
+
project = self._normalize_empty_to_none(project)
|
207
|
+
context = self._normalize_empty_to_none(context)
|
208
|
+
due = self._normalize_empty_to_none(due)
|
209
|
+
# Note: duration is not normalized - empty strings should raise validation errors
|
210
|
+
|
211
|
+
# Validate inputs
|
212
|
+
self._validate_priority(priority)
|
213
|
+
self._validate_date_format(due, "due date")
|
214
|
+
self._validate_duration(duration)
|
215
|
+
self._validate_parent_number(parent_number)
|
216
|
+
|
217
|
+
# Build the full task description
|
218
|
+
full_description = self._build_task_description(
|
219
|
+
description, priority, project, context, due, duration, parent_number
|
220
|
+
)
|
221
|
+
|
102
222
|
self.todo_shell.add(full_description)
|
103
223
|
return f"Added task: {full_description}"
|
104
224
|
|
@@ -107,50 +227,53 @@ class TodoManager:
|
|
107
227
|
) -> str:
|
108
228
|
"""List tasks with optional filtering."""
|
109
229
|
result = self.todo_shell.list_tasks(filter, suppress_color=suppress_color)
|
110
|
-
|
111
|
-
return "No tasks found."
|
112
|
-
|
113
|
-
# Return the raw todo.txt format for the LLM to format conversationally
|
114
|
-
# The LLM will convert this into natural language in its response
|
115
|
-
return result
|
230
|
+
return self._handle_empty_result(result, "No tasks found.")
|
116
231
|
|
117
232
|
def complete_task(self, task_number: int) -> str:
|
118
233
|
"""Mark task complete by line number."""
|
119
234
|
result = self.todo_shell.complete(task_number)
|
120
|
-
return
|
235
|
+
return self._format_task_operation_response("Completed", task_number, result)
|
121
236
|
|
122
237
|
def replace_task(self, task_number: int, new_description: str) -> str:
|
123
238
|
"""Replace entire task content."""
|
124
239
|
result = self.todo_shell.replace(task_number, new_description)
|
125
|
-
return
|
240
|
+
return self._format_task_operation_response("Replaced", task_number, result)
|
126
241
|
|
127
242
|
def append_to_task(self, task_number: int, text: str) -> str:
|
128
243
|
"""Add text to end of existing task."""
|
129
244
|
result = self.todo_shell.append(task_number, text)
|
130
|
-
return
|
245
|
+
return self._format_task_operation_response("Appended to", task_number, result)
|
131
246
|
|
132
247
|
def prepend_to_task(self, task_number: int, text: str) -> str:
|
133
248
|
"""Add text to beginning of existing task."""
|
134
249
|
result = self.todo_shell.prepend(task_number, text)
|
135
|
-
return
|
250
|
+
return self._format_task_operation_response("Prepended to", task_number, result)
|
136
251
|
|
137
252
|
def delete_task(self, task_number: int, term: Optional[str] = None) -> str:
|
138
253
|
"""Delete entire task or specific term from task."""
|
139
254
|
result = self.todo_shell.delete(task_number, term)
|
140
|
-
if term:
|
141
|
-
return
|
255
|
+
if term is not None:
|
256
|
+
return self._format_task_operation_response(
|
257
|
+
"Removed", task_number, result, f"'{term}'"
|
258
|
+
)
|
142
259
|
else:
|
143
|
-
return
|
260
|
+
return self._format_task_operation_response("Deleted", task_number, result)
|
144
261
|
|
145
262
|
def set_priority(self, task_number: int, priority: str) -> str:
|
146
263
|
"""Set or change task priority (A-Z)."""
|
264
|
+
priority = self._normalize_empty_to_none(priority)
|
265
|
+
self._validate_priority(priority)
|
147
266
|
result = self.todo_shell.set_priority(task_number, priority)
|
148
|
-
return
|
267
|
+
return self._format_task_operation_response(
|
268
|
+
"Set priority", task_number, result, priority or ""
|
269
|
+
)
|
149
270
|
|
150
271
|
def remove_priority(self, task_number: int) -> str:
|
151
272
|
"""Remove priority from task."""
|
152
273
|
result = self.todo_shell.remove_priority(task_number)
|
153
|
-
return
|
274
|
+
return self._format_task_operation_response(
|
275
|
+
"Removed priority from", task_number, result
|
276
|
+
)
|
154
277
|
|
155
278
|
def set_due_date(self, task_number: int, due_date: str) -> str:
|
156
279
|
"""
|
@@ -164,19 +287,17 @@ class TodoManager:
|
|
164
287
|
Confirmation message with the updated task
|
165
288
|
"""
|
166
289
|
# Validate due date format only if not empty
|
167
|
-
|
168
|
-
try:
|
169
|
-
datetime.strptime(due_date, "%Y-%m-%d")
|
170
|
-
except ValueError:
|
171
|
-
raise ValueError(
|
172
|
-
f"Invalid due date format '{due_date}'. Must be YYYY-MM-DD."
|
173
|
-
)
|
290
|
+
self._validate_date_format(due_date, "due date")
|
174
291
|
|
175
292
|
result = self.todo_shell.set_due_date(task_number, due_date)
|
176
293
|
if due_date.strip():
|
177
|
-
return
|
294
|
+
return self._format_task_operation_response(
|
295
|
+
"Set due date", task_number, result, due_date
|
296
|
+
)
|
178
297
|
else:
|
179
|
-
return
|
298
|
+
return self._format_task_operation_response(
|
299
|
+
"Removed due date from", task_number, result
|
300
|
+
)
|
180
301
|
|
181
302
|
def set_context(self, task_number: int, context: str) -> str:
|
182
303
|
"""
|
@@ -190,20 +311,17 @@ class TodoManager:
|
|
190
311
|
Confirmation message with the updated task
|
191
312
|
"""
|
192
313
|
# Validate context name if not empty
|
193
|
-
if context.strip()
|
194
|
-
# Remove any existing @ symbols to prevent duplication
|
195
|
-
clean_context = context.strip().lstrip("@")
|
196
|
-
if not clean_context:
|
197
|
-
raise ValueError(
|
198
|
-
"Context name cannot be empty after removing @ symbol."
|
199
|
-
)
|
314
|
+
clean_context = self._clean_context_name(context) if context.strip() else ""
|
200
315
|
|
201
316
|
result = self.todo_shell.set_context(task_number, context)
|
202
317
|
if context.strip():
|
203
|
-
|
204
|
-
|
318
|
+
return self._format_task_operation_response(
|
319
|
+
"Set context", task_number, result, f"@{clean_context}"
|
320
|
+
)
|
205
321
|
else:
|
206
|
-
return
|
322
|
+
return self._format_task_operation_response(
|
323
|
+
"Removed context from", task_number, result
|
324
|
+
)
|
207
325
|
|
208
326
|
def set_project(self, task_number: int, projects: list) -> str:
|
209
327
|
"""
|
@@ -223,12 +341,7 @@ class TodoManager:
|
|
223
341
|
if projects:
|
224
342
|
for project in projects:
|
225
343
|
if project.strip() and not project.startswith("-"):
|
226
|
-
|
227
|
-
clean_project = project.strip().lstrip("+")
|
228
|
-
if not clean_project:
|
229
|
-
raise ValueError(
|
230
|
-
"Project name cannot be empty after removing + symbol."
|
231
|
-
)
|
344
|
+
self._clean_project_name(project)
|
232
345
|
elif project.startswith("-"):
|
233
346
|
clean_project = project[1:].strip().lstrip("+")
|
234
347
|
if not clean_project:
|
@@ -239,7 +352,9 @@ class TodoManager:
|
|
239
352
|
result = self.todo_shell.set_project(task_number, projects)
|
240
353
|
|
241
354
|
if not projects:
|
242
|
-
return
|
355
|
+
return self._format_task_operation_response(
|
356
|
+
"No project changes made to", task_number, result
|
357
|
+
)
|
243
358
|
else:
|
244
359
|
# Build operation description
|
245
360
|
operations = []
|
@@ -255,24 +370,52 @@ class TodoManager:
|
|
255
370
|
operations.append(f"added +{clean_project}")
|
256
371
|
|
257
372
|
if not operations:
|
258
|
-
return
|
373
|
+
return self._format_task_operation_response(
|
374
|
+
"No project changes made to", task_number, result
|
375
|
+
)
|
259
376
|
else:
|
260
377
|
operation_desc = ", ".join(operations)
|
261
|
-
return
|
378
|
+
return self._format_task_operation_response(
|
379
|
+
"Updated projects for", task_number, result, operation_desc
|
380
|
+
)
|
381
|
+
|
382
|
+
def set_parent(self, task_number: int, parent_number: Optional[int]) -> str:
|
383
|
+
"""
|
384
|
+
Set or update parent task number for a task by intelligently rewriting it.
|
385
|
+
|
386
|
+
Args:
|
387
|
+
task_number: The task number to modify
|
388
|
+
parent_number: Parent task number, or None to remove parent
|
389
|
+
|
390
|
+
Returns:
|
391
|
+
Confirmation message with the updated task
|
392
|
+
"""
|
393
|
+
# Validate parent_number if provided
|
394
|
+
self._validate_parent_number(parent_number)
|
395
|
+
|
396
|
+
result = self.todo_shell.set_parent(task_number, parent_number)
|
397
|
+
if parent_number is not None:
|
398
|
+
return self._format_task_operation_response(
|
399
|
+
"Set parent task", task_number, result, str(parent_number)
|
400
|
+
)
|
401
|
+
else:
|
402
|
+
return self._format_task_operation_response(
|
403
|
+
"Removed parent from", task_number, result
|
404
|
+
)
|
405
|
+
|
406
|
+
# ============================================================================
|
407
|
+
# LISTING AND QUERY METHODS
|
408
|
+
# ============================================================================
|
262
409
|
|
263
410
|
def list_projects(self, suppress_color: bool = True, **kwargs: Any) -> str:
|
264
411
|
"""List all available projects in todo.txt."""
|
265
412
|
result = self.todo_shell.list_projects(suppress_color=suppress_color)
|
266
|
-
|
267
|
-
return "No projects found."
|
268
|
-
return result
|
413
|
+
return self._handle_empty_result(result, "No projects found.")
|
269
414
|
|
270
415
|
def list_contexts(self, suppress_color: bool = True, **kwargs: Any) -> str:
|
271
416
|
"""List all available contexts in todo.txt."""
|
272
417
|
result = self.todo_shell.list_contexts(suppress_color=suppress_color)
|
273
|
-
|
274
|
-
return "No contexts found."
|
275
|
-
return result
|
418
|
+
return self._handle_empty_result(result, "No contexts found.")
|
276
419
|
|
277
420
|
def list_completed_tasks(
|
278
421
|
self,
|
@@ -335,16 +478,22 @@ class TodoManager:
|
|
335
478
|
result = self.todo_shell.list_completed(
|
336
479
|
combined_filter, suppress_color=suppress_color
|
337
480
|
)
|
338
|
-
|
339
|
-
|
340
|
-
|
481
|
+
return self._handle_empty_result(
|
482
|
+
result, "No completed tasks found matching the criteria."
|
483
|
+
)
|
484
|
+
|
485
|
+
# ============================================================================
|
486
|
+
# UTILITY OPERATIONS
|
487
|
+
# ============================================================================
|
341
488
|
|
342
489
|
def move_task(
|
343
490
|
self, task_number: int, destination: str, source: Optional[str] = None
|
344
491
|
) -> str:
|
345
492
|
"""Move task from source to destination file."""
|
346
493
|
result = self.todo_shell.move(task_number, destination, source)
|
347
|
-
return
|
494
|
+
return self._format_task_operation_response(
|
495
|
+
"Moved", task_number, result, f"to {destination}"
|
496
|
+
)
|
348
497
|
|
349
498
|
def archive_tasks(self, **kwargs: Any) -> str:
|
350
499
|
"""Archive completed tasks."""
|
@@ -363,12 +512,17 @@ class TodoManager:
|
|
363
512
|
timezone = now.astimezone().tzinfo
|
364
513
|
return f"Current date and time: {now.strftime('%Y-%m-%d %H:%M:%S')} {timezone} ({now.strftime('%A, %B %d, %Y at %I:%M %p')}) - Week {week_number}"
|
365
514
|
|
366
|
-
|
515
|
+
# ============================================================================
|
516
|
+
# ADVANCED OPERATIONS
|
517
|
+
# ============================================================================
|
518
|
+
|
519
|
+
def create_completed_task(
|
367
520
|
self,
|
368
521
|
description: str,
|
369
522
|
completion_date: Optional[str] = None,
|
370
523
|
project: Optional[str] = None,
|
371
524
|
context: Optional[str] = None,
|
525
|
+
parent_number: Optional[int] = None,
|
372
526
|
) -> str:
|
373
527
|
"""
|
374
528
|
Create a task and immediately mark it as completed.
|
@@ -381,74 +535,48 @@ class TodoManager:
|
|
381
535
|
completion_date: Completion date in YYYY-MM-DD format (defaults to today)
|
382
536
|
project: Optional project name (without the + symbol)
|
383
537
|
context: Optional context name (without the @ symbol)
|
538
|
+
parent_number: Optional parent task number (required for subtasks)
|
384
539
|
|
385
540
|
Returns:
|
386
541
|
Confirmation message with the completed task details
|
387
542
|
"""
|
543
|
+
# Normalize empty strings to None
|
544
|
+
project = self._normalize_empty_to_none(project)
|
545
|
+
context = self._normalize_empty_to_none(context)
|
546
|
+
|
388
547
|
# Set default completion date to today if not provided
|
389
548
|
if not completion_date:
|
390
549
|
completion_date = datetime.now().strftime("%Y-%m-%d")
|
391
550
|
|
392
|
-
# Validate
|
393
|
-
|
394
|
-
|
395
|
-
except ValueError:
|
396
|
-
raise ValueError(
|
397
|
-
f"Invalid completion date format '{completion_date}'. Must be YYYY-MM-DD."
|
398
|
-
)
|
399
|
-
|
400
|
-
# Build the task description with project and context
|
401
|
-
full_description = description
|
551
|
+
# Validate inputs
|
552
|
+
self._validate_date_format(completion_date, "completion date")
|
553
|
+
self._validate_parent_number(parent_number)
|
402
554
|
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
raise ValueError(
|
408
|
-
"Project name cannot be empty after removing + symbol."
|
409
|
-
)
|
410
|
-
full_description = f"{full_description} +{clean_project}"
|
411
|
-
|
412
|
-
if context:
|
413
|
-
# Remove any existing @ symbols to prevent duplication
|
414
|
-
clean_context = context.strip().lstrip("@")
|
415
|
-
if not clean_context:
|
416
|
-
raise ValueError(
|
417
|
-
"Context name cannot be empty after removing @ symbol."
|
418
|
-
)
|
419
|
-
full_description = f"{full_description} @{clean_context}"
|
420
|
-
|
421
|
-
# Add the task first
|
422
|
-
self.todo_shell.add(full_description)
|
423
|
-
|
424
|
-
# Get the task number by finding the newly added task
|
425
|
-
tasks = self.todo_shell.list_tasks()
|
426
|
-
task_lines = [line.strip() for line in tasks.split("\n") if line.strip()]
|
427
|
-
if not task_lines:
|
428
|
-
raise RuntimeError("Failed to add task - no tasks found after addition")
|
429
|
-
|
430
|
-
# Find the task that matches our description (it should be the last one added)
|
431
|
-
# Look for the task that contains our description
|
432
|
-
task_number = None
|
433
|
-
for i, line in enumerate(task_lines, 1): # Start from 1 for todo.sh numbering
|
434
|
-
if description in line:
|
435
|
-
task_number = i
|
436
|
-
break
|
555
|
+
# Build the task description
|
556
|
+
full_description = self._build_task_description(
|
557
|
+
description, project=project, context=context, parent_number=parent_number
|
558
|
+
)
|
437
559
|
|
438
|
-
if
|
439
|
-
|
440
|
-
|
441
|
-
# Log a warning that we're using fallback logic
|
442
|
-
import logging
|
560
|
+
# Check if we need to use a specific completion date
|
561
|
+
current_date = datetime.now().strftime("%Y-%m-%d")
|
562
|
+
use_specific_date = completion_date != current_date
|
443
563
|
|
444
|
-
|
445
|
-
|
446
|
-
|
564
|
+
if use_specific_date:
|
565
|
+
# When using a specific completion date, add directly to done.txt
|
566
|
+
# Format: "x YYYY-MM-DD [task description]"
|
567
|
+
completed_task_line = f"x {completion_date} {full_description}"
|
568
|
+
self.todo_shell.addto("done.txt", completed_task_line)
|
569
|
+
return f"Created and completed task: {full_description} (completed on {completion_date})"
|
570
|
+
else:
|
571
|
+
# When using current date, use the standard add + complete workflow
|
572
|
+
# Add the task first
|
573
|
+
self.todo_shell.add(full_description)
|
447
574
|
|
448
|
-
|
449
|
-
|
575
|
+
# Find the task number and mark it complete
|
576
|
+
task_number = self._find_task_number_by_description(description)
|
577
|
+
self.todo_shell.complete(task_number)
|
450
578
|
|
451
|
-
|
579
|
+
return f"Created and completed task: {full_description} (completed on {completion_date})"
|
452
580
|
|
453
581
|
def restore_completed_task(self, task_number: int) -> str:
|
454
582
|
"""
|