wip
This commit is contained in:
607
src/cli/ticktick.py
Normal file
607
src/cli/ticktick.py
Normal 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]")
|
||||
Reference in New Issue
Block a user