Files
luk/src/cli/ticktick.py
Tim Bendt ca6e4cdf5d wip
2025-08-18 10:58:48 -04:00

608 lines
18 KiB
Python

"""
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]")