Created
May 18, 2025 21:02
-
-
Save kumekay/dc469d4bfb30e7e4905fa9b890fef9b1 to your computer and use it in GitHub Desktop.
Export Microsoft To-Do tasks to todo.txt text format
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 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