diff --git a/tracker/cli/commands.py b/tracker/cli/commands.py index 648210f..437c406 100644 --- a/tracker/cli/commands.py +++ b/tracker/cli/commands.py @@ -659,6 +659,229 @@ def _update_readme_autogen(slug: str, session: Session) -> None: storage.update_readme_autogen(slug, "NEXT_STEPS", next_steps_content.strip()) +# ============================================================================= +# task add command +# ============================================================================= +def task_add( + slug: str = typer.Argument(..., help="Project slug"), + title: str = typer.Argument(..., help="Task title"), + section: str = typer.Option("Inbox", help="Section name (Inbox, Próximo, En curso, Bloqueado, En espera, Hecho)"), +) -> None: + """Add a task to the project's TASKS.md. + + Valid sections: Inbox, Próximo, En curso, Bloqueado, En espera, Hecho. + If the section doesn't exist, it will be created. + """ + # Validate project exists + if not storage._project_path(slug).exists(): + typer.echo(f"Error: Project '{slug}' does not exist.", err=True) + raise typer.Exit(code=1) + + # Valid sections (Spanish names as per spec) + valid_sections = {"Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"} + if section not in valid_sections: + typer.echo( + f"Error: Invalid section '{section}'. Valid sections are: {', '.join(sorted(valid_sections))}", + err=True, + ) + raise typer.Exit(code=1) + + # Read current TASKS.md + tasks_content = storage.read_tasks(slug) + + # Parse sections and tasks + sections = _parse_tasks_sections(tasks_content) + + # Add task to the specified section + new_task = f"- [ ] {title}" + if section in sections: + sections[section].append(new_task) + else: + # Create new section with the task + sections[section] = [new_task] + + # Rebuild TASKS.md content + new_content = _build_tasks_content(sections) + storage.write_tasks(slug, new_content) + + typer.echo(f"Added task to '{section}' section: {title}") + + +# ============================================================================= +# task move command +# ============================================================================= +def task_move( + slug: str = typer.Argument(..., help="Project slug"), + task_title: str = typer.Argument(..., help="Task title to search for"), + to_section: str = typer.Argument(..., help="Destination section"), +) -> None: + """Move a task to a different section in TASKS.md. + + Searches for the task by title in any section and moves it to the destination. + Tasks moved to 'Hecho' will be marked as completed ([x]). + """ + # Validate project exists + if not storage._project_path(slug).exists(): + typer.echo(f"Error: Project '{slug}' does not exist.", err=True) + raise typer.Exit(code=1) + + # Valid sections + valid_sections = {"Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"} + if to_section not in valid_sections: + typer.echo( + f"Error: Invalid section '{to_section}'. Valid sections are: {', '.join(sorted(valid_sections))}", + err=True, + ) + raise typer.Exit(code=1) + + # Read current TASKS.md + tasks_content = storage.read_tasks(slug) + + # Parse sections and tasks + sections = _parse_tasks_sections(tasks_content) + + # Find the task + found_task = None + found_in_section = None + task_pattern = f"- [ ] {task_title}" + task_pattern_done = f"- [x] {task_title}" + + for section_name, tasks in sections.items(): + for task in tasks: + if task == task_pattern or task == task_pattern_done: + found_task = task + found_in_section = section_name + break + if found_task: + break + + if not found_task: + typer.echo(f"Error: Task '{task_title}' not found in any section.", err=True) + raise typer.Exit(code=1) + + if found_in_section == to_section: + typer.echo(f"Task '{task_title}' is already in '{to_section}' section.") + return + + # Remove from original section + sections[found_in_section].remove(found_task) + if not sections[found_in_section]: + del sections[found_in_section] + + # Determine checkbox state based on destination section + if to_section == "Hecho": + new_task = f"- [x] {task_title}" + else: + new_task = f"- [ ] {task_title}" + + # Add to destination section + if to_section in sections: + sections[to_section].append(new_task) + else: + sections[to_section] = [new_task] + + # Rebuild TASKS.md content + new_content = _build_tasks_content(sections) + storage.write_tasks(slug, new_content) + + checkbox = "[x]" if to_section == "Hecho" else "[ ]" + typer.echo(f"Moved task '{task_title}' from '{found_in_section}' to '{to_section}' ({checkbox})") + + +# ============================================================================= +# Helper functions for TASKS.md parsing +# ============================================================================= +def _parse_tasks_sections(content: str) -> dict: + """Parse TASKS.md content into sections and tasks. + + Returns a dict mapping section names to lists of task strings. + Normalizes English section names to Spanish and merges duplicate sections. + """ + import re + + # Mapping from English to Spanish section names + section_mapping = { + "Next": "Próximo", + "In Progress": "En curso", + "Blocked": "Bloqueado", + "Waiting": "En espera", + "Done": "Hecho", + "Inbox": "Inbox", # Same in both + } + + sections = {} + current_section = None + current_tasks = [] + + # Match section headers (## Section Name) + section_pattern = re.compile(r"^##\s+(.+)$") + # Match task items (- [ ] task or - [x] task) + task_pattern = re.compile(r"^(- \[[ x]\]) (.+)$") + + def save_current_section(): + """Save current section tasks, merging if normalized name already exists.""" + if current_section is not None and current_tasks: + if current_section in sections: + sections[current_section].extend(current_tasks) + else: + sections[current_section] = current_tasks + + for line in content.split("\n"): + section_match = section_pattern.match(line) + if section_match: + # Save previous section if exists + save_current_section() + raw_section = section_match.group(1) + # Normalize section name + current_section = section_mapping.get(raw_section, raw_section) + current_tasks = [] + else: + task_match = task_pattern.match(line) + if task_match and current_section is not None: + checkbox = task_match.group(1) + title = task_match.group(2) + current_tasks.append(f"{checkbox} {title}") + + # Save last section + save_current_section() + + return sections + + +def _build_tasks_content(sections: dict) -> str: + """Build TASKS.md content from sections dict. + + Maintains the order of sections as specified. + """ + section_order = ["Inbox", "Próximo", "En curso", "Bloqueado", "En espera", "Hecho"] + + lines = ["# Tasks", ""] + + for section_name in section_order: + lines.append(f"## {section_name}") + tasks = sections.get(section_name, []) + if tasks: + for task in tasks: + lines.append(task) + else: + lines.append("-") + lines.append("") + + # Add any sections not in the standard order + for section_name in sections: + if section_name not in section_order: + lines.append(f"## {section_name}") + tasks = sections[section_name] + if tasks: + for task in tasks: + lines.append(task) + else: + lines.append("-") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + # Register all commands at module level for direct access __all__ = [ "init_project", @@ -670,4 +893,6 @@ __all__ = [ "add_change", "suggest_next", "review", + "task_add", + "task_move", ] diff --git a/tracker/cli/main.py b/tracker/cli/main.py index a8d0edf..220be6e 100644 --- a/tracker/cli/main.py +++ b/tracker/cli/main.py @@ -12,6 +12,8 @@ from tracker.cli.commands import ( add_change, suggest_next, review, + task_add, + task_move, ) app = typer.Typer( @@ -19,6 +21,14 @@ app = typer.Typer( help="Personal Project Tracker CLI - Track your projects with Markdown and YAML", ) +# Sub-command group for task management +task_app = typer.Typer(help="Task management commands") + + +# Register task subcommands +task_app.command("add")(task_add) +task_app.command("move")(task_move) + # Register all commands app.command("init-project")(init_project) @@ -30,6 +40,7 @@ app.command("stop")(stop_session) app.command("change")(add_change) app.command("next")(suggest_next) app.command("review")(review) +app.add_typer(task_app, name="task") @app.callback()