This commit is contained in:
Tim Bendt
2025-08-18 10:58:48 -04:00
parent c64fbbb072
commit ca6e4cdf5d
12 changed files with 2220 additions and 39 deletions

607
src/cli/ticktick.py Normal file
View File

@@ -0,0 +1,607 @@
"""
TickTick CLI commands with aliases for task management.
"""
import click
from datetime import datetime
from typing import Optional, List
from rich.console import Console
from src.services.ticktick import TickTickService
from src.utils.ticktick_utils import (
create_task_table,
print_task_details,
open_task,
parse_priority,
validate_date,
console,
)
# Initialize service lazily
def get_ticktick_service():
"""Get the TickTick service, initializing it if needed."""
global _ticktick_service
if "_ticktick_service" not in globals():
_ticktick_service = TickTickService()
return _ticktick_service
@click.group()
def ticktick():
"""TickTick task management CLI."""
pass
@ticktick.command(name="list")
@click.option("--project", "-p", help="Filter by project name")
@click.option(
"--due-date", "-d", help="Filter by due date (today, tomorrow, YYYY-MM-DD)"
)
@click.option("--all", "-a", is_flag=True, help="Show all tasks including completed")
@click.option("--priority", "-pr", help="Filter by priority (0-5, low, medium, high)")
@click.option("--tag", "-t", help="Filter by tag name")
@click.option("--limit", "-l", default=20, help="Limit number of results")
def list_tasks(
project: Optional[str],
due_date: Optional[str],
all: bool,
priority: Optional[str],
tag: Optional[str],
limit: int,
):
"""List tasks (alias: ls)."""
try:
ticktick_service = get_ticktick_service()
if due_date:
if not validate_date(due_date):
console.print(f"[red]Invalid date format: {due_date}[/red]")
return
tasks = get_ticktick_service().get_tasks_by_due_date(due_date)
elif project:
tasks = get_ticktick_service().get_tasks_by_project(project)
else:
tasks = get_ticktick_service().get_tasks(completed=all)
# Apply additional filters
if priority:
priority_val = parse_priority(priority)
tasks = [t for t in tasks if t.get("priority", 0) == priority_val]
if tag:
tasks = [
t
for t in tasks
if tag.lower() in [t.lower() for t in t.get("tags", [])]
]
# Limit results
if limit > 0:
tasks = tasks[:limit]
if not tasks:
console.print("[yellow]No tasks found matching criteria[/yellow]")
return
# Display results
table = create_task_table(tasks, show_project=not project)
console.print(table)
console.print(f"\n[dim]Showing {len(tasks)} tasks[/dim]")
except Exception as e:
console.print(f"[red]Error listing tasks: {str(e)}[/red]")
@ticktick.command(name="add")
@click.argument("title")
@click.option("--project", "-p", help="Project name")
@click.option("--due-date", "-d", help="Due date (today, tomorrow, YYYY-MM-DD)")
@click.option("--priority", "-pr", help="Priority (0-5, low, medium, high)")
@click.option("--content", "-c", help="Task description/content")
@click.option("--tags", "-t", help="Comma-separated list of tags")
def add_task(
title: str,
project: Optional[str],
due_date: Optional[str],
priority: Optional[str],
content: Optional[str],
tags: Optional[str],
):
"""Add a new task (alias: a)."""
try:
# Validate due date if provided
if due_date and not validate_date(due_date):
console.print(f"[red]Invalid date format: {due_date}[/red]")
return
# Parse priority
priority_val = parse_priority(priority) if priority else None
# Parse tags
tag_list = [tag.strip() for tag in tags.split(",")] if tags else None
# Create task
task = get_ticktick_service().create_task(
title=title,
project_name=project,
due_date=due_date,
priority=priority_val,
content=content,
tags=tag_list,
)
if task:
console.print(f"[green]✓ Created task: {title}[/green]")
console.print(f"[dim]Task ID: {task.get('id', 'N/A')}[/dim]")
else:
console.print("[red]Failed to create task[/red]")
except Exception as e:
console.print(f"[red]Error creating task: {str(e)}[/red]")
@ticktick.command(name="edit")
@click.argument("task_id")
@click.option("--title", help="New task title")
@click.option("--project", "-p", help="New project name")
@click.option("--due-date", "-d", help="New due date (today, tomorrow, YYYY-MM-DD)")
@click.option("--priority", "-pr", help="New priority (0-5, low, medium, high)")
@click.option("--content", "-c", help="New task description/content")
def edit_task(
task_id: str,
title: Optional[str],
project: Optional[str],
due_date: Optional[str],
priority: Optional[str],
content: Optional[str],
):
"""Edit an existing task (alias: e)."""
try:
# Build update dictionary
updates = {}
if title:
updates["title"] = title
if project:
updates["project_name"] = project
if due_date:
if not validate_date(due_date):
console.print(f"[red]Invalid date format: {due_date}[/red]")
return
updates["due_date"] = due_date
if priority:
updates["priority"] = parse_priority(priority)
if content:
updates["content"] = content
if not updates:
console.print("[yellow]No changes specified[/yellow]")
return
# Update task
updated_task = get_ticktick_service().update_task(task_id, **updates)
if updated_task:
console.print(
f"[green]✓ Updated task: {updated_task.get('title', task_id)}[/green]"
)
else:
console.print("[red]Failed to update task[/red]")
except Exception as e:
console.print(f"[red]Error updating task: {str(e)}[/red]")
@ticktick.command(name="complete")
@click.argument("task_id")
def complete_task(task_id: str):
"""Mark a task as completed (aliases: done, c)."""
try:
success = get_ticktick_service().complete_task(task_id)
if success:
console.print(f"[green]✓ Completed task: {task_id}[/green]")
else:
console.print(f"[red]Failed to complete task: {task_id}[/red]")
except Exception as e:
console.print(f"[red]Error completing task: {str(e)}[/red]")
@ticktick.command(name="delete")
@click.argument("task_id")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
def delete_task(task_id: str, force: bool):
"""Delete a task (aliases: del, rm)."""
try:
if not force:
if not click.confirm(f"Delete task {task_id}?"):
console.print("[yellow]Cancelled[/yellow]")
return
success = get_ticktick_service().delete_task(task_id)
if success:
console.print(f"[green]✓ Deleted task: {task_id}[/green]")
else:
console.print(f"[red]Failed to delete task: {task_id}[/red]")
except Exception as e:
console.print(f"[red]Error deleting task: {str(e)}[/red]")
@ticktick.command(name="open")
@click.argument("task_id")
@click.option(
"--browser", "-b", is_flag=True, help="Force open in browser instead of app"
)
def open_task_cmd(task_id: str, browser: bool):
"""Open a task in browser or TickTick app (alias: o)."""
try:
open_task(task_id, prefer_app=not browser)
except Exception as e:
console.print(f"[red]Error opening task: {str(e)}[/red]")
@ticktick.command(name="show")
@click.argument("task_id")
def show_task(task_id: str):
"""Show detailed task information (aliases: view, s)."""
try:
get_ticktick_service()._ensure_client()
task = get_ticktick_service().client.get_by_id(task_id, search="tasks")
if not task:
console.print(f"[red]Task not found: {task_id}[/red]")
return
print_task_details(task)
except Exception as e:
console.print(f"[red]Error showing task: {str(e)}[/red]")
@ticktick.command(name="projects")
def list_projects():
"""List all projects (alias: proj)."""
try:
projects = get_ticktick_service().get_projects()
if not projects:
console.print("[yellow]No projects found[/yellow]")
return
console.print("[bold cyan]Projects:[/bold cyan]")
for project in projects:
name = project.get("name", "Unnamed")
project_id = project.get("id", "N/A")
console.print(f" • [white]{name}[/white] [dim]({project_id})[/dim]")
console.print(f"\n[dim]Total: {len(projects)} projects[/dim]")
except Exception as e:
console.print(f"[red]Error listing projects: {str(e)}[/red]")
@ticktick.command(name="tags")
def list_tags():
"""List all tags."""
try:
tags = get_ticktick_service().get_tags()
if not tags:
console.print("[yellow]No tags found[/yellow]")
return
console.print("[bold green]Tags:[/bold green]")
for tag in tags:
name = tag.get("name", "Unnamed")
console.print(f" • [green]#{name}[/green]")
console.print(f"\n[dim]Total: {len(tags)} tags[/dim]")
except Exception as e:
console.print(f"[red]Error listing tags: {str(e)}[/red]")
@ticktick.command(name="sync")
def sync_tasks():
"""Sync tasks with TickTick servers."""
try:
get_ticktick_service().sync()
console.print("[green]✓ Synced with TickTick servers[/green]")
except Exception as e:
console.print(f"[red]Error syncing: {str(e)}[/red]")
# Add alias commands manually
@click.command()
@click.option("--project", "-p", help="Filter by project name")
@click.option(
"--due-date", "-d", help="Filter by due date (today, tomorrow, YYYY-MM-DD)"
)
@click.option("--all", "-a", is_flag=True, help="Show all tasks including completed")
@click.option("--priority", "-pr", help="Filter by priority (0-5, low, medium, high)")
@click.option("--tag", "-t", help="Filter by tag name")
@click.option("--limit", "-l", default=20, help="Limit number of results")
def ls(
project: Optional[str],
due_date: Optional[str],
all: bool,
priority: Optional[str],
tag: Optional[str],
limit: int,
):
"""Alias for list command."""
list_tasks.callback(project, due_date, all, priority, tag, limit)
@click.command()
@click.argument("title")
@click.option("--project", "-p", help="Project name")
@click.option("--due-date", "-d", help="Due date (today, tomorrow, YYYY-MM-DD)")
@click.option("--priority", "-pr", help="Priority (0-5, low, medium, high)")
@click.option("--content", "-c", help="Task description/content")
@click.option("--tags", "-t", help="Comma-separated list of tags")
def a(
title: str,
project: Optional[str],
due_date: Optional[str],
priority: Optional[str],
content: Optional[str],
tags: Optional[str],
):
"""Alias for add command."""
add_task.callback(title, project, due_date, priority, content, tags)
@click.command()
@click.argument("task_id")
@click.option("--title", help="New task title")
@click.option("--project", "-p", help="New project name")
@click.option("--due-date", "-d", help="New due date (today, tomorrow, YYYY-MM-DD)")
@click.option("--priority", "-pr", help="New priority (0-5, low, medium, high)")
@click.option("--content", "-c", help="New task description/content")
def e(
task_id: str,
title: Optional[str],
project: Optional[str],
due_date: Optional[str],
priority: Optional[str],
content: Optional[str],
):
"""Alias for edit command."""
edit_task.callback(task_id, title, project, due_date, priority, content)
@click.command()
@click.argument("task_id")
def c(task_id: str):
"""Alias for complete command."""
complete_task.callback(task_id)
@click.command()
@click.argument("task_id")
def done(task_id: str):
"""Alias for complete command."""
complete_task.callback(task_id)
@click.command()
@click.argument("task_id")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
def rm(task_id: str, force: bool):
"""Alias for delete command."""
delete_task.callback(task_id, force)
@click.command()
@click.argument("task_id")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
def del_cmd(task_id: str, force: bool):
"""Alias for delete command."""
delete_task.callback(task_id, force)
@click.command()
@click.argument("task_id")
@click.option(
"--browser", "-b", is_flag=True, help="Force open in browser instead of app"
)
def o(task_id: str, browser: bool):
"""Alias for open command."""
open_task_cmd.callback(task_id, browser)
@click.command()
@click.argument("task_id")
def s(task_id: str):
"""Alias for show command."""
show_task.callback(task_id)
@click.command()
@click.argument("task_id")
def view(task_id: str):
"""Alias for show command."""
show_task.callback(task_id)
@click.command()
def proj():
"""Alias for projects command."""
list_projects.callback()
# Register all alias commands
ticktick.add_command(ls)
ticktick.add_command(a)
ticktick.add_command(e)
ticktick.add_command(c)
ticktick.add_command(done)
ticktick.add_command(rm)
ticktick.add_command(del_cmd, name="del")
ticktick.add_command(o)
ticktick.add_command(s)
ticktick.add_command(view)
ticktick.add_command(proj)
@ticktick.command(name="setup")
def setup_ticktick():
"""Show TickTick setup instructions."""
from rich.panel import Panel
from rich.markdown import Markdown
setup_text = """
# TickTick Setup Instructions
## 1. Register TickTick Developer App
Visit: https://developer.ticktick.com/docs#/openapi
- Click "Manage Apps""+App Name"
- Set OAuth Redirect URL: `http://localhost:8080`
- Note your Client ID and Client Secret
## 2. Set Environment Variables
```bash
export TICKTICK_CLIENT_ID="your_client_id"
export TICKTICK_CLIENT_SECRET="your_client_secret"
export TICKTICK_REDIRECT_URI="http://localhost:8080"
# Optional (you'll be prompted if not set):
export TICKTICK_USERNAME="your_email@example.com"
export TICKTICK_PASSWORD="your_password"
```
## 3. Authentication Note
The TickTick library requires **both** OAuth2 AND login credentials:
- OAuth2: For API authorization
- Username/Password: For initial session setup
This is how the library works, not a limitation of our CLI.
## 4. Start Using
```bash
ticktick ls # List tasks
ticktick a "Task" # Add task
```
"""
console.print(
Panel(
Markdown(setup_text),
title="[bold green]TickTick Setup[/bold green]",
border_style="green",
)
)
@ticktick.command(name="test-auth")
def test_auth():
"""Test authentication and API connectivity."""
import os
from src.services.ticktick.auth import (
get_token_file_path,
check_token_validity,
get_ticktick_client,
)
console.print("[bold cyan]TickTick Authentication Test[/bold cyan]\n")
# Check environment
client_id = os.getenv("TICKTICK_CLIENT_ID")
client_secret = os.getenv("TICKTICK_CLIENT_SECRET")
if not client_id or not client_secret:
console.print("[red]❌ OAuth credentials not set[/red]")
console.print("Please set TICKTICK_CLIENT_ID and TICKTICK_CLIENT_SECRET")
return
console.print("[green]✓ OAuth credentials found[/green]")
# Check token cache
validity = check_token_validity()
if validity["valid"]:
console.print(f"[green]✓ Token cache: {validity['reason']}[/green]")
else:
console.print(f"[yellow]⚠ Token cache: {validity['reason']}[/yellow]")
# Test client creation
console.print("\n[bold]Testing TickTick client initialization...[/bold]")
try:
client = get_ticktick_client()
console.print("[green]✓ TickTick client created successfully[/green]")
# Test API call
console.print("Testing API connectivity...")
try:
projects = client.get_by_fields(search="projects")
console.print(
f"[green]✓ API test successful - found {len(projects)} projects[/green]"
)
except Exception as api_e:
console.print(f"[red]❌ API test failed: {api_e}[/red]")
except Exception as e:
console.print(f"[red]❌ Client creation failed: {str(e)}[/red]")
return
console.print("\n[green]🎉 Authentication test completed successfully![/green]")
@ticktick.command(name="auth-status")
def auth_status():
"""Check TickTick authentication status."""
import os
from src.services.ticktick.auth import get_token_file_path, check_token_validity
console.print("[bold cyan]TickTick Authentication Status[/bold cyan]\n")
# Check OAuth credentials
client_id = os.getenv("TICKTICK_CLIENT_ID")
client_secret = os.getenv("TICKTICK_CLIENT_SECRET")
redirect_uri = os.getenv("TICKTICK_REDIRECT_URI")
console.print(f"OAuth Client ID: {'✓ Set' if client_id else '✗ Not set'}")
console.print(f"OAuth Client Secret: {'✓ Set' if client_secret else '✗ Not set'}")
console.print(
f"OAuth Redirect URI: {redirect_uri or '✗ Not set (will use default)'}"
)
# Check login credentials
username = os.getenv("TICKTICK_USERNAME")
password = os.getenv("TICKTICK_PASSWORD")
console.print(f"Username: {'✓ Set' if username else '✗ Not set (will prompt)'}")
console.print(f"Password: {'✓ Set' if password else '✗ Not set (will prompt)'}")
# Check token cache with validity
token_file = get_token_file_path()
token_exists = token_file.exists()
if token_exists:
validity = check_token_validity()
if validity["valid"]:
console.print("OAuth Token Cache: [green]✓ Valid[/green]")
if "expires_in_hours" in validity:
console.print(
f"Token expires in: [yellow]{validity['expires_in_hours']} hours[/yellow]"
)
else:
console.print(
f"OAuth Token Cache: [red]✗ Invalid ({validity['reason']})[/red]"
)
import datetime
mod_time = datetime.datetime.fromtimestamp(token_file.stat().st_mtime)
console.print(f"Token file: {token_file}")
console.print(f"Last modified: {mod_time.strftime('%Y-%m-%d %H:%M:%S')}")
else:
console.print("OAuth Token Cache: [red]✗ Not found[/red]")
console.print(f"Token file: {token_file}")
console.print("\n[dim]Run 'ticktick setup' for setup instructions[/dim]")