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