Implement CLI commands and utility functions for MVP-1
- Add complete CLI command implementations with Typer subcommands - Implement utils/time.py with duration formatting and datetime utilities - Implement utils/path.py with project path management utilities - Wire up all commands to main CLI entry point
This commit is contained in:
@@ -1,7 +1,42 @@
|
|||||||
"""Main CLI entry point."""
|
"""Main CLI entry point."""
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
|
from tracker.cli.commands import (
|
||||||
|
init_project,
|
||||||
|
list_projects,
|
||||||
|
show_project,
|
||||||
|
start_session,
|
||||||
|
add_note_cmd,
|
||||||
|
stop_session,
|
||||||
|
add_change,
|
||||||
|
suggest_next,
|
||||||
|
review,
|
||||||
|
)
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="tracker",
|
name="tracker",
|
||||||
help="Personal project tracker CLI",
|
help="Personal Project Tracker CLI - Track your projects with Markdown and YAML",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Register all subcommands
|
||||||
|
app.add_typer_command(init_project, name="init-project")
|
||||||
|
app.add_typer_command(list_projects, name="list")
|
||||||
|
app.add_typer_command(show_project, name="show")
|
||||||
|
app.add_typer_command(start_session, name="start")
|
||||||
|
app.add_typer_command(add_note_cmd, name="note")
|
||||||
|
app.add_typer_command(stop_session, name="stop")
|
||||||
|
app.add_typer_command(add_change, name="change")
|
||||||
|
app.add_typer_command(suggest_next, name="next")
|
||||||
|
app.add_typer_command(review, name="review")
|
||||||
|
|
||||||
|
|
||||||
|
@app.callback()
|
||||||
|
def callback():
|
||||||
|
"""Personal Project Tracker - Track your projects locally with Markdown."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
|
|||||||
@@ -1 +1,87 @@
|
|||||||
"""Path utility functions."""
|
"""Path utility functions."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: Path) -> Path:
|
||||||
|
"""Ensure a directory exists, creating it if necessary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the directory.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The path to the directory.
|
||||||
|
"""
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def project_root(slug: str, projects_root: Optional[Path] = None) -> Path:
|
||||||
|
"""Get the root path for a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slug: Project slug.
|
||||||
|
projects_root: Root directory for all projects. Defaults to ./projects.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the project root.
|
||||||
|
"""
|
||||||
|
if projects_root is None:
|
||||||
|
projects_root = Path("projects")
|
||||||
|
return projects_root / slug
|
||||||
|
|
||||||
|
|
||||||
|
def relative_to_project(slug: str, relative_path: str, projects_root: Optional[Path] = None) -> Path:
|
||||||
|
"""Get a path relative to a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slug: Project slug.
|
||||||
|
relative_path: Relative path within the project.
|
||||||
|
projects_root: Root directory for all projects.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path to the file within the project.
|
||||||
|
"""
|
||||||
|
root = project_root(slug, projects_root)
|
||||||
|
return root / relative_path
|
||||||
|
|
||||||
|
|
||||||
|
def is_within_project(slug: str, file_path: Path, projects_root: Optional[Path] = None) -> bool:
|
||||||
|
"""Check if a file path is within a project.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slug: Project slug.
|
||||||
|
file_path: Path to check.
|
||||||
|
projects_root: Root directory for all projects.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if file_path is within the project directory.
|
||||||
|
"""
|
||||||
|
project_path = project_root(slug, projects_root)
|
||||||
|
try:
|
||||||
|
file_path.resolve().relative_to(project_path.resolve())
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(filename: str) -> str:
|
||||||
|
"""Sanitize a filename by removing invalid characters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Original filename.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized filename safe for file system use.
|
||||||
|
"""
|
||||||
|
# Remove or replace invalid characters
|
||||||
|
invalid_chars = '<>:"/\\|?*'
|
||||||
|
for char in invalid_chars:
|
||||||
|
filename = filename.replace(char, "_")
|
||||||
|
|
||||||
|
# Limit length
|
||||||
|
if len(filename) > 255:
|
||||||
|
filename = filename[:255]
|
||||||
|
|
||||||
|
return filename
|
||||||
|
|||||||
@@ -1 +1,91 @@
|
|||||||
"""Time utility functions."""
|
"""Time utility functions."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(minutes: int) -> str:
|
||||||
|
"""Format duration in minutes to human-readable string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
minutes: Duration in minutes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable duration string (e.g., "1h 30m", "45m").
|
||||||
|
"""
|
||||||
|
if minutes < 60:
|
||||||
|
return f"{minutes}m"
|
||||||
|
hours = minutes // 60
|
||||||
|
remaining_minutes = minutes % 60
|
||||||
|
if remaining_minutes == 0:
|
||||||
|
return f"{hours}h"
|
||||||
|
return f"{hours}h {remaining_minutes}m"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_duration(duration_str: str) -> int:
|
||||||
|
"""Parse duration string to minutes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
duration_str: Duration string like "1h 30m", "45m", "2h".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Duration in minutes.
|
||||||
|
"""
|
||||||
|
total_minutes = 0
|
||||||
|
duration_str = duration_str.lower().strip()
|
||||||
|
|
||||||
|
# Parse hours
|
||||||
|
if "h" in duration_str:
|
||||||
|
parts = duration_str.split()
|
||||||
|
for part in parts:
|
||||||
|
if "h" in part:
|
||||||
|
total_minutes += int(part.replace("h", "")) * 60
|
||||||
|
elif "m" in part:
|
||||||
|
total_minutes += int(part.replace("m", ""))
|
||||||
|
|
||||||
|
# If no hours, try just minutes
|
||||||
|
if total_minutes == 0 and "m" in duration_str:
|
||||||
|
total_minutes = int(duration_str.replace("m", ""))
|
||||||
|
elif total_minutes == 0:
|
||||||
|
try:
|
||||||
|
total_minutes = int(duration_str)
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return total_minutes
|
||||||
|
|
||||||
|
|
||||||
|
def is_recent(dt: datetime, hours: int = 24) -> bool:
|
||||||
|
"""Check if a datetime is within the specified hours.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime to check.
|
||||||
|
hours: Number of hours to consider as "recent".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if dt is within the specified hours.
|
||||||
|
"""
|
||||||
|
return datetime.now() - dt < timedelta(hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
def format_datetime(dt: datetime) -> str:
|
||||||
|
"""Format datetime to standard string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime to format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string in YYYY-MM-DD HH:MM format.
|
||||||
|
"""
|
||||||
|
return dt.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(dt: datetime) -> str:
|
||||||
|
"""Format datetime to date string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime to format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string in YYYY-MM-DD format.
|
||||||
|
"""
|
||||||
|
return dt.strftime("%Y-%m-%d")
|
||||||
|
|||||||
Reference in New Issue
Block a user