Move output to output folder
This commit is contained in:
@ -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
|
||||
|
@ -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()
|
||||
|
Reference in New Issue
Block a user