Store competed tasks across runs to maintain completed tasks older than 90 days

This commit is contained in:
2025-10-18 12:11:46 -04:00
parent 406f8cef0b
commit 1b352d2586
4 changed files with 98 additions and 15 deletions

1
.gitignore vendored
View File

@ -29,3 +29,4 @@ Thumbs.db
*.swp *.swp
*.bak *.bak
*.tmp *.tmp
Todoist-Completed-History.json

View File

@ -1,15 +1,14 @@
# Todoist Actual Backup # 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. 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 Python script provides a command-line tool to export all available active and completed tasks from the Todoist API to a JSON file, including attachments, subtasks and comments, and generates a human-readable HTML backup.
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 ## Features
- Exports all active and completed tasks from all projects (active and archived) - 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 - Nests tasks under their respective projects, including all available fields
- Includes comments for each task - Includes comments for each task
- Outputs both a JSON file for programmatic access and a styled HTML file for viewing in a browser - Downloads 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
## Setup ## Setup
- Ensure you have Python 3.8 or newer installed. Check with `python --version` on the command line. - Ensure you have Python 3.8 or newer installed. Check with `python --version` on the command line.
@ -22,12 +21,13 @@ This project provides a command-line tool to export all active and completed tas
## Usage ## Usage
1. Run `source .venv/bin/activate` if needed to enter the virtual enivronment. 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. 2. Set your Todoist API key in the `TODOIST_KEY` environment variable. If the environment variable is not set, the script will prompt for it.
3. Run the script with the `export` argument: 3. Run the script with the `export` argument:
```bash ```bash
python export_todoist.py export 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. 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.
4. To see usage instructions, run the script with no arguments or any argument other than `export`. 4. To see usage instructions, run the script with no arguments or any argument other than `export`.
## Requirements ## Requirements
@ -35,5 +35,11 @@ This project provides a command-line tool to export all active and completed tas
- [todoist-api-python](https://doist.github.io/todoist-api-python/) - [todoist-api-python](https://doist.github.io/todoist-api-python/)
- [Jinja2](https://palletsprojects.com/p/jinja/) - [Jinja2](https://palletsprojects.com/p/jinja/)
## License ## MIT License
MIT Copyright © 2025 bagaag.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -10,6 +10,13 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
ATTACHMENTS_DIR = "attachments" ATTACHMENTS_DIR = "attachments"
TODOIST_API_TOKEN: str | None = None TODOIST_API_TOKEN: str | None = None
COMPLETED_HISTORY_FILE = "Todoist-Completed-History.json"
def json_serial(obj):
if isinstance(obj, datetime):
return obj.isoformat()
return str(obj)
def usage(): def usage():
@ -39,6 +46,70 @@ def ensure_attachments_dir():
os.makedirs(ATTACHMENTS_DIR) os.makedirs(ATTACHMENTS_DIR)
def load_completed_history():
if not os.path.exists(COMPLETED_HISTORY_FILE):
return {}
try:
with open(COMPLETED_HISTORY_FILE, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError) as exc: # pylint: disable=broad-except
print(f"Warning: failed to load completed history ({exc}). Starting fresh.")
return {}
if isinstance(data, dict):
history = {}
for key, value in data.items():
if isinstance(value, list):
history[str(key)] = value
return history
if isinstance(data, list):
history = defaultdict(list)
for item in data:
if isinstance(item, dict):
project_id = str(item.get("project_id", ""))
if project_id:
history[project_id].append(item)
return {key: value for key, value in history.items()}
return {}
def save_completed_history(history):
try:
with open(COMPLETED_HISTORY_FILE, "w", encoding="utf-8") as handle:
json.dump(history, handle, ensure_ascii=False, indent=2, default=json_serial)
except OSError as exc: # pylint: disable=broad-except
print(f"Warning: failed to write completed history ({exc}).")
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 ""
return (task_id, completed_at)
def add_task(task):
key = make_key(task)
if key in seen:
return
seen.add(key)
merged.append(task)
for item in new_tasks:
add_task(item)
for item in history_tasks:
add_task(item)
def sort_key(task):
completed_at = task.get('completed_at') or ""
updated_at = task.get('updated_at') or ""
return (completed_at, updated_at)
merged.sort(key=sort_key, reverse=True)
return merged
def _file_looks_like_html(path): def _file_looks_like_html(path):
try: try:
with open(path, 'rb') as handle: with open(path, 'rb') as handle:
@ -336,6 +407,8 @@ def main():
comments_by_task = fetch_comments_by_task( comments_by_task = fetch_comments_by_task(
api, comment_project_ids, sorted(task_ids_for_comments) api, comment_project_ids, sorted(task_ids_for_comments)
) )
completed_history = load_completed_history()
updated_history = {}
data = [] data = []
for project in projects: for project in projects:
project_dict = project.__dict__.copy() project_dict = project.__dict__.copy()
@ -366,15 +439,18 @@ def main():
"content": parent_name, "content": parent_name,
} }
project_dict['completed_tasks'] = processed_completed historical = completed_history.get(project_id, [])
merged_completed = merge_completed_lists(historical, processed_completed)
project_dict['completed_tasks'] = merged_completed
updated_history[project_id] = merged_completed
data.append(project_dict) data.append(project_dict)
for project_id, tasks in completed_history.items():
if project_id not in updated_history:
updated_history[project_id] = tasks
save_completed_history(updated_history)
# Write JSON # Write JSON
today = datetime.now().strftime("%Y-%m-%d") today = datetime.now().strftime("%Y-%m-%d")
json_filename = f"Todoist-Actual-Backup-{today}.json" 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: with open(json_filename, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2, default=json_serial) 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_filename}")

View File

@ -40,7 +40,7 @@
{% macro render_task(task, level=0) %} {% macro render_task(task, level=0) %}
<div class="task level-{{ level }}"> <div class="task level-{{ level }}">
<span class="taskname">{{ task.content | markdown | safe }}</span><br> <div class="taskname">{{ task.content | markdown | safe }}</div>
{% if task.description %} {% if task.description %}
<div class="taskdesc">{{ task.description | markdown | safe }}</div> <div class="taskdesc">{{ task.description | markdown | safe }}</div>
{% endif %} {% endif %}