Created
August 23, 2025 14:03
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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