Initial
This commit is contained in:
43
.github/copilot-instructions.md
vendored
Normal file
43
.github/copilot-instructions.md
vendored
Normal 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
31
.gitignore
vendored
Normal 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
39
README.md
Normal 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
172
export_todoist.py
Normal 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
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
todoist-api-python
|
||||
Jinja2
|
||||
requests
|
92
todoist_backup_template.html
Normal file
92
todoist_backup_template.html
Normal 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>
|
Reference in New Issue
Block a user