This commit is contained in:
2025-10-17 07:25:07 -04:00
commit c4d7391aa5
6 changed files with 380 additions and 0 deletions

43
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,43 @@
<!-- Use this file to provide workspace-specific custom instructions to Copilot. For more details, visit https://code.visualstudio.com/docs/copilot/copilot-customization#_use-a-githubcopilotinstructionsmd-file -->
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.

31
.gitignore vendored Normal file
View File

@ -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

39
README.md Normal file
View File

@ -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

172
export_todoist.py Normal file
View File

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

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
todoist-api-python
Jinja2
requests

View File

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Todoist Backup - {{ date }}</title>
<style>
body { font-family: Arial, sans-serif; background: #f8f9fa; color: #222; margin: 0; padding: 0; }
.container { max-width: 900px; margin: 2em auto; background: #fff; padding: 2em; border-radius: 8px; box-shadow: 0 2px 8px #0001; }
h1, h2, h3 { color: #2d72d9; }
.project { margin-bottom: 2em; }
.task-list { margin: 0 0 1em 1em; }
.task { border-bottom: 1px solid #eee; padding: 0.5em 0; }
.completed { color: #888; }
.attachments { margin: 0.5em 0 0.5em 1em; }
.comments { margin: 0.5em 0 0.5em 1em; font-size: 0.95em; color: #444; }
.field { font-weight: bold; }
a.attachment-link { color: #2d72d9; text-decoration: underline; }
.meta { color: #666; font-size: 0.95em; }
</style>
</head>
<body>
<div class="container">
<h1>Todoist Backup ({{ date }})</h1>
{% for project in projects %}
<div class="project">
<h2>{{ project.name }} {% if project.is_archived %}<span class="meta">[Archived]</span>{% endif %}</h2>
<div class="meta">
<span>ID: {{ project.id }}</span> | <span>Color: {{ project.color }}</span> | <span>Created: {{ project.created_at }}</span>
</div>
<h3>Active Tasks</h3>
<div class="task-list">
{% for task in project.tasks %}
<div class="task">
<span class="field">Content:</span> {{ task.content }}<br>
<span class="meta">ID: {{ task.id }} | Due: {{ task.due }} | Priority: {{ task.priority }}</span><br>
{% if task.attachments %}
<div class="attachments">
<span class="field">Attachments:</span>
<ul>
{% for att in task.attachments %}
<li><a class="attachment-link" href="{{ att.local_file }}" download>{{ att.file_name or att.local_file }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if task.comments %}
<div class="comments">
<span class="field">Comments:</span>
<ul>
{% for comment in task.comments %}
<li>{{ comment.content }} <span class="meta">({{ comment.posted_at }})</span></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endfor %}
</div>
<h3>Completed Tasks</h3>
<div class="task-list">
{% for task in project.completed_tasks %}
<div class="task completed">
<span class="field">Content:</span> {{ task.content }}<br>
<span class="meta">ID: {{ task.id }} | Due: {{ task.due }} | Priority: {{ task.priority }}</span><br>
{% if task.attachments %}
<div class="attachments">
<span class="field">Attachments:</span>
<ul>
{% for att in task.attachments %}
<li><a class="attachment-link" href="{{ att.local_file }}" download>{{ att.file_name or att.local_file }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if task.comments %}
<div class="comments">
<span class="field">Comments:</span>
<ul>
{% for comment in task.comments %}
<li>{{ comment.content }} <span class="meta">({{ comment.posted_at }})</span></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</body>
</html>