Add task management commands to CLI
Implement task add and task move subcommands for managing tasks in TASKS.md. task add <slug> <title> [--section <section>]: - Adds a task to the specified section (default: Inbox) - Valid sections: Inbox, Proximo, En curso, Bloqueado, En espera, Hecho - Creates section if it doesn't exist task move <slug> <task_title> <to_section>: - Searches for task by title across all sections - Moves task to destination section - Marks task as completed ([x]) when moved to Hecho - Updates checkbox state based on destination Includes parsing and building functions for TASKS.md with: - Section normalization (English to Spanish names) - Merge support for duplicate normalized sections - Standard section ordering Uses app.add_typer to register task subcommand group.
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user