Skip to content

Instantly share code, notes, and snippets.

@kumekay
Created May 18, 2025 21:02
Show Gist options
  • Save kumekay/dc469d4bfb30e7e4905fa9b890fef9b1 to your computer and use it in GitHub Desktop.
Save kumekay/dc469d4bfb30e7e4905fa9b890fef9b1 to your computer and use it in GitHub Desktop.
Export Microsoft To-Do tasks to todo.txt text format
import msal
import requests
import json
import os
import atexit
from dotenv import load_dotenv
from datetime import datetime
# --- Configuration ---
# Load environment variables from .env file
load_dotenv()
# Application (client) ID from Azure AD App Registration (read from .env)
CLIENT_ID = os.getenv("CLIENT_ID")
# SCOPES: Tasks.Read is sufficient to read all tasks and lists the user has access to.
SCOPES = ["Tasks.Read"]
AUTHORITY = "https://login.microsoftonline.com/common"
GRAPH_API_ENDPOINT = "https://graph.microsoft.com/v1.0"
# Cache file for MSAL tokens
CACHE_FILENAME = "ms_todo_token_cache.bin"
OUTPUT_FILENAME = "todo.txt"
# --- End Configuration ---
def get_access_token():
"""Authenticates the user and returns an access token."""
cache = msal.SerializableTokenCache()
if os.path.exists(CACHE_FILENAME):
cache.deserialize(open(CACHE_FILENAME, "r").read())
atexit.register(
lambda: open(CACHE_FILENAME, "w").write(cache.serialize())
if cache.has_state_changed
else None
)
app = msal.PublicClientApplication(
CLIENT_ID, authority=AUTHORITY, token_cache=cache
)
accounts = app.get_accounts()
result = None
if accounts:
print("Attempting to acquire token silently...")
result = app.acquire_token_silent(SCOPES, account=accounts[0])
if not result:
print(
"No cached token found or silent acquisition failed. Starting interactive login."
)
flow = app.initiate_device_flow(scopes=SCOPES)
if "user_code" not in flow:
raise ValueError(
"Failed to create device flow. Err: %s" % json.dumps(flow.get("error"))
)
print(flow["message"])
result = app.acquire_token_by_device_flow(flow)
if "access_token" in result:
return result["access_token"]
else:
print("Error acquiring token:")
print(result.get("error"))
print(result.get("error_description"))
print(result.get("correlation_id"))
raise Exception("Authentication failed.")
def get_date_from_graph_datetime(graph_datetime_str: str | None) -> str | None:
"""
Parses a datetime string from Graph API (ISO 8601 format) and returns YYYY-MM-DD.
Handles potential variations in microsecond precision for wider Python compatibility.
"""
if not graph_datetime_str:
return None
try:
dt_str = graph_datetime_str
# Truncate microseconds to 6 digits if they are longer, for Python < 3.11 compatibility
if "." in dt_str:
main_part, fractional_part = dt_str.split(".", 1)
micro_digits = ""
idx = 0
while idx < len(fractional_part) and fractional_part[idx].isdigit():
micro_digits += fractional_part[idx]
idx += 1
suffix = fractional_part[idx:] # e.g., "Z", "+01:00", or ""
if len(micro_digits) > 6:
micro_digits = micro_digits[:6] # Truncate
if micro_digits: # Only add dot and microseconds if they exist
dt_str = f"{main_part}.{micro_digits}{suffix}"
else: # No microsecond digits after dot, or dot was followed by non-digits
dt_str = f"{main_part}{suffix}"
# datetime.fromisoformat in Python 3.7+ handles 'Z' by parsing it as UTC.
dt_obj = datetime.fromisoformat(dt_str)
return dt_obj.strftime("%Y-%m-%d")
except Exception as e:
print(
f"Warning: Could not parse date from '{graph_datetime_str}' (Error: {e}). Trying direct extraction."
)
# Fallback: try to extract YYYY-MM-DD directly if full parsing fails
if (
len(graph_datetime_str) >= 10
and graph_datetime_str[4] == "-"
and graph_datetime_str[7] == "-"
):
return graph_datetime_str[:10]
print(f"Error: Failed to parse date: {graph_datetime_str}. Full error: {e}")
return None
def format_task_for_todotxt(task_obj: dict, list_name: str) -> str | None:
"""Formats a Microsoft To-Do task object into a todo.txt string."""
parts = []
is_completed = task_obj.get("status") == "completed"
title = task_obj.get("title", "").strip()
if not title:
print(
f"Warning: Skipping task without a title in list '{list_name}'. Task ID: {task_obj.get('id')}"
)
return None
if is_completed:
parts.append("x")
completion_date = get_date_from_graph_datetime(
task_obj.get("completedDateTime", {}).get("dateTime")
)
if completion_date:
parts.append(completion_date)
# Add creation date after completion date for completed tasks, if available
creation_date_completed = get_date_from_graph_datetime(
task_obj.get("createdDateTime")
)
if creation_date_completed:
parts.append(creation_date_completed)
parts.append(title)
else: # Incomplete task
# Priority
priority_str = None
importance = task_obj.get("importance")
if importance == "high":
priority_str = "(A)"
elif importance == "normal":
priority_str = "(B)"
elif importance == "low":
priority_str = "(C)"
if priority_str:
parts.append(priority_str)
# Creation Date
creation_date_incomplete = get_date_from_graph_datetime(
task_obj.get("createdDateTime")
)
if creation_date_incomplete:
parts.append(creation_date_incomplete)
parts.append(title)
# Project (from list name, sanitized)
if list_name:
# Remove spaces and common separators to make it a single "word" for todo.txt project tag
project_name_sanitized = "".join(char for char in list_name if char.isalnum())
if project_name_sanitized: # Ensure not empty after sanitizing
parts.append(f"+{project_name_sanitized}")
# Due Date (key:value)
due_date_obj = task_obj.get("dueDateTime")
if due_date_obj and due_date_obj.get("dateTime"):
due_date = get_date_from_graph_datetime(due_date_obj.get("dateTime"))
if due_date:
parts.append(f"due:{due_date}")
return " ".join(filter(None, parts))
def get_all_task_lists(access_token: str) -> list:
"""Fetches all To-Do task lists from Microsoft Graph API."""
headers = {"Authorization": "Bearer " + access_token}
all_lists = []
url = f"{GRAPH_API_ENDPOINT}/me/todo/lists?$top=100" # Max 100 lists per page
print("Fetching task lists...")
while url:
response = requests.get(url, headers=headers)
response.raise_for_status()
data = response.json()
fetched_lists = data.get("value", [])
all_lists.extend(fetched_lists)
for lst in fetched_lists:
print(f" Found list: {lst.get('displayName', 'Unknown List')}")
url = data.get("@odata.nextLink")
print(f"Found {len(all_lists)} task list(s).")
return all_lists
def get_tasks_from_list(access_token: str, list_id: str, list_name: str) -> list:
"""Gets all tasks from the specified list, handling pagination."""
headers = {"Authorization": "Bearer " + access_token}
all_tasks = []
url = f"{GRAPH_API_ENDPOINT}/me/todo/lists/{list_id}/tasks?$top=100"
print(f"Fetching tasks from list: '{list_name}'...")
while url:
response = requests.get(url, headers=headers)
response.raise_for_status()
data = response.json()
all_tasks.extend(data.get("value", []))
url = data.get("@odata.nextLink")
print(f" Fetched {len(all_tasks)} tasks from '{list_name}'.")
return all_tasks
def main():
"""Main function to authenticate, fetch tasks, format, and export."""
if not CLIENT_ID:
print("Error: CLIENT_ID not found. Make sure it's set in your .env file.")
return
token = get_access_token()
print("Successfully obtained access token.\n")
task_lists = get_all_task_lists(token)
if not task_lists:
print("No task lists found.")
return
all_formatted_tasks = []
for lst in task_lists:
list_id = lst["id"]
# Use displayName as it's user-visible; 'wellknownListName' is for special lists
list_name = lst.get("displayName", f"List_{list_id}")
tasks = get_tasks_from_list(token, list_id, list_name)
for task_obj in tasks:
formatted_task = format_task_for_todotxt(task_obj, list_name)
if formatted_task:
all_formatted_tasks.append(formatted_task)
if not all_formatted_tasks:
print("\nNo tasks found to export.")
return
with open(OUTPUT_FILENAME, "w", encoding="utf-8") as f:
for task_line in all_formatted_tasks:
f.write(task_line + "\n")
print(
f"\nSuccessfully exported {len(all_formatted_tasks)} tasks to {OUTPUT_FILENAME}"
)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment