import os import sys import json import time import getpass from collections import defaultdict import requests from datetime import datetime, timedelta from todoist_api_python.api import TodoistAPI from jinja2 import Environment, FileSystemLoader, select_autoescape ATTACHMENTS_DIR = "attachments" TODOIST_API_TOKEN: str | None = None COMPLETED_HISTORY_FILE = "Todoist-Completed-History.json" def json_serial(obj): if isinstance(obj, datetime): return obj.isoformat() return str(obj) def usage(): print(""" Todoist Export Script --------------------- Exports all active and completed tasks from the Todoist API to a JSON file, including attachments and comments, and generates a human-readable HTML backup using Jinja2. Usage: python export_todoist.py export - Exports all data and generates JSON and HTML files. python export_todoist.py [any other argument or none] - Shows this help message. """) def get_api_key(): key = os.environ.get("TODOIST_KEY") if not key: try: key = getpass.getpass("The TODOIST_KEY environment variable is not set. Enter TODOIST API key to continue: ").strip() except (EOFError, KeyboardInterrupt): print("\nError: TODOIST API key is required.") sys.exit(1) if not key: print("Error: TODOIST API key is required.") sys.exit(1) os.environ["TODOIST_KEY"] = key return key def ensure_attachments_dir(): if not os.path.exists(ATTACHMENTS_DIR): os.makedirs(ATTACHMENTS_DIR) def load_completed_history(): if not os.path.exists(COMPLETED_HISTORY_FILE): return {} try: with open(COMPLETED_HISTORY_FILE, "r", encoding="utf-8") as handle: data = json.load(handle) except (OSError, json.JSONDecodeError) as exc: # pylint: disable=broad-except print(f"Warning: failed to load completed history ({exc}). Starting fresh.") return {} if isinstance(data, dict): history = {} for key, value in data.items(): if isinstance(value, list): history[str(key)] = value return history if isinstance(data, list): history = defaultdict(list) for item in data: if isinstance(item, dict): project_id = str(item.get("project_id", "")) if project_id: history[project_id].append(item) return {key: value for key, value in history.items()} return {} def save_completed_history(history): try: with open(COMPLETED_HISTORY_FILE, "w", encoding="utf-8") as handle: json.dump(history, handle, ensure_ascii=False, indent=2, default=json_serial) except OSError as exc: # pylint: disable=broad-except print(f"Warning: failed to write completed history ({exc}).") def merge_completed_lists(history_tasks, new_tasks): merged = [] seen = set() def make_key(task): task_id = str(task.get('id', '')) completed_at = task.get('completed_at') or task.get('updated_at') or "" return (task_id, completed_at) def add_task(task): key = make_key(task) if key in seen: return seen.add(key) merged.append(task) for item in new_tasks: add_task(item) for item in history_tasks: add_task(item) def sort_key(task): completed_at = task.get('completed_at') or "" updated_at = task.get('updated_at') or "" return (completed_at, updated_at) merged.sort(key=sort_key, reverse=True) return merged def _file_looks_like_html(path): try: with open(path, 'rb') as handle: prefix = handle.read(256) except OSError: return False if not prefix: return True snippet = prefix.lstrip().lower() return snippet.startswith(b"