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()