Move output to output folder

This commit is contained in:
2025-10-18 12:27:56 -04:00
parent 6c3c752c43
commit 8e81dcc864
2 changed files with 49 additions and 14 deletions

View File

@ -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,7 +26,7 @@ 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.
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`.

View File

@ -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):
os.makedirs(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, 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()