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