This commit is contained in:
Tim Bendt
2025-08-18 10:58:48 -04:00
parent c64fbbb072
commit ca6e4cdf5d
12 changed files with 2220 additions and 39 deletions

83
AGENTS.txt Normal file
View File

@@ -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/<service_name>/`
- Include authentication in `auth.py`
- Main client logic in `client.py`
- Exports in `__init__.py`
### CLI Commands
- Group related commands in `src/cli/<service_name>.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/<service_name>_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.

237
TICKTICK_SETUP.md Normal file
View File

@@ -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
```

129
debug_ticktick.py Executable file
View File

@@ -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)

View File

@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"aiohttp>=3.11.18", "aiohttp>=3.11.18",
"certifi>=2025.4.26",
"html2text>=2025.4.15", "html2text>=2025.4.15",
"mammoth>=1.9.0", "mammoth>=1.9.0",
"markitdown[all]>=0.1.1", "markitdown[all]>=0.1.1",
@@ -18,6 +19,7 @@ dependencies = [
"rich>=14.0.0", "rich>=14.0.0",
"textual>=3.2.0", "textual>=3.2.0",
"textual-image>=0.8.2", "textual-image>=0.8.2",
"ticktick-py>=2.0.0",
] ]
[dependency-groups] [dependency-groups]

View File

@@ -6,6 +6,7 @@ from .sync import sync
from .drive import drive from .drive import drive
from .email import email from .email import email
from .calendar import calendar from .calendar import calendar
from .ticktick import ticktick
@click.group() @click.group()
@@ -18,3 +19,7 @@ cli.add_command(sync)
cli.add_command(drive) cli.add_command(drive)
cli.add_command(email) cli.add_command(email)
cli.add_command(calendar) cli.add_command(calendar)
cli.add_command(ticktick)
# Add 'tt' as a short alias for ticktick
cli.add_command(ticktick, name="tt")

607
src/cli/ticktick.py Normal file
View File

@@ -0,0 +1,607 @@
"""
TickTick CLI commands with aliases for task management.
"""
import click
from datetime import datetime
from typing import Optional, List
from rich.console import Console
from src.services.ticktick import TickTickService
from src.utils.ticktick_utils import (
create_task_table,
print_task_details,
open_task,
parse_priority,
validate_date,
console,
)
# Initialize service lazily
def get_ticktick_service():
"""Get the TickTick service, initializing it if needed."""
global _ticktick_service
if "_ticktick_service" not in globals():
_ticktick_service = TickTickService()
return _ticktick_service
@click.group()
def ticktick():
"""TickTick task management CLI."""
pass
@ticktick.command(name="list")
@click.option("--project", "-p", help="Filter by project name")
@click.option(
"--due-date", "-d", help="Filter by due date (today, tomorrow, YYYY-MM-DD)"
)
@click.option("--all", "-a", is_flag=True, help="Show all tasks including completed")
@click.option("--priority", "-pr", help="Filter by priority (0-5, low, medium, high)")
@click.option("--tag", "-t", help="Filter by tag name")
@click.option("--limit", "-l", default=20, help="Limit number of results")
def list_tasks(
project: Optional[str],
due_date: Optional[str],
all: bool,
priority: Optional[str],
tag: Optional[str],
limit: int,
):
"""List tasks (alias: ls)."""
try:
ticktick_service = get_ticktick_service()
if due_date:
if not validate_date(due_date):
console.print(f"[red]Invalid date format: {due_date}[/red]")
return
tasks = get_ticktick_service().get_tasks_by_due_date(due_date)
elif project:
tasks = get_ticktick_service().get_tasks_by_project(project)
else:
tasks = get_ticktick_service().get_tasks(completed=all)
# Apply additional filters
if priority:
priority_val = parse_priority(priority)
tasks = [t for t in tasks if t.get("priority", 0) == priority_val]
if tag:
tasks = [
t
for t in tasks
if tag.lower() in [t.lower() for t in t.get("tags", [])]
]
# Limit results
if limit > 0:
tasks = tasks[:limit]
if not tasks:
console.print("[yellow]No tasks found matching criteria[/yellow]")
return
# Display results
table = create_task_table(tasks, show_project=not project)
console.print(table)
console.print(f"\n[dim]Showing {len(tasks)} tasks[/dim]")
except Exception as e:
console.print(f"[red]Error listing tasks: {str(e)}[/red]")
@ticktick.command(name="add")
@click.argument("title")
@click.option("--project", "-p", help="Project name")
@click.option("--due-date", "-d", help="Due date (today, tomorrow, YYYY-MM-DD)")
@click.option("--priority", "-pr", help="Priority (0-5, low, medium, high)")
@click.option("--content", "-c", help="Task description/content")
@click.option("--tags", "-t", help="Comma-separated list of tags")
def add_task(
title: str,
project: Optional[str],
due_date: Optional[str],
priority: Optional[str],
content: Optional[str],
tags: Optional[str],
):
"""Add a new task (alias: a)."""
try:
# Validate due date if provided
if due_date and not validate_date(due_date):
console.print(f"[red]Invalid date format: {due_date}[/red]")
return
# Parse priority
priority_val = parse_priority(priority) if priority else None
# Parse tags
tag_list = [tag.strip() for tag in tags.split(",")] if tags else None
# Create task
task = get_ticktick_service().create_task(
title=title,
project_name=project,
due_date=due_date,
priority=priority_val,
content=content,
tags=tag_list,
)
if task:
console.print(f"[green]✓ Created task: {title}[/green]")
console.print(f"[dim]Task ID: {task.get('id', 'N/A')}[/dim]")
else:
console.print("[red]Failed to create task[/red]")
except Exception as e:
console.print(f"[red]Error creating task: {str(e)}[/red]")
@ticktick.command(name="edit")
@click.argument("task_id")
@click.option("--title", help="New task title")
@click.option("--project", "-p", help="New project name")
@click.option("--due-date", "-d", help="New due date (today, tomorrow, YYYY-MM-DD)")
@click.option("--priority", "-pr", help="New priority (0-5, low, medium, high)")
@click.option("--content", "-c", help="New task description/content")
def edit_task(
task_id: str,
title: Optional[str],
project: Optional[str],
due_date: Optional[str],
priority: Optional[str],
content: Optional[str],
):
"""Edit an existing task (alias: e)."""
try:
# Build update dictionary
updates = {}
if title:
updates["title"] = title
if project:
updates["project_name"] = project
if due_date:
if not validate_date(due_date):
console.print(f"[red]Invalid date format: {due_date}[/red]")
return
updates["due_date"] = due_date
if priority:
updates["priority"] = parse_priority(priority)
if content:
updates["content"] = content
if not updates:
console.print("[yellow]No changes specified[/yellow]")
return
# Update task
updated_task = get_ticktick_service().update_task(task_id, **updates)
if updated_task:
console.print(
f"[green]✓ Updated task: {updated_task.get('title', task_id)}[/green]"
)
else:
console.print("[red]Failed to update task[/red]")
except Exception as e:
console.print(f"[red]Error updating task: {str(e)}[/red]")
@ticktick.command(name="complete")
@click.argument("task_id")
def complete_task(task_id: str):
"""Mark a task as completed (aliases: done, c)."""
try:
success = get_ticktick_service().complete_task(task_id)
if success:
console.print(f"[green]✓ Completed task: {task_id}[/green]")
else:
console.print(f"[red]Failed to complete task: {task_id}[/red]")
except Exception as e:
console.print(f"[red]Error completing task: {str(e)}[/red]")
@ticktick.command(name="delete")
@click.argument("task_id")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
def delete_task(task_id: str, force: bool):
"""Delete a task (aliases: del, rm)."""
try:
if not force:
if not click.confirm(f"Delete task {task_id}?"):
console.print("[yellow]Cancelled[/yellow]")
return
success = get_ticktick_service().delete_task(task_id)
if success:
console.print(f"[green]✓ Deleted task: {task_id}[/green]")
else:
console.print(f"[red]Failed to delete task: {task_id}[/red]")
except Exception as e:
console.print(f"[red]Error deleting task: {str(e)}[/red]")
@ticktick.command(name="open")
@click.argument("task_id")
@click.option(
"--browser", "-b", is_flag=True, help="Force open in browser instead of app"
)
def open_task_cmd(task_id: str, browser: bool):
"""Open a task in browser or TickTick app (alias: o)."""
try:
open_task(task_id, prefer_app=not browser)
except Exception as e:
console.print(f"[red]Error opening task: {str(e)}[/red]")
@ticktick.command(name="show")
@click.argument("task_id")
def show_task(task_id: str):
"""Show detailed task information (aliases: view, s)."""
try:
get_ticktick_service()._ensure_client()
task = get_ticktick_service().client.get_by_id(task_id, search="tasks")
if not task:
console.print(f"[red]Task not found: {task_id}[/red]")
return
print_task_details(task)
except Exception as e:
console.print(f"[red]Error showing task: {str(e)}[/red]")
@ticktick.command(name="projects")
def list_projects():
"""List all projects (alias: proj)."""
try:
projects = get_ticktick_service().get_projects()
if not projects:
console.print("[yellow]No projects found[/yellow]")
return
console.print("[bold cyan]Projects:[/bold cyan]")
for project in projects:
name = project.get("name", "Unnamed")
project_id = project.get("id", "N/A")
console.print(f" • [white]{name}[/white] [dim]({project_id})[/dim]")
console.print(f"\n[dim]Total: {len(projects)} projects[/dim]")
except Exception as e:
console.print(f"[red]Error listing projects: {str(e)}[/red]")
@ticktick.command(name="tags")
def list_tags():
"""List all tags."""
try:
tags = get_ticktick_service().get_tags()
if not tags:
console.print("[yellow]No tags found[/yellow]")
return
console.print("[bold green]Tags:[/bold green]")
for tag in tags:
name = tag.get("name", "Unnamed")
console.print(f" • [green]#{name}[/green]")
console.print(f"\n[dim]Total: {len(tags)} tags[/dim]")
except Exception as e:
console.print(f"[red]Error listing tags: {str(e)}[/red]")
@ticktick.command(name="sync")
def sync_tasks():
"""Sync tasks with TickTick servers."""
try:
get_ticktick_service().sync()
console.print("[green]✓ Synced with TickTick servers[/green]")
except Exception as e:
console.print(f"[red]Error syncing: {str(e)}[/red]")
# Add alias commands manually
@click.command()
@click.option("--project", "-p", help="Filter by project name")
@click.option(
"--due-date", "-d", help="Filter by due date (today, tomorrow, YYYY-MM-DD)"
)
@click.option("--all", "-a", is_flag=True, help="Show all tasks including completed")
@click.option("--priority", "-pr", help="Filter by priority (0-5, low, medium, high)")
@click.option("--tag", "-t", help="Filter by tag name")
@click.option("--limit", "-l", default=20, help="Limit number of results")
def ls(
project: Optional[str],
due_date: Optional[str],
all: bool,
priority: Optional[str],
tag: Optional[str],
limit: int,
):
"""Alias for list command."""
list_tasks.callback(project, due_date, all, priority, tag, limit)
@click.command()
@click.argument("title")
@click.option("--project", "-p", help="Project name")
@click.option("--due-date", "-d", help="Due date (today, tomorrow, YYYY-MM-DD)")
@click.option("--priority", "-pr", help="Priority (0-5, low, medium, high)")
@click.option("--content", "-c", help="Task description/content")
@click.option("--tags", "-t", help="Comma-separated list of tags")
def a(
title: str,
project: Optional[str],
due_date: Optional[str],
priority: Optional[str],
content: Optional[str],
tags: Optional[str],
):
"""Alias for add command."""
add_task.callback(title, project, due_date, priority, content, tags)
@click.command()
@click.argument("task_id")
@click.option("--title", help="New task title")
@click.option("--project", "-p", help="New project name")
@click.option("--due-date", "-d", help="New due date (today, tomorrow, YYYY-MM-DD)")
@click.option("--priority", "-pr", help="New priority (0-5, low, medium, high)")
@click.option("--content", "-c", help="New task description/content")
def e(
task_id: str,
title: Optional[str],
project: Optional[str],
due_date: Optional[str],
priority: Optional[str],
content: Optional[str],
):
"""Alias for edit command."""
edit_task.callback(task_id, title, project, due_date, priority, content)
@click.command()
@click.argument("task_id")
def c(task_id: str):
"""Alias for complete command."""
complete_task.callback(task_id)
@click.command()
@click.argument("task_id")
def done(task_id: str):
"""Alias for complete command."""
complete_task.callback(task_id)
@click.command()
@click.argument("task_id")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
def rm(task_id: str, force: bool):
"""Alias for delete command."""
delete_task.callback(task_id, force)
@click.command()
@click.argument("task_id")
@click.option("--force", "-f", is_flag=True, help="Skip confirmation prompt")
def del_cmd(task_id: str, force: bool):
"""Alias for delete command."""
delete_task.callback(task_id, force)
@click.command()
@click.argument("task_id")
@click.option(
"--browser", "-b", is_flag=True, help="Force open in browser instead of app"
)
def o(task_id: str, browser: bool):
"""Alias for open command."""
open_task_cmd.callback(task_id, browser)
@click.command()
@click.argument("task_id")
def s(task_id: str):
"""Alias for show command."""
show_task.callback(task_id)
@click.command()
@click.argument("task_id")
def view(task_id: str):
"""Alias for show command."""
show_task.callback(task_id)
@click.command()
def proj():
"""Alias for projects command."""
list_projects.callback()
# Register all alias commands
ticktick.add_command(ls)
ticktick.add_command(a)
ticktick.add_command(e)
ticktick.add_command(c)
ticktick.add_command(done)
ticktick.add_command(rm)
ticktick.add_command(del_cmd, name="del")
ticktick.add_command(o)
ticktick.add_command(s)
ticktick.add_command(view)
ticktick.add_command(proj)
@ticktick.command(name="setup")
def setup_ticktick():
"""Show TickTick setup instructions."""
from rich.panel import Panel
from rich.markdown import Markdown
setup_text = """
# TickTick Setup Instructions
## 1. Register TickTick Developer App
Visit: https://developer.ticktick.com/docs#/openapi
- Click "Manage Apps""+App Name"
- Set OAuth Redirect URL: `http://localhost:8080`
- Note your Client ID and Client Secret
## 2. Set Environment Variables
```bash
export TICKTICK_CLIENT_ID="your_client_id"
export TICKTICK_CLIENT_SECRET="your_client_secret"
export TICKTICK_REDIRECT_URI="http://localhost:8080"
# Optional (you'll be prompted if not set):
export TICKTICK_USERNAME="your_email@example.com"
export TICKTICK_PASSWORD="your_password"
```
## 3. Authentication Note
The TickTick library requires **both** OAuth2 AND login credentials:
- OAuth2: For API authorization
- Username/Password: For initial session setup
This is how the library works, not a limitation of our CLI.
## 4. Start Using
```bash
ticktick ls # List tasks
ticktick a "Task" # Add task
```
"""
console.print(
Panel(
Markdown(setup_text),
title="[bold green]TickTick Setup[/bold green]",
border_style="green",
)
)
@ticktick.command(name="test-auth")
def test_auth():
"""Test authentication and API connectivity."""
import os
from src.services.ticktick.auth import (
get_token_file_path,
check_token_validity,
get_ticktick_client,
)
console.print("[bold cyan]TickTick Authentication Test[/bold cyan]\n")
# Check environment
client_id = os.getenv("TICKTICK_CLIENT_ID")
client_secret = os.getenv("TICKTICK_CLIENT_SECRET")
if not client_id or not client_secret:
console.print("[red]❌ OAuth credentials not set[/red]")
console.print("Please set TICKTICK_CLIENT_ID and TICKTICK_CLIENT_SECRET")
return
console.print("[green]✓ OAuth credentials found[/green]")
# Check token cache
validity = check_token_validity()
if validity["valid"]:
console.print(f"[green]✓ Token cache: {validity['reason']}[/green]")
else:
console.print(f"[yellow]⚠ Token cache: {validity['reason']}[/yellow]")
# Test client creation
console.print("\n[bold]Testing TickTick client initialization...[/bold]")
try:
client = get_ticktick_client()
console.print("[green]✓ TickTick client created successfully[/green]")
# Test API call
console.print("Testing API connectivity...")
try:
projects = client.get_by_fields(search="projects")
console.print(
f"[green]✓ API test successful - found {len(projects)} projects[/green]"
)
except Exception as api_e:
console.print(f"[red]❌ API test failed: {api_e}[/red]")
except Exception as e:
console.print(f"[red]❌ Client creation failed: {str(e)}[/red]")
return
console.print("\n[green]🎉 Authentication test completed successfully![/green]")
@ticktick.command(name="auth-status")
def auth_status():
"""Check TickTick authentication status."""
import os
from src.services.ticktick.auth import get_token_file_path, check_token_validity
console.print("[bold cyan]TickTick Authentication Status[/bold cyan]\n")
# Check OAuth credentials
client_id = os.getenv("TICKTICK_CLIENT_ID")
client_secret = os.getenv("TICKTICK_CLIENT_SECRET")
redirect_uri = os.getenv("TICKTICK_REDIRECT_URI")
console.print(f"OAuth Client ID: {'✓ Set' if client_id else '✗ Not set'}")
console.print(f"OAuth Client Secret: {'✓ Set' if client_secret else '✗ Not set'}")
console.print(
f"OAuth Redirect URI: {redirect_uri or '✗ Not set (will use default)'}"
)
# Check login credentials
username = os.getenv("TICKTICK_USERNAME")
password = os.getenv("TICKTICK_PASSWORD")
console.print(f"Username: {'✓ Set' if username else '✗ Not set (will prompt)'}")
console.print(f"Password: {'✓ Set' if password else '✗ Not set (will prompt)'}")
# Check token cache with validity
token_file = get_token_file_path()
token_exists = token_file.exists()
if token_exists:
validity = check_token_validity()
if validity["valid"]:
console.print("OAuth Token Cache: [green]✓ Valid[/green]")
if "expires_in_hours" in validity:
console.print(
f"Token expires in: [yellow]{validity['expires_in_hours']} hours[/yellow]"
)
else:
console.print(
f"OAuth Token Cache: [red]✗ Invalid ({validity['reason']})[/red]"
)
import datetime
mod_time = datetime.datetime.fromtimestamp(token_file.stat().st_mtime)
console.print(f"Token file: {token_file}")
console.print(f"Last modified: {mod_time.strftime('%Y-%m-%d %H:%M:%S')}")
else:
console.print("OAuth Token Cache: [red]✗ Not found[/red]")
console.print(f"Token file: {token_file}")
console.print("\n[dim]Run 'ticktick setup' for setup instructions[/dim]")

View File

@@ -0,0 +1,8 @@
"""
TickTick API service module.
"""
from .client import TickTickService
from .auth import get_ticktick_client
__all__ = ["TickTickService", "get_ticktick_client"]

View File

@@ -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.")

View File

@@ -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

View File

@@ -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,
}

284
src/utils/ticktick_utils.py Normal file
View File

@@ -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

77
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.13'", "python_full_version >= '3.13'",
@@ -249,37 +249,11 @@ wheels = [
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.2" version = "2.0.12"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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" },
{ 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" },
] ]
[[package]] [[package]]
@@ -461,6 +435,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "certifi" },
{ name = "html2text" }, { name = "html2text" },
{ name = "mammoth" }, { name = "mammoth" },
{ name = "markitdown", extra = ["all"] }, { name = "markitdown", extra = ["all"] },
@@ -473,6 +448,7 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "textual" }, { name = "textual" },
{ name = "textual-image" }, { name = "textual-image" },
{ name = "ticktick-py" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -484,6 +460,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = ">=3.11.18" }, { name = "aiohttp", specifier = ">=3.11.18" },
{ name = "certifi", specifier = ">=2025.4.26" },
{ name = "html2text", specifier = ">=2025.4.15" }, { name = "html2text", specifier = ">=2025.4.15" },
{ name = "mammoth", specifier = ">=1.9.0" }, { name = "mammoth", specifier = ">=1.9.0" },
{ name = "markitdown", extras = ["all"], specifier = ">=0.1.1" }, { name = "markitdown", extras = ["all"], specifier = ">=0.1.1" },
@@ -496,6 +473,7 @@ requires-dist = [
{ name = "rich", specifier = ">=14.0.0" }, { name = "rich", specifier = ">=14.0.0" },
{ name = "textual", specifier = ">=3.2.0" }, { name = "textual", specifier = ">=3.2.0" },
{ name = "textual-image", specifier = ">=0.8.2" }, { name = "textual-image", specifier = ">=0.8.2" },
{ name = "ticktick-py", specifier = ">=2.0.0" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -1356,16 +1334,22 @@ wheels = [
[[package]] [[package]]
name = "pytz" name = "pytz"
version = "2025.2" version = "2021.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
name = "requests" name = "requests"
version = "2.32.3" version = "2.26.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },
@@ -1373,9 +1357,9 @@ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "urllib3" }, { 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 = [ 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]] [[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" }, { 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]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.1" version = "4.67.1"
@@ -1572,11 +1571,11 @@ wheels = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.4.0" version = "1.26.7"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]