Coverage for momentum.py: 76%

848 statements  

« 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. 

4 

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. 

8 

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""" 

15 

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 

26 

27 

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 

38 

39 

40# ===== Global toggles ===== 

41USE_PLAIN = False 

42STORE: Path = Path("storage.json") 

43 

44 

45# ===== Configuration ===== 

46class Config: 

47 """Configuration constants for Momentum.""" 

48 

49 MAX_TASK_LENGTH = 500 

50 STORAGE_ENCODING = "utf-8" 

51 DATE_FORMAT = "%m/%d" 

52 TIME_FORMAT = "%H:%M" 

53 

54 

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 

62 

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 

70 

71 

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) 

80 

81 

82# ===== Styling helpers ===== 

83RESET = "\033[0m" 

84CYAN = "\033[96m" 

85GREEN = "\033[92m" 

86GRAY = "\033[90m" 

87BOLD = "\033[1m" 

88 

89EMOJI = { 

90 "added": "✅", 

91 "complete": "🎉", 

92 "backlog_add": "📥", 

93 "backlog_list": "📋", 

94 "backlog_pull": "📤", 

95 "newday": "🌅", 

96 "error": "❌", 

97} 

98 

99 

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 "" 

112 

113 

114def emoji(k): 

115 """Return emoji for given key or empty string if plain mode is enabled.""" 

116 if USE_PLAIN: 

117 return "" 

118 

119 emoji_char = EMOJI.get(k, "") 

120 if not emoji_char: 

121 return "" 

122 

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, "") 

140 

141 

142RESET, CYAN, GRAY, BOLD, GREEN = map(style, (RESET, CYAN, GRAY, BOLD, GREEN)) 

143 

144# ===== Input Validation ===== 

145 

146 

147def validate_task_name(task): 

148 """ 

149 Validate task name input. 

150 

151 Args: 

152 task: The task name to validate 

153 

154 Returns: 

155 tuple: (is_valid: bool, error_message: str) 

156 """ 

157 if not task: 

158 return False, "Task name cannot be empty." 

159 

160 task_stripped = task.strip() 

161 if not task_stripped: 

162 return False, "Task name cannot be only whitespace." 

163 

164 if len(task_stripped) > Config.MAX_TASK_LENGTH: 

165 return False, f"Task name too long (max {Config.MAX_TASK_LENGTH} characters)." 

166 

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." 

170 

171 return True, "" 

172 

173 

174def safe_input(prompt, validator=None): 

175 """ 

176 Get user input with optional validation and Unicode safety. 

177 

178 Args: 

179 prompt: The input prompt to display 

180 validator: Optional function that takes input and returns (valid, error_msg) 

181 

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") 

195 

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 

206 

207 

208def safe_int_input(prompt, min_val=None, max_val=None): 

209 """ 

210 Get integer input with validation and Unicode safety. 

211 

212 Args: 

213 prompt: The input prompt to display 

214 min_val: Minimum allowed value (inclusive) 

215 max_val: Maximum allowed value (inclusive) 

216 

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") 

229 

230 user_input = input(safe_prompt).strip() 

231 if not user_input: 

232 return None 

233 

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 

248 

249 

250# ===== Storage helpers ===== 

251 

252 

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 

259 

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 

265 

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 

278 

279 

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 {} 

305 

306 

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 

320 

321 

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()) 

328 

329 

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"] = [] 

335 

336 # Ensure today's entry exists 

337 today = data.setdefault(today_key(), {"todo": None, "done": []}) 

338 

339 return today 

340 

341 

342def get_backlog(data): 

343 """Get the global backlog, creating it if it doesn't exist.""" 

344 return data.setdefault("backlog", []) 

345 

346 

347# ===== Display/Formatting helpers ===== 

348 

349 

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'}]" 

359 

360 

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}") 

411 

412 

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 

434 

435 

436def handle_next_task_selection(data, today): 

437 """Handle user selection of next task after completing current one.""" 

438 backlog = get_backlog(data) 

439 

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.") 

446 

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") 

451 

452 choice = safe_input("> ") 

453 if choice is None: 

454 return # User cancelled or error occurred 

455 

456 if choice.isdigit(): 

457 index = int(choice) - 1 

458 if 0 <= index < len(backlog): 

459 task_item = backlog.pop(index) 

460 

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) 

475 

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 

491 

492 

493# ===== Tag Parsing Functions ===== 

494 

495 

496def parse_tags(task_text: str) -> Tuple[str, List[str], List[str]]: 

497 """ 

498 Parse @categories and #tags from task text. 

499 

500 Args: 

501 task_text: The task description potentially containing tags 

502 

503 Returns: 

504 tuple: (original_text, categories_list, tags_list) 

505 

506 Examples: 

507 >>> parse_tags("Fix bug @work #urgent") 

508 ("Fix bug @work #urgent", ["work"], ["urgent"]) 

509 

510 >>> parse_tags("Simple task") 

511 ("Simple task", [], []) 

512 """ 

513 if not task_text: 

514 return task_text, [], [] 

515 

516 # Regular expressions for matching tags 

517 category_pattern = r"@([a-zA-Z0-9_-]+)" 

518 tag_pattern = r"#([a-zA-Z0-9_-]+)" 

519 

520 # Find all categories and tags 

521 categories = re.findall(category_pattern, task_text) 

522 tags = re.findall(tag_pattern, task_text) 

523 

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)) 

527 

528 return task_text, categories, tags 

529 

530 

531def validate_tag_format(tag: str) -> bool: 

532 """ 

533 Validate if a tag follows the correct format. 

534 

535 Args: 

536 tag: The tag to validate (without @ or # prefix) 

537 

538 Returns: 

539 bool: True if valid, False otherwise 

540 

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 

548 

549 if len(tag) > 50: # reasonable limit 

550 return False 

551 

552 # Only allow alphanumeric, underscore, and hyphen 

553 pattern = r"^[a-zA-Z0-9_-]+$" 

554 return bool(re.match(pattern, tag)) 

555 

556 

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. 

562 

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 

568 

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 

575 

576 # Color codes for highlighting 

577 CATEGORY_COLOR = "\033[94m" # Blue 

578 TAG_COLOR = "\033[93m" # Yellow 

579 RESET_COLOR = "\033[0m" 

580 

581 formatted_text = task_text 

582 

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 ) 

593 

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 ) 

600 

601 return formatted_text 

602 

603 

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 } 

620 

621 

622# ===== Update existing validation function ===== 

623 

624 

625def validate_task_name_with_tags(task: str) -> Tuple[bool, str]: 

626 """ 

627 Enhanced task validation that includes tag format validation. 

628 

629 Args: 

630 task: The task name to validate (may include tags) 

631 

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 

639 

640 # Parse and validate tags 

641 text, categories, tags = parse_tags(task) 

642 

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 ) 

650 

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 ) 

658 

659 return True, "" 

660 

661 

662# ===== Helper functions for filtering (we'll implement these next) ===== 

663 

664 

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)) 

676 

677 

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)) 

689 

690 

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. 

698 

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"]) 

703 

704 Returns: 

705 List of tasks matching the filters 

706 """ 

707 if not filter_categories and not filter_tags: 

708 return tasks 

709 

710 filtered_tasks = [] 

711 

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", "")) 

721 

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 ) 

726 

727 # Check if task matches tag filter 

728 tag_match = not filter_tags or any(tag in task_tags for tag in filter_tags) 

729 

730 # Task must match both filters (if specified) 

731 if category_match and tag_match: 

732 filtered_tasks.append(task) 

733 

734 return filtered_tasks 

735 

736 

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. 

740 

741 Args: 

742 filter_str: Comma-separated string like "@work,#urgent,@personal" 

743 

744 Returns: 

745 tuple: (is_valid: bool, categories: List[str], tags: List[str], error_message: str) 

746 

747 Examples: 

748 >>> parse_filter_string("@work,#urgent") 

749 (True, ["work"], ["urgent"], "") 

750 

751 >>> parse_filter_string("@work, #urgent, @personal") # spaces ok 

752 (True, ["work", "personal"], ["urgent"], "") 

753 

754 >>> parse_filter_string("work,#urgent") 

755 (False, [], ["urgent"], "Invalid filter item: 'work'. Must start with @ (category) or # (tag).") 

756 

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, [], [], "" 

762 

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] 

767 

768 if not items: 

769 # This can happen if filter_str was just commas or whitespace 

770 return True, [], [], "" 

771 

772 categories = [] 

773 tags = [] 

774 errors = [] 

775 overall_valid = True 

776 

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 

814 

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)) 

819 

820 error_message = " ".join(errors) # Concatenate multiple error messages 

821 

822 return overall_valid, categories, tags, error_message 

823 

824 

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 

890 

891 

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 

948 

949 

950# ===== Data migration helper ===== 

951 

952 

953def migrate_task_to_tagged_format(task_data: dict) -> dict: 

954 """ 

955 Migrate a legacy task to include categories and tags fields. 

956 

957 Args: 

958 task_data: Legacy task dictionary 

959 

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 

966 

967 # Parse tags from task text 

968 task_text = task_data.get("task", "") 

969 text, categories, tags = parse_tags(task_text) 

970 

971 # Update task data 

972 updated_task = task_data.copy() 

973 updated_task["categories"] = categories 

974 updated_task["tags"] = tags 

975 

976 return updated_task 

977 

978 

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 } 

991 

992 

993# ===== Command functions ===== 

994def prompt_next_action(data): 

995 """ 

996 Ask user what to do after completing a task. 

997 

998 Args: 

999 data: The full data dictionary containing backlog 

1000 

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() 

1018 

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) 

1026 

1027 

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 

1037 

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 

1044 

1045 data = load() 

1046 today = ensure_today(data) 

1047 

1048 # Clean the task name 

1049 clean_task = args.task.strip() 

1050 

1051 # Parse tags from the task 

1052 task_data = create_task_data(clean_task) 

1053 

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"] 

1060 

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 

1078 

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) 

1084 

1085 

1086def cmd_done(args): 

1087 """Complete the current active task and prompt for next action.""" 

1088 data = load() 

1089 today = ensure_today(data) 

1090 

1091 if not today["todo"]: 

1092 safe_print(f"{emoji('error')} No active task to complete.") 

1093 return 

1094 

1095 # Complete the task 

1096 complete_current_task(today) 

1097 if not save(data): 

1098 return # Don't proceed if save failed 

1099 

1100 cmd_status(args) 

1101 

1102 # Handle next task selection 

1103 handle_next_task_selection(data, today) 

1104 

1105 

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() 

1116 

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 

1127 

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)})" 

1139 

1140 safe_print(f"\n=== TODAY: {today_str}{filter_info} ===") 

1141 

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 

1148 

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.") 

1192 

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)}") 

1252 

1253 safe_print("=" * (17 + len(today_str) + len(filter_info))) 

1254 

1255 

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()}") 

1262 

1263 

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) 

1269 

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 

1276 

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) 

1281 

1282 if save(data): 

1283 safe_print(f"{emoji('backlog_add')} Backlog task added: {clean_task}") 

1284 

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 

1296 

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 

1303 

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)})" 

1315 

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) 

1321 

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 

1334 

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 

1350 

1351 task_item = backlog.pop(idx) 

1352 

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) 

1367 

1368 if save(data): 

1369 safe_print( 

1370 f"{emoji('backlog_pull')} Pulled from backlog: {repr(task_text)}" 

1371 ) 

1372 cmd_status(args) 

1373 

1374 elif args.subcmd == "remove": 

1375 if not backlog: 

1376 safe_print("No backlog items to remove.") 

1377 return 

1378 

1379 index = args.index - 1 

1380 if 0 <= index < len(backlog): 

1381 removed = backlog.pop(index) 

1382 

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) 

1391 

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 ) 

1398 

1399 elif args.subcmd == "cancel": 

1400 if not backlog: 

1401 safe_print(f"{emoji('error')} No backlog items to cancel.") 

1402 return 

1403 

1404 index_to_cancel = args.index - 1 # 1-based to 0-based 

1405 

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 

1411 

1412 task_to_cancel = backlog.pop(index_to_cancel) 

1413 

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 

1420 

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 ) 

1426 

1427 # Move to history 

1428 if "history" not in data: 

1429 data["history"] = [] 

1430 data["history"].append(task_to_cancel) 

1431 

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 ) 

1441 

1442 

1443def cmd_cancel(args): 

1444 global STORE 

1445 if hasattr(args, "store") and args.store: 

1446 STORE = Path(args.store) 

1447 

1448 data = load() 

1449 today = ensure_today(data) 

1450 

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() 

1455 

1456 if "done" not in today: 

1457 today["done"] = [] 

1458 

1459 today["done"].append(create_done_item(task_to_cancel)) 

1460 today["todo"] = None 

1461 

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.") 

1468 

1469 

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}") 

1497 

1498 

1499# ===== Argparse + main ===== 

1500 

1501 

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) 

1508 

1509 a = sub.add_parser("add") 

1510 a.add_argument("task", nargs="+") 

1511 a.set_defaults(func=cmd_add) 

1512 

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) 

1520 

1521 sub.add_parser("done").set_defaults(func=cmd_done) 

1522 sub.add_parser("newday").set_defaults(func=cmd_newday) 

1523 

1524 # Add cancel command 

1525 sub.add_parser("cancel").set_defaults(func=cmd_cancel) 

1526 

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) 

1538 

1539 b = sub.add_parser("backlog") 

1540 b_sub = b.add_subparsers(dest="subcmd", required=True) 

1541 

1542 b_a = b_sub.add_parser("add") 

1543 b_a.add_argument("task", nargs="+") 

1544 b_a.set_defaults(func=cmd_backlog) 

1545 

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) 

1553 

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) 

1559 

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) 

1563 

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) 

1568 

1569 return p 

1570 

1571 

1572def main(): 

1573 """Main entry point for the task tracker CLI.""" 

1574 setup_console_encoding() # Set up Unicode handling 

1575 

1576 args = build_parser().parse_args() 

1577 

1578 if args.cmd == "add" or (args.cmd == "backlog" and args.subcmd == "add"): 

1579 args.task = " ".join(args.task) 

1580 

1581 global USE_PLAIN, STORE 

1582 USE_PLAIN = args.plain 

1583 if args.store: 

1584 STORE = Path(args.store) 

1585 

1586 args.func(args) 

1587 

1588 

1589if __name__ == "__main__": 

1590 main()