Add TickTick integration, MCP server, and dev project tracking
New integration with TickTick API for project/task management: - OAuth 2.0 auth flow (mcp-servers/ticktick/ticktick_auth.py) - MCP server with 9 tools for Claude Code (ticktick_mcp.py) - FastAPI service with SOPS vault credentials (api/services/ticktick_service.py) - JWT-protected REST router at /api/ticktick/ (api/routers/ticktick.py) - Credentials stored in SOPS vault (services/ticktick.sops.yaml) Dev project tracking (hybrid TickTick + DB): - New dev_projects table migration (14 columns, status index) - TickTick "Dev Projects" list for mobile visibility - First project seeded: TickTick Integration (linked both sides) Security: .tokens.json gitignored, token file permissions restricted, HTML-escaped OAuth callback, SOPS vault (not env vars) for secrets. Also: Installed Tailscale on ACG-5070 for office network access. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ from api.routers import (
|
||||
version,
|
||||
quotes,
|
||||
admin_quotes,
|
||||
ticktick,
|
||||
)
|
||||
|
||||
# Import middleware
|
||||
@@ -130,6 +131,9 @@ app.include_router(bulk_import.router, prefix="/api/bulk-import", tags=["Bulk Im
|
||||
app.include_router(quotes.router, prefix="/api/quotes", tags=["Quotes"])
|
||||
app.include_router(admin_quotes.router, prefix="/api/admin/quotes", tags=["Admin Quotes"])
|
||||
|
||||
# External integrations
|
||||
app.include_router(ticktick.router, prefix="/api/ticktick", tags=["TickTick"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
333
api/routers/ticktick.py
Normal file
333
api/routers/ticktick.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
TickTick API router for ClaudeTools.
|
||||
|
||||
This module defines REST API endpoints for managing TickTick projects and tasks,
|
||||
proxying requests through the TickTickService with automatic token management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.middleware.auth import get_current_user
|
||||
from api.services.ticktick_service import TickTickResult, get_ticktick_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Pydantic request/response schemas
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
"""Schema for creating a new TickTick project."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=200, description="Project name")
|
||||
color: Optional[str] = Field(
|
||||
None, description="Hex color string (e.g., '#FF6347')"
|
||||
)
|
||||
view_mode: Optional[str] = Field(
|
||||
None, description="View mode: 'list', 'kanban', or 'timeline'"
|
||||
)
|
||||
kind: Optional[str] = Field(
|
||||
None, description="Project kind: 'TASK' or 'NOTE'"
|
||||
)
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
"""Schema for updating an existing TickTick project."""
|
||||
|
||||
name: Optional[str] = Field(
|
||||
None, min_length=1, max_length=200, description="New project name"
|
||||
)
|
||||
color: Optional[str] = Field(None, description="New hex color string")
|
||||
view_mode: Optional[str] = Field(None, description="New view mode")
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
"""Schema for creating a new task in a TickTick project."""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=500, description="Task title")
|
||||
content: Optional[str] = Field(None, description="Task description/content")
|
||||
priority: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=5,
|
||||
description="Priority: 0=none, 1=low, 3=medium, 5=high",
|
||||
)
|
||||
due_date: Optional[str] = Field(
|
||||
None, description="Due date in ISO 8601 format"
|
||||
)
|
||||
tags: Optional[list[str]] = Field(None, description="List of tag strings")
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""Schema for updating an existing task."""
|
||||
|
||||
title: Optional[str] = Field(
|
||||
None, min_length=1, max_length=500, description="New task title"
|
||||
)
|
||||
content: Optional[str] = Field(None, description="New task content")
|
||||
priority: Optional[int] = Field(
|
||||
None, ge=0, le=5, description="New priority level"
|
||||
)
|
||||
due_date: Optional[str] = Field(None, description="New due date in ISO 8601 format")
|
||||
tags: Optional[list[str]] = Field(None, description="New list of tags")
|
||||
|
||||
|
||||
class TickTickResponse(BaseModel):
|
||||
"""Standard response wrapper for all TickTick endpoints."""
|
||||
|
||||
success: bool
|
||||
data: Optional[dict] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_response(result: TickTickResult, status_code_on_error: int = 500) -> dict:
|
||||
"""
|
||||
Convert a TickTickResult to a JSON-serializable response dict.
|
||||
|
||||
Raises an HTTPException when the result indicates failure.
|
||||
|
||||
Args:
|
||||
result: The service result to convert.
|
||||
status_code_on_error: HTTP status code for error responses.
|
||||
|
||||
Returns:
|
||||
Dict matching the TickTickResponse schema.
|
||||
"""
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=status_code_on_error,
|
||||
detail={
|
||||
"success": False,
|
||||
"data": None,
|
||||
"error": result.error or "Unknown error",
|
||||
},
|
||||
)
|
||||
return {"success": True, "data": result.data, "error": None}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Project endpoints
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=TickTickResponse,
|
||||
summary="List all TickTick projects",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def list_projects(current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Retrieve all projects (lists) from the authenticated TickTick account.
|
||||
|
||||
**Example Request:**
|
||||
```
|
||||
GET /api/ticktick
|
||||
```
|
||||
"""
|
||||
service = get_ticktick_service()
|
||||
result = await service.list_projects()
|
||||
return _to_response(result)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{project_id}",
|
||||
response_model=TickTickResponse,
|
||||
summary="Get a TickTick project with tasks",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def get_project(project_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Retrieve a single project and its associated task data.
|
||||
|
||||
**Path Parameters:**
|
||||
- **project_id**: The TickTick project ID.
|
||||
"""
|
||||
service = get_ticktick_service()
|
||||
result = await service.get_project(project_id)
|
||||
return _to_response(result, status_code_on_error=404)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TickTickResponse,
|
||||
summary="Create a new TickTick project",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_project(body: ProjectCreate, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Create a new project (list) in TickTick.
|
||||
|
||||
**Request Body:**
|
||||
- **name** (required): Project name.
|
||||
- **color**: Hex color string.
|
||||
- **view_mode**: View mode ('list', 'kanban', 'timeline').
|
||||
- **kind**: Project kind ('TASK' or 'NOTE').
|
||||
"""
|
||||
service = get_ticktick_service()
|
||||
result = await service.create_project(
|
||||
name=body.name,
|
||||
color=body.color,
|
||||
view_mode=body.view_mode,
|
||||
kind=body.kind,
|
||||
)
|
||||
return _to_response(result, status_code_on_error=400)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{project_id}",
|
||||
response_model=TickTickResponse,
|
||||
summary="Update a TickTick project",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def update_project(project_id: str, body: ProjectUpdate, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Update an existing project's properties.
|
||||
|
||||
**Path Parameters:**
|
||||
- **project_id**: The TickTick project ID to update.
|
||||
|
||||
**Request Body:**
|
||||
At least one field must be provided.
|
||||
"""
|
||||
service = get_ticktick_service()
|
||||
result = await service.update_project(
|
||||
project_id=project_id,
|
||||
name=body.name,
|
||||
color=body.color,
|
||||
view_mode=body.view_mode,
|
||||
)
|
||||
return _to_response(result, status_code_on_error=400)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}",
|
||||
response_model=TickTickResponse,
|
||||
summary="Delete a TickTick project",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def delete_project(project_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Delete a project from TickTick.
|
||||
|
||||
**Path Parameters:**
|
||||
- **project_id**: The TickTick project ID to delete.
|
||||
"""
|
||||
service = get_ticktick_service()
|
||||
result = await service.delete_project(project_id)
|
||||
return _to_response(result, status_code_on_error=404)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Task endpoints
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/tasks",
|
||||
response_model=TickTickResponse,
|
||||
summary="Create a task in a TickTick project",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_task(project_id: str, body: TaskCreate, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Create a new task within the specified project.
|
||||
|
||||
**Path Parameters:**
|
||||
- **project_id**: The TickTick project ID.
|
||||
|
||||
**Request Body:**
|
||||
- **title** (required): Task title.
|
||||
- **content**: Task description.
|
||||
- **priority**: 0=none, 1=low, 3=medium, 5=high.
|
||||
- **due_date**: ISO 8601 date string.
|
||||
- **tags**: List of tag strings.
|
||||
"""
|
||||
service = get_ticktick_service()
|
||||
result = await service.create_task(
|
||||
title=body.title,
|
||||
project_id=project_id,
|
||||
content=body.content,
|
||||
priority=body.priority,
|
||||
due_date=body.due_date,
|
||||
tags=body.tags,
|
||||
)
|
||||
return _to_response(result, status_code_on_error=400)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{project_id}/tasks/{task_id}",
|
||||
response_model=TickTickResponse,
|
||||
summary="Update a task in a TickTick project",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def update_task(project_id: str, task_id: str, body: TaskUpdate, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Update an existing task's properties.
|
||||
|
||||
**Path Parameters:**
|
||||
- **project_id**: The TickTick project ID.
|
||||
- **task_id**: The task ID to update.
|
||||
"""
|
||||
service = get_ticktick_service()
|
||||
result = await service.update_task(
|
||||
task_id=task_id,
|
||||
project_id=project_id,
|
||||
title=body.title,
|
||||
content=body.content,
|
||||
priority=body.priority,
|
||||
due_date=body.due_date,
|
||||
tags=body.tags,
|
||||
)
|
||||
return _to_response(result, status_code_on_error=400)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/tasks/{task_id}/complete",
|
||||
response_model=TickTickResponse,
|
||||
summary="Complete a task",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def complete_task(project_id: str, task_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Mark a task as complete in TickTick.
|
||||
|
||||
**Path Parameters:**
|
||||
- **project_id**: The TickTick project ID.
|
||||
- **task_id**: The task ID to mark complete.
|
||||
"""
|
||||
service = get_ticktick_service()
|
||||
result = await service.complete_task(task_id=task_id, project_id=project_id)
|
||||
return _to_response(result, status_code_on_error=400)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}/tasks/{task_id}",
|
||||
response_model=TickTickResponse,
|
||||
summary="Delete a task",
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def delete_task(project_id: str, task_id: str, current_user: dict = Depends(get_current_user)):
|
||||
"""
|
||||
Delete a task from a TickTick project.
|
||||
|
||||
**Path Parameters:**
|
||||
- **project_id**: The TickTick project ID.
|
||||
- **task_id**: The task ID to delete.
|
||||
"""
|
||||
service = get_ticktick_service()
|
||||
result = await service.delete_task(task_id=task_id, project_id=project_id)
|
||||
return _to_response(result, status_code_on_error=404)
|
||||
596
api/services/ticktick_service.py
Normal file
596
api/services/ticktick_service.py
Normal file
@@ -0,0 +1,596 @@
|
||||
"""
|
||||
TickTick API integration service for ClaudeTools.
|
||||
|
||||
This module handles all interactions with the TickTick Open API for project
|
||||
and task management. Tokens are managed via a local JSON file with automatic
|
||||
refresh on 401 responses.
|
||||
|
||||
API Documentation: https://developer.ticktick.com/api
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TICKTICK_API_BASE_URL = "https://api.ticktick.com/open/v1"
|
||||
TICKTICK_TOKEN_URL = "https://ticktick.com/oauth/token"
|
||||
TICKTICK_TOKEN_FILE = Path(__file__).resolve().parents[2] / "mcp-servers" / "ticktick" / ".tokens.json"
|
||||
|
||||
VAULT_SCRIPT = "D:/vault/scripts/vault.sh"
|
||||
VAULT_ENTRY = "services/ticktick.sops.yaml"
|
||||
|
||||
TICKTICK_TIMEOUT_SECONDS = 30.0
|
||||
TICKTICK_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TickTickResult:
|
||||
"""Result wrapper for all TickTick API operations."""
|
||||
|
||||
success: bool
|
||||
data: Optional[dict] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def _vault_get_field(field: str) -> str:
|
||||
"""Retrieve a single field from the SOPS vault entry."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["bash", VAULT_SCRIPT, "get-field", VAULT_ENTRY, field],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
logger.error("[ERROR] Vault returned empty or error for %s", field)
|
||||
return ""
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
||||
logger.error("[ERROR] Vault retrieval failed for %s: %s", field, exc)
|
||||
return ""
|
||||
|
||||
|
||||
class TickTickService:
|
||||
"""
|
||||
Service for interacting with the TickTick Open API.
|
||||
|
||||
Handles project and task CRUD operations with automatic OAuth token
|
||||
refresh when the access token expires. Credentials are retrieved from
|
||||
the SOPS vault.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_base_url: str = TICKTICK_API_BASE_URL,
|
||||
token_file: Path = TICKTICK_TOKEN_FILE,
|
||||
timeout: float = TICKTICK_TIMEOUT_SECONDS,
|
||||
connect_timeout: float = TICKTICK_CONNECT_TIMEOUT_SECONDS,
|
||||
):
|
||||
self.api_base_url = api_base_url.rstrip("/")
|
||||
self.token_file = token_file
|
||||
self.timeout = httpx.Timeout(timeout, connect=connect_timeout)
|
||||
self._access_token: Optional[str] = None
|
||||
self._refresh_token: Optional[str] = None
|
||||
self._client_id: Optional[str] = None
|
||||
self._client_secret: Optional[str] = None
|
||||
self._load_tokens()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_tokens(self) -> None:
|
||||
"""Load access and refresh tokens from the local token file."""
|
||||
if not self.token_file.exists():
|
||||
logger.warning(
|
||||
"[WARNING] TickTick token file not found at %s", self.token_file
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(self.token_file.read_text(encoding="utf-8"))
|
||||
self._access_token = data.get("access_token")
|
||||
self._refresh_token = data.get("refresh_token")
|
||||
logger.info("[OK] TickTick tokens loaded from %s", self.token_file)
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.error(
|
||||
"[ERROR] Failed to read TickTick token file: %s", exc
|
||||
)
|
||||
|
||||
def _save_tokens(self) -> None:
|
||||
"""Persist current tokens back to the token file."""
|
||||
try:
|
||||
existing: dict = {}
|
||||
if self.token_file.exists():
|
||||
try:
|
||||
existing = json.loads(
|
||||
self.token_file.read_text(encoding="utf-8")
|
||||
)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
existing = {}
|
||||
|
||||
existing["access_token"] = self._access_token
|
||||
existing["refresh_token"] = self._refresh_token
|
||||
|
||||
self.token_file.write_text(
|
||||
json.dumps(existing, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
logger.info("[OK] TickTick tokens saved to %s", self.token_file)
|
||||
except OSError as exc:
|
||||
logger.error(
|
||||
"[ERROR] Failed to write TickTick token file: %s", exc
|
||||
)
|
||||
|
||||
async def _refresh_access_token(self) -> bool:
|
||||
"""
|
||||
Refresh the OAuth access token using the stored refresh token.
|
||||
|
||||
Returns:
|
||||
True if the token was refreshed successfully, False otherwise.
|
||||
"""
|
||||
if not self._refresh_token:
|
||||
logger.error("[ERROR] No refresh token available for TickTick")
|
||||
return False
|
||||
|
||||
# Lazy-load vault credentials for refresh
|
||||
if not self._client_id:
|
||||
self._client_id = _vault_get_field("credentials.client_id")
|
||||
if not self._client_secret:
|
||||
self._client_secret = _vault_get_field("credentials.client_secret")
|
||||
|
||||
if not self._client_id or not self._client_secret:
|
||||
logger.error(
|
||||
"[ERROR] Could not retrieve TickTick client credentials from SOPS vault"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("[INFO] Refreshing TickTick access token")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
TICKTICK_TOKEN_URL,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self._refresh_token,
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(
|
||||
"[ERROR] TickTick token refresh failed with status %d: %s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
return False
|
||||
|
||||
token_data = response.json()
|
||||
self._access_token = token_data.get("access_token")
|
||||
if "refresh_token" in token_data:
|
||||
self._refresh_token = token_data["refresh_token"]
|
||||
|
||||
self._save_tokens()
|
||||
logger.info("[OK] TickTick access token refreshed successfully")
|
||||
return True
|
||||
|
||||
except httpx.HTTPError as exc:
|
||||
logger.error(
|
||||
"[ERROR] TickTick token refresh request failed: %s", exc
|
||||
)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
"""
|
||||
Create an async HTTP client with configured settings.
|
||||
|
||||
Returns:
|
||||
Configured httpx.AsyncClient for TickTick API calls.
|
||||
"""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if self._access_token:
|
||||
headers["Authorization"] = f"Bearer {self._access_token}"
|
||||
|
||||
return httpx.AsyncClient(timeout=self.timeout, headers=headers)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
json_body: Optional[dict] = None,
|
||||
retry_on_401: bool = True,
|
||||
) -> TickTickResult:
|
||||
"""
|
||||
Execute an API request with automatic 401 retry after token refresh.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE).
|
||||
endpoint: API path relative to the base URL (e.g., '/project').
|
||||
json_body: Optional JSON payload for POST/PUT requests.
|
||||
retry_on_401: Whether to attempt a token refresh on 401.
|
||||
|
||||
Returns:
|
||||
TickTickResult with success status and response data or error.
|
||||
"""
|
||||
url = f"{self.api_base_url}{endpoint}"
|
||||
|
||||
try:
|
||||
async with self._get_client() as client:
|
||||
response = await client.request(
|
||||
method, url, json=json_body
|
||||
)
|
||||
|
||||
if response.status_code == 401 and retry_on_401:
|
||||
logger.info(
|
||||
"[INFO] TickTick API returned 401, attempting token refresh"
|
||||
)
|
||||
refreshed = await self._refresh_access_token()
|
||||
if refreshed:
|
||||
return await self._request(
|
||||
method, endpoint, json_body, retry_on_401=False
|
||||
)
|
||||
return TickTickResult(
|
||||
success=False,
|
||||
error="Authentication failed and token refresh was unsuccessful",
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
return TickTickResult(success=True, data={})
|
||||
|
||||
if response.status_code >= 400:
|
||||
error_text = response.text
|
||||
logger.error(
|
||||
"[ERROR] TickTick API %s %s returned %d: %s",
|
||||
method,
|
||||
endpoint,
|
||||
response.status_code,
|
||||
error_text,
|
||||
)
|
||||
return TickTickResult(
|
||||
success=False,
|
||||
error=f"API returned {response.status_code}: {error_text}",
|
||||
)
|
||||
|
||||
# Some responses may have empty bodies (e.g., 200 with no content)
|
||||
if not response.text.strip():
|
||||
return TickTickResult(success=True, data={})
|
||||
|
||||
return TickTickResult(success=True, data=response.json())
|
||||
|
||||
except httpx.HTTPError as exc:
|
||||
logger.error(
|
||||
"[ERROR] TickTick API request failed (%s %s): %s",
|
||||
method,
|
||||
endpoint,
|
||||
exc,
|
||||
)
|
||||
return TickTickResult(
|
||||
success=False, error=f"Request failed: {exc}"
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error(
|
||||
"[ERROR] TickTick API returned invalid JSON (%s %s): %s",
|
||||
method,
|
||||
endpoint,
|
||||
exc,
|
||||
)
|
||||
return TickTickResult(
|
||||
success=False, error=f"Invalid JSON in response: {exc}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Project operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def list_projects(self) -> TickTickResult:
|
||||
"""
|
||||
List all projects (lists) in the TickTick account.
|
||||
|
||||
Returns:
|
||||
TickTickResult with data containing a list of project dicts.
|
||||
"""
|
||||
logger.info("[INFO] Fetching TickTick project list")
|
||||
result = await self._request("GET", "/project")
|
||||
if result.success and isinstance(result.data, list):
|
||||
result.data = {"projects": result.data}
|
||||
return result
|
||||
|
||||
async def get_project(self, project_id: str) -> TickTickResult:
|
||||
"""
|
||||
Get a single project with its task data.
|
||||
|
||||
Args:
|
||||
project_id: The TickTick project ID.
|
||||
|
||||
Returns:
|
||||
TickTickResult with the project data including tasks.
|
||||
"""
|
||||
if not project_id:
|
||||
return TickTickResult(success=False, error="project_id is required")
|
||||
|
||||
logger.info("[INFO] Fetching TickTick project %s", project_id)
|
||||
result = await self._request("GET", f"/project/{project_id}/data")
|
||||
return result
|
||||
|
||||
async def create_project(
|
||||
self,
|
||||
name: str,
|
||||
color: Optional[str] = None,
|
||||
view_mode: Optional[str] = None,
|
||||
kind: Optional[str] = None,
|
||||
) -> TickTickResult:
|
||||
"""
|
||||
Create a new project (list) in TickTick.
|
||||
|
||||
Args:
|
||||
name: Project name.
|
||||
color: Optional hex color string (e.g., '#FF6347').
|
||||
view_mode: Optional view mode ('list', 'kanban', 'timeline').
|
||||
kind: Optional project kind ('TASK' or 'NOTE').
|
||||
|
||||
Returns:
|
||||
TickTickResult with the created project data.
|
||||
"""
|
||||
if not name:
|
||||
return TickTickResult(success=False, error="name is required")
|
||||
|
||||
body: dict = {"name": name}
|
||||
if color is not None:
|
||||
body["color"] = color
|
||||
if view_mode is not None:
|
||||
body["viewMode"] = view_mode
|
||||
if kind is not None:
|
||||
body["kind"] = kind
|
||||
|
||||
logger.info("[INFO] Creating TickTick project: %s", name)
|
||||
return await self._request("POST", "/project", json_body=body)
|
||||
|
||||
async def update_project(
|
||||
self,
|
||||
project_id: str,
|
||||
name: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
view_mode: Optional[str] = None,
|
||||
) -> TickTickResult:
|
||||
"""
|
||||
Update an existing project in TickTick.
|
||||
|
||||
Args:
|
||||
project_id: The TickTick project ID to update.
|
||||
name: Optional new project name.
|
||||
color: Optional new hex color string.
|
||||
view_mode: Optional new view mode.
|
||||
|
||||
Returns:
|
||||
TickTickResult with the updated project data.
|
||||
"""
|
||||
if not project_id:
|
||||
return TickTickResult(success=False, error="project_id is required")
|
||||
|
||||
body: dict = {}
|
||||
if name is not None:
|
||||
body["name"] = name
|
||||
if color is not None:
|
||||
body["color"] = color
|
||||
if view_mode is not None:
|
||||
body["viewMode"] = view_mode
|
||||
|
||||
if not body:
|
||||
return TickTickResult(
|
||||
success=False, error="At least one field to update is required"
|
||||
)
|
||||
|
||||
logger.info("[INFO] Updating TickTick project %s", project_id)
|
||||
return await self._request(
|
||||
"POST", f"/project/{project_id}", json_body=body
|
||||
)
|
||||
|
||||
async def delete_project(self, project_id: str) -> TickTickResult:
|
||||
"""
|
||||
Delete a project from TickTick.
|
||||
|
||||
Args:
|
||||
project_id: The TickTick project ID to delete.
|
||||
|
||||
Returns:
|
||||
TickTickResult with success status.
|
||||
"""
|
||||
if not project_id:
|
||||
return TickTickResult(success=False, error="project_id is required")
|
||||
|
||||
logger.info("[INFO] Deleting TickTick project %s", project_id)
|
||||
return await self._request("DELETE", f"/project/{project_id}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Task operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def create_task(
|
||||
self,
|
||||
title: str,
|
||||
project_id: str,
|
||||
content: Optional[str] = None,
|
||||
priority: Optional[int] = None,
|
||||
due_date: Optional[str] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
) -> TickTickResult:
|
||||
"""
|
||||
Create a new task in a TickTick project.
|
||||
|
||||
Args:
|
||||
title: Task title.
|
||||
project_id: ID of the project to create the task in.
|
||||
content: Optional task description/content.
|
||||
priority: Optional priority (0=none, 1=low, 3=medium, 5=high).
|
||||
due_date: Optional due date in ISO 8601 format.
|
||||
tags: Optional list of tag strings.
|
||||
|
||||
Returns:
|
||||
TickTickResult with the created task data.
|
||||
"""
|
||||
if not title:
|
||||
return TickTickResult(success=False, error="title is required")
|
||||
if not project_id:
|
||||
return TickTickResult(success=False, error="project_id is required")
|
||||
|
||||
body: dict = {"title": title, "projectId": project_id}
|
||||
if content is not None:
|
||||
body["content"] = content
|
||||
if priority is not None:
|
||||
body["priority"] = priority
|
||||
if due_date is not None:
|
||||
body["dueDate"] = due_date
|
||||
if tags is not None:
|
||||
body["tags"] = tags
|
||||
|
||||
logger.info(
|
||||
"[INFO] Creating TickTick task '%s' in project %s",
|
||||
title,
|
||||
project_id,
|
||||
)
|
||||
return await self._request("POST", "/task", json_body=body)
|
||||
|
||||
async def update_task(
|
||||
self,
|
||||
task_id: str,
|
||||
project_id: str,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
priority: Optional[int] = None,
|
||||
due_date: Optional[str] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
) -> TickTickResult:
|
||||
"""
|
||||
Update an existing task in TickTick.
|
||||
|
||||
Args:
|
||||
task_id: The task ID to update.
|
||||
project_id: The project ID containing the task.
|
||||
title: Optional new task title.
|
||||
content: Optional new task content.
|
||||
priority: Optional new priority level.
|
||||
due_date: Optional new due date in ISO 8601 format.
|
||||
tags: Optional new list of tags.
|
||||
|
||||
Returns:
|
||||
TickTickResult with the updated task data.
|
||||
"""
|
||||
if not task_id:
|
||||
return TickTickResult(success=False, error="task_id is required")
|
||||
if not project_id:
|
||||
return TickTickResult(success=False, error="project_id is required")
|
||||
|
||||
body: dict = {"id": task_id, "projectId": project_id}
|
||||
if title is not None:
|
||||
body["title"] = title
|
||||
if content is not None:
|
||||
body["content"] = content
|
||||
if priority is not None:
|
||||
body["priority"] = priority
|
||||
if due_date is not None:
|
||||
body["dueDate"] = due_date
|
||||
if tags is not None:
|
||||
body["tags"] = tags
|
||||
|
||||
logger.info(
|
||||
"[INFO] Updating TickTick task %s in project %s",
|
||||
task_id,
|
||||
project_id,
|
||||
)
|
||||
return await self._request(
|
||||
"POST", f"/task/{task_id}", json_body=body
|
||||
)
|
||||
|
||||
async def complete_task(
|
||||
self, task_id: str, project_id: str
|
||||
) -> TickTickResult:
|
||||
"""
|
||||
Mark a task as complete in TickTick.
|
||||
|
||||
Args:
|
||||
task_id: The task ID to complete.
|
||||
project_id: The project ID containing the task.
|
||||
|
||||
Returns:
|
||||
TickTickResult with success status.
|
||||
"""
|
||||
if not task_id:
|
||||
return TickTickResult(success=False, error="task_id is required")
|
||||
if not project_id:
|
||||
return TickTickResult(success=False, error="project_id is required")
|
||||
|
||||
logger.info(
|
||||
"[INFO] Completing TickTick task %s in project %s",
|
||||
task_id,
|
||||
project_id,
|
||||
)
|
||||
return await self._request(
|
||||
"POST", f"/project/{project_id}/task/{task_id}/complete"
|
||||
)
|
||||
|
||||
async def delete_task(
|
||||
self, task_id: str, project_id: str
|
||||
) -> TickTickResult:
|
||||
"""
|
||||
Delete a task from TickTick.
|
||||
|
||||
Args:
|
||||
task_id: The task ID to delete.
|
||||
project_id: The project ID containing the task.
|
||||
|
||||
Returns:
|
||||
TickTickResult with success status.
|
||||
"""
|
||||
if not task_id:
|
||||
return TickTickResult(success=False, error="task_id is required")
|
||||
if not project_id:
|
||||
return TickTickResult(success=False, error="project_id is required")
|
||||
|
||||
logger.info(
|
||||
"[INFO] Deleting TickTick task %s from project %s",
|
||||
task_id,
|
||||
project_id,
|
||||
)
|
||||
return await self._request(
|
||||
"DELETE", f"/project/{project_id}/task/{task_id}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Singleton accessor
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_ticktick_service: Optional[TickTickService] = None
|
||||
|
||||
|
||||
def get_ticktick_service() -> TickTickService:
|
||||
"""
|
||||
Return a singleton TickTickService instance.
|
||||
|
||||
Creates the service on first call, reuses it thereafter.
|
||||
|
||||
Returns:
|
||||
The shared TickTickService instance.
|
||||
"""
|
||||
global _ticktick_service
|
||||
if _ticktick_service is None:
|
||||
_ticktick_service = TickTickService()
|
||||
return _ticktick_service
|
||||
Reference in New Issue
Block a user