Skip to content

Instantly share code, notes, and snippets.

@extratone
Created August 23, 2025 14:03
Show Gist options
  • Select an option

  • Save extratone/2c24a6ae70e431f809813213489940d9 to your computer and use it in GitHub Desktop.

Select an option

Save extratone/2c24a6ae70e431f809813213489940d9 to your computer and use it in GitHub Desktop.
A Google Gemini-authored Python script for comprehensively migrating from Things 3 to Todoist via extracted/converted database info and the Todoist API.
import pandas as pd
import requests
import json
import time
# --- Configuration ---
# IMPORTANT: Replace "YOUR_TODOIST_API_TOKEN" with your actual Todoist API token.
# You can find your token in Todoist Settings > Integrations > API token.
TODOIST_API_TOKEN = "{{YOUR API TOKEN}}"
THINGS_CSV_PATH = "TMTask.csv" # The path to your CSV file.
# --- Helper Functions ---
def get_todoist_headers():
"""Returns the authorization headers for the Todoist API."""
return {
"Authorization": f"Bearer {TODOIST_API_TOKEN}",
"Content-Type": "application/json",
}
def get_existing_todoist_items(item_type):
"""Fetches existing projects or labels from Todoist to avoid duplicates."""
url = f"https://api.todoist.com/rest/v2/{item_type}"
response = requests.get(url, headers=get_todoist_headers())
if response.status_code == 200:
return {item['name']: item['id'] for item in response.json()}
else:
print(f"Error fetching existing {item_type}: {response.text}")
return {}
def create_todoist_project(name):
"""Creates a new project in Todoist and returns its ID."""
url = "https://api.todoist.com/rest/v2/projects"
data = {"name": name}
response = requests.post(url, headers=get_todoist_headers(), data=json.dumps(data))
if response.status_code == 200:
return response.json()["id"]
else:
print(f"Error creating project '{name}': {response.text}")
return None
def create_todoist_section(name, project_id):
"""Creates a new section in a Todoist project and returns its ID."""
url = "https://api.todoist.com/rest/v2/sections"
data = {"name": name, "project_id": project_id}
response = requests.post(url, headers=get_todoist_headers(), data=json.dumps(data))
if response.status_code == 200:
return response.json()["id"]
else:
print(f"Error creating section '{name}': {response.text}")
return None
def create_todoist_label(name):
"""Creates a new label in Todoist and returns its ID."""
url = "https://api.todoist.com/rest/v2/labels"
data = {"name": name}
response = requests.post(url, headers=get_todoist_headers(), data=json.dumps(data))
if response.status_code == 200:
return response.json()["id"]
else:
print(f"Error creating label '{name}': {response.text}")
return None
def create_todoist_task(content, description, project_id, section_id, due_date, label_ids):
"""Creates a new task in Todoist and returns its ID."""
url = "https://api.todoist.com/rest/v2/tasks"
data = {
"content": content,
"description": description,
"project_id": project_id,
"section_id": section_id,
"due_date": due_date,
"label_ids": label_ids
}
# Remove keys with None values
data = {k: v for k, v in data.items() if v is not None}
response = requests.post(url, headers=get_todoist_headers(), data=json.dumps(data))
if response.status_code == 200:
return response.json()["id"]
else:
print(f"Error creating task '{content}': {response.text}")
return None
def complete_todoist_task(task_id):
"""Marks a task in Todoist as complete."""
url = f"https://api.todoist.com/rest/v2/tasks/{task_id}/close"
response = requests.post(url, headers=get_todoist_headers())
if response.status_code != 204:
print(f"Error completing task {task_id}: {response.text}")
def parse_cached_tags(cached_tag):
"""
Attempts to parse the 'cachedTags' field. This is an imperfect process
as the format is not officially documented.
"""
if pd.isna(cached_tag) or not isinstance(cached_tag, str):
return []
try:
# This is a guess based on the format seen in the CSV.
# It assumes the tags are stored in a binary-like format.
# We'll split by '⦙' and try to decode.
parts = cached_tag.split('⦙')
if len(parts) > 4:
# A very rough heuristic. This will need adjustment based on the actual data.
return [part for part in parts[4:] if part and 'binary' not in part]
except Exception as e:
print(f"Could not parse tag: {cached_tag}, error: {e}")
return []
# --- Main Migration Logic ---
def main():
"""The main function to run the Things 3 to Todoist migration."""
print("Starting Things 3 to Todoist migration...")
# Load and clean the data
try:
df = pd.read_csv(THINGS_CSV_PATH)
except FileNotFoundError:
print(f"Error: The file '{THINGS_CSV_PATH}' was not found.")
return
df_filtered = df[df['type'].isin([0, 1, 2])].copy()
df_filtered['type'] = df_filtered['type'].astype(int)
# Get existing Todoist data
print("Fetching existing data from Todoist...")
existing_projects = get_existing_todoist_items("projects")
existing_labels = get_existing_todoist_items("labels")
# Mappings from Things UUIDs to Todoist IDs
project_mapping = {}
section_mapping = {}
# --- Step 1: Create Projects ---
print("\n--- Migrating Projects ---")
projects_df = df_filtered[df_filtered['type'] == 1]
for _, row in projects_df.iterrows():
project_name = row['title']
project_uuid = row['uuid']
if project_name not in existing_projects:
print(f"Creating project: {project_name}")
new_project_id = create_todoist_project(project_name)
if new_project_id:
project_mapping[project_uuid] = new_project_id
time.sleep(0.5) # Be nice to the API
else:
print(f"Project '{project_name}' already exists in Todoist.")
project_mapping[project_uuid] = existing_projects[project_name]
# --- Step 2: Create Sections (Headings) ---
print("\n--- Migrating Sections (Headings) ---")
sections_df = df_filtered[df_filtered['type'] == 2]
for _, row in sections_df.iterrows():
section_name = row['title']
section_uuid = row['uuid']
parent_project_uuid = row['project']
if parent_project_uuid in project_mapping:
parent_project_id = project_mapping[parent_project_uuid]
print(f"Creating section: {section_name}")
new_section_id = create_todoist_section(section_name, parent_project_id)
if new_section_id:
section_mapping[section_uuid] = new_section_id
time.sleep(0.5)
else:
print(f"Warning: Could not find parent project for section '{section_name}'")
# --- Step 3: Create Labels (Tags) ---
print("\n--- Migrating Labels (Tags) ---")
all_tags = set()
tasks_df = df_filtered[df_filtered['type'] == 0]
for _, row in tasks_df.iterrows():
tags = parse_cached_tags(row['cachedTags'])
for tag in tags:
all_tags.add(tag)
for tag_name in all_tags:
if tag_name not in existing_labels:
print(f"Creating label: {tag_name}")
new_label_id = create_todoist_label(tag_name)
if new_label_id:
existing_labels[tag_name] = new_label_id
time.sleep(0.5)
else:
print(f"Label '{tag_name}' already exists in Todoist.")
# --- Step 4: Create Tasks ---
print("\n--- Migrating Tasks ---")
for _, row in tasks_df.iterrows():
task_title = row['title']
if pd.isna(task_title):
continue
print(f"Migrating task: {task_title}")
task_notes = row['notes'] if not pd.isna(row['notes']) else ""
project_uuid = row['project']
heading_uuid = row['heading']
project_id = project_mapping.get(project_uuid)
section_id = section_mapping.get(heading_uuid)
due_date = row['deadline'] if not pd.isna(row['deadline']) else None
tags = parse_cached_tags(row['cachedTags'])
label_ids = [existing_labels[tag] for tag in tags if tag in existing_labels]
new_task_id = create_todoist_task(task_title, task_notes, project_id, section_id, due_date, label_ids)
if new_task_id and row['status'] == 3.0:
print(f" - Marking task '{task_title}' as complete.")
complete_todoist_task(new_task_id)
time.sleep(0.5) # Rate limiting
print("\nMigration complete!")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment