todo-agent 0.3.2__py3-none-any.whl → 0.3.5__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/exceptions.py +6 -6
- todo_agent/core/todo_manager.py +315 -182
- todo_agent/infrastructure/inference.py +120 -52
- todo_agent/infrastructure/llm_client.py +56 -22
- todo_agent/infrastructure/ollama_client.py +23 -13
- todo_agent/infrastructure/openrouter_client.py +20 -12
- todo_agent/infrastructure/prompts/system_prompt.txt +190 -438
- todo_agent/infrastructure/todo_shell.py +94 -11
- todo_agent/interface/cli.py +51 -33
- todo_agent/interface/formatters.py +7 -4
- todo_agent/interface/progress.py +30 -19
- todo_agent/interface/tools.py +73 -30
- todo_agent/main.py +17 -1
- {todo_agent-0.3.2.dist-info → todo_agent-0.3.5.dist-info}/METADATA +1 -1
- todo_agent-0.3.5.dist-info/RECORD +30 -0
- todo_agent-0.3.2.dist-info/RECORD +0 -30
- {todo_agent-0.3.2.dist-info → todo_agent-0.3.5.dist-info}/WHEEL +0 -0
- {todo_agent-0.3.2.dist-info → todo_agent-0.3.5.dist-info}/entry_points.txt +0 -0
- {todo_agent-0.3.2.dist-info → todo_agent-0.3.5.dist-info}/licenses/LICENSE +0 -0
- {todo_agent-0.3.2.dist-info → todo_agent-0.3.5.dist-info}/top_level.txt +0 -0
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,56 +139,141 @@ 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
|
|
105
|
-
def list_tasks(
|
225
|
+
def list_tasks(
|
226
|
+
self, filter: Optional[str] = None, suppress_color: bool = True
|
227
|
+
) -> str:
|
106
228
|
"""List tasks with optional filtering."""
|
107
|
-
result = self.todo_shell.list_tasks(filter)
|
108
|
-
|
109
|
-
return "No tasks found."
|
110
|
-
|
111
|
-
# Return the raw todo.txt format for the LLM to format conversationally
|
112
|
-
# The LLM will convert this into natural language in its response
|
113
|
-
return result
|
229
|
+
result = self.todo_shell.list_tasks(filter, suppress_color=suppress_color)
|
230
|
+
return self._handle_empty_result(result, "No tasks found.")
|
114
231
|
|
115
232
|
def complete_task(self, task_number: int) -> str:
|
116
233
|
"""Mark task complete by line number."""
|
117
234
|
result = self.todo_shell.complete(task_number)
|
118
|
-
return
|
235
|
+
return self._format_task_operation_response("Completed", task_number, result)
|
119
236
|
|
120
237
|
def replace_task(self, task_number: int, new_description: str) -> str:
|
121
238
|
"""Replace entire task content."""
|
122
239
|
result = self.todo_shell.replace(task_number, new_description)
|
123
|
-
return
|
240
|
+
return self._format_task_operation_response("Replaced", task_number, result)
|
124
241
|
|
125
242
|
def append_to_task(self, task_number: int, text: str) -> str:
|
126
243
|
"""Add text to end of existing task."""
|
127
244
|
result = self.todo_shell.append(task_number, text)
|
128
|
-
return
|
245
|
+
return self._format_task_operation_response("Appended to", task_number, result)
|
129
246
|
|
130
247
|
def prepend_to_task(self, task_number: int, text: str) -> str:
|
131
248
|
"""Add text to beginning of existing task."""
|
132
249
|
result = self.todo_shell.prepend(task_number, text)
|
133
|
-
return
|
250
|
+
return self._format_task_operation_response("Prepended to", task_number, result)
|
134
251
|
|
135
252
|
def delete_task(self, task_number: int, term: Optional[str] = None) -> str:
|
136
253
|
"""Delete entire task or specific term from task."""
|
137
254
|
result = self.todo_shell.delete(task_number, term)
|
138
|
-
if term:
|
139
|
-
return
|
255
|
+
if term is not None:
|
256
|
+
return self._format_task_operation_response(
|
257
|
+
"Removed", task_number, result, f"'{term}'"
|
258
|
+
)
|
140
259
|
else:
|
141
|
-
return
|
260
|
+
return self._format_task_operation_response("Deleted", task_number, result)
|
142
261
|
|
143
262
|
def set_priority(self, task_number: int, priority: str) -> str:
|
144
263
|
"""Set or change task priority (A-Z)."""
|
264
|
+
priority = self._normalize_empty_to_none(priority)
|
265
|
+
self._validate_priority(priority)
|
145
266
|
result = self.todo_shell.set_priority(task_number, priority)
|
146
|
-
return
|
267
|
+
return self._format_task_operation_response(
|
268
|
+
"Set priority", task_number, result, priority or ""
|
269
|
+
)
|
147
270
|
|
148
271
|
def remove_priority(self, task_number: int) -> str:
|
149
272
|
"""Remove priority from task."""
|
150
273
|
result = self.todo_shell.remove_priority(task_number)
|
151
|
-
return
|
274
|
+
return self._format_task_operation_response(
|
275
|
+
"Removed priority from", task_number, result
|
276
|
+
)
|
152
277
|
|
153
278
|
def set_due_date(self, task_number: int, due_date: str) -> str:
|
154
279
|
"""
|
@@ -162,19 +287,17 @@ class TodoManager:
|
|
162
287
|
Confirmation message with the updated task
|
163
288
|
"""
|
164
289
|
# Validate due date format only if not empty
|
165
|
-
|
166
|
-
try:
|
167
|
-
datetime.strptime(due_date, "%Y-%m-%d")
|
168
|
-
except ValueError:
|
169
|
-
raise ValueError(
|
170
|
-
f"Invalid due date format '{due_date}'. Must be YYYY-MM-DD."
|
171
|
-
)
|
290
|
+
self._validate_date_format(due_date, "due date")
|
172
291
|
|
173
292
|
result = self.todo_shell.set_due_date(task_number, due_date)
|
174
293
|
if due_date.strip():
|
175
|
-
return
|
294
|
+
return self._format_task_operation_response(
|
295
|
+
"Set due date", task_number, result, due_date
|
296
|
+
)
|
176
297
|
else:
|
177
|
-
return
|
298
|
+
return self._format_task_operation_response(
|
299
|
+
"Removed due date from", task_number, result
|
300
|
+
)
|
178
301
|
|
179
302
|
def set_context(self, task_number: int, context: str) -> str:
|
180
303
|
"""
|
@@ -188,20 +311,17 @@ class TodoManager:
|
|
188
311
|
Confirmation message with the updated task
|
189
312
|
"""
|
190
313
|
# Validate context name if not empty
|
191
|
-
if context.strip()
|
192
|
-
# Remove any existing @ symbols to prevent duplication
|
193
|
-
clean_context = context.strip().lstrip("@")
|
194
|
-
if not clean_context:
|
195
|
-
raise ValueError(
|
196
|
-
"Context name cannot be empty after removing @ symbol."
|
197
|
-
)
|
314
|
+
clean_context = self._clean_context_name(context) if context.strip() else ""
|
198
315
|
|
199
316
|
result = self.todo_shell.set_context(task_number, context)
|
200
317
|
if context.strip():
|
201
|
-
|
202
|
-
|
318
|
+
return self._format_task_operation_response(
|
319
|
+
"Set context", task_number, result, f"@{clean_context}"
|
320
|
+
)
|
203
321
|
else:
|
204
|
-
return
|
322
|
+
return self._format_task_operation_response(
|
323
|
+
"Removed context from", task_number, result
|
324
|
+
)
|
205
325
|
|
206
326
|
def set_project(self, task_number: int, projects: list) -> str:
|
207
327
|
"""
|
@@ -221,12 +341,7 @@ class TodoManager:
|
|
221
341
|
if projects:
|
222
342
|
for project in projects:
|
223
343
|
if project.strip() and not project.startswith("-"):
|
224
|
-
|
225
|
-
clean_project = project.strip().lstrip("+")
|
226
|
-
if not clean_project:
|
227
|
-
raise ValueError(
|
228
|
-
"Project name cannot be empty after removing + symbol."
|
229
|
-
)
|
344
|
+
self._clean_project_name(project)
|
230
345
|
elif project.startswith("-"):
|
231
346
|
clean_project = project[1:].strip().lstrip("+")
|
232
347
|
if not clean_project:
|
@@ -237,7 +352,9 @@ class TodoManager:
|
|
237
352
|
result = self.todo_shell.set_project(task_number, projects)
|
238
353
|
|
239
354
|
if not projects:
|
240
|
-
return
|
355
|
+
return self._format_task_operation_response(
|
356
|
+
"No project changes made to", task_number, result
|
357
|
+
)
|
241
358
|
else:
|
242
359
|
# Build operation description
|
243
360
|
operations = []
|
@@ -253,24 +370,52 @@ class TodoManager:
|
|
253
370
|
operations.append(f"added +{clean_project}")
|
254
371
|
|
255
372
|
if not operations:
|
256
|
-
return
|
373
|
+
return self._format_task_operation_response(
|
374
|
+
"No project changes made to", task_number, result
|
375
|
+
)
|
257
376
|
else:
|
258
377
|
operation_desc = ", ".join(operations)
|
259
|
-
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)
|
260
395
|
|
261
|
-
|
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
|
+
# ============================================================================
|
409
|
+
|
410
|
+
def list_projects(self, suppress_color: bool = True, **kwargs: Any) -> str:
|
262
411
|
"""List all available projects in todo.txt."""
|
263
|
-
result = self.todo_shell.list_projects()
|
264
|
-
|
265
|
-
return "No projects found."
|
266
|
-
return result
|
412
|
+
result = self.todo_shell.list_projects(suppress_color=suppress_color)
|
413
|
+
return self._handle_empty_result(result, "No projects found.")
|
267
414
|
|
268
|
-
def list_contexts(self, **kwargs: Any) -> str:
|
415
|
+
def list_contexts(self, suppress_color: bool = True, **kwargs: Any) -> str:
|
269
416
|
"""List all available contexts in todo.txt."""
|
270
|
-
result = self.todo_shell.list_contexts()
|
271
|
-
|
272
|
-
return "No contexts found."
|
273
|
-
return result
|
417
|
+
result = self.todo_shell.list_contexts(suppress_color=suppress_color)
|
418
|
+
return self._handle_empty_result(result, "No contexts found.")
|
274
419
|
|
275
420
|
def list_completed_tasks(
|
276
421
|
self,
|
@@ -280,6 +425,7 @@ class TodoManager:
|
|
280
425
|
text_search: Optional[str] = None,
|
281
426
|
date_from: Optional[str] = None,
|
282
427
|
date_to: Optional[str] = None,
|
428
|
+
suppress_color: bool = True,
|
283
429
|
**kwargs: Any,
|
284
430
|
) -> str:
|
285
431
|
"""List completed tasks with optional filtering.
|
@@ -329,17 +475,25 @@ class TodoManager:
|
|
329
475
|
# Combine all filters
|
330
476
|
combined_filter = " ".join(filter_parts) if filter_parts else None
|
331
477
|
|
332
|
-
result = self.todo_shell.list_completed(
|
333
|
-
|
334
|
-
|
335
|
-
return
|
478
|
+
result = self.todo_shell.list_completed(
|
479
|
+
combined_filter, suppress_color=suppress_color
|
480
|
+
)
|
481
|
+
return self._handle_empty_result(
|
482
|
+
result, "No completed tasks found matching the criteria."
|
483
|
+
)
|
484
|
+
|
485
|
+
# ============================================================================
|
486
|
+
# UTILITY OPERATIONS
|
487
|
+
# ============================================================================
|
336
488
|
|
337
489
|
def move_task(
|
338
490
|
self, task_number: int, destination: str, source: Optional[str] = None
|
339
491
|
) -> str:
|
340
492
|
"""Move task from source to destination file."""
|
341
493
|
result = self.todo_shell.move(task_number, destination, source)
|
342
|
-
return
|
494
|
+
return self._format_task_operation_response(
|
495
|
+
"Moved", task_number, result, f"to {destination}"
|
496
|
+
)
|
343
497
|
|
344
498
|
def archive_tasks(self, **kwargs: Any) -> str:
|
345
499
|
"""Archive completed tasks."""
|
@@ -358,12 +512,17 @@ class TodoManager:
|
|
358
512
|
timezone = now.astimezone().tzinfo
|
359
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}"
|
360
514
|
|
361
|
-
|
515
|
+
# ============================================================================
|
516
|
+
# ADVANCED OPERATIONS
|
517
|
+
# ============================================================================
|
518
|
+
|
519
|
+
def create_completed_task(
|
362
520
|
self,
|
363
521
|
description: str,
|
364
522
|
completion_date: Optional[str] = None,
|
365
523
|
project: Optional[str] = None,
|
366
524
|
context: Optional[str] = None,
|
525
|
+
parent_number: Optional[int] = None,
|
367
526
|
) -> str:
|
368
527
|
"""
|
369
528
|
Create a task and immediately mark it as completed.
|
@@ -376,74 +535,48 @@ class TodoManager:
|
|
376
535
|
completion_date: Completion date in YYYY-MM-DD format (defaults to today)
|
377
536
|
project: Optional project name (without the + symbol)
|
378
537
|
context: Optional context name (without the @ symbol)
|
538
|
+
parent_number: Optional parent task number (required for subtasks)
|
379
539
|
|
380
540
|
Returns:
|
381
541
|
Confirmation message with the completed task details
|
382
542
|
"""
|
543
|
+
# Normalize empty strings to None
|
544
|
+
project = self._normalize_empty_to_none(project)
|
545
|
+
context = self._normalize_empty_to_none(context)
|
546
|
+
|
383
547
|
# Set default completion date to today if not provided
|
384
548
|
if not completion_date:
|
385
549
|
completion_date = datetime.now().strftime("%Y-%m-%d")
|
386
550
|
|
387
|
-
# Validate
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
#
|
409
|
-
|
410
|
-
if not clean_context:
|
411
|
-
raise ValueError(
|
412
|
-
"Context name cannot be empty after removing @ symbol."
|
413
|
-
)
|
414
|
-
full_description = f"{full_description} @{clean_context}"
|
415
|
-
|
416
|
-
# Add the task first
|
417
|
-
self.todo_shell.add(full_description)
|
418
|
-
|
419
|
-
# Get the task number by finding the newly added task
|
420
|
-
tasks = self.todo_shell.list_tasks()
|
421
|
-
task_lines = [line.strip() for line in tasks.split("\n") if line.strip()]
|
422
|
-
if not task_lines:
|
423
|
-
raise RuntimeError("Failed to add task - no tasks found after addition")
|
424
|
-
|
425
|
-
# Find the task that matches our description (it should be the last one added)
|
426
|
-
# Look for the task that contains our description
|
427
|
-
task_number = None
|
428
|
-
for i, line in enumerate(task_lines, 1): # Start from 1 for todo.sh numbering
|
429
|
-
if description in line:
|
430
|
-
task_number = i
|
431
|
-
break
|
432
|
-
|
433
|
-
if task_number is None:
|
434
|
-
# Fallback: use the last task number if we can't find a match
|
435
|
-
task_number = len(task_lines)
|
436
|
-
# Log a warning that we're using fallback logic
|
437
|
-
import logging
|
438
|
-
|
439
|
-
logging.warning(
|
440
|
-
f"Could not find exact match for '{description}', using fallback task number {task_number}"
|
441
|
-
)
|
551
|
+
# Validate inputs
|
552
|
+
self._validate_date_format(completion_date, "completion date")
|
553
|
+
self._validate_parent_number(parent_number)
|
554
|
+
|
555
|
+
# Build the task description
|
556
|
+
full_description = self._build_task_description(
|
557
|
+
description, project=project, context=context, parent_number=parent_number
|
558
|
+
)
|
559
|
+
|
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
|
563
|
+
|
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)
|
442
574
|
|
443
|
-
|
444
|
-
|
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)
|
445
578
|
|
446
|
-
|
579
|
+
return f"Created and completed task: {full_description} (completed on {completion_date})"
|
447
580
|
|
448
581
|
def restore_completed_task(self, task_number: int) -> str:
|
449
582
|
"""
|
@@ -464,7 +597,7 @@ class TodoManager:
|
|
464
597
|
|
465
598
|
# Use the move command to restore the task from done.txt to todo.txt
|
466
599
|
result = self.todo_shell.move(task_number, "todo.txt", "done.txt")
|
467
|
-
|
600
|
+
|
468
601
|
# Extract the task description from the result for confirmation
|
469
602
|
# The result format is typically: "TODO: X moved from '.../done.txt' to '.../todo.txt'."
|
470
603
|
if "moved from" in result and "to" in result:
|