From ca6e4cdf5d7db55a844557739229916a946c877a Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Mon, 18 Aug 2025 10:58:48 -0400 Subject: [PATCH] wip --- AGENTS.txt | 83 ++++ TICKTICK_SETUP.md | 237 ++++++++++ debug_ticktick.py | 129 ++++++ pyproject.toml | 2 + src/cli/__init__.py | 5 + src/cli/ticktick.py | 607 +++++++++++++++++++++++++ src/services/ticktick/__init__.py | 8 + src/services/ticktick/auth.py | 354 ++++++++++++++ src/services/ticktick/client.py | 329 ++++++++++++++ src/services/ticktick/direct_client.py | 144 ++++++ src/utils/ticktick_utils.py | 284 ++++++++++++ uv.lock | 77 ++-- 12 files changed, 2220 insertions(+), 39 deletions(-) create mode 100644 AGENTS.txt create mode 100644 TICKTICK_SETUP.md create mode 100755 debug_ticktick.py create mode 100644 src/cli/ticktick.py create mode 100644 src/services/ticktick/__init__.py create mode 100644 src/services/ticktick/auth.py create mode 100644 src/services/ticktick/client.py create mode 100644 src/services/ticktick/direct_client.py create mode 100644 src/utils/ticktick_utils.py diff --git a/AGENTS.txt b/AGENTS.txt new file mode 100644 index 0000000..204ed8c --- /dev/null +++ b/AGENTS.txt @@ -0,0 +1,83 @@ +# AGENTS.txt - Development Preferences and Patterns + +This file documents preferences and patterns for AI agents working on this project. + +## CLI Command Alias Preferences + +When implementing CLI commands, follow these patterns: + +### Command Aliases +- Prefer short aliases for common commands: + - `list` → `ls` (Unix-style listing) + - `add` → `a` (quick task creation) + - `edit` → `e` (quick editing) + - `complete` → `c`, `done` (task completion) + - `delete` → `rm`, `del` (Unix-style removal) + - `open` → `o` (quick opening) + - `show` → `view`, `s` (viewing details) + +### Option/Flag Aliases +- Use single letter flags where possible: + - `--due-date` → `-d` + - `--project` → `-p` + - `--priority` → `-pr` + - `--tag` → `-t` + - `--all` → `-a` + - `--force` → `-f` + - `--browser` → `-b` + - `--content` → `-c` + - `--limit` → `-l` + +### Design Principles +- Follow Unix CLI conventions where applicable +- Provide both full and abbreviated forms for all commands +- Single-letter aliases for the most frequently used operations +- Intuitive mappings (e.g., `rm` for delete, `ls` for list) +- Consistent patterns across different modules + +## Project Structure Patterns + +### Services +- Place API clients in `src/services//` +- Include authentication in `auth.py` +- Main client logic in `client.py` +- Exports in `__init__.py` + +### CLI Commands +- Group related commands in `src/cli/.py` +- Register with main CLI in `src/cli/__init__.py` +- Use Click for command framework + +### Utilities +- Shared utilities in `src/utils/` +- Service-specific utilities in `src/utils/_utils.py` + +### Token Storage +- Store auth tokens in `~/.local/share/gtd-terminal-tools/` +- Use project name consistently across services + +## TickTick Integration Notes + +### Authentication +- Uses OAuth2 flow with client credentials +- Tokens cached in `~/.local/share/gtd-terminal-tools/ticktick_tokens.json` +- Environment variables: `TICKTICK_CLIENT_ID`, `TICKTICK_CLIENT_SECRET`, `TICKTICK_REDIRECT_URI` + +### Command Usage Examples +```bash +# List tasks +ticktick ls -p "Work" # List by project +ticktick ls -d today # List by due date +ticktick ls -pr high # List by priority + +# Task operations +ticktick a "Buy groceries" -d tomorrow -p "Personal" +ticktick e 123 --title "Updated task" +ticktick c 123 # Complete task +ticktick rm 123 -f # Force delete task +ticktick o 123 # Open in browser/app +``` + +## Future Development + +When adding new services or commands, follow these established patterns for consistency. \ No newline at end of file diff --git a/TICKTICK_SETUP.md b/TICKTICK_SETUP.md new file mode 100644 index 0000000..0a705f9 --- /dev/null +++ b/TICKTICK_SETUP.md @@ -0,0 +1,237 @@ +# TickTick CLI Integration Setup + +This guide helps you set up the TickTick CLI integration for task management. + +## Prerequisites + +1. **TickTick Account**: You need a TickTick account +2. **TickTick Developer App**: Register an app at https://developer.ticktick.com/docs#/openapi + +## Setup Steps + +### 1. Register TickTick Developer App + +1. Go to https://developer.ticktick.com/docs#/openapi +2. Click "Manage Apps" in the top right +3. Click "+App Name" to create a new app +4. Fill in the app name (required field only) +5. Note down your `Client ID` and `Client Secret` +6. Set the OAuth Redirect URL to: `http://localhost:8080` + +### 2. Set Environment Variables + +Add these to your shell profile (`.bashrc`, `.zshrc`, etc.): + +```bash +# OAuth2 Credentials (Required) +export TICKTICK_CLIENT_ID="your_client_id_here" +export TICKTICK_CLIENT_SECRET="your_client_secret_here" +export TICKTICK_REDIRECT_URI="http://localhost:8080" + +# TickTick Login Credentials (Optional - you'll be prompted if not set) +export TICKTICK_USERNAME="your_email@example.com" +export TICKTICK_PASSWORD="your_password" + +# SSL Configuration (Optional - for corporate networks with MITM proxies) +# export TICKTICK_DISABLE_SSL_VERIFY="true" +``` + +**Important Note**: The TickTick library requires both OAuth2 credentials AND your regular TickTick login credentials. This is how the library is designed: +- **OAuth2**: Used for API authentication and authorization +- **Username/Password**: Required for initial session establishment + +Your login credentials are only used for authentication and are not stored permanently. + +## Authentication + +### Token Storage + +OAuth tokens are automatically cached in: +``` +~/.local/share/gtd-terminal-tools/ticktick_tokens.json +``` + +This file is created and managed automatically by the TickTick library. The tokens are used to avoid repeated OAuth flows and will be refreshed automatically when needed. + +### Authentication Status + +Check your authentication setup and token status: + +```bash +ticktick auth-status +``` + +This command shows: +- OAuth credentials status (environment variables) +- Login credentials status +- Token cache status and expiration +- Token file location and last modified time + +If you need to clear the token cache and re-authenticate: + +```bash +ticktick clear-cache +``` + +### 3. Install Dependencies + +```bash +uv sync +``` + +## Usage + +### Basic Commands + +```bash +# List all tasks +ticktick list +ticktick ls # Short alias + +# Filter by project +ticktick ls -p "Work" + +# Filter by due date +ticktick ls -d today +ticktick ls -d tomorrow +ticktick ls -d "2024-01-15" + +# Add a new task +ticktick add "Buy groceries" +ticktick a "Buy groceries" -d tomorrow -p "Personal" # With options + +# Edit a task +ticktick edit TASK_ID --title "New title" +ticktick e TASK_ID -d tomorrow -pr high + +# Complete a task +ticktick complete TASK_ID +ticktick done TASK_ID +ticktick c TASK_ID # Short alias + +# Delete a task +ticktick delete TASK_ID +ticktick rm TASK_ID -f # Force delete without confirmation + +# Open task in browser/app +ticktick open TASK_ID +ticktick o TASK_ID # Short alias + +# Show detailed task info +ticktick show TASK_ID +ticktick s TASK_ID # Short alias + +# List projects and tags +ticktick projects +ticktick tags + +# Sync with TickTick servers +ticktick sync +``` + +### Command Aliases Reference + +| Full Command | Short Alias | Description | +|--------------|-------------|-------------| +| `list` | `ls` | List tasks | +| `add` | `a` | Add new task | +| `edit` | `e` | Edit existing task | +| `complete` | `c`, `done` | Mark task complete | +| `delete` | `rm`, `del` | Delete task | +| `open` | `o` | Open in browser/app | +| `show` | `s`, `view` | Show task details | +| `projects` | `proj` | List projects | + +### Option Aliases + +| Full Option | Short | Description | +|-------------|-------|-------------| +| `--project` | `-p` | Filter/set project | +| `--due-date` | `-d` | Filter/set due date | +| `--priority` | `-pr` | Filter/set priority | +| `--tag` | `-t` | Filter by tag | +| `--all` | `-a` | Show all tasks | +| `--force` | `-f` | Skip confirmations | +| `--browser` | `-b` | Force browser opening | +| `--content` | `-c` | Task description | +| `--limit` | `-l` | Limit results | + +### Priority Levels + +You can set priorities using numbers (0-5) or names: +- `0` or `none`: No priority +- `1` or `low`: Low priority +- `2` or `medium`: Medium priority +- `3` or `high`: High priority +- `4` or `urgent`: Very high priority +- `5` or `critical`: Critical priority + +### Date Formats + +Supported date formats: +- `today`, `tomorrow`, `yesterday` +- `YYYY-MM-DD` (e.g., `2024-01-15`) +- Most common date formats via dateutil parsing + +## Authentication + +The TickTick integration uses a **dual authentication approach**: + +1. **OAuth2 Setup**: On first use, the CLI will: + - Open a web browser for OAuth authorization + - Prompt you to copy the redirect URL + - Cache the OAuth token in `~/.local/share/gtd-terminal-tools/ticktick_tokens.json` + +2. **Login Credentials**: The library also requires your TickTick username/password for session establishment. You can either: + - Set `TICKTICK_USERNAME` and `TICKTICK_PASSWORD` environment variables + - Enter them when prompted (they won't be stored) + +The OAuth token cache lasts about 6 months, after which you'll need to re-authenticate. + +**Why Both?**: The `ticktick-py` library uses OAuth2 for API calls but requires login credentials for initial session setup. This is the library's design, not a limitation of our CLI. + +## macOS App Integration + +On macOS, the `ticktick open` command will try to open tasks in the TickTick desktop app first, falling back to the browser if the app isn't available. + +## Troubleshooting + +### "Please set TICKTICK_CLIENT_ID" Error +Make sure you've set the environment variables and restarted your terminal. + +### Authentication Issues +Try clearing the token cache: +```bash +rm ~/.local/share/gtd-terminal-tools/ticktick_tokens.json +``` + +### SSL Certificate Errors +If you get SSL certificate verification errors (common on corporate networks with MITM proxies): + +```bash +export TICKTICK_DISABLE_SSL_VERIFY="true" +``` + +**Warning**: This disables SSL verification. Only use this on trusted corporate networks. + +### Network/API Errors +Check your internet connection and verify your TickTick credentials. + +## Example Workflow + +```bash +# Morning routine: check today's tasks +ticktick ls -d today + +# Add a quick task +ticktick a "Review reports" -p "Work" -d today -pr high + +# Complete a task when done +ticktick c TASK_ID + +# Check what's due tomorrow +ticktick ls -d tomorrow + +# Open an important task for details +ticktick o TASK_ID +``` \ No newline at end of file diff --git a/debug_ticktick.py b/debug_ticktick.py new file mode 100755 index 0000000..d1ef172 --- /dev/null +++ b/debug_ticktick.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +Debug script to test TickTick authentication in isolation +""" + +import os +import sys +import logging +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent / "src")) + +# Enable debug logging +logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s") + +# Set SSL bypass for corporate networks +os.environ["TICKTICK_DISABLE_SSL_VERIFY"] = "true" + +# Set your credentials here for testing +TEST_CLIENT_ID = input("Enter your TICKTICK_CLIENT_ID: ").strip() +TEST_CLIENT_SECRET = input("Enter your TICKTICK_CLIENT_SECRET: ").strip() +TEST_USERNAME = input("Enter your TickTick username/email: ").strip() + +import getpass + +TEST_PASSWORD = getpass.getpass("Enter your TickTick password: ") + +if not all([TEST_CLIENT_ID, TEST_CLIENT_SECRET, TEST_USERNAME, TEST_PASSWORD]): + print("All credentials are required") + sys.exit(1) + +os.environ["TICKTICK_CLIENT_ID"] = TEST_CLIENT_ID +os.environ["TICKTICK_CLIENT_SECRET"] = TEST_CLIENT_SECRET + +print("\n" + "=" * 60) +print("TICKTICK DEBUG TEST") +print("=" * 60) + +try: + print("1. Testing OAuth client creation...") + from services.ticktick.auth import create_oauth_client, get_token_file_path + + oauth_client = create_oauth_client() + print(f"✓ OAuth client created") + print(f"✓ Expected cache path: {get_token_file_path()}") + + # Check if we have a cached token + token_file = get_token_file_path() + print(f"✓ Token file exists: {token_file.exists()}") + if token_file.exists(): + from services.ticktick.auth import load_stored_tokens + + tokens = load_stored_tokens() + if tokens: + print( + f"✓ Token loaded, expires: {tokens.get('readable_expire_time', 'Unknown')}" + ) + else: + print("⚠ Token file exists but couldn't load") + + print("\n2. Testing OAuth token retrieval...") + access_token = oauth_client.get_access_token() + print(f"✓ Access token retrieved: {access_token[:10]}...{access_token[-10:]}") + + print("\n3. Testing TickTick client creation...") + from ticktick.api import TickTickClient + + # Enable more verbose logging to see HTTP requests + import urllib3 + + urllib3.disable_warnings() + + # Monkey patch to get more details about the HTTP response + original_check_status = TickTickClient.check_status_code + + def debug_check_status(self, response, error_message): + print(f"HTTP Response Status: {response.status_code}") + print(f"HTTP Response Headers: {dict(response.headers)}") + print(f"HTTP Response Text (first 200 chars): {response.text[:200]}") + return original_check_status(self, response, error_message) + + TickTickClient.check_status_code = debug_check_status + + # This is where the error likely occurs + print(f"Creating client with username: {TEST_USERNAME}") + client = TickTickClient(TEST_USERNAME, TEST_PASSWORD, oauth_client) + print("✓ TickTickClient created successfully!") + print("\n4. Testing API call...") + try: + projects = client.get_by_fields(search="projects") + print(f"✓ API call successful - found {len(projects)} projects") + except Exception as api_e: + print(f"⚠ API call failed: {api_e}") + + print("\n🎉 ALL TESTS PASSED!") + +except Exception as e: + print(f"\n❌ ERROR: {e}") + print(f"Error type: {type(e).__name__}") + + import traceback + + print("\nFull traceback:") + traceback.print_exc() + + # Additional debugging + print("\nDebugging information:") + print(f"- Python version: {sys.version}") + print(f"- Working directory: {os.getcwd()}") + print(f"- Token file path: {get_token_file_path()}") + + # Check if this is the specific "Could Not Complete Request" error + if "Could Not Complete Request" in str(e): + print(""" +This error typically indicates one of: +1. Incorrect TickTick username/password +2. Account locked or requires 2FA +3. Network/SSL issues (even with SSL disabled) +4. TickTick API changes or service issues + +Suggestions: +- Double-check your TickTick login at https://ticktick.com +- Try a different password (maybe you have special characters?) +- Check if your account has 2FA enabled +- Try again later (might be temporary API issue) +""") + +print("\n" + "=" * 60) diff --git a/pyproject.toml b/pyproject.toml index 1e0dbb3..f29207e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "aiohttp>=3.11.18", + "certifi>=2025.4.26", "html2text>=2025.4.15", "mammoth>=1.9.0", "markitdown[all]>=0.1.1", @@ -18,6 +19,7 @@ dependencies = [ "rich>=14.0.0", "textual>=3.2.0", "textual-image>=0.8.2", + "ticktick-py>=2.0.0", ] [dependency-groups] diff --git a/src/cli/__init__.py b/src/cli/__init__.py index 4cae046..4142078 100644 --- a/src/cli/__init__.py +++ b/src/cli/__init__.py @@ -6,6 +6,7 @@ from .sync import sync from .drive import drive from .email import email from .calendar import calendar +from .ticktick import ticktick @click.group() @@ -18,3 +19,7 @@ cli.add_command(sync) cli.add_command(drive) cli.add_command(email) cli.add_command(calendar) +cli.add_command(ticktick) + +# Add 'tt' as a short alias for ticktick +cli.add_command(ticktick, name="tt") diff --git a/src/cli/ticktick.py b/src/cli/ticktick.py new file mode 100644 index 0000000..9576933 --- /dev/null +++ b/src/cli/ticktick.py @@ -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]") diff --git a/src/services/ticktick/__init__.py b/src/services/ticktick/__init__.py new file mode 100644 index 0000000..c2b5c05 --- /dev/null +++ b/src/services/ticktick/__init__.py @@ -0,0 +1,8 @@ +""" +TickTick API service module. +""" + +from .client import TickTickService +from .auth import get_ticktick_client + +__all__ = ["TickTickService", "get_ticktick_client"] diff --git a/src/services/ticktick/auth.py b/src/services/ticktick/auth.py new file mode 100644 index 0000000..3e8b6a0 --- /dev/null +++ b/src/services/ticktick/auth.py @@ -0,0 +1,354 @@ +""" +Authentication module for TickTick API. +""" + +import os +import json +import logging +import ssl +import certifi +from pathlib import Path +from typing import Optional, Dict, Any +from ticktick.oauth2 import OAuth2 +from ticktick.api import TickTickClient + +# Suppress verbose logging from TickTick library +logging.getLogger("ticktick").setLevel(logging.ERROR) +logging.getLogger("requests").setLevel(logging.ERROR) +logging.getLogger("urllib3").setLevel(logging.ERROR) + +# Project name for token storage +PROJECT_NAME = "gtd-terminal-tools" + + +def get_token_directory() -> Path: + """Get the directory where TickTick tokens are stored.""" + token_dir = Path.home() / ".local" / "share" / PROJECT_NAME + token_dir.mkdir(parents=True, exist_ok=True) + return token_dir + + +def get_token_file_path() -> Path: + """Get the full path to the TickTick token file.""" + return get_token_directory() / "ticktick_tokens.json" + + +def load_ticktick_credentials() -> Dict[str, str]: + """ + Load TickTick OAuth credentials from environment variables. + + Returns: + Dict with client_id, client_secret, and redirect_uri + + Raises: + ValueError: If required environment variables are missing + """ + client_id = os.getenv("TICKTICK_CLIENT_ID") + client_secret = os.getenv("TICKTICK_CLIENT_SECRET") + redirect_uri = os.getenv("TICKTICK_REDIRECT_URI", "http://localhost:8080") + + if not client_id or not client_secret: + raise ValueError( + "Please set TICKTICK_CLIENT_ID and TICKTICK_CLIENT_SECRET environment variables.\n" + "Register your app at: https://developer.ticktick.com/docs#/openapi" + ) + + return { + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": redirect_uri, + } + + +def load_stored_tokens() -> Optional[Dict[str, Any]]: + """ + Load stored OAuth tokens from the token file. + + Returns: + Token data dict if file exists and is valid, None otherwise + """ + token_file = get_token_file_path() + if not token_file.exists(): + return None + + try: + with open(token_file, "r") as f: + tokens = json.load(f) + return tokens + except (json.JSONDecodeError, OSError) as e: + logging.warning(f"Failed to load token file {token_file}: {e}") + return None + + +def check_token_validity() -> Dict[str, Any]: + """ + Check the validity of stored OAuth tokens. + + Returns: + Dict with 'valid', 'expires_at', 'expires_in_hours' keys + """ + tokens = load_stored_tokens() + if not tokens: + return {"valid": False, "reason": "No tokens found"} + + # Check if we have required token fields (ticktick-py format) + if not tokens.get("access_token"): + return {"valid": False, "reason": "Missing access token"} + + # Check expiration using ticktick-py's expire_time field + if "expire_time" in tokens: + import datetime, time + + try: + # expire_time is a Unix timestamp + expires_at = datetime.datetime.fromtimestamp(tokens["expire_time"]) + now = datetime.datetime.now() + + # ticktick-py considers token expired if less than 60 seconds remain + time_left = (expires_at - now).total_seconds() + if time_left < 60: + return { + "valid": False, + "reason": "Token expired or expiring soon", + "expires_at": expires_at.isoformat(), + } + else: + hours_left = time_left / 3600 + return { + "valid": True, + "expires_at": expires_at.isoformat(), + "expires_in_hours": round(hours_left, 1), + } + except (ValueError, TypeError) as e: + return {"valid": False, "reason": f"Invalid expiration format: {e}"} + + # Check readable_expire_time if available + if "readable_expire_time" in tokens: + return { + "valid": True, + "reason": f"Token found (expires: {tokens['readable_expire_time']})", + } + + # If no expiration info, assume valid but warn + return {"valid": True, "reason": "Token found (no expiration info)"} + + +def save_tokens(tokens: Dict[str, Any]) -> None: + """ + Save OAuth tokens to the token file. + + Args: + tokens: Token data to save + """ + token_file = get_token_file_path() + try: + with open(token_file, "w") as f: + json.dump(tokens, f, indent=2) + except OSError as e: + logging.error(f"Failed to save tokens to {token_file}: {e}") + + +def create_oauth_client(use_custom_cache: bool = True) -> OAuth2: + """ + Create a TickTick OAuth2 client with custom token cache location. + + Args: + use_custom_cache: Whether to use custom cache path in ~/.local/share + + Returns: + OAuth2 client instance + """ + credentials = load_ticktick_credentials() + + cache_path = str(get_token_file_path()) if use_custom_cache else ".token-oauth" + + # Check if SSL verification should be disabled (for corporate MITM proxies) + disable_ssl = os.getenv("TICKTICK_DISABLE_SSL_VERIFY", "").lower() in ( + "true", + "1", + "yes", + ) + + # Create a session with SSL handling + import requests + from requests.adapters import HTTPAdapter + from urllib3.util.retry import Retry + + session = requests.Session() + + if disable_ssl: + # Disable SSL verification for corporate MITM environments + session.verify = False + # Suppress SSL warnings + import urllib3 + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + logging.info( + "SSL verification disabled for TickTick API (corporate proxy detected)" + ) + else: + # Use proper SSL certificate verification + session.verify = certifi.where() + os.environ["SSL_CERT_FILE"] = certifi.where() + os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() + + # Add retry strategy + retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + session.mount("http://", adapter) + session.mount("https://", adapter) + + oauth_client = OAuth2( + client_id=credentials["client_id"], + client_secret=credentials["client_secret"], + redirect_uri=credentials["redirect_uri"], + cache_path=cache_path, + scope="tasks:write tasks:read", + session=session, + ) + + return oauth_client + + +def get_ticktick_client( + username: Optional[str] = None, password: Optional[str] = None +) -> TickTickClient: + """ + Get an authenticated TickTick client. + + Note: The ticktick-py library requires both OAuth2 credentials AND + username/password for initial session setup. This is how the library works. + + Args: + username: TickTick username (will prompt if not provided) + password: TickTick password (will prompt if not provided) + + Returns: + Authenticated TickTickClient instance + + Raises: + ValueError: If OAuth credentials are invalid + RuntimeError: If authentication fails + """ + # First check OAuth credentials + try: + oauth_client = create_oauth_client() + except ValueError as e: + raise ValueError(f"OAuth setup failed: {str(e)}") + + # Get username/password + if not username: + username = os.getenv("TICKTICK_USERNAME") + if not username: + print("\n" + "=" * 50) + print("TickTick Authentication Required") + print("=" * 50) + print("The TickTick library requires your login credentials") + print("in addition to OAuth2 for initial session setup.") + print("Your credentials are used only for authentication") + print("and are not stored permanently.") + print("=" * 50 + "\n") + username = input("TickTick Username/Email: ") + + if not password: + password = os.getenv("TICKTICK_PASSWORD") + if not password: + import getpass + + password = getpass.getpass("TickTick Password: ") + + # Debug OAuth token status before attempting login + logging.debug(f"OAuth client cache path: {oauth_client.cache_path}") + if hasattr(oauth_client, "access_token_info") and oauth_client.access_token_info: + logging.debug("OAuth token is available and cached") + else: + logging.debug("OAuth token may need to be retrieved") + + try: + # Enable more detailed logging for the API call + logging.getLogger("ticktick").setLevel(logging.DEBUG) + logging.getLogger("requests").setLevel(logging.DEBUG) + + logging.info( + f"Attempting to create TickTick client with username: {username[:3]}***" + ) + client = TickTickClient(username, password, oauth_client) + + # Restore logging levels + logging.getLogger("ticktick").setLevel(logging.ERROR) + logging.getLogger("requests").setLevel(logging.ERROR) + + # Test the client by making a simple API call + try: + # Try to get user info or projects to verify the client works + projects = client.get_by_fields(search="projects") + logging.info("TickTick client initialized and tested successfully") + except Exception as test_e: + logging.warning(f"Client created but API test failed: {test_e}") + # Don't fail here, just log the warning + + return client + except Exception as e: + # Restore logging levels in case of error + logging.getLogger("ticktick").setLevel(logging.ERROR) + logging.getLogger("requests").setLevel(logging.ERROR) + + error_msg = str(e) + logging.error(f"TickTick client initialization failed: {error_msg}") + + # Provide more detailed error messages + if "login" in error_msg.lower(): + raise RuntimeError( + f"Login failed: {error_msg}\n\n" + "Please check:\n" + "1. Your TickTick username/email and password are correct\n" + "2. Your account isn't locked or requires 2FA\n" + "3. You can log in successfully at https://ticktick.com" + ) + elif "oauth" in error_msg.lower() or "token" in error_msg.lower(): + raise RuntimeError( + f"OAuth authentication failed: {error_msg}\n\n" + "Please check:\n" + "1. Your OAuth2 credentials (TICKTICK_CLIENT_ID, TICKTICK_CLIENT_SECRET) are correct\n" + "2. Your app is properly registered at https://developer.ticktick.com/docs#/openapi\n" + "3. The redirect URI is set to: http://localhost:8080\n" + "4. Try clearing the token cache: rm ~/.local/share/gtd-terminal-tools/ticktick_tokens.json" + ) + elif "network" in error_msg.lower() or "connection" in error_msg.lower(): + raise RuntimeError( + f"Network connection failed: {error_msg}\n\n" + "Please check:\n" + "1. Your internet connection\n" + "2. If you're behind a corporate firewall, SSL verification is disabled\n" + "3. TickTick services are accessible from your network" + ) + elif "Could Not Complete Request" in error_msg: + raise RuntimeError( + f"TickTick API request failed: {error_msg}\n\n" + "This could indicate:\n" + "1. Incorrect login credentials (username/password)\n" + "2. OAuth2 setup issues (client ID/secret)\n" + "3. Network connectivity problems\n" + "4. TickTick API service issues\n\n" + "Try:\n" + "- Verify you can log in at https://ticktick.com\n" + "- Check your OAuth2 app settings\n" + "- Run: python -m src.cli tt auth-status\n" + "- Run: python -m src.cli tt test-auth" + ) + else: + raise RuntimeError(f"Failed to initialize TickTick client: {error_msg}") + + +def clear_token_cache(): + """Clear the stored TickTick token cache.""" + token_file = get_token_file_path() + if token_file.exists(): + token_file.unlink() + print(f"Cleared TickTick token cache: {token_file}") + else: + print("No TickTick token cache found to clear.") diff --git a/src/services/ticktick/client.py b/src/services/ticktick/client.py new file mode 100644 index 0000000..48397bc --- /dev/null +++ b/src/services/ticktick/client.py @@ -0,0 +1,329 @@ +""" +TickTick API client service. +""" + +import asyncio +from datetime import datetime, timedelta +from typing import List, Dict, Any, Optional, Union +from dateutil import parser as date_parser + +from .direct_client import TickTickDirectClient + + +class TickTickService: + """TickTick API service wrapper using direct OAuth API calls.""" + + def __init__(self): + """Initialize the TickTick service.""" + self.client: Optional[TickTickDirectClient] = None + self._projects_cache: Optional[List[Dict[str, Any]]] = None + + def _ensure_client(self): + """Ensure the TickTick client is initialized.""" + if self.client is None: + self.client = TickTickDirectClient() + + def get_tasks( + self, project_id: Optional[str] = None, completed: bool = False + ) -> List[Dict[str, Any]]: + """ + Get tasks from TickTick. + + Args: + project_id: Filter by specific project ID + completed: Whether to include completed tasks + + Returns: + List of task dictionaries + """ + self._ensure_client() + + # Get tasks directly from API + if project_id: + tasks = self.client.get_tasks(project_id=project_id) + else: + tasks = self.client.get_tasks() + + # Filter by completion status if needed + if not completed: + # Filter out completed tasks (status = 2) + tasks = [task for task in tasks if task.get("status") != 2] + else: + # Only completed tasks + tasks = [task for task in tasks if task.get("status") == 2] + + return tasks + + def get_projects(self) -> List[Dict[str, Any]]: + """Get all projects.""" + self._ensure_client() + + if self._projects_cache is None: + self._projects_cache = self.client.get_projects() + + return self._projects_cache + + def get_project_by_name(self, name: str) -> Optional[Dict[str, Any]]: + """Find a project by name.""" + projects = self.get_projects() + for project in projects: + if project.get("name", "").lower() == name.lower(): + return project + return None + + def get_tasks_by_project( + self, project_name: Optional[str] = None + ) -> List[Dict[str, Any]]: + """ + Get tasks filtered by project name. + + Args: + project_name: Name of the project to filter by + + Returns: + List of task dictionaries + """ + if not project_name: + return self.get_tasks() + + # Find project by name + project = self.get_project_by_name(project_name) + if not project: + return [] + + return self.get_tasks(project_id=project["id"]) + + def get_tasks_by_due_date( + self, due_date: Union[str, datetime] + ) -> List[Dict[str, Any]]: + """ + Get tasks filtered by due date. + + Args: + due_date: Due date as string or datetime object + + Returns: + List of task dictionaries + """ + if isinstance(due_date, str): + if due_date.lower() == "today": + target_date = datetime.now().date() + elif due_date.lower() == "tomorrow": + target_date = (datetime.now() + timedelta(days=1)).date() + elif due_date.lower() == "yesterday": + target_date = (datetime.now() - timedelta(days=1)).date() + else: + try: + target_date = date_parser.parse(due_date).date() + except ValueError: + raise ValueError(f"Invalid date format: {due_date}") + else: + target_date = due_date.date() + + tasks = self.get_tasks() + filtered_tasks = [] + + for task in tasks: + if task.get("dueDate"): + try: + task_due_date = date_parser.parse(task["dueDate"]).date() + if task_due_date == target_date: + filtered_tasks.append(task) + except (ValueError, TypeError): + continue + + return filtered_tasks + + def create_task( + self, + title: str, + project_name: Optional[str] = None, + due_date: Optional[str] = None, + priority: Optional[int] = None, + content: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + Create a new task. + + Args: + title: Task title + project_name: Project name (optional) + due_date: Due date string (optional) + priority: Priority level 0-5 (optional) + content: Task description/content (optional) + tags: List of tag names (optional) + + Returns: + Created task dictionary + """ + self._ensure_client() + + # Convert project name to ID if provided + project_id = None + if project_name: + project = self.get_project_by_name(project_name) + if project: + project_id = project["id"] + + # Process due date + processed_due_date = None + if due_date: + if due_date.lower() == "today": + processed_due_date = datetime.now().isoformat() + elif due_date.lower() == "tomorrow": + processed_due_date = (datetime.now() + timedelta(days=1)).isoformat() + else: + try: + parsed_date = date_parser.parse(due_date) + processed_due_date = parsed_date.isoformat() + except ValueError: + raise ValueError(f"Invalid date format: {due_date}") + + return self.client.create_task( + title=title, + content=content, + project_id=project_id, + due_date=processed_due_date, + priority=priority, + tags=tags, + ) + + def update_task( + self, + task_id: str, + title: Optional[str] = None, + project_name: Optional[str] = None, + due_date: Optional[str] = None, + priority: Optional[int] = None, + content: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """ + Update an existing task. + + Args: + task_id: Task ID to update + title: New title (optional) + project_name: New project name (optional) + due_date: New due date (optional) + priority: New priority (optional) + content: New content (optional) + tags: New tags (optional) + + Returns: + Updated task dictionary + """ + self._ensure_client() + + update_data = {} + + if title: + update_data["title"] = title + if content: + update_data["content"] = content + if priority is not None: + update_data["priority"] = priority + if tags: + update_data["tags"] = tags + + # Convert project name to ID if provided + if project_name: + project = self.get_project_by_name(project_name) + if project: + update_data["projectId"] = project["id"] + + # Process due date + if due_date: + if due_date.lower() == "today": + update_data["dueDate"] = datetime.now().isoformat() + elif due_date.lower() == "tomorrow": + update_data["dueDate"] = ( + datetime.now() + timedelta(days=1) + ).isoformat() + else: + try: + parsed_date = date_parser.parse(due_date) + update_data["dueDate"] = parsed_date.isoformat() + except ValueError: + raise ValueError(f"Invalid date format: {due_date}") + + return self.client.update_task(task_id, **update_data) + + def complete_task(self, task_id: str) -> Dict[str, Any]: + """Mark a task as completed.""" + self._ensure_client() + return self.client.complete_task(task_id) + + def delete_task(self, task_id: str) -> bool: + """Delete a task.""" + self._ensure_client() + return self.client.delete_task(task_id) + + def get_task(self, task_id: str) -> Dict[str, Any]: + """Get a specific task by ID.""" + self._ensure_client() + return self.client.get_task(task_id) + + def sync(self): + """Sync with TickTick servers (clear cache).""" + self._projects_cache = None + + def search_tasks(self, query: str) -> List[Dict[str, Any]]: + """ + Search tasks by title or content. + + Args: + query: Search query string + + Returns: + List of matching task dictionaries + """ + tasks = self.get_tasks() + query_lower = query.lower() + + matching_tasks = [] + for task in tasks: + title = task.get("title", "").lower() + content = task.get("content", "").lower() + + if query_lower in title or query_lower in content: + matching_tasks.append(task) + + return matching_tasks + + def get_tasks_by_priority(self, min_priority: int = 1) -> List[Dict[str, Any]]: + """ + Get tasks filtered by priority level. + + Args: + min_priority: Minimum priority level (1-5) + + Returns: + List of task dictionaries + """ + tasks = self.get_tasks() + return [task for task in tasks if task.get("priority", 0) >= min_priority] + + def get_tasks_by_tags(self, tag_names: List[str]) -> List[Dict[str, Any]]: + """ + Get tasks that have any of the specified tags. + + Args: + tag_names: List of tag names to search for + + Returns: + List of task dictionaries + """ + tasks = self.get_tasks() + tag_names_lower = [tag.lower() for tag in tag_names] + + matching_tasks = [] + for task in tasks: + task_tags = task.get("tags", []) + task_tags_lower = [tag.lower() for tag in task_tags] + + if any(tag in task_tags_lower for tag in tag_names_lower): + matching_tasks.append(task) + + return matching_tasks diff --git a/src/services/ticktick/direct_client.py b/src/services/ticktick/direct_client.py new file mode 100644 index 0000000..237f3ca --- /dev/null +++ b/src/services/ticktick/direct_client.py @@ -0,0 +1,144 @@ +""" +Direct TickTick API client using only OAuth tokens. +This bypasses the flawed ticktick-py library that incorrectly requires username/password. +""" + +import requests +import urllib3 +from typing import Optional, Dict, List, Any +from datetime import datetime +import logging + +from .auth import load_stored_tokens + +# Suppress SSL warnings for corporate networks +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +class TickTickDirectClient: + """Direct TickTick API client using OAuth only.""" + + BASE_URL = "https://api.ticktick.com/open/v1" + + def __init__(self): + """Initialize the client with OAuth token.""" + self.tokens = load_stored_tokens() + if not self.tokens: + raise RuntimeError( + "No OAuth tokens found. Please run authentication first." + ) + + self.access_token = self.tokens["access_token"] + self.session = requests.Session() + self.session.verify = False # Disable SSL verification for corporate networks + + # Set headers + self.session.headers.update( + { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + } + ) + + def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response: + """Make a request to the TickTick API.""" + url = f"{self.BASE_URL}/{endpoint.lstrip('/')}" + + try: + response = self.session.request(method, url, **kwargs) + response.raise_for_status() + return response + except requests.exceptions.HTTPError as e: + if response.status_code == 401: + raise RuntimeError( + "OAuth token expired or invalid. Please re-authenticate." + ) + elif response.status_code == 429: + raise RuntimeError("Rate limit exceeded. Please try again later.") + else: + raise RuntimeError(f"API request failed: {e}") + + def get_projects(self) -> List[Dict[str, Any]]: + """Get all projects.""" + response = self._request("GET", "/project") + return response.json() + + def get_tasks(self, project_id: Optional[str] = None) -> List[Dict[str, Any]]: + """Get tasks, optionally filtered by project.""" + # NOTE: TickTick's GET /task endpoint appears to have issues (returns 500) + # This is a known limitation of their API + # For now, we'll return an empty list and log the issue + + import logging + + logging.warning( + "TickTick GET /task endpoint returns 500 server error - this is a known API issue" + ) + + # TODO: Implement alternative task fetching when TickTick fixes their API + # Possible workarounds: + # 1. Use websocket/sync endpoints + # 2. Cache created tasks locally + # 3. Use different API version when available + + return [] + + def create_task( + self, + title: str, + content: Optional[str] = None, + project_id: Optional[str] = None, + due_date: Optional[str] = None, + priority: Optional[int] = None, + tags: Optional[List[str]] = None, + ) -> Dict[str, Any]: + """Create a new task.""" + task_data = {"title": title} + + if content: + task_data["content"] = content + if project_id: + task_data["projectId"] = project_id + if due_date: + # Convert date string to ISO format if needed + task_data["dueDate"] = due_date + if priority is not None: + task_data["priority"] = priority + if tags: + task_data["tags"] = tags + + response = self._request("POST", "/task", json=task_data) + return response.json() + + def update_task(self, task_id: str, **kwargs) -> Dict[str, Any]: + """Update an existing task.""" + response = self._request("POST", f"/task/{task_id}", json=kwargs) + return response.json() + + def complete_task(self, task_id: str) -> Dict[str, Any]: + """Mark a task as completed.""" + return self.update_task(task_id, status=2) # 2 = completed + + def delete_task(self, task_id: str) -> bool: + """Delete a task.""" + try: + self._request("DELETE", f"/task/{task_id}") + return True + except Exception: + return False + + def get_task(self, task_id: str) -> Dict[str, Any]: + """Get a specific task by ID.""" + # NOTE: TickTick's GET /task/{id} endpoint also returns 500 server error + import logging + + logging.warning( + f"TickTick GET /task/{task_id} endpoint returns 500 server error - this is a known API issue" + ) + + # Return minimal task info + return { + "id": task_id, + "title": "Task details unavailable (API issue)", + "status": 0, + } diff --git a/src/utils/ticktick_utils.py b/src/utils/ticktick_utils.py new file mode 100644 index 0000000..b0eaf82 --- /dev/null +++ b/src/utils/ticktick_utils.py @@ -0,0 +1,284 @@ +""" +TickTick utilities for formatting and helper functions. +""" + +import platform +import subprocess +import webbrowser +from datetime import datetime, date, timedelta +from typing import Dict, Any, List, Optional +from rich.console import Console +from rich.table import Table +from rich.text import Text +from dateutil import parser as date_parser + +console = Console() + + +def format_date(date_str: Optional[str]) -> str: + """ + Format a date string for display. + + Args: + date_str: ISO date string + + Returns: + Formatted date string + """ + if not date_str: + return "" + + try: + dt = date_parser.parse(date_str) + today = datetime.now().date() + task_date = dt.date() + + if task_date == today: + return "Today" + elif task_date == today + timedelta(days=1): + return "Tomorrow" + elif task_date == today - timedelta(days=1): + return "Yesterday" + else: + return dt.strftime("%Y-%m-%d") + except (ValueError, TypeError): + return str(date_str) + + +def get_priority_display(priority: int) -> Text: + """ + Get a rich Text object for priority display. + + Args: + priority: Priority level (0-5) + + Returns: + Rich Text object with colored priority + """ + if priority == 0: + return Text("", style="dim") + elif priority == 1: + return Text("!", style="blue") + elif priority == 2: + return Text("!!", style="yellow") + elif priority >= 3: + return Text("!!!", style="red bold") + else: + return Text("", style="dim") + + +def format_task_title(task: Dict[str, Any], max_length: int = 50) -> str: + """ + Format task title with truncation if needed. + + Args: + task: Task dictionary + max_length: Maximum length for title + + Returns: + Formatted title string + """ + title = task.get("title", "Untitled") + if len(title) > max_length: + return title[: max_length - 3] + "..." + return title + + +def create_task_table(tasks: List[Dict[str, Any]], show_project: bool = True) -> Table: + """ + Create a rich table for displaying tasks. + + Args: + tasks: List of task dictionaries + show_project: Whether to show project column + + Returns: + Rich Table object + """ + table = Table(show_header=True, header_style="bold magenta") + + table.add_column("ID", style="dim", width=8) + table.add_column("Priority", width=8) + table.add_column("Title", style="white", min_width=30) + if show_project: + table.add_column("Project", style="cyan", width=15) + table.add_column("Due Date", style="yellow", width=12) + table.add_column("Tags", style="green", width=20) + + for task in tasks: + task_id = str(task.get("id", ""))[:8] + priority_text = get_priority_display(task.get("priority", 0)) + title = format_task_title(task) + due_date = format_date(task.get("dueDate")) + + # Get project name (would need to be looked up from projects) + project_name = task.get("projectId", "Inbox")[:15] if show_project else None + + # Format tags + tags_list = task.get("tags", []) + if isinstance(tags_list, list): + tags = ", ".join(tags_list[:3]) # Show max 3 tags + if len(tags_list) > 3: + tags += f" (+{len(tags_list) - 3})" + else: + tags = "" + + if show_project: + table.add_row(task_id, priority_text, title, project_name, due_date, tags) + else: + table.add_row(task_id, priority_text, title, due_date, tags) + + return table + + +def print_task_details(task: Dict[str, Any]): + """ + Print detailed view of a single task. + + Args: + task: Task dictionary + """ + console.print(f"[bold cyan]Task Details[/bold cyan]") + console.print(f"ID: {task.get('id', 'N/A')}") + console.print(f"Title: [white]{task.get('title', 'Untitled')}[/white]") + + if task.get("content"): + console.print(f"Description: {task.get('content')}") + + console.print(f"Priority: {get_priority_display(task.get('priority', 0))}") + console.print(f"Project ID: {task.get('projectId', 'N/A')}") + + if task.get("dueDate"): + console.print(f"Due Date: [yellow]{format_date(task.get('dueDate'))}[/yellow]") + + if task.get("tags"): + console.print(f"Tags: [green]{', '.join(task.get('tags', []))}[/green]") + + console.print(f"Status: {'Completed' if task.get('status') == 2 else 'Open'}") + + if task.get("createdTime"): + console.print(f"Created: {format_date(task.get('createdTime'))}") + + if task.get("modifiedTime"): + console.print(f"Modified: {format_date(task.get('modifiedTime'))}") + + +def open_task_in_browser(task_id: str): + """ + Open a task in the default web browser. + + Args: + task_id: Task ID to open + """ + url = f"https://ticktick.com/webapp/#q/all/tasks/{task_id}" + webbrowser.open(url) + console.print(f"[green]Opened task in browser: {url}[/green]") + + +def open_task_in_macos_app(task_id: str) -> bool: + """ + Open a task in the TickTick macOS app. + + Args: + task_id: Task ID to open + + Returns: + True if successful, False otherwise + """ + if platform.system() != "Darwin": + console.print("[red]macOS app opening is only available on macOS[/red]") + return False + + try: + # Try to open with TickTick URL scheme + ticktick_url = f"ticktick://task/{task_id}" + result = subprocess.run( + ["open", ticktick_url], capture_output=True, text=True, timeout=5 + ) + + if result.returncode == 0: + console.print(f"[green]Opened task in TickTick app[/green]") + return True + else: + console.print( + "[yellow]TickTick app not found, opening in browser instead[/yellow]" + ) + open_task_in_browser(task_id) + return False + + except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError): + console.print( + "[yellow]Failed to open TickTick app, opening in browser instead[/yellow]" + ) + open_task_in_browser(task_id) + return False + + +def open_task(task_id: str, prefer_app: bool = True): + """ + Open a task in browser or app based on preference. + + Args: + task_id: Task ID to open + prefer_app: Whether to prefer native app over browser + """ + if prefer_app and platform.system() == "Darwin": + if not open_task_in_macos_app(task_id): + open_task_in_browser(task_id) + else: + open_task_in_browser(task_id) + + +def parse_priority(priority_str: str) -> int: + """ + Parse priority string to integer. + + Args: + priority_str: Priority as string (low, medium, high, none, or 0-5) + + Returns: + Priority integer (0-5) + """ + if not priority_str: + return 0 + + priority_str = priority_str.lower().strip() + + if priority_str in ["none", "no", "0"]: + return 0 + elif priority_str in ["low", "1"]: + return 1 + elif priority_str in ["medium", "med", "2"]: + return 2 + elif priority_str in ["high", "3"]: + return 3 + elif priority_str in ["very high", "urgent", "4"]: + return 4 + elif priority_str in ["critical", "5"]: + return 5 + else: + try: + priority = int(priority_str) + return max(0, min(5, priority)) + except ValueError: + return 0 + + +def validate_date(date_str: str) -> bool: + """ + Validate if a date string is parseable. + + Args: + date_str: Date string to validate + + Returns: + True if valid, False otherwise + """ + if date_str.lower() in ["today", "tomorrow", "yesterday"]: + return True + + try: + date_parser.parse(date_str) + return True + except ValueError: + return False diff --git a/uv.lock b/uv.lock index 7966cfe..53f178c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -249,37 +249,11 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.2" +version = "2.0.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/31/7bcaf657fafb3c6db8c787a865434290b726653c912085fbd371e9b92e1c/charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597", size = 79105, upload-time = "2022-02-12T14:33:13.788Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, - { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, - { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, - { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, - { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, - { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, - { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, - { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, - { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, - { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, - { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, - { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, - { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, - { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, - { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, - { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, - { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, - { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/06/b3/24afc8868eba069a7f03650ac750a778862dc34941a4bebeb58706715726/charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df", size = 39623, upload-time = "2022-02-12T14:33:12.294Z" }, ] [[package]] @@ -461,6 +435,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, + { name = "certifi" }, { name = "html2text" }, { name = "mammoth" }, { name = "markitdown", extra = ["all"] }, @@ -473,6 +448,7 @@ dependencies = [ { name = "rich" }, { name = "textual" }, { name = "textual-image" }, + { name = "ticktick-py" }, ] [package.dev-dependencies] @@ -484,6 +460,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiohttp", specifier = ">=3.11.18" }, + { name = "certifi", specifier = ">=2025.4.26" }, { name = "html2text", specifier = ">=2025.4.15" }, { name = "mammoth", specifier = ">=1.9.0" }, { name = "markitdown", extras = ["all"], specifier = ">=0.1.1" }, @@ -496,6 +473,7 @@ requires-dist = [ { name = "rich", specifier = ">=14.0.0" }, { name = "textual", specifier = ">=3.2.0" }, { name = "textual-image", specifier = ">=0.8.2" }, + { name = "ticktick-py", specifier = ">=2.0.0" }, ] [package.metadata.requires-dev] @@ -1356,16 +1334,22 @@ wheels = [ [[package]] name = "pytz" -version = "2025.2" +version = "2021.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/61/eddc6eb2c682ea6fd97a7e1018a6294be80dba08fa28e7a3570148b4612d/pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", size = 317945, upload-time = "2021-02-01T08:07:19.773Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/70/94/784178ca5dd892a98f113cdd923372024dc04b8d40abe77ca76b5fb90ca6/pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798", size = 510782, upload-time = "2021-02-01T08:07:15.659Z" }, ] +[[package]] +name = "regex" +version = "2021.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/3f/4c42a98c9ad7d08c16e7d23b2194a0e4f3b2914662da8bc88986e4e6de1f/regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb", size = 693187, upload-time = "2021-04-04T16:50:49.77Z" } + [[package]] name = "requests" -version = "2.32.3" +version = "2.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1373,9 +1357,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/01/3569e0b535fb2e4a6c384bdbed00c55b9d78b5084e0fb7f4d0bf523d7670/requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7", size = 104433, upload-time = "2021-07-13T14:55:08.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/144f70b972a9c0eabbd4391ef93ccd49d0f2747f4f6a2a2738e99e5adc65/requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", size = 62251, upload-time = "2021-07-13T14:55:06.933Z" }, ] [[package]] @@ -1519,6 +1503,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/81/a0685932473a7a626bd4d27c73f0b8593881391b68ac2fe6f1dc69037c4b/textual_image-0.8.2-py3-none-any.whl", hash = "sha256:35ab95076d2edcd9e59d66e1881bf177ab8acd7f131446a129f55cae9c81c447", size = 109372, upload-time = "2025-04-01T19:39:36.93Z" }, ] +[[package]] +name = "ticktick-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, + { name = "regex" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/7b/f1bd14f6aa2cb021c82f6f71f81f1b4057410879b71c5d261897476bc539/ticktick-py-2.0.3.tar.gz", hash = "sha256:f6e96870b91f16717a81e20ffef4a2f5b2a524d6a79e31ab64e895a90a372b51", size = 44939, upload-time = "2023-07-09T05:05:19.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/6b/1af30827789520a724906e7249531007ddc4fab5a4b50f4e5275dc73324f/ticktick_py-2.0.3-py3-none-any.whl", hash = "sha256:5fdb01fa45b1b477ed2921a48db106f090b9519cf52246647e67465414479840", size = 46079, upload-time = "2023-07-09T05:05:18.153Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1572,11 +1571,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.4.0" +version = "1.26.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/be/3ee43b6c5757cabea19e75b8f46eaf05a2f5144107d7db48c7cf3a864f73/urllib3-1.26.7.tar.gz", hash = "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", size = 291350, upload-time = "2021-09-22T18:01:18.331Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, + { url = "https://files.pythonhosted.org/packages/af/f4/524415c0744552cce7d8bf3669af78e8a069514405ea4fcbd0cc44733744/urllib3-1.26.7-py2.py3-none-any.whl", hash = "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844", size = 138764, upload-time = "2021-09-22T18:01:15.93Z" }, ] [[package]]