"""
TickTick Notes Exporter - Extensible class-based implementation
"""
import os
from datetime import datetime
from abc import ABC, abstractmethod
from typing import List, Dict, Optional
from src.api.ticktick_api import TickTickAPI, TickTickOAuth
from dotenv import load_dotenv
load_dotenv()
class BaseExporter(ABC):
"""Abstract base class for different export formats"""
@abstractmethod
def export(self, tasks: List[Dict], output_file: str, metadata: Dict):
"""Export tasks to a file"""
pass
class MarkdownExporter(BaseExporter):
"""Export tasks to Markdown format"""
def extract_date_from_title(self, title: str) -> Optional[str]:
"""Extract date from title if present (e.g., '2025-07-20 Karla Call')"""
import re
# Match YYYY-MM-DD pattern at the start of the title (with optional leading whitespace)
match = re.match(r'^\s*(\d{4}-\d{2}-\d{2})', title)
if match:
try:
# Parse the date
dt = datetime.strptime(match.group(1), "%Y-%m-%d")
return dt.strftime("%B %d, %Y")
except:
return None
return None
def format_date(self, date_string: str) -> str:
"""Convert TickTick date format to readable format"""
if not date_string:
return "No date"
try:
dt = datetime.fromisoformat(date_string.replace('+0000', '+00:00'))
return dt.strftime("%B %d, %Y at %I:%M %p")
except:
return date_string
def export(self, tasks: List[Dict], output_file: str, metadata: Dict):
"""Export tasks to markdown file"""
tag_name = metadata.get('tag_name', 'Notes')
with open(output_file, 'w', encoding='utf-8') as f:
# Header
f.write(f"# {metadata.get('title', f'{tag_name.title()} Notes')}\n\n")
f.write(f"Exported on {datetime.now().strftime('%B %d, %Y at %I:%M %p')}\n\n")
f.write(f"Total notes: {len(tasks)}\n\n")
f.write("---\n\n")
# Sort tasks by date from title, or creation date if available (most recent first)
def get_sort_key(task):
"""Get sorting key - prefer date from title, fall back to createdTime"""
import re
title = task.get('title', '')
match = re.match(r'^\s*(\d{4}-\d{2}-\d{2})', title)
if match:
return match.group(1) # Return YYYY-MM-DD for sorting
return task.get('createdTime', '')
sorted_tasks = sorted(
tasks,
key=get_sort_key,
reverse=True
)
for task in sorted_tasks:
title = task.get('title', 'Untitled')
content = task.get('content', '')
created_time = task.get('createdTime', '')
modified_time = task.get('modifiedTime', '')
# Try to extract date from title first (for journal entries like "2025-07-20 Karla Call")
date_from_title = self.extract_date_from_title(title)
if date_from_title:
formatted_date = date_from_title
else:
# Fall back to API dates if available
date = modified_time if modified_time else created_time
formatted_date = self.format_date(date)
# Write task
f.write(f"## {title}\n\n")
f.write(f"**Date:** {formatted_date}\n\n")
if content:
f.write(f"{content}\n\n")
else:
f.write("*No content*\n\n")
f.write("---\n\n")
class JSONExporter(BaseExporter):
"""Export tasks to JSON format"""
def export(self, tasks: List[Dict], output_file: str, metadata: Dict):
"""Export tasks to JSON file"""
import json
export_data = {
'metadata': {
'exported_at': datetime.now().isoformat(),
'total_tasks': len(tasks),
**metadata
},
'tasks': tasks
}
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(export_data, f, indent=2, ensure_ascii=False)
class NotesManager:
"""Main class to manage TickTick notes and exports"""
def __init__(self, access_token: Optional[str] = None):
"""
Initialize the NotesManager
Args:
access_token: TickTick access token (optional, will try to get from env)
"""
self.access_token = access_token or os.getenv('TICKTICK_ACCESS_TOKEN')
self.api = None
self.projects = None
if self.access_token:
self.api = TickTickAPI(self.access_token)
def authenticate(self) -> bool:
"""
Authenticate with TickTick (OAuth flow if needed)
Returns:
True if authentication successful, False otherwise
"""
if self.access_token:
print("β
Using existing access token")
# Verify the token is valid
if self.api and self.api.get_user_info():
return True
else:
print("β Token is invalid")
return False
print("No access token found. Starting OAuth flow...\n")
oauth = TickTickOAuth()
# Get authorization URL
print("="*60)
print("STEP 1: Get Authorization")
print("="*60)
auth_url = oauth.get_authorization_url()
print(f"\nπ Click or copy this URL to your browser:\n\n{auth_url}\n")
# Get authorization code
print("="*60)
print("STEP 2: Authorize and Get Code")
print("="*60)
print("After authorization, you'll be redirected to:")
print("http://localhost:8000/auth/ticktick/callback?code=XXXXX&state=...\n")
authorization_code = input("Enter the authorization code: ").strip()
if not authorization_code:
print("β No code provided!")
return False
# Exchange code for token
print("\n" + "="*60)
print("STEP 3: Exchange Code for Token")
print("="*60)
token_data = oauth.exchange_code_for_token(authorization_code)
if token_data:
print("\nβ
SUCCESS! Got access token!")
self.access_token = token_data['access_token']
self.api = TickTickAPI(self.access_token)
# Save to .env
with open('.env', 'a') as f:
f.write(f"\nTICKTICK_ACCESS_TOKEN={self.access_token}\n")
print("β
Token saved to .env file!")
return True
else:
print("\nβ Failed to get access token")
return False
def get_projects(self, refresh: bool = False) -> List[Dict]:
"""
Get all projects
Args:
refresh: Force refresh projects from API
Returns:
List of project dictionaries
"""
if not self.api:
print("β Not authenticated!")
return []
if self.projects is None or refresh:
self.projects = self.api.get_projects()
return self.projects or []
def find_project(self, name: str) -> Optional[Dict]:
"""
Find a project by name (case-insensitive)
Args:
name: Project name to search for
Returns:
Project dictionary or None
"""
projects = self.get_projects()
for project in projects:
if name.lower() in project['name'].lower():
return project
return None
def get_tasks_by_tag(self, project_id: str, tag_name: str) -> List[Dict]:
"""
Get all tasks with a specific tag from a project
Args:
project_id: Project ID to search in
tag_name: Tag name to filter by
Returns:
List of task dictionaries
"""
if not self.api:
print("β Not authenticated!")
return []
project_data = self.api.get_projects_data(project_id)
if not project_data:
return []
all_tasks = project_data.get('tasks', [])
# Filter by tag (case-insensitive)
filtered_tasks = [
task for task in all_tasks
if tag_name.lower() in [t.lower() for t in task.get('tags', [])]
]
return filtered_tasks
def get_all_tasks_by_tag(self, tag_name: str) -> List[Dict]:
"""
Get all tasks with a specific tag across ALL projects
Args:
tag_name: Tag name to filter by
Returns:
List of task dictionaries with project info attached
"""
if not self.api:
print("β Not authenticated!")
return []
projects = self.get_projects()
all_matching_tasks = []
for project in projects:
project_data = self.api.get_projects_data(project['id'])
if not project_data:
continue
tasks = project_data.get('tasks', [])
# Filter by tag (case-insensitive) and add project info
for task in tasks:
if tag_name.lower() in [t.lower() for t in task.get('tags', [])]:
task['_project_name'] = project['name']
task['_project_id'] = project['id']
all_matching_tasks.append(task)
return all_matching_tasks
def get_tasks_by_project(self, project_id: str) -> List[Dict]:
"""
Get all tasks from a project
Args:
project_id: Project ID
Returns:
List of task dictionaries
"""
if not self.api:
print("β Not authenticated!")
return []
project_data = self.api.get_projects_data(project_id)
if not project_data:
return []
return project_data.get('tasks', [])
def export_by_tag(
self,
tag_name: str,
output_file: str = "notes.md",
project_name: Optional[str] = None,
exporter: Optional[BaseExporter] = None
) -> bool:
"""
Export tasks with a specific tag
Args:
tag_name: Tag to filter by
output_file: Output filename
project_name: Project name to search in (optional - if None, searches all projects)
exporter: Exporter instance (default: MarkdownExporter)
Returns:
True if successful, False otherwise
"""
if not self.api:
print("β Not authenticated!")
return False
if project_name:
# Search within specific project
project = self.find_project(project_name)
if not project:
print(f"β Project '{project_name}' not found")
return False
print(f"π Searching in project: {project['name']}")
tasks = self.get_tasks_by_tag(project['id'], tag_name)
metadata = {
'tag_name': tag_name,
'project_name': project['name'],
'project_id': project['id'],
'title': f"{tag_name.title()} Notes"
}
else:
# Search across ALL projects
print(f"π Searching for tag '{tag_name}' across all projects...")
tasks = self.get_all_tasks_by_tag(tag_name)
# Group by project for display
projects_found = set(task.get('_project_name', 'Unknown') for task in tasks)
if projects_found:
print(f"π Found in projects: {', '.join(projects_found)}")
metadata = {
'tag_name': tag_name,
'project_name': 'All Projects',
'title': f"{tag_name.title()} Notes (All Projects)"
}
print(f"Found {len(tasks)} tasks with tag '{tag_name}'")
if not tasks:
print(f"β οΈ No tasks found with tag '{tag_name}'")
return False
# Export
if exporter is None:
exporter = MarkdownExporter()
exporter.export(tasks, output_file, metadata)
print(f"β
Successfully exported {len(tasks)} notes to {output_file}")
return True
def export_project(
self,
project_name: str,
output_file: str = "notes.md",
exporter: Optional[BaseExporter] = None
) -> bool:
"""
Export all tasks from a project
Args:
project_name: Project name
output_file: Output filename
exporter: Exporter instance (default: MarkdownExporter)
Returns:
True if successful, False otherwise
"""
if not self.api:
print("β Not authenticated!")
return False
# Find project
project = self.find_project(project_name)
if not project:
print(f"β Project '{project_name}' not found")
return False
print(f"π Using project: {project['name']}")
# Get tasks
tasks = self.get_tasks_by_project(project['id'])
print(f"Found {len(tasks)} tasks in project")
if not tasks:
print(f"β οΈ No tasks found in project")
return False
# Export
if exporter is None:
exporter = MarkdownExporter()
metadata = {
'project_name': project['name'],
'project_id': project['id'],
'title': f"{project['name']} - All Notes"
}
exporter.export(tasks, output_file, metadata)
print(f"β
Successfully exported {len(tasks)} notes to {output_file}")
return True
def list_projects(self):
"""Print all available projects"""
projects = self.get_projects()
if not projects:
print("β No projects found")
return
print("\nYour projects:")
for project in projects:
print(f" - {project['name']} (ID: {project['id']})")
def list_tags(self, project_name: Optional[str] = None):
"""
List all unique tags in a project or across all projects
Args:
project_name: Project name to search in (optional - if None, lists tags across all projects)
"""
if project_name:
project = self.find_project(project_name)
if not project:
print(f"β Project '{project_name}' not found")
return
tasks = self.get_tasks_by_project(project['id'])
print(f"\nTags in '{project['name']}':")
else:
# Get tasks from all projects
projects = self.get_projects()
tasks = []
for proj in projects:
project_data = self.api.get_projects_data(proj['id'])
if project_data:
tasks.extend(project_data.get('tasks', []))
print(f"\nTags across all projects:")
# Collect all unique tags
all_tags = set()
for task in tasks:
all_tags.update(task.get('tags', []))
if not all_tags:
print(" No tags found")
return
for tag in sorted(all_tags):
# Count tasks with this tag
count = sum(1 for task in tasks if tag in task.get('tags', []))
print(f" - {tag} ({count} tasks)")
def main():
"""Main interactive CLI"""
print("="*60)
print("TickTick Notes Exporter")
print("="*60)
# Initialize manager
manager = NotesManager()
# Authenticate
if not manager.authenticate():
print("β Authentication failed")
return
# Show projects
print("\n" + "="*60)
print("Your Projects")
print("="*60)
manager.list_projects()
# Interactive menu
while True:
print("\n" + "="*60)
print("What would you like to do?")
print("="*60)
print("1. Export notes by tag")
print("2. Export entire project")
print("3. List tags in a project")
print("4. Exit")
choice = input("\nEnter choice (1-4): ").strip()
if choice == "1":
# Export by tag
tag_name = input("Enter tag name: ").strip()
project_name = input("Enter project name (leave empty to search ALL projects): ").strip() or None
output_file = input("Enter output filename (default: notes.md): ").strip() or "notes.md"
# Choose format
format_choice = input("Export format (1=Markdown, 2=JSON, default=1): ").strip() or "1"
if format_choice == "2":
exporter = JSONExporter()
if not output_file.endswith('.json'):
output_file = output_file.rsplit('.', 1)[0] + '.json'
else:
exporter = MarkdownExporter()
if not output_file.endswith('.md'):
output_file = output_file.rsplit('.', 1)[0] + '.md'
manager.export_by_tag(tag_name, output_file, project_name, exporter)
elif choice == "2":
# Export entire project
project_name = input("Enter project name: ").strip()
output_file = input("Enter output filename (default: notes.md): ").strip() or "notes.md"
# Choose format
format_choice = input("Export format (1=Markdown, 2=JSON, default=1): ").strip() or "1"
if format_choice == "2":
exporter = JSONExporter()
if not output_file.endswith('.json'):
output_file = output_file.rsplit('.', 1)[0] + '.json'
else:
exporter = MarkdownExporter()
if not output_file.endswith('.md'):
output_file = output_file.rsplit('.', 1)[0] + '.md'
manager.export_project(project_name, output_file, exporter)
elif choice == "3":
# List tags
project_name = input("Enter project name (leave empty to list ALL tags): ").strip() or None
manager.list_tags(project_name)
elif choice == "4":
print("\nβ
Done!")
break
else:
print("Invalid choice, please try again")
if __name__ == "__main__":
main()