From 1b352d258692e8d320695a1401e24a15fef30835 Mon Sep 17 00:00:00 2001 From: Matt Wiseley Date: Sat, 18 Oct 2025 12:11:46 -0400 Subject: [PATCH] Store competed tasks across runs to maintain completed tasks older than 90 days --- .gitignore | 1 + README.md | 24 ++++++---- export_todoist.py | 86 +++++++++++++++++++++++++++++++++--- todoist_backup_template.html | 2 +- 4 files changed, 98 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 1917467..0f02ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ Thumbs.db *.swp *.bak *.tmp +Todoist-Completed-History.json diff --git a/README.md b/README.md index 64d7e52..314761f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ # 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. +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. ## 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 +- 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 - 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 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: ```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. + 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`. ## 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/) - [Jinja2](https://palletsprojects.com/p/jinja/) -## License -MIT +## MIT License +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. diff --git a/export_todoist.py b/export_todoist.py index 735663e..4928cf9 100644 --- a/export_todoist.py +++ b/export_todoist.py @@ -10,6 +10,13 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape ATTACHMENTS_DIR = "attachments" 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(): @@ -39,6 +46,70 @@ def ensure_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): try: with open(path, 'rb') as handle: @@ -336,6 +407,8 @@ def main(): comments_by_task = fetch_comments_by_task( api, comment_project_ids, sorted(task_ids_for_comments) ) + completed_history = load_completed_history() + updated_history = {} data = [] for project in projects: project_dict = project.__dict__.copy() @@ -366,15 +439,18 @@ def main(): "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) + 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 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}") diff --git a/todoist_backup_template.html b/todoist_backup_template.html index d33ac93..65179ee 100644 --- a/todoist_backup_template.html +++ b/todoist_backup_template.html @@ -40,7 +40,7 @@ {% macro render_task(task, level=0) %}
- {{ task.content | markdown | safe }}
+
{{ task.content | markdown | safe }}
{% if task.description %}
{{ task.description | markdown | safe }}
{% endif %}