wip
This commit is contained in:
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,
|
||||
}
|
||||
Reference in New Issue
Block a user