608 lines
18 KiB
Python
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]")
|