diff --git a/README.md b/README.md index 314761f..70a4af2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Todoist is a SaaS task manager. Todoist provides backups of current tasks, but d - Exports all active and completed tasks from all projects (active and archived) - Nests tasks under their respective projects, including all available fields - Includes comments for each task -- Downloads attachments and references them in the JSON and HTML output +- Downloads attachments to `output/attachments/` and references them in the JSON and HTML output - JSON and HTML files are named with the current date when the script is run - Maintains `Todoist-Completed-History.json` so completed tasks older than Todoist's 90-day API window stay in future exports @@ -26,8 +26,8 @@ Todoist is a SaaS task manager. Todoist provides backups of current tasks, but d ```bash python export_todoist.py export ``` - This will create `Todoist-Actual-Backup-YYYY-MM-DD.json` and `Todoist-Actual-Backup-YYYY-MM-DD.html` in the current directory, and it will update `Todoist-Completed-History.json` with every completed task encountered. - Keep `Todoist-Completed-History.json` somewhere safe (e.g., in source control or a backup location); it is the only way the exporter can retain completions older than Todoist's 90-day API retention window. + This will create `output/Todoist-Actual-Backup-YYYY-MM-DD.json` and `output/Todoist-Actual-Backup-YYYY-MM-DD.html`, and it will update `output/attachments/` with any downloaded files while leaving `Todoist-Completed-History.json` in the project root. + Keep `Todoist-Completed-History.json` somewhere safe (e.g., in source control or a backup location); it is the only way the exporter can retain completions older than Todoist's 90-day API retention window. 4. To see usage instructions, run the script with no arguments or any argument other than `export`. ## Requirements diff --git a/export_todoist.py b/export_todoist.py index 9203996..96555bb 100644 --- a/export_todoist.py +++ b/export_todoist.py @@ -3,13 +3,16 @@ import sys import json import time import getpass +import shutil 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" +OUTPUT_DIR = "output" +ATTACHMENTS_DIR = os.path.join(OUTPUT_DIR, "attachments") +LEGACY_ATTACHMENTS_DIR = "attachments" TODOIST_API_TOKEN: str | None = None COMPLETED_HISTORY_FILE = "Todoist-Completed-History.json" @@ -49,9 +52,29 @@ def get_api_key(): return key +def ensure_output_dir(): + if not os.path.exists(OUTPUT_DIR): + os.makedirs(OUTPUT_DIR, exist_ok=True) + + def ensure_attachments_dir(): + ensure_output_dir() + if os.path.isdir(LEGACY_ATTACHMENTS_DIR) and LEGACY_ATTACHMENTS_DIR != ATTACHMENTS_DIR: + try: + if not os.path.exists(ATTACHMENTS_DIR): + shutil.move(LEGACY_ATTACHMENTS_DIR, ATTACHMENTS_DIR) + else: + for name in os.listdir(LEGACY_ATTACHMENTS_DIR): + shutil.move( + os.path.join(LEGACY_ATTACHMENTS_DIR, name), + os.path.join(ATTACHMENTS_DIR, name), + ) + os.rmdir(LEGACY_ATTACHMENTS_DIR) + print(f"Moved legacy attachments into {ATTACHMENTS_DIR}") + except (OSError, shutil.Error) as exc: # pylint: disable=broad-except + print(f"Warning: failed to migrate legacy attachments: {exc}") if not os.path.exists(ATTACHMENTS_DIR): - os.makedirs(ATTACHMENTS_DIR) + os.makedirs(ATTACHMENTS_DIR, exist_ok=True) def load_completed_history(): @@ -88,13 +111,23 @@ def save_completed_history(history): print(f"Warning: failed to write completed history ({exc}).") +def normalize_timestamp(value): + if not value: + return "" + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + 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 "" + completed_at = normalize_timestamp(task.get('completed_at')) + if not completed_at: + completed_at = normalize_timestamp(task.get('updated_at')) return (task_id, completed_at) def add_task(task): @@ -110,8 +143,8 @@ def merge_completed_lists(history_tasks, new_tasks): add_task(item) def sort_key(task): - completed_at = task.get('completed_at') or "" - updated_at = task.get('updated_at') or "" + completed_at = normalize_timestamp(task.get('completed_at')) + updated_at = normalize_timestamp(task.get('updated_at')) return (completed_at, updated_at) merged.sort(key=sort_key, reverse=True) @@ -307,7 +340,7 @@ def process_task(task, comments_lookup): filename = att_dict.get('file_name') or os.path.basename(att_dict['file_url']) local_path = download_attachment(att_dict['file_url'], filename) if local_path: - att_dict['local_file'] = os.path.relpath(local_path) + att_dict['local_file'] = os.path.relpath(local_path, OUTPUT_DIR) attachments.append(att_dict) if attachments: task_dict['attachments'] = attachments @@ -325,7 +358,7 @@ def process_task(task, comments_lookup): filename = attachment_dict.get("file_name") or os.path.basename(file_url) local_path = download_attachment(file_url, filename) if local_path: - attachment_dict['local_file'] = os.path.relpath(local_path) + attachment_dict['local_file'] = os.path.relpath(local_path, OUTPUT_DIR) comment_dict['attachment'] = attachment_dict serialized_comments.append(comment_dict) task_dict['comments'] = serialized_comments @@ -459,9 +492,10 @@ def main(): # Write JSON today = datetime.now().strftime("%Y-%m-%d") json_filename = f"Todoist-Actual-Backup-{today}.json" - with open(json_filename, "w", encoding="utf-8") as f: + json_output_path = os.path.join(OUTPUT_DIR, json_filename) + with open(json_output_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2, default=json_serial) - print(f"Exported data to {json_filename}") + print(f"Exported data to {json_output_path}") # Write HTML env = Environment( loader=FileSystemLoader(os.path.dirname(__file__)), @@ -475,9 +509,10 @@ def main(): env.filters['markdown'] = lambda text: text or "" template = env.get_template("todoist_backup_template.html") html_filename = f"Todoist-Actual-Backup-{today}.html" - with open(html_filename, "w", encoding="utf-8") as f: + html_output_path = os.path.join(OUTPUT_DIR, html_filename) + with open(html_output_path, "w", encoding="utf-8") as f: f.write(template.render(projects=data, date=today)) - print(f"Generated HTML backup at {html_filename}") + print(f"Generated HTML backup at {html_output_path}") if __name__ == "__main__": main()