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())
|
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
|
# Register all commands at module level for direct access
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"init_project",
|
"init_project",
|
||||||
@@ -670,4 +893,6 @@ __all__ = [
|
|||||||
"add_change",
|
"add_change",
|
||||||
"suggest_next",
|
"suggest_next",
|
||||||
"review",
|
"review",
|
||||||
|
"task_add",
|
||||||
|
"task_move",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from tracker.cli.commands import (
|
|||||||
add_change,
|
add_change,
|
||||||
suggest_next,
|
suggest_next,
|
||||||
review,
|
review,
|
||||||
|
task_add,
|
||||||
|
task_move,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
@@ -19,6 +21,14 @@ app = typer.Typer(
|
|||||||
help="Personal Project Tracker CLI - Track your projects with Markdown and YAML",
|
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
|
# Register all commands
|
||||||
app.command("init-project")(init_project)
|
app.command("init-project")(init_project)
|
||||||
@@ -30,6 +40,7 @@ app.command("stop")(stop_session)
|
|||||||
app.command("change")(add_change)
|
app.command("change")(add_change)
|
||||||
app.command("next")(suggest_next)
|
app.command("next")(suggest_next)
|
||||||
app.command("review")(review)
|
app.command("review")(review)
|
||||||
|
app.add_typer(task_app, name="task")
|
||||||
|
|
||||||
|
|
||||||
@app.callback()
|
@app.callback()
|
||||||
|
|||||||
Reference in New Issue
Block a user