Coverage for momentum.py: 76%
848 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-01 00:18 -0500
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-01 00:18 -0500
1#!/usr/bin/env python3
2"""
3Momentum - A minimal, one-task-at-a-time CLI tracker.
5A command-line task management tool that enforces focus by allowing only one
6active task at a time. Features a persistent backlog, interactive prompts,
7and clean status displays with optional emoji/color output.
9Usage:
10 python momentum.py add "Task description"
11 python momentum.py done
12 python momentum.py backlog add "Future task"
13 python momentum.py status
14"""
16import argparse
17import json
18import uuid
19import sys
20import re
21from datetime import datetime
22from typing import Tuple, List, Optional
23from datetime import date
24from pathlib import Path
25import os
28# ===== Helper for case-insensitive deduplication =====
29def merge_and_dedup_case_insensitive(list1, list2):
30 seen = set()
31 result = []
32 for item in list1 + list2:
33 key = item.lower()
34 if key not in seen:
35 seen.add(key)
36 result.append(item)
37 return result
40# ===== Global toggles =====
41USE_PLAIN = False
42STORE: Path = Path("storage.json")
45# ===== Configuration =====
46class Config:
47 """Configuration constants for Momentum."""
49 MAX_TASK_LENGTH = 500
50 STORAGE_ENCODING = "utf-8"
51 DATE_FORMAT = "%m/%d"
52 TIME_FORMAT = "%H:%M"
55# ===== Console setup =====
56def setup_console_encoding():
57 """Set up console encoding for better Unicode support."""
58 if sys.platform == "win32":
59 try:
60 # Try to enable UTF-8 mode on Windows
61 import codecs
63 if hasattr(sys.stdout, "detach"):
64 sys.stdout = codecs.getwriter("utf-8")(sys.stdout.detach())
65 if hasattr(sys.stderr, "detach"):
66 sys.stderr = codecs.getwriter("utf-8")(sys.stderr.detach())
67 except Exception:
68 # If that fails, we'll rely on safe_print fallbacks
69 pass
72def safe_print(text, **kwargs):
73 """Print text with Unicode error handling."""
74 try:
75 print(text, **kwargs)
76 except UnicodeEncodeError:
77 # Fallback: encode to ASCII with replacement characters
78 safe_text = text.encode("ascii", errors="replace").decode("ascii")
79 print(safe_text, **kwargs)
82# ===== Styling helpers =====
83RESET = "\033[0m"
84CYAN = "\033[96m"
85GREEN = "\033[92m"
86GRAY = "\033[90m"
87BOLD = "\033[1m"
89EMOJI = {
90 "added": "✅",
91 "complete": "🎉",
92 "backlog_add": "📥",
93 "backlog_list": "📋",
94 "backlog_pull": "📤",
95 "newday": "🌅",
96 "error": "❌",
97}
100def style(s):
101 """Return styled text or empty string if plain mode is enabled."""
102 if USE_PLAIN:
103 return ""
104 try:
105 # Test if the string can be encoded safely
106 encoding = getattr(sys.stdout, "encoding", None) or "ascii"
107 s.encode(encoding, errors="strict")
108 return s
109 except (UnicodeEncodeError, LookupError, AttributeError):
110 # If can't encode safely, return empty
111 return ""
114def emoji(k):
115 """Return emoji for given key or empty string if plain mode is enabled."""
116 if USE_PLAIN:
117 return ""
119 emoji_char = EMOJI.get(k, "")
120 if not emoji_char:
121 return ""
123 try:
124 # Test if emoji can be encoded safely
125 encoding = getattr(sys.stdout, "encoding", None) or "ascii"
126 emoji_char.encode(encoding, errors="strict")
127 return emoji_char
128 except (UnicodeEncodeError, LookupError, AttributeError):
129 # Return ASCII alternatives in plain mode or encoding issues
130 ascii_alternatives = {
131 "added": "[OK]",
132 "complete": "[DONE]",
133 "backlog_add": "[+]",
134 "backlog_list": "[-]",
135 "backlog_pull": "[>]",
136 "newday": "[NEW]",
137 "error": "[!]",
138 }
139 return ascii_alternatives.get(k, "")
142RESET, CYAN, GRAY, BOLD, GREEN = map(style, (RESET, CYAN, GRAY, BOLD, GREEN))
144# ===== Input Validation =====
147def validate_task_name(task):
148 """
149 Validate task name input.
151 Args:
152 task: The task name to validate
154 Returns:
155 tuple: (is_valid: bool, error_message: str)
156 """
157 if not task:
158 return False, "Task name cannot be empty."
160 task_stripped = task.strip()
161 if not task_stripped:
162 return False, "Task name cannot be only whitespace."
164 if len(task_stripped) > Config.MAX_TASK_LENGTH:
165 return False, f"Task name too long (max {Config.MAX_TASK_LENGTH} characters)."
167 # Check for potentially problematic characters
168 if "\n" in task_stripped or "\r" in task_stripped:
169 return False, "Task name cannot contain line breaks."
171 return True, ""
174def safe_input(prompt, validator=None):
175 """
176 Get user input with optional validation and Unicode safety.
178 Args:
179 prompt: The input prompt to display
180 validator: Optional function that takes input and returns (valid, error_msg)
182 Returns:
183 str: The validated input, or None if user cancels/validation fails
184 """
185 try:
186 # Make prompt safe for current console encoding
187 safe_prompt = prompt
188 try:
189 # Get encoding safely, with fallbacks
190 encoding = getattr(sys.stdout, "encoding", None) or "ascii"
191 prompt.encode(encoding, errors="strict")
192 except (UnicodeEncodeError, LookupError, AttributeError):
193 # Fallback to ASCII-safe version
194 safe_prompt = prompt.encode("ascii", errors="replace").decode("ascii")
196 user_input = input(safe_prompt).strip()
197 if validator:
198 is_valid, error_msg = validator(user_input)
199 if not is_valid:
200 safe_print(f"{emoji('error')} {error_msg}")
201 return None
202 return user_input
203 except (KeyboardInterrupt, EOFError):
204 safe_print(f"\n{emoji('error')} Input cancelled.")
205 return None
208def safe_int_input(prompt, min_val=None, max_val=None):
209 """
210 Get integer input with validation and Unicode safety.
212 Args:
213 prompt: The input prompt to display
214 min_val: Minimum allowed value (inclusive)
215 max_val: Maximum allowed value (inclusive)
217 Returns:
218 int or None: The validated integer, or None if invalid/cancelled
219 """
220 try:
221 # Make prompt safe for current console encoding
222 safe_prompt = prompt
223 try:
224 # Get encoding safely, with fallbacks
225 encoding = getattr(sys.stdout, "encoding", None) or "ascii"
226 prompt.encode(encoding, errors="strict")
227 except (UnicodeEncodeError, LookupError, AttributeError):
228 safe_prompt = prompt.encode("ascii", errors="replace").decode("ascii")
230 user_input = input(safe_prompt).strip()
231 if not user_input:
232 return None
234 value = int(user_input)
235 if min_val is not None and value < min_val:
236 safe_print(f"{emoji('error')} Value must be at least {min_val}.")
237 return None
238 if max_val is not None and value > max_val:
239 safe_print(f"{emoji('error')} Value must be at most {max_val}.")
240 return None
241 return value
242 except ValueError:
243 safe_print(f"{emoji('error')} Invalid input. Must be a number.")
244 return None
245 except (KeyboardInterrupt, EOFError):
246 safe_print(f"\n{emoji('error')} Input cancelled.")
247 return None
250# ===== Storage helpers =====
253def migrate_task_data(data: dict) -> bool:
254 """
255 Migrate all tasks in the data dict to ensure they have a 'state' field.
256 Returns True if any migration was performed.
257 """
258 migrated = False
260 def migrate_task(task, default_state):
261 nonlocal migrated
262 if isinstance(task, dict) and "state" not in task:
263 task["state"] = default_state
264 migrated = True
266 for key, value in data.items():
267 if key == "backlog":
268 for task in value:
269 migrate_task(task, "active")
270 elif isinstance(value, dict):
271 if "todo" in value and value["todo"]:
272 migrate_task(value["todo"], "active")
273 if "done" in value and isinstance(value["done"], list):
274 for task in value["done"]:
275 if isinstance(task, dict) and "task" in task:
276 migrate_task(task["task"], "done")
277 return migrated
280def load():
281 """Load data from storage file, returning empty dict if file doesn't exist."""
282 try:
283 if STORE.exists():
284 content = STORE.read_text(encoding=Config.STORAGE_ENCODING)
285 data = json.loads(content)
286 if migrate_task_data(data):
287 save(data) # Save migrated data
288 return data
289 else:
290 return {}
291 except json.JSONDecodeError as e:
292 safe_print(f"{emoji('error')} Storage file corrupted: {e}")
293 safe_print("Creating backup and starting fresh...")
294 # Create backup of corrupted file
295 backup_path = STORE.with_suffix(".json.backup")
296 try:
297 STORE.rename(backup_path)
298 safe_print(f"Corrupted file backed up to: {backup_path}")
299 except OSError:
300 safe_print("Could not create backup of corrupted file.")
301 return {}
302 except (OSError, PermissionError) as e:
303 safe_print(f"{emoji('error')} Cannot read storage file: {e}")
304 return {}
307def save(data):
308 """Save data to storage file with UTF-8 encoding and error handling."""
309 try:
310 content = json.dumps(data, indent=2)
311 STORE.write_text(content, encoding=Config.STORAGE_ENCODING)
312 return True
313 except (OSError, PermissionError) as e_os:
314 safe_print(f"{emoji('error')} Cannot save to storage file: {e_os}")
315 safe_print("Changes will be lost when the program exits.")
316 return False
317 except (TypeError, ValueError) as e_json:
318 safe_print(f"{emoji('error')} Data serialization error: {e_json}")
319 return False
322def today_key():
323 """Return today's date as a string in YYYY-MM-DD format."""
324 env_date = os.environ.get("MOMENTUM_TODAY_KEY")
325 if env_date:
326 return env_date
327 return str(date.today())
330def ensure_today(data):
331 """Ensure today's date exists in data with proper structure and return today's data."""
332 # Ensure global backlog exists
333 if "backlog" not in data:
334 data["backlog"] = []
336 # Ensure today's entry exists
337 today = data.setdefault(today_key(), {"todo": None, "done": []})
339 return today
342def get_backlog(data):
343 """Get the global backlog, creating it if it doesn't exist."""
344 return data.setdefault("backlog", [])
347# ===== Display/Formatting helpers =====
350def format_backlog_timestamp(ts):
351 """Format timestamp for display in backlog listings."""
352 try:
353 dt = datetime.fromisoformat(ts)
354 date_str = dt.strftime(Config.DATE_FORMAT)
355 time_str = dt.strftime(Config.TIME_FORMAT)
356 return f"[{date_str} {time_str}]"
357 except (ValueError, KeyError):
358 return f"[{ts if ts else 'no timestamp'}]"
361def print_backlog_list(backlog, title="Backlog"):
362 """Print formatted backlog with consistent styling and tag highlighting."""
363 safe_print(f"{emoji('backlog_list')} {title}:")
364 for i, item in enumerate(backlog, 1):
365 timestamp = format_backlog_timestamp(item.get("ts", ""))
366 task_text = ""
367 field_categories = []
368 field_tags = []
369 if isinstance(item, dict):
370 # Always check for top-level fields first
371 if "categories" in item or "tags" in item:
372 field_categories = item.get("categories", [])
373 field_tags = item.get("tags", [])
374 task_text = item.get("task", "")
375 elif "task" in item:
376 if isinstance(item["task"], dict):
377 task_text = item["task"]["task"]
378 field_categories = item["task"].get("categories", [])
379 field_tags = item["task"].get("tags", [])
380 else:
381 task_text = item["task"]
382 _, field_categories, field_tags = parse_tags(task_text)
383 else:
384 task_text = str(item)
385 else:
386 task_text = str(item)
387 _, field_categories, field_tags = parse_tags(task_text)
388 _, text_categories, text_tags = parse_tags(task_text)
389 categories = merge_and_dedup_case_insensitive(field_categories, text_categories)
390 tags = merge_and_dedup_case_insensitive(field_tags, text_tags)
391 display_text = task_text
392 for cat in categories:
393 if not any(
394 f"@{cat.lower()}" == part.lower()
395 for part in display_text.split()
396 if part.startswith("@")
397 ):
398 display_text += f" @{cat}"
399 for tag in tags:
400 if not any(
401 f"#{tag.lower()}" == part.lower()
402 for part in display_text.split()
403 if part.startswith("#")
404 ):
405 display_text += f" #{tag}"
406 display_text = display_text.strip()
407 formatted_task = format_task_with_tags(
408 display_text, categories, tags, USE_PLAIN
409 )
410 safe_print(f" {i}. {formatted_task} {timestamp}")
413def complete_current_task(today):
414 """Mark the current task as completed."""
415 # Handle both old format (string) and new format (dict)
416 if isinstance(today["todo"], dict):
417 task_data = today["todo"]
418 task_text = task_data["task"]
419 task_data["state"] = "done"
420 else:
421 # Legacy format - convert string to dict
422 task_text = today["todo"]
423 task_data = create_task_data(task_text)
424 task_data["state"] = "done"
425 # Store complete task data in done list
426 done_item = {
427 "id": uuid.uuid4().hex[:8],
428 "task": task_data, # Store full task data structure
429 "ts": datetime.now().isoformat(timespec="seconds"),
430 }
431 today["done"].append(done_item)
432 safe_print(f"{emoji('complete')} Completed: {repr(task_text)}")
433 today["todo"] = None
436def handle_next_task_selection(data, today):
437 """Handle user selection of next task after completing current one."""
438 backlog = get_backlog(data)
440 # Show current backlog
441 if backlog:
442 safe_print("") # Empty line
443 print_backlog_list(backlog)
444 else:
445 safe_print("\nBacklog is empty.")
447 safe_print("\nSelect next task:")
448 safe_print(" - Enter a number to pull from backlog")
449 safe_print(" - [n] Add a new task")
450 safe_print(" - [Enter] to skip")
452 choice = safe_input("> ")
453 if choice is None:
454 return # User cancelled or error occurred
456 if choice.isdigit():
457 index = int(choice) - 1
458 if 0 <= index < len(backlog):
459 task_item = backlog.pop(index)
461 # Handle different backlog item formats
462 if isinstance(task_item, dict) and "task" in task_item:
463 if isinstance(task_item["task"], dict):
464 # New structured format
465 today["todo"] = task_item["task"]
466 task_text = task_item["task"]["task"]
467 else:
468 # Old format
469 task_text = task_item["task"]
470 today["todo"] = create_task_data(task_text)
471 else:
472 # Very old format
473 task_text = str(task_item)
474 today["todo"] = create_task_data(task_text)
476 if save(data):
477 safe_print(
478 f"{emoji('backlog_pull')} Pulled from backlog: {repr(task_text)}"
479 )
480 cmd_status(None) # Show status after pulling
481 else:
482 safe_print(f"{emoji('error')} Invalid backlog index.")
483 elif choice.lower() == "n":
484 new_task = safe_input("Enter new task: ", validate_task_name)
485 if new_task:
486 today["todo"] = create_task_data(new_task) # Use structured data
487 if save(data):
488 safe_print(f"{emoji('added')} Added: {repr(new_task)}")
489 cmd_status(None) # Show status after adding
490 # Empty choice (Enter) - skip, no action needed
493# ===== Tag Parsing Functions =====
496def parse_tags(task_text: str) -> Tuple[str, List[str], List[str]]:
497 """
498 Parse @categories and #tags from task text.
500 Args:
501 task_text: The task description potentially containing tags
503 Returns:
504 tuple: (original_text, categories_list, tags_list)
506 Examples:
507 >>> parse_tags("Fix bug @work #urgent")
508 ("Fix bug @work #urgent", ["work"], ["urgent"])
510 >>> parse_tags("Simple task")
511 ("Simple task", [], [])
512 """
513 if not task_text:
514 return task_text, [], []
516 # Regular expressions for matching tags
517 category_pattern = r"@([a-zA-Z0-9_-]+)"
518 tag_pattern = r"#([a-zA-Z0-9_-]+)"
520 # Find all categories and tags
521 categories = re.findall(category_pattern, task_text)
522 tags = re.findall(tag_pattern, task_text)
524 # Normalize to lowercase and remove duplicates while preserving order
525 categories = list(dict.fromkeys(cat.lower() for cat in categories))
526 tags = list(dict.fromkeys(tag.lower() for tag in tags))
528 return task_text, categories, tags
531def validate_tag_format(tag: str) -> bool:
532 """
533 Validate if a tag follows the correct format.
535 Args:
536 tag: The tag to validate (without @ or # prefix)
538 Returns:
539 bool: True if valid, False otherwise
541 Valid format:
542 - Only letters, numbers, underscores, hyphens
543 - 1-50 characters long
544 - No spaces or special characters
545 """
546 if not tag:
547 return False
549 if len(tag) > 50: # reasonable limit
550 return False
552 # Only allow alphanumeric, underscore, and hyphen
553 pattern = r"^[a-zA-Z0-9_-]+$"
554 return bool(re.match(pattern, tag))
557def format_task_with_tags(
558 task_text: str, categories: List[str], tags: List[str], plain_mode: bool = False
559) -> str:
560 """
561 Format task text with highlighted categories and tags.
563 Args:
564 task_text: The original task text
565 categories: List of categories found in the task
566 tags: List of tags found in the task
567 plain_mode: If True, don't add color codes
569 Returns:
570 str: Formatted task text with highlighted tags
571 """
572 if plain_mode or USE_PLAIN:
573 # In plain mode, just return the original text
574 return task_text
576 # Color codes for highlighting
577 CATEGORY_COLOR = "\033[94m" # Blue
578 TAG_COLOR = "\033[93m" # Yellow
579 RESET_COLOR = "\033[0m"
581 formatted_text = task_text
583 # Highlight categories (@category)
584 for category in categories:
585 replacement = f"{CATEGORY_COLOR}@{category}{RESET_COLOR}"
586 # Use word boundaries to avoid partial matches
587 formatted_text = re.sub(
588 f"@{re.escape(category)}\\\\b",
589 replacement,
590 formatted_text,
591 flags=re.IGNORECASE,
592 )
594 # Highlight tags (#tag)
595 for tag in tags:
596 replacement = f"{TAG_COLOR}#{tag}{RESET_COLOR}"
597 formatted_text = re.sub(
598 f"#{re.escape(tag)}\\\\b", replacement, formatted_text, flags=re.IGNORECASE
599 )
601 return formatted_text
604def create_task_data(task_text: str) -> dict:
605 """
606 Create a task data structure with parsed tags.
607 Args:
608 task_text: The task description
609 Returns:
610 dict: Task data with text, categories, tags, state, and timestamp
611 """
612 text, categories, tags = parse_tags(task_text)
613 return {
614 "task": text,
615 "categories": categories,
616 "tags": tags,
617 "ts": datetime.now().isoformat(timespec="seconds"),
618 "state": "active",
619 }
622# ===== Update existing validation function =====
625def validate_task_name_with_tags(task: str) -> Tuple[bool, str]:
626 """
627 Enhanced task validation that includes tag format validation.
629 Args:
630 task: The task name to validate (may include tags)
632 Returns:
633 tuple: (is_valid: bool, error_message: str)
634 """
635 # First run the basic validation
636 is_valid, error_msg = validate_task_name(task)
637 if not is_valid:
638 return is_valid, error_msg
640 # Parse and validate tags
641 text, categories, tags = parse_tags(task)
643 # Validate each category format
644 for category in categories:
645 if not validate_tag_format(category):
646 return (
647 False,
648 f"Invalid category format: @{category}. Use only letters, numbers, underscores, and hyphens.",
649 )
651 # Validate each tag format
652 for tag in tags:
653 if not validate_tag_format(tag):
654 return (
655 False,
656 f"Invalid tag format: #{tag}. Use only letters, numbers, underscores, and hyphens.",
657 )
659 return True, ""
662# ===== Helper functions for filtering (we'll implement these next) =====
665def extract_categories_from_tasks(tasks: List[dict]) -> List[str]:
666 """Extract all unique categories from a list of tasks."""
667 categories = set()
668 for task in tasks:
669 if isinstance(task, dict) and "categories" in task:
670 categories.update(task["categories"])
671 elif isinstance(task, dict) and "task" in task:
672 # Handle legacy tasks without category field
673 _, cats, _ = parse_tags(task["task"])
674 categories.update(cats)
675 return sorted(list(categories))
678def extract_tags_from_tasks(tasks: List[dict]) -> List[str]:
679 """Extract all unique tags from a list of tasks."""
680 tags = set()
681 for task in tasks:
682 if isinstance(task, dict) and "tags" in task:
683 tags.update(task["tags"])
684 elif isinstance(task, dict) and "task" in task:
685 # Handle legacy tasks without tags field
686 _, _, task_tags = parse_tags(task["task"])
687 tags.update(task_tags)
688 return sorted(list(tags))
691def filter_tasks(
692 tasks: List[dict],
693 filter_categories: Optional[List[str]] = None,
694 filter_tags: Optional[List[str]] = None,
695) -> List[dict]:
696 """
697 Filter tasks by categories and/or tags.
699 Args:
700 tasks: List of task dictionaries
701 filter_categories: Categories to filter by (e.g., ["work", "personal"])
702 filter_tags: Tags to filter by (e.g., ["urgent", "low"])
704 Returns:
705 List of tasks matching the filters
706 """
707 if not filter_categories and not filter_tags:
708 return tasks
710 filtered_tasks = []
712 for task in tasks:
713 # Handle both new format (with categories/tags fields) and legacy format
714 if isinstance(task, dict):
715 if "categories" in task and "tags" in task:
716 task_categories = task["categories"]
717 task_tags = task["tags"]
718 else:
719 # Legacy format - parse from task text
720 _, task_categories, task_tags = parse_tags(task.get("task", ""))
722 # Check if task matches category filter
723 category_match = not filter_categories or any(
724 cat in task_categories for cat in filter_categories
725 )
727 # Check if task matches tag filter
728 tag_match = not filter_tags or any(tag in task_tags for tag in filter_tags)
730 # Task must match both filters (if specified)
731 if category_match and tag_match:
732 filtered_tasks.append(task)
734 return filtered_tasks
737def parse_filter_string(filter_str: str) -> Tuple[bool, List[str], List[str], str]:
738 """
739 Parse filter string into lists of categories and tags.
741 Args:
742 filter_str: Comma-separated string like "@work,#urgent,@personal"
744 Returns:
745 tuple: (is_valid: bool, categories: List[str], tags: List[str], error_message: str)
747 Examples:
748 >>> parse_filter_string("@work,#urgent")
749 (True, ["work"], ["urgent"], "")
751 >>> parse_filter_string("@work, #urgent, @personal") # spaces ok
752 (True, ["work", "personal"], ["urgent"], "")
754 >>> parse_filter_string("work,#urgent")
755 (False, [], ["urgent"], "Invalid filter item: 'work'. Must start with @ (category) or # (tag).")
757 >>> parse_filter_string("@,@work,oops,#tag1")
758 (False, ["work"], ["tag1"], "Invalid category format: '@'. Use letters, numbers, underscores, and hyphens only. Invalid filter item: 'oops'. Must start with @ (category) or # (tag).")
759 """
760 if not filter_str:
761 return True, [], [], ""
763 items = [item.strip() for item in filter_str.split(",")]
764 # Filter out empty strings that might result from multiple commas like ",,"
765 # or leading/trailing commas like ",@work" or "@work,"
766 items = [item for item in items if item]
768 if not items:
769 # This can happen if filter_str was just commas or whitespace
770 return True, [], [], ""
772 categories = []
773 tags = []
774 errors = []
775 overall_valid = True
777 for item in items:
778 if item.startswith("@"):
779 name = item[1:]
780 if not name: # Check for empty name like "@" or "@,"
781 errors.append(
782 f"Invalid category format: '{item}'. Name cannot be empty."
783 )
784 overall_valid = False
785 continue
786 if not validate_tag_format(
787 name
788 ): # Reusing validate_tag_format as category names have same rules
789 errors.append(
790 f"Invalid category format: '{item}'. Use letters, numbers, underscores, and hyphens only."
791 )
792 overall_valid = False
793 continue # Skip adding this invalid item
794 categories.append(name.lower())
795 elif item.startswith("#"):
796 name = item[1:]
797 if not name: # Check for empty name like "#" or "#,"
798 errors.append(f"Invalid tag format: '{item}'. Name cannot be empty.")
799 overall_valid = False
800 continue
801 if not validate_tag_format(name):
802 errors.append(
803 f"Invalid tag format: '{item}'. Use letters, numbers, underscores, and hyphens only."
804 )
805 overall_valid = False
806 continue # Skip adding this invalid item
807 tags.append(name.lower())
808 else:
809 errors.append(
810 f"Invalid filter item: '{item}'. Must start with @ (category) or # (tag)."
811 )
812 overall_valid = False
813 # No continue here, as we don't add it to categories/tags anyway
815 # Deduplicate categories and tags
816 # Using dict.fromkeys preserves order and is efficient for deduplication
817 categories = list(dict.fromkeys(categories))
818 tags = list(dict.fromkeys(tags))
820 error_message = " ".join(errors) # Concatenate multiple error messages
822 return overall_valid, categories, tags, error_message
825def filter_tasks_by_tags_or_categories(
826 tasks: List[dict],
827 filter_categories: Optional[List[str]] = None,
828 filter_tags: Optional[List[str]] = None,
829) -> List[dict]:
830 if not filter_categories and not filter_tags:
831 return tasks
832 normalized_filter_categories = (
833 [cat.lower() for cat in filter_categories] if filter_categories else []
834 )
835 normalized_filter_tags = [tag.lower() for tag in filter_tags] if filter_tags else []
836 filtered_tasks = []
837 for task_item in tasks:
838 task_text = ""
839 field_categories = []
840 field_tags = []
841 # Always check for top-level fields first
842 if isinstance(task_item, dict):
843 if "categories" in task_item or "tags" in task_item:
844 field_categories = task_item.get("categories", [])
845 field_tags = task_item.get("tags", [])
846 task_text = task_item.get("task", "")
847 elif "task" in task_item:
848 if isinstance(task_item["task"], dict):
849 task_data = task_item["task"]
850 task_text = task_data["task"]
851 field_categories = task_data.get("categories", [])
852 field_tags = task_data.get("tags", [])
853 else:
854 task_text = task_item["task"]
855 _, field_categories, field_tags = parse_tags(task_text)
856 else:
857 task_text = str(task_item)
858 elif isinstance(task_item, str):
859 task_text = task_item
860 _, field_categories, field_tags = parse_tags(task_item)
861 else:
862 field_categories, field_tags = [], []
863 _, text_categories, text_tags = parse_tags(task_text)
864 merged_categories = [
865 cat.lower()
866 for cat in merge_and_dedup_case_insensitive(
867 field_categories, text_categories
868 )
869 ]
870 merged_tags = [
871 tag.lower()
872 for tag in merge_and_dedup_case_insensitive(field_tags, text_tags)
873 ]
874 category_match = not normalized_filter_categories or any(
875 cat in normalized_filter_categories for cat in merged_categories
876 )
877 tag_match = not normalized_filter_tags or any(
878 tag in normalized_filter_tags for tag in merged_tags
879 )
880 if normalized_filter_categories and normalized_filter_tags:
881 if category_match and tag_match:
882 filtered_tasks.append(task_item)
883 elif normalized_filter_categories:
884 if category_match:
885 filtered_tasks.append(task_item)
886 elif normalized_filter_tags:
887 if tag_match:
888 filtered_tasks.append(task_item)
889 return filtered_tasks
892def filter_single_task_by_tags_or_categories(
893 task,
894 filter_categories: Optional[List[str]] = None,
895 filter_tags: Optional[List[str]] = None,
896) -> bool:
897 normalized_filter_categories = (
898 [cat.lower() for cat in filter_categories] if filter_categories else []
899 )
900 normalized_filter_tags = [tag.lower() for tag in filter_tags] if filter_tags else []
901 if not normalized_filter_categories and not normalized_filter_tags:
902 return True
903 task_text = ""
904 field_categories = []
905 field_tags = []
906 if isinstance(task, dict):
907 if "categories" in task or "tags" in task:
908 field_categories = task.get("categories", [])
909 field_tags = task.get("tags", [])
910 task_text = task.get("task", "")
911 elif "task" in task:
912 if isinstance(task["task"], dict):
913 task_data = task["task"]
914 task_text = task_data["task"]
915 field_categories = task_data.get("categories", [])
916 field_tags = task_data.get("tags", [])
917 else:
918 task_text = task["task"]
919 _, field_categories, field_tags = parse_tags(task_text)
920 else:
921 task_text = str(task)
922 elif isinstance(task, str):
923 task_text = task
924 _, field_categories, field_tags = parse_tags(task)
925 else:
926 field_categories, field_tags = [], []
927 _, text_categories, text_tags = parse_tags(task_text)
928 merged_categories = [
929 cat.lower()
930 for cat in merge_and_dedup_case_insensitive(field_categories, text_categories)
931 ]
932 merged_tags = [
933 tag.lower() for tag in merge_and_dedup_case_insensitive(field_tags, text_tags)
934 ]
935 category_match = not normalized_filter_categories or any(
936 cat in normalized_filter_categories for cat in merged_categories
937 )
938 tag_match = not normalized_filter_tags or any(
939 tag in normalized_filter_tags for tag in merged_tags
940 )
941 if normalized_filter_categories and normalized_filter_tags:
942 return category_match and tag_match
943 elif normalized_filter_categories:
944 return category_match
945 elif normalized_filter_tags:
946 return tag_match
947 return True
950# ===== Data migration helper =====
953def migrate_task_to_tagged_format(task_data: dict) -> dict:
954 """
955 Migrate a legacy task to include categories and tags fields.
957 Args:
958 task_data: Legacy task dictionary
960 Returns:
961 Updated task dictionary with categories and tags
962 """
963 if "categories" in task_data and "tags" in task_data:
964 # Already migrated
965 return task_data
967 # Parse tags from task text
968 task_text = task_data.get("task", "")
969 text, categories, tags = parse_tags(task_text)
971 # Update task data
972 updated_task = task_data.copy()
973 updated_task["categories"] = categories
974 updated_task["tags"] = tags
976 return updated_task
979def create_done_item(task_data: dict) -> dict:
980 """Create a 'done' item structure for a given task.
981 Args:
982 task_data: The task dictionary (should have 'state', 'task', etc.)
983 Returns:
984 dict: A structured item for the 'done' list.
985 """
986 return {
987 "id": uuid.uuid4().hex[:8], # Generate a new ID for the done entry
988 "task": task_data, # This is the task dict itself (which includes its original ts, state, etc.)
989 "ts": datetime.now().isoformat(), # Timestamp of completion/cancellation for this 'done' record
990 }
993# ===== Command functions =====
994def prompt_next_action(data):
995 """
996 Ask user what to do after completing a task.
998 Args:
999 data: The full data dictionary containing backlog
1001 Returns:
1002 tuple: ("pull", None) to pull from backlog,
1003 ("add", str) to add the given task,
1004 (None, None) to skip
1005 """
1006 backlog = get_backlog(data)
1007 has_backlog = bool(backlog)
1008 if has_backlog:
1009 choice = safe_input("[p] pull next from backlog, [a] add new, ENTER to skip ▶ ")
1010 if choice is None:
1011 return (None, None)
1012 choice = choice.strip().lower()
1013 else:
1014 choice = safe_input("[a] add new, ENTER to skip ▶ ")
1015 if choice is None:
1016 return (None, None)
1017 choice = choice.strip().lower()
1019 if choice == "p" and has_backlog:
1020 return ("pull", None)
1021 if choice == "a":
1022 new_task = safe_input("Describe the next task ▶ ")
1023 if new_task and new_task.strip():
1024 return ("add", new_task.strip())
1025 return (None, None)
1028def cmd_add(args):
1029 """Add a new task or offer to add to backlog if active task exists."""
1030 global STORE
1031 if hasattr(args, "store") and args.store:
1032 # Only set STORE if it's a string or Path
1033 if isinstance(args.store, (str, Path)):
1034 STORE = Path(args.store)
1035 else:
1036 import warnings
1038 warnings.warn(f"cmd_add: args.store is not a valid path: {args.store!r}")
1039 # Validate task name
1040 is_valid, error_msg = validate_task_name(args.task)
1041 if not is_valid:
1042 safe_print(f"{emoji('error')} {error_msg}")
1043 return
1045 data = load()
1046 today = ensure_today(data)
1048 # Clean the task name
1049 clean_task = args.task.strip()
1051 # Parse tags from the task
1052 task_data = create_task_data(clean_task)
1054 if today["todo"]:
1055 # Extract task name for display - handle both old and new formats
1056 if isinstance(today["todo"], dict):
1057 existing_task_name = today["todo"]["task"]
1058 else:
1059 existing_task_name = today["todo"]
1061 safe_print(f"{emoji('error')} Active task already exists: {existing_task_name}")
1062 # Use ASCII-safe prompt character
1063 prompt_char = (
1064 "+" if USE_PLAIN else "+"
1065 ) # Always use + for Windows compatibility
1066 response = safe_input(
1067 f"{prompt_char} Would you like to add '{clean_task}' to the backlog instead? [y/N]: "
1068 )
1069 if response and response.lower() == "y":
1070 get_backlog(data).append(
1071 task_data
1072 ) # Use task_data instead of separate dict
1073 if save(data):
1074 safe_print(
1075 f"{emoji('backlog_add')} Added to backlog: {repr(clean_task)}"
1076 )
1077 return
1079 # Store the full task data structure instead of just text
1080 today["todo"] = task_data
1081 if save(data):
1082 safe_print(f"{emoji('added')} Added: {clean_task}")
1083 cmd_status(args)
1086def cmd_done(args):
1087 """Complete the current active task and prompt for next action."""
1088 data = load()
1089 today = ensure_today(data)
1091 if not today["todo"]:
1092 safe_print(f"{emoji('error')} No active task to complete.")
1093 return
1095 # Complete the task
1096 complete_current_task(today)
1097 if not save(data):
1098 return # Don't proceed if save failed
1100 cmd_status(args)
1102 # Handle next task selection
1103 handle_next_task_selection(data, today)
1106def cmd_status(args):
1107 """Display current status showing completed tasks and active task."""
1108 global USE_PLAIN, STORE
1109 if hasattr(args, "plain"):
1110 USE_PLAIN = args.plain
1111 if hasattr(args, "store") and args.store:
1112 STORE = Path(args.store)
1113 data = load()
1114 today = ensure_today(data)
1115 today_str = today_key()
1117 # Parse filter if provided
1118 filter_categories = []
1119 filter_tags = [] # Initialize filter_tags
1120 if hasattr(args, "filter") and args.filter:
1121 is_valid, filter_categories, filter_tags, error_msg = parse_filter_string(
1122 args.filter
1123 ) # Update to get tags
1124 if not is_valid:
1125 safe_print(f"{emoji('error')} {error_msg}")
1126 return
1128 # Show filter info if filtering
1129 filter_info = ""
1130 if filter_categories or filter_tags: # Check both
1131 formatted_cats = ", ".join(f"@{cat}" for cat in filter_categories)
1132 formatted_tags = ", ".join(f"#{tag}" for tag in filter_tags) # Format tags
1133 parts = []
1134 if formatted_cats:
1135 parts.append(formatted_cats)
1136 if formatted_tags:
1137 parts.append(formatted_tags)
1138 filter_info = f" (filtered by: {', '.join(parts)})"
1140 safe_print(f"\n=== TODAY: {today_str}{filter_info} ===")
1142 # Filter and display completed tasks
1143 completed_tasks = today["done"]
1144 if filter_categories or filter_tags: # Check both
1145 completed_tasks = filter_tasks_by_tags_or_categories(
1146 completed_tasks, filter_categories, filter_tags
1147 ) # Pass tags
1149 if completed_tasks:
1150 for it in completed_tasks:
1151 ts = it["ts"].split("T")[1]
1152 if isinstance(it["task"], dict):
1153 task_text = it["task"]["task"]
1154 field_categories = it["task"].get("categories", [])
1155 field_tags = it["task"].get("tags", [])
1156 else:
1157 task_text = it["task"]
1158 _, field_categories, field_tags = parse_tags(task_text)
1159 _, text_categories, text_tags = parse_tags(task_text)
1160 categories = merge_and_dedup_case_insensitive(
1161 field_categories, text_categories
1162 )
1163 tags = merge_and_dedup_case_insensitive(field_tags, text_tags)
1164 display_text = task_text
1165 for cat in categories:
1166 if not any(
1167 f"@{cat.lower()}" == part.lower()
1168 for part in display_text.split()
1169 if part.startswith("@")
1170 ):
1171 display_text += f" @{cat}"
1172 for tag in tags:
1173 if not any(
1174 f"#{tag.lower()}" == part.lower()
1175 for part in display_text.split()
1176 if part.startswith("#")
1177 ):
1178 display_text += f" #{tag}"
1179 display_text = display_text.strip()
1180 formatted_task = format_task_with_tags(
1181 display_text, categories, tags, USE_PLAIN
1182 )
1183 if USE_PLAIN:
1184 safe_print(display_text + f" [{ts}]")
1185 else:
1186 safe_print(f"{style(GREEN)}{formatted_task}{style(RESET)} [{ts}]")
1187 else:
1188 if filter_categories or filter_tags:
1189 safe_print("No completed tasks match the filter.")
1190 else:
1191 safe_print("No completed tasks yet.")
1193 # Display active task (if it matches filter)
1194 if today["todo"]:
1195 if isinstance(today["todo"], dict):
1196 # Always check for top-level fields first
1197 if "categories" in today["todo"] or "tags" in today["todo"]:
1198 field_categories = today["todo"].get("categories", [])
1199 field_tags = today["todo"].get("tags", [])
1200 task_text = today["todo"].get("task", "")
1201 elif "task" in today["todo"]:
1202 if isinstance(today["todo"]["task"], dict):
1203 task_text = today["todo"]["task"]["task"]
1204 field_categories = today["todo"]["task"].get("categories", [])
1205 field_tags = today["todo"]["task"].get("tags", [])
1206 else:
1207 task_text = today["todo"]["task"]
1208 _, field_categories, field_tags = parse_tags(task_text)
1209 else:
1210 task_text = str(today["todo"])
1211 else:
1212 task_text = today["todo"]
1213 _, field_categories, field_tags = parse_tags(task_text)
1214 _, text_categories, text_tags = parse_tags(task_text)
1215 categories = merge_and_dedup_case_insensitive(field_categories, text_categories)
1216 tags = merge_and_dedup_case_insensitive(field_tags, text_tags)
1217 matches_filter = True
1218 if filter_categories or filter_tags:
1219 matches_filter = filter_single_task_by_tags_or_categories(
1220 today["todo"], filter_categories, filter_tags
1221 )
1222 if matches_filter:
1223 display_text = task_text
1224 for cat in categories:
1225 if not any(
1226 f"@{cat.lower()}" == part.lower()
1227 for part in display_text.split()
1228 if part.startswith("@")
1229 ):
1230 display_text += f" @{cat}"
1231 for tag in tags:
1232 if not any(
1233 f"#{tag.lower()}" == part.lower()
1234 for part in display_text.split()
1235 if part.startswith("#")
1236 ):
1237 display_text += f" #{tag}"
1238 display_text = f"Active task {display_text.strip()}"
1239 formatted_task = format_task_with_tags(
1240 display_text, categories, tags, USE_PLAIN
1241 )
1242 if USE_PLAIN:
1243 safe_print(display_text)
1244 else:
1245 safe_print(f"{style(BOLD+CYAN)}{formatted_task}{style(RESET)}")
1246 else:
1247 safe_print(f"{style(GRAY)}No active task matches filter{style(RESET)}")
1248 safe_print("=" * (17 + len(today_str) + len(filter_info)))
1249 return # Do not print TBD if no active task matches filter
1250 else:
1251 safe_print(f"{style(GRAY)}TBD{style(RESET)}")
1253 safe_print("=" * (17 + len(today_str) + len(filter_info)))
1256def cmd_newday(args):
1257 """Initialize a new day's data structure."""
1258 data = load()
1259 ensure_today(data)
1260 if save(data):
1261 safe_print(f"{emoji('newday')} New day initialized -> {today_key()}")
1264def cmd_backlog(args):
1265 """Handle backlog subcommands: add, list, pull, remove."""
1266 data = load()
1267 today = ensure_today(data)
1268 backlog = get_backlog(data)
1270 if args.subcmd == "add":
1271 # Validate task name
1272 is_valid, error_msg = validate_task_name(args.task)
1273 if not is_valid:
1274 safe_print(f"{emoji('error')} {error_msg}")
1275 return
1277 clean_task = args.task.strip()
1278 # Create structured task data for backlog
1279 task_data = create_task_data(clean_task)
1280 backlog.append(task_data)
1282 if save(data):
1283 safe_print(f"{emoji('backlog_add')} Backlog task added: {clean_task}")
1285 elif args.subcmd == "list":
1286 # Parse filter if provided
1287 filter_categories = []
1288 filter_tags = [] # Initialize filter_tags
1289 if hasattr(args, "filter") and args.filter:
1290 is_valid, filter_categories, filter_tags, error_msg = parse_filter_string(
1291 args.filter
1292 ) # Update to get tags
1293 if not is_valid:
1294 safe_print(f"{emoji('error')} {error_msg}")
1295 return
1297 # Filter backlog if categories provided
1298 filtered_backlog = backlog
1299 if filter_categories or filter_tags: # Check both
1300 filtered_backlog = filter_tasks_by_tags_or_categories(
1301 backlog, filter_categories, filter_tags
1302 ) # Pass tags
1304 # Show filter info if filtering
1305 title = "Backlog"
1306 if filter_categories or filter_tags: # Check both
1307 formatted_cats = ", ".join(f"@{cat}" for cat in filter_categories)
1308 formatted_tags = ", ".join(f"#{tag}" for tag in filter_tags) # Format tags
1309 parts = []
1310 if formatted_cats:
1311 parts.append(formatted_cats)
1312 if formatted_tags:
1313 parts.append(formatted_tags)
1314 title = f"Backlog (filtered by: {', '.join(parts)})"
1316 if not filtered_backlog and (filter_categories or filter_tags): # Check both
1317 safe_print(f"{emoji('backlog_list')} {title}:")
1318 safe_print("No backlog items match the filter.")
1319 else:
1320 print_backlog_list(filtered_backlog, title=title)
1322 elif args.subcmd == "pull":
1323 if today["todo"]:
1324 # Handle display of existing active task
1325 if isinstance(today["todo"], dict):
1326 existing_task = today["todo"]["task"]
1327 else:
1328 existing_task = today["todo"]
1329 safe_print(f"{emoji('error')} Active task already exists: {existing_task}")
1330 return
1331 if not backlog:
1332 safe_print("No backlog items to pull.")
1333 return
1335 if hasattr(args, "index") and args.index:
1336 idx = args.index - 1
1337 if idx < 0 or idx >= len(backlog):
1338 safe_print(f"{emoji('error')} Invalid index: {args.index}")
1339 return
1340 elif not USE_PLAIN:
1341 print_backlog_list(backlog)
1342 idx = safe_int_input(
1343 "Select task to pull [1-n]: ", min_val=1, max_val=len(backlog)
1344 )
1345 if idx is None:
1346 return
1347 idx -= 1 # Convert to 0-based index
1348 else:
1349 idx = 0 # default to top item in plain/CI mode
1351 task_item = backlog.pop(idx)
1353 # Handle different backlog item formats
1354 if isinstance(task_item, dict) and "task" in task_item:
1355 if isinstance(task_item["task"], dict):
1356 # New structured format
1357 today["todo"] = task_item["task"]
1358 task_text = task_item["task"]["task"]
1359 else:
1360 # Old format
1361 task_text = task_item["task"]
1362 today["todo"] = create_task_data(task_text)
1363 else:
1364 # Very old format
1365 task_text = str(task_item)
1366 today["todo"] = create_task_data(task_text)
1368 if save(data):
1369 safe_print(
1370 f"{emoji('backlog_pull')} Pulled from backlog: {repr(task_text)}"
1371 )
1372 cmd_status(args)
1374 elif args.subcmd == "remove":
1375 if not backlog:
1376 safe_print("No backlog items to remove.")
1377 return
1379 index = args.index - 1
1380 if 0 <= index < len(backlog):
1381 removed = backlog.pop(index)
1383 # Get task text for display
1384 if isinstance(removed, dict) and "task" in removed:
1385 if isinstance(removed["task"], dict):
1386 task_text = removed["task"]["task"]
1387 else:
1388 task_text = removed["task"]
1389 else:
1390 task_text = str(removed)
1392 if save(data):
1393 safe_print(f"{emoji('error')} Removed from backlog: {repr(task_text)}")
1394 else:
1395 safe_print(
1396 f"{emoji('error')} Invalid backlog index: {args.index} (valid range: 1-{len(backlog)})"
1397 )
1399 elif args.subcmd == "cancel":
1400 if not backlog:
1401 safe_print(f"{emoji('error')} No backlog items to cancel.")
1402 return
1404 index_to_cancel = args.index - 1 # 1-based to 0-based
1406 if not (0 <= index_to_cancel < len(backlog)):
1407 safe_print(
1408 f"{emoji('error')} Invalid backlog index: {args.index} (valid range: 1-{len(backlog)})"
1409 )
1410 return
1412 task_to_cancel = backlog.pop(index_to_cancel)
1414 if not isinstance(task_to_cancel, dict):
1415 safe_print(
1416 f"{emoji('error')} Task item at index {args.index} has unexpected format. Cannot cancel."
1417 )
1418 backlog.insert(index_to_cancel, task_to_cancel)
1419 return
1421 task_text = task_to_cancel.get("task", "Unknown task")
1422 task_to_cancel["state"] = "cancelled"
1423 task_to_cancel["cancellation_date"] = datetime.now().isoformat(
1424 timespec="seconds"
1425 )
1427 # Move to history
1428 if "history" not in data:
1429 data["history"] = []
1430 data["history"].append(task_to_cancel)
1432 if save(data):
1433 safe_print(f"{emoji('error')} Cancelled from backlog: {repr(task_text)}")
1434 else:
1435 backlog.insert(index_to_cancel, task_to_cancel)
1436 if data["history"] and data["history"][-1] is task_to_cancel:
1437 data["history"].pop()
1438 safe_print(
1439 f"{emoji('error')} Failed to save cancellation. Task restored to backlog in memory."
1440 )
1443def cmd_cancel(args):
1444 global STORE
1445 if hasattr(args, "store") and args.store:
1446 STORE = Path(args.store)
1448 data = load()
1449 today = ensure_today(data)
1451 if today.get("todo"):
1452 task_to_cancel = today["todo"]
1453 task_to_cancel["state"] = "cancelled"
1454 task_to_cancel["cancelled_ts"] = datetime.now().isoformat()
1456 if "done" not in today:
1457 today["done"] = []
1459 today["done"].append(create_done_item(task_to_cancel))
1460 today["todo"] = None
1462 if save(data):
1463 safe_print(f"{emoji('error')} Cancelled: '{task_to_cancel.get('task')}'")
1464 else:
1465 safe_print(f"{emoji('error')} Failed to save cancelled task.")
1466 else:
1467 safe_print(f"{emoji('error')} No active task to cancel.")
1470def cmd_history(args):
1471 """Show task history filtered by type (cancelled, archived, all)."""
1472 data = load()
1473 history = data.get("history", [])
1474 type_filter = getattr(args, "type", "all")
1475 filtered = []
1476 if type_filter == "cancelled":
1477 filtered = [t for t in history if t.get("state") == "cancelled"]
1478 elif type_filter == "archived":
1479 filtered = [t for t in history if t.get("state") == "archived"]
1480 else:
1481 filtered = history
1482 if not filtered:
1483 safe_print("No matching tasks in history.")
1484 return
1485 safe_print(f"=== HISTORY: {type_filter} ===")
1486 for t in filtered:
1487 task_text = t.get("task", "")
1488 state = t.get("state", "")
1489 ts = (
1490 t.get("cancellation_date")
1491 or t.get("archival_date")
1492 or t.get("completion_date")
1493 or t.get("ts")
1494 )
1495 ts_str = f"[{ts}]" if ts else ""
1496 safe_print(f"- {task_text} [{state}] {ts_str}")
1499# ===== Argparse + main =====
1502def build_parser():
1503 """Build and configure the argument parser for all CLI commands."""
1504 p = argparse.ArgumentParser(description="One-task-at-a-time tracker")
1505 p.add_argument("--store", default=None, help="Custom storage path")
1506 p.add_argument("--plain", action="store_true", help="Disable emoji / colour")
1507 sub = p.add_subparsers(dest="cmd", required=True)
1509 a = sub.add_parser("add")
1510 a.add_argument("task", nargs="+")
1511 a.set_defaults(func=cmd_add)
1513 # Add --filter to status command
1514 status_parser = sub.add_parser("status")
1515 status_parser.add_argument(
1516 "--filter",
1517 help="Filter by categories and/or tags (e.g., '@work,#urgent'). Remember to quote if using #.",
1518 )
1519 status_parser.set_defaults(func=cmd_status)
1521 sub.add_parser("done").set_defaults(func=cmd_done)
1522 sub.add_parser("newday").set_defaults(func=cmd_newday)
1524 # Add cancel command
1525 sub.add_parser("cancel").set_defaults(func=cmd_cancel)
1527 # Add history command
1528 history_parser = sub.add_parser(
1529 "history", help="View task history (cancelled, archived, all)"
1530 )
1531 history_parser.add_argument(
1532 "--type",
1533 choices=["cancelled", "archived", "all"],
1534 default="all",
1535 help="Type of history to view (cancelled, archived, all)",
1536 )
1537 history_parser.set_defaults(func=cmd_history)
1539 b = sub.add_parser("backlog")
1540 b_sub = b.add_subparsers(dest="subcmd", required=True)
1542 b_a = b_sub.add_parser("add")
1543 b_a.add_argument("task", nargs="+")
1544 b_a.set_defaults(func=cmd_backlog)
1546 # Add --filter to backlog list command
1547 b_list = b_sub.add_parser("list")
1548 b_list.add_argument(
1549 "--filter",
1550 help="Filter by categories and/or tags (e.g., '@work,#urgent'). Remember to quote if using #.",
1551 )
1552 b_list.set_defaults(func=cmd_backlog)
1554 b_pull = b_sub.add_parser("pull", help="Pull next backlog item as active")
1555 b_pull.add_argument(
1556 "--index", type=int, help="Select specific backlog item by 1-based index"
1557 )
1558 b_pull.set_defaults(func=cmd_backlog)
1560 b_remove = b_sub.add_parser("remove", help="Remove a backlog item by index")
1561 b_remove.add_argument("index", type=int, help="1-based index of item to remove")
1562 b_remove.set_defaults(func=cmd_backlog)
1564 # Add backlog cancel sub-command
1565 b_cancel = b_sub.add_parser("cancel", help="Cancel a backlog item by index")
1566 b_cancel.add_argument("index", type=int, help="1-based index of item to cancel")
1567 b_cancel.set_defaults(func=cmd_backlog)
1569 return p
1572def main():
1573 """Main entry point for the task tracker CLI."""
1574 setup_console_encoding() # Set up Unicode handling
1576 args = build_parser().parse_args()
1578 if args.cmd == "add" or (args.cmd == "backlog" and args.subcmd == "add"):
1579 args.task = " ".join(args.task)
1581 global USE_PLAIN, STORE
1582 USE_PLAIN = args.plain
1583 if args.store:
1584 STORE = Path(args.store)
1586 args.func(args)
1589if __name__ == "__main__":
1590 main()