From 070b0fea014fa7c54bd04bd63375d2d42eff1897 Mon Sep 17 00:00:00 2001 From: Marcel Kapfer Date: Mon, 14 Nov 2022 23:30:18 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20[todoist2org]=20Added=20initial?= =?UTF-8?q?=20(more=20or=20less)=20working=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- todoist2org/.env.example | 2 + todoist2org/.gitignore | 3 + todoist2org/README.org | 12 ++ todoist2org/requirements.txt | 3 + todoist2org/todoist2org.py | 213 +++++++++++++++++++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 todoist2org/.env.example create mode 100644 todoist2org/.gitignore create mode 100644 todoist2org/README.org create mode 100644 todoist2org/requirements.txt create mode 100644 todoist2org/todoist2org.py diff --git a/todoist2org/.env.example b/todoist2org/.env.example new file mode 100644 index 0000000..321e0f6 --- /dev/null +++ b/todoist2org/.env.example @@ -0,0 +1,2 @@ +OUTPUT_DIR=./output/ +TOKEN= diff --git a/todoist2org/.gitignore b/todoist2org/.gitignore new file mode 100644 index 0000000..e0d725c --- /dev/null +++ b/todoist2org/.gitignore @@ -0,0 +1,3 @@ +.env +venv/ +output/ diff --git a/todoist2org/README.org b/todoist2org/README.org new file mode 100644 index 0000000..ad374bc --- /dev/null +++ b/todoist2org/README.org @@ -0,0 +1,12 @@ +* Opinionated Todoist to Org-Mode Exporter + +Very early WIP state. + +** Quick Start + +1. Copy =.env.example= to =.env= +2. Set =TOKEN= to Todoist API token in =.env= +3. Adjust =OUTPUT_DIR= in =.env= if necessary +4. Create new Python virtual environment and activated it +5. Install dependencies with =pip install -r requirements.txt= +6. Execute script with =python todoist2org.py= diff --git a/todoist2org/requirements.txt b/todoist2org/requirements.txt new file mode 100644 index 0000000..f29f8da --- /dev/null +++ b/todoist2org/requirements.txt @@ -0,0 +1,3 @@ +todoist_api_python +tqdm +python-dotenv diff --git a/todoist2org/todoist2org.py b/todoist2org/todoist2org.py new file mode 100644 index 0000000..f82f3ab --- /dev/null +++ b/todoist2org/todoist2org.py @@ -0,0 +1,213 @@ +"""Playground application for working with the todoist api.""" +import os +from datetime import datetime +from todoist_api_python.api import TodoistAPI +from tqdm import tqdm +from dotenv import load_dotenv + +# API Documentation: https://developer.todoist.com/rest/v2/?python#tasks +# Perhaps helpful: https://github.com/novoid/orgformat + +counter = { + 'projects': 0, + 'tasks': 0, + 'sections': 0, + 'comments': 0 +} + + +def get_filename(project_name, extension=True): + """Create a filename from a project name.""" + filename = project_name.replace(" ", "-") + filename = filename.replace("/", "") + filename = filename.replace("--", "-") + filename = filename.lower() + if extension: + filename += ".org" + return filename + + +def create_output_dir(): + """Create the output dir if it doesn't exist.""" + output_dir = os.environ.get("OUTPUT_DIR", "./output") + if not os.path.exists(output_dir): + os.mkdir(output_dir) + + +def write_file(path, content): + """Write a org mode file.""" + outputFile = open(path, "w") + outputFile.write("\n".join(content)) + outputFile.close() + + +def parse_comments(task_id): + """Parse comments of a task.""" + output = [] + comments = api.get_comments(task_id=task_id) + counter['comments'] += len(comments) + + output.append("*** Comments") + + for comment in comments: + output.append(f" - {comment.content}") + if comment.attachment: + output.append(f" Attachment: {comment.attachment.file_url}") + + return output + + +def parse_task(task, subtask): + """Print a task.""" + output = [] + if subtask: + headline = f"**** TODO {task.content}" + else: + headline = f"** TODO {task.content}" + + labels = task.labels + if len(labels) > 0: + headline += " :" + ":".join(labels) + ":" + + output.append(headline) + + if task.due: + if task.due.datetime: + dueDate = datetime.strptime( + task.due.datetime, + "%Y-%m-%dT%H:%M:%S" + ) + formattedDueDate = dueDate.strftime("<%Y-%m-%d %a %H:%M>") + due = f"SCHEDULED: {formattedDueDate}" + output.append(due) + elif task.due.date: + dueDate = datetime.strptime(task.due.date, "%Y-%m-%d") + formattedDueDate = dueDate.strftime("<%Y-%m-%d %a>") + due = f"SCHEDULED: {formattedDueDate}" + output.append(due) + + humanDue = (f"*Due: {task.due.string}*") + if task.due.is_recurring: + humanDue += " (recurring)" + + output.append(humanDue) + output.append("") + + createdDate = datetime.strptime( + task.created_at, + "%Y-%m-%dT%H:%M:%S.%fZ" + ) + formattedCreatedDate = createdDate.strftime("[%Y-%m-%d %a %H:%M]") + output.append(f"CREATED: {formattedCreatedDate}") + + if task.description: + output.append(task.description) + + if task.comment_count > 0: + output.extend(parse_comments(task.id)) + + output.append("") + return output + + +def parse_sections(project_id, tasks): + """Print a section.""" + output = [] + sections = api.get_sections(project_id=project_id) + counter['sections'] += len(sections) + + for section in sections: + output.append(f"* {section.name}") + output.append("") + subtask = False + for task in tasks: + if task.section_id == section.id: + if not subtask and task.parent_id: + output.append("*** Subtasks") + subtask = True + if subtask and not task.parent_id: + subtask = False + output.extend(parse_task(task, subtask)) + + return output + + +def parse_project(project, parent_project_name=""): + """Parse a project.""" + filename = get_filename(project.name) + content = [] + + output_dir = os.environ.get("OUTPUT_DIR", "./output") + + if parent_project_name and project.parent_id: + content.append(f"#+title: {parent_project_name}: {project.name}") + parent_filename = get_filename(parent_project_name, False) + path = f"{output_dir}/{parent_filename}_{filename}" + else: + content.append(f"#+title: {project.name}") + path = f"{output_dir}/{filename}" + + if not project.parent_id: + parent_project_name = project.name + + content.append("#+startup: indent overview") + content.append("") + content.append("* Normal Tasks") + content.append("") + + tasks = api.get_tasks(project_id=project.id) + counter['tasks'] += len(tasks) + + subtask = False + for task in tasks: + if not task.section_id: + if not subtask and task.parent_id: + content.append("*** Subtasks") + subtask = True + if subtask and not task.parent_id: + subtask = False + content.extend(parse_task(task, subtask)) + + content.extend(parse_sections(project.id, tasks)) + + write_file(path, content) + return parent_project_name + + +def print_stats(): + """Print some useless stats at the end.""" + output = "Retrieved, parsed and stored" + output += f" {counter['projects']} projects," + output += f" {counter['sections']} sections," + output += f" {counter['tasks']} tasks, and" + output += f" {counter['comments']} comments." + print(output) + + +def parse_projects(): + """Parse the list of projects.""" + try: + print("Retrieving list of projects to start parsing.") + projects = api.get_projects() + except Exception as error: + print(error) + + counter['projects'] = len(projects) + + parent_project_name = "" + for project in tqdm(projects, "Parsing Projects"): + parent_project_name = parse_project(project, parent_project_name) + + +def main(): + """Run the program.""" + load_dotenv() + global api + api = TodoistAPI(os.environ.get("TOKEN", "")) + create_output_dir() + parse_projects() + print_stats() + + +if __name__ == "__main__": + main()