From c4d7391aa5568ffcc84edde898c975c6e46868dd Mon Sep 17 00:00:00 2001 From: Matt Wiseley Date: Fri, 17 Oct 2025 07:25:07 -0400 Subject: [PATCH] Initial --- .github/copilot-instructions.md | 43 ++++++++ .gitignore | 31 ++++++ README.md | 39 ++++++++ export_todoist.py | 172 ++++++++++++++++++++++++++++++++ requirements.txt | 3 + todoist_backup_template.html | 92 +++++++++++++++++ 6 files changed, 380 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 README.md create mode 100644 export_todoist.py create mode 100644 requirements.txt create mode 100644 todoist_backup_template.html diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..352035c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,43 @@ + + +This project is a Python script that 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. + +# Copilot Workspace Instructions + +## Project Purpose +Export all active and completed tasks from Todoist, including attachments and comments, to a JSON file and a human-readable HTML file. Uses the official Todoist Python client and Jinja2 for HTML rendering. + +## Key Implementation Details +- Use the Todoist API via the official Python client (`todoist-api-python`). +- Fetch projects and tasks using the iterator objects returned by the API for paging. +- For completed tasks, use `get_completed_tasks_by_completion_date` for the last 3 months only (API limitation). +- For comments and tasks, always iterate over the returned iterator to collect all items. +- Download attachments to the `attachments/` folder and reference their local filenames in the JSON and HTML outputs. +- Tasks are nested under their project in the JSON output, with all available fields included. +- Comments are nested under each task as an array. +- Use a `.venv` Python environment for dependencies. +- Output files are named `Todoist-Actual-Backup-YYYY-MM-DD.json` and `.html` (current date). +- The script is run with the `export` argument; any other argument or none shows usage instructions. +- Handle datetime serialization for JSON output using a custom function. + +## File Structure +- `export_todoist.py`: Main script for export logic. +- `requirements.txt`: Lists dependencies (`todoist-api-python`, `Jinja2`, `requests`). +- `todoist_backup_template.html`: Jinja2 template for HTML output. +- `attachments/`: Folder for downloaded attachments. +- `.venv/`: Python virtual environment. +- `.github/copilot-instructions.md`: Copilot workspace instructions. + +## Error Handling +- Print errors for missing API key, failed API requests, and failed downloads. +- If a field is not serializable, convert to string or ISO format for JSON. + +## Usage +- Activate the virtual environment and install requirements before running. +- Set the `TODOIST_KEY` environment variable with your Todoist API key. +- Run `python export_todoist.py export` to perform the export. + +## Suggestions for Copilot +- When generating code, prefer iterators for API responses and handle paging. +- Always include all available fields from API objects in output. +- Use Jinja2 for HTML rendering and reference local attachment files. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1917467 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.pyc + +# Virtual environments +.venv/ +venv/ +ENV/ + +# VS Code +.vscode/ + +# Attachments and backups +attachments/ +Todoist-Actual-Backup-*.json +Todoist-Actual-Backup-*.html + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log + +# Misc +*.swp +*.bak +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..64d7e52 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Todoist Actual Backup + +Todoist is a SaaS task manager. Todoist provides backups of current tasks, but does not include completed tasks. Nor does it provide a human-readable backup. This script exports everything to JSON and HTML. + +This project provides a command-line tool to export all active and completed tasks from the Todoist API to a JSON file, including attachments and comments, and generates a human-readable HTML backup. + +## Features +- Exports all active and completed tasks from all projects (active and archived) +- Downloads attachments and references them in the JSON and HTML output +- Nests tasks under their respective projects, including all available fields +- Includes comments for each task +- Outputs both a JSON file for programmatic access and a styled HTML file for viewing in a browser + +## Setup +- Ensure you have Python 3.8 or newer installed. Check with `python --version` on the command line. +- The script uses a `.venv` for dependencies. Run: + ```bash + python -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` + +## Usage +1. Run `source .venv/bin/activate` if needed to enter the virtual enivronment. +2. Set your Todoist API key in the `TODOIST_KEY` environment variable. +3. Run the script with the `export` argument: + ```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. +4. To see usage instructions, run the script with no arguments or any argument other than `export`. + +## Requirements +- Python 3.8+ +- [todoist-api-python](https://doist.github.io/todoist-api-python/) +- [Jinja2](https://palletsprojects.com/p/jinja/) + +## License +MIT diff --git a/export_todoist.py b/export_todoist.py new file mode 100644 index 0000000..4d45a62 --- /dev/null +++ b/export_todoist.py @@ -0,0 +1,172 @@ +import os +import sys +import json +import requests +from datetime import datetime, timedelta +from todoist_api_python.api import TodoistAPI +from jinja2 import Environment, FileSystemLoader, select_autoescape + +ATTACHMENTS_DIR = "attachments" + + +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: + print("Error: TODOIST_KEY environment variable not set.") + sys.exit(1) + return key + + +def ensure_attachments_dir(): + if not os.path.exists(ATTACHMENTS_DIR): + os.makedirs(ATTACHMENTS_DIR) + + +def download_attachment(url, filename): + local_path = os.path.join(ATTACHMENTS_DIR, filename) + if os.path.exists(local_path): + return local_path + print(f"Downloading attachment {url}") + r = requests.get(url, stream=True) + if r.status_code == 200: + with open(local_path, 'wb') as f: + for chunk in r.iter_content(1024): + f.write(chunk) + return local_path + else: + return None + + +def fetch_all_projects(api): + ret = [] + projects_iter = api.get_projects() + for projects in projects_iter: + for project in projects: + name = getattr(project, 'name', None) + id = getattr(project, 'id', None) + print(f"Found project {name} with ID {id}") + ret.append(project) + return ret + +def fetch_all_completed_tasks(api, project_id): + # Fetch all completed tasks for a project using get_completed_tasks_by_completion_date + # The API only allows up to 3 months per call, so we fetch just once for the last 3 months + all_completed = [] + since = (datetime.now() - timedelta(days=90)).replace(hour=0, minute=0, second=0, microsecond=0) + until = datetime.now() + try: + completed_iter = api.get_completed_tasks_by_completion_date(since=since, until=until) + for completed_list in completed_iter: + for task in completed_list: + if hasattr(task, 'project_id') and str(task.project_id) == str(project_id): + all_completed.append(task) + except Exception as e: + print(f"Error fetching completed tasks for {since} to {until}: {e}") + print(f"Found {len(all_completed)} completed tasks for project {project_id}") + return all_completed + +def fetch_all_tasks(api, project_id, completed=False): + if completed: + return fetch_all_completed_tasks(api, project_id) + else: + tasks = [] + try: + tasks_iter = api.get_tasks(project_id=project_id) + for batch in tasks_iter: + for task in batch: + tasks.append(task) + except Exception as e: + print(f"Error fetching active tasks for project {project_id}: {e}") + print(f"Found {len(tasks)} completed tasks for project {project_id}") + return tasks + + +def fetch_comments(api, task_id): + comments = [] + try: + comments_iter = api.get_comments(task_id=task_id) + for batch in comments_iter: + for comment in batch: + comments.append(comment) + except Exception: + return [] + return comments + + +def process_task(api, task, completed=False): + task_dict = task.__dict__.copy() + # Attachments (if any) + attachments = [] + if hasattr(task, 'attachments') and task.attachments: + for att in task.attachments: + att_dict = att.__dict__.copy() + if 'file_url' in att_dict and att_dict['file_url']: + 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) + attachments.append(att_dict) + if attachments: + task_dict['attachments'] = attachments + # Comments + comments = fetch_comments(api, task.id) + if comments: + task_dict['comments'] = [c.__dict__ for c in comments] + return task_dict + + +def main(): + if len(sys.argv) != 2 or sys.argv[1] != "export": + usage() + return + ensure_attachments_dir() + api = TodoistAPI(get_api_key()) + projects = fetch_all_projects(api) + data = [] + for project in projects: + project_dict = project.__dict__.copy() + project_id = project.id + # Active tasks + active_tasks = fetch_all_tasks(api, project_id, completed=False) + # Completed tasks + completed_tasks = fetch_all_tasks(api, project_id, completed=True) + project_dict['tasks'] = [process_task(api, t, completed=False) for t in active_tasks] + project_dict['completed_tasks'] = [process_task(api, t, completed=True) for t in completed_tasks] + data.append(project_dict) + # Write JSON + today = datetime.now().strftime("%Y-%m-%d") + json_filename = f"Todoist-Actual-Backup-{today}.json" + def json_serial(obj): + if isinstance(obj, datetime): + return obj.isoformat() + return str(obj) + with open(json_filename, "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}") + # Write HTML + env = Environment( + loader=FileSystemLoader(os.path.dirname(__file__)), + autoescape=select_autoescape(['html', 'xml']) + ) + 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: + f.write(template.render(projects=data, date=today)) + print(f"Generated HTML backup at {html_filename}") + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..402d659 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +todoist-api-python +Jinja2 +requests diff --git a/todoist_backup_template.html b/todoist_backup_template.html new file mode 100644 index 0000000..3581938 --- /dev/null +++ b/todoist_backup_template.html @@ -0,0 +1,92 @@ + + + + + Todoist Backup - {{ date }} + + + +
+

Todoist Backup ({{ date }})

+ {% for project in projects %} +
+

{{ project.name }} {% if project.is_archived %}[Archived]{% endif %}

+
+ ID: {{ project.id }} | Color: {{ project.color }} | Created: {{ project.created_at }} +
+

Active Tasks

+
+ {% for task in project.tasks %} +
+ Content: {{ task.content }}
+ ID: {{ task.id }} | Due: {{ task.due }} | Priority: {{ task.priority }}
+ {% if task.attachments %} +
+ Attachments: + +
+ {% endif %} + {% if task.comments %} +
+ Comments: +
    + {% for comment in task.comments %} +
  • {{ comment.content }} ({{ comment.posted_at }})
  • + {% endfor %} +
+
+ {% endif %} +
+ {% endfor %} +
+

Completed Tasks

+
+ {% for task in project.completed_tasks %} +
+ Content: {{ task.content }}
+ ID: {{ task.id }} | Due: {{ task.due }} | Priority: {{ task.priority }}
+ {% if task.attachments %} +
+ Attachments: + +
+ {% endif %} + {% if task.comments %} +
+ Comments: +
    + {% for comment in task.comments %} +
  • {{ comment.content }} ({{ comment.posted_at }})
  • + {% endfor %} +
+
+ {% endif %} +
+ {% endfor %} +
+
+ {% endfor %} +
+ +