├── .DS_Store ├── .gitignore ├── CLAUDE.md ├── README.md ├── config ├── kanbanflow-config.yaml.example └── log.conf ├── omnifocustokanban ├── kanban_board.py ├── kanban_flow_board.py ├── of-to-kb └── omnifocus.py ├── requirements.txt └── setup.py /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhydlewis/omnifocus-to-kanban/43e830319edaf4ddcfe56d5ca2b6e519fb70999e/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/leankit-config.yaml 2 | config/trello-config.yaml 3 | config/kanbanflow-config.yaml 4 | log/omnifocus-to-kanban.log 5 | .idea/ 6 | *.pyc 7 | sync.sh 8 | log/ 9 | venv/ 10 | .python-version 11 | pyvenv.cfg 12 | .aider* 13 | .DS_Store -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # OmniFocus to Kanban - Development Guide 2 | 3 | ## Build & Run Commands 4 | - Install dependencies: `pip install -r requirements.txt` 5 | - Run app: `./omnifocustokanban/of-to-kb --kanbanflow` (or `--plane`) 6 | - Evaluation mode: `./omnifocustokanban/of-to-kb --kanbanflow --eval` 7 | - Run single test: `python -m unittest tests/test_filename.py::TestClass::test_method` 8 | 9 | ## Code Style Guidelines 10 | - Type hints required for function parameters and return values 11 | - Use dataclasses for structured data (e.g., Task class) 12 | - Exception handling: catch specific exceptions with informative error messages 13 | - Logging: use the module-level logger with appropriate levels (debug/info/warning/error) 14 | - Constants defined at module level in UPPER_CASE 15 | - SQL queries defined as module-level constants 16 | - Class methods that don't use instance variables should be @staticmethod 17 | - Follow PEP 8 for naming: snake_case for functions/variables, CamelCase for classes 18 | 19 | ## Error Handling Pattern 20 | Try specific operations individually with appropriate error messages. Use the format: 21 | ```python 22 | try: 23 | # operation 24 | except SpecificException as e: 25 | logging.error(f"Meaningful error message: {e}") 26 | # handle or raise 27 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # omnifocus-to-kanban 2 | 3 | ## What is this? 4 | 5 | This is a tool that synchronises data from [OmniFocus](http://www.omnigroup.com/omnifocus) to a Kanban board (e.g. [KanbanFlow](https://kanbanflow.com/)). 6 | 7 | ## Why? 8 | 9 | This allows you to visualise your Omnifocus actions on a Kanban board. I prefer this to manage my work in progress than using Omnifocus alone. Here's a [blog I wrote](http://rhydlewis.net/blog/2015/9/29/how-i-use-personal-kanban-to-stay-in-control-of-my-work-and-get-stuff-done-part-2) with more info on my thinking. 10 | 11 | ## How to install 12 | 13 | ### Dependencies 14 | 15 | This tool needs Python 3 and [these libraries](https://github.com/rhydlewis/omnifocus-to-kanban/blob/master/requirements.txt) to run. 16 | 17 | Run: 18 | 19 | `pip install -r requirements.txt` 20 | 21 | to install them. 22 | 23 | ## How to use 24 | 25 | ### Kanban Flow 26 | 27 | 1. Copy the `kanbanflow-config.yaml.example` file as `kanbanflow-config.yaml`. Edit the file to include your API token, lane IDs and contexts. 28 | 2. Run `of-to-kb --kanbanflow` 29 | -------------------------------------------------------------------------------- /config/kanbanflow-config.yaml.example: -------------------------------------------------------------------------------- 1 | token: 2 | default_drop_lane: 3 | completed_lanes: 4 | - : 7 | color: orange 8 | : 9 | color: blue 10 | -------------------------------------------------------------------------------- /config/log.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root,omnifocus,kanban_board,kanban_flow_board 3 | 4 | [handlers] 5 | keys=consoleHandler,fileHandler 6 | 7 | [formatters] 8 | keys=consoleFormat,fileFormat 9 | 10 | [logger_root] 11 | level=DEBUG 12 | handlers=consoleHandler,fileHandler 13 | 14 | [logger_omnifocus] 15 | level=DEBUG 16 | handlers=consoleHandler,fileHandler 17 | qualname=omnifocus 18 | propagate=0 19 | 20 | [logger_kanban_board] 21 | level=DEBUG 22 | handlers=consoleHandler,fileHandler 23 | qualname=kanban_board 24 | propagate=0 25 | 26 | [logger_kanban_flow_board] 27 | level=DEBUG 28 | handlers=consoleHandler,fileHandler 29 | qualname=kanban_flow_board 30 | propagate=0 31 | 32 | [handler_consoleHandler] 33 | class=StreamHandler 34 | level=INFO 35 | formatter=consoleFormat 36 | args=(sys.stdout,) 37 | 38 | [handler_fileHandler] 39 | class=logging.FileHandler 40 | level=DEBUG 41 | formatter=fileFormat 42 | args=('log/omnifocus-to-kanban.log',) 43 | 44 | [formatter_consoleFormat] 45 | format=%(asctime)s %(name)-12s %(levelname)-8s %(message)s 46 | 47 | [formatter_fileFormat] 48 | format=%(asctime)s %(name)-12s %(levelname)-8s %(message)s 49 | -------------------------------------------------------------------------------- /omnifocustokanban/kanban_board.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import yaml 3 | import os 4 | from kanban_flow_board import KanbanFlowBoard 5 | 6 | 7 | class KanbanFlow: 8 | kb = None 9 | log = logging.getLogger(__name__) 10 | 11 | def __init__(self): 12 | self.config = load_config("./config/kanbanflow-config.yaml") 13 | 14 | token = self.config['token'] 15 | default_drop_lane = self.config['default_drop_lane'] 16 | types = self.config['card_types'] 17 | completed_columns = self.config['completed_lanes'] 18 | 19 | self.kb = KanbanFlowBoard(token, default_drop_lane, types, completed_columns) 20 | 21 | def find_completed_card_ids(self): 22 | return self.kb.completed_tasks 23 | 24 | def card_exists(self, identifier): 25 | return identifier in self.kb.all_tasks 26 | 27 | def add_cards(self, cards): 28 | cards_added = self.kb.create_tasks(cards) 29 | self.log.debug("Made {0} API requests in this session ({1} total bytes)".format(self.kb.api_requests, 30 | self.kb.bytes_transferred)) 31 | return cards_added 32 | 33 | def remove_comments_from_repeating_tasks(self, identifiers): 34 | for _id in identifiers: 35 | self.kb.delete_external_id_comment(_id['id']) 36 | 37 | 38 | def load_config(path): 39 | path = "{0}/{1}".format(os.getcwd(), path) 40 | logging.debug("Loading config file {0}".format(path)) 41 | f = open(path) 42 | config = yaml.safe_load(f) 43 | f.close() 44 | return config 45 | -------------------------------------------------------------------------------- /omnifocustokanban/kanban_flow_board.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import logging 3 | import base64 4 | from operator import itemgetter 5 | from typing import List, Dict, Any, Optional 6 | from omnifocus import Task 7 | 8 | COMMENT_PREFIX = "external_id=" 9 | TASKS_URI = "https://kanbanflow.com/api/v1/tasks/" 10 | BOARD_URI = "https://kanbanflow.com/api/v1/board" 11 | 12 | 13 | class KanbanFlowBoard: 14 | def __init__(self, token: str, default_drop_column: str, types: Dict[str, Any], completed_columns: List[str]): 15 | self.log = logging.getLogger(__name__) 16 | self.auth = {'Authorization': f"Basic {base64.b64encode(f'apiToken:{token}'.encode()).decode('utf-8')}"} 17 | self.default_drop_column = default_drop_column 18 | self.types = types 19 | self.api_requests = 0 20 | self.bytes_transferred = 0 21 | self.board_details = self.request(BOARD_URI) 22 | self.default_swimlane = self.board_details['swimlanes'][0]['uniqueId'] 23 | self.all_tasks: Dict[str, Any] = {} 24 | self.completed_tasks: List[Dict[str, str]] = [] 25 | self.classify_board(completed_columns) 26 | 27 | def classify_board(self, completed_columns: List[str]) -> None: 28 | columns = self.request(TASKS_URI) 29 | 30 | for column in columns: 31 | column_id = column["columnId"] 32 | column_name = column["columnName"] 33 | tasks = column["tasks"] 34 | is_completed_column = column_id in completed_columns 35 | 36 | self.log.debug(f"Found {len(tasks)} tasks in {column_name} (completed column? {is_completed_column})") 37 | 38 | for task in tasks: 39 | _id = task["_id"] 40 | self.log.debug(f"Parsing task {task['name']}") 41 | comment = self.get_comment_containing_id(_id) 42 | 43 | if comment is not None: 44 | external_id = comment["text"][len(COMMENT_PREFIX):] 45 | self.all_tasks[external_id] = task 46 | if is_completed_column: 47 | self.completed_tasks.append({"id": external_id, "name": task["name"]}) 48 | 49 | def get_comment_containing_id(self, _id: str) -> Optional[Dict[str, Any]]: 50 | self.log.debug(f"Looking for comments in task {_id}") 51 | try: 52 | comment_json = self.request(f"{TASKS_URI}{_id}/comments") 53 | if comment_json: 54 | return next((item for item in comment_json if COMMENT_PREFIX in item["text"]), None) 55 | except Exception as e: 56 | self.log.warning(f"Failed to get comments for {_id}: {str(e)}") 57 | return None 58 | 59 | def create_tasks(self, tasks: List[Task]): 60 | self.log.debug(u"Checking which of {0} tasks to add".format(len(tasks))) 61 | 62 | tasks_added = 0 63 | external_ids = self.all_tasks.keys() 64 | 65 | for task in tasks: 66 | name = task.name 67 | identifier = task.identifier 68 | _type = task.type 69 | note = task.note 70 | subtasks = task.children 71 | 72 | if identifier in external_ids: 73 | tasks_added += self.update_task(identifier, name, note, subtasks) 74 | else: 75 | tasks_added += self.create_task(name, self.default_drop_column, identifier, None, note, _type, 76 | subtasks) 77 | 78 | return tasks_added 79 | 80 | def create_task(self, name: str, column: str, identifier: str, swimlane: str, description: str = '', _type: str = None, subtasks: List[Task] = None): 81 | if 'None' == _type: 82 | raise ValueError("Task '{0}' can't have context value of 'None'".format(name)) 83 | 84 | if description is None: 85 | self.log.error(u"Task description is None? {0}".format(name)) 86 | description="" 87 | 88 | color = None 89 | if _type: 90 | type_config = self.types[_type] 91 | color = type_config['color'] 92 | 93 | if 'column' in type_config: 94 | column = type_config['column'] 95 | 96 | updates_made = 0 97 | 98 | if swimlane is None: 99 | swimlane = self.default_swimlane 100 | 101 | if "Look" in name: 102 | print("here") 103 | 104 | properties = {"name": name, "columnId": column, "description": description, 105 | "color": color, "swimlaneId": swimlane} 106 | # properties = {"name": name, "columnId": column, "description": description, 107 | # "color": color} 108 | comment = COMMENT_PREFIX + identifier 109 | 110 | json = self.request("https://kanbanflow.com/api/v1/tasks", properties) 111 | self.log.debug(u"{0}".format(json)) 112 | if json is not None: 113 | self.log.debug(u"Adding task: {0}".format(properties)) 114 | task_id = json["taskId"] 115 | updates_made += 1 116 | self.request(TASKS_URI + "{0}/comments".format(task_id), {"text": comment}) 117 | else: 118 | self.log.error(u"Task add failed: {0}".format(properties)) 119 | 120 | if subtasks: 121 | sorted_subtasks = sorted(subtasks, key=itemgetter('name')) 122 | for subtask in sorted_subtasks: 123 | self.log.debug(u"Adding subtask '{0}' to task {1} with id {2}".format(subtask, name, task_id)) 124 | self.create_subtask(task_id, subtask) 125 | updates_made += 1 126 | 127 | return updates_made 128 | 129 | def delete_external_id_comment(self, identifier): 130 | task = self.all_tasks[identifier] 131 | task_id = task['_id'] 132 | 133 | comment = self.get_comment_containing_id(task_id) 134 | 135 | if comment is not None: 136 | comment_id = comment["_id"] 137 | requests.delete(TASKS_URI + "{0}/comments/{1}".format(task_id, comment_id), headers=self.auth) 138 | 139 | def create_subtask(self, task_id: str, subtask: Task): 140 | name = subtask.name 141 | completed = subtask.completed if subtask.completed is not None else False 142 | uri = TASKS_URI + "{0}/subtasks".format(task_id) 143 | body = {"name": name, "finished": completed} 144 | json = self.request(uri, body) 145 | 146 | def update_task(self, identifier, name, note, subtasks=None): 147 | task = self.all_tasks[identifier] 148 | task_id = task['_id'] 149 | existing_subtask_names = self.get_subtasks(task_id) 150 | properties = {} 151 | updates_made = 0 152 | 153 | if task['name'] != name: 154 | properties['name'] = name 155 | if compare_description(task['description'], note): 156 | properties['description'] = note 157 | 158 | if not len(properties) and not subtasks: 159 | self.log.debug(u"Nothing to update in task {0} '{1}'".format(identifier, name)) 160 | return updates_made 161 | 162 | if len(properties): 163 | self.log.debug(u"Updating pre-existing task {0} '{1}'".format(identifier, name)) 164 | self.request(TASKS_URI + "{0}".format(task_id), properties) 165 | updates_made += 1 166 | 167 | if subtasks: 168 | sorted_subtasks = sorted(subtasks, key=itemgetter('name')) 169 | self.log.debug(u"Existing sub-tasks in {0}, {1}: {2}".format(identifier, name, sorted_subtasks)) 170 | for subtask in sorted_subtasks: 171 | subtask_name = subtask['name'] 172 | if subtask_name not in existing_subtask_names: 173 | self.log.debug(u"Adding new subtask '{0}' to '{1}'".format(subtask_name, name)) 174 | self.create_subtask(task_id, subtask) 175 | updates_made += 1 176 | 177 | return updates_made 178 | 179 | def get_column_name(self, _id): 180 | columns = self.board_details["columns"] 181 | column = next(item for item in columns if item["uniqueId"] == _id) 182 | return column 183 | 184 | def clear_board(self): 185 | response = requests.get(TASKS_URI, headers=self.auth) 186 | 187 | for column in response: 188 | tasks = column["tasks"] 189 | for task in tasks: 190 | _id = task["_id"] 191 | requests.delete(TASKS_URI + "{0}".format(_id), headers=self.auth) 192 | 193 | def get_subtasks(self, task_id): 194 | names = [] 195 | sub_tasks = self.request(TASKS_URI + "{0}/subtasks".format(task_id)) 196 | for sub_task in sub_tasks: 197 | names.append(sub_task['name']) 198 | return names 199 | 200 | def request(self, url, body=None): 201 | try: 202 | if body: 203 | # self.log.debug("POST {0}".format(url)) 204 | headers = self.auth.copy() 205 | headers['Accept-Encoding'] = "gzip" 206 | response = requests.post(url, headers=headers, json=body) 207 | else: 208 | # self.log.debug("GET {0}".format(url)) 209 | response = requests.get(url, headers=self.auth) 210 | 211 | self.api_requests += 1 212 | self.bytes_transferred += len(response.content) 213 | 214 | code = response.status_code 215 | # self.log.debug("RESPONSE {0}".format(code)) 216 | 217 | if code == 200: 218 | json = response.json() 219 | return json 220 | except ( 221 | requests.ConnectionError, 222 | requests.exceptions.ReadTimeout, 223 | requests.exceptions.Timeout, 224 | requests.exceptions.ConnectTimeout, 225 | ) as e: 226 | print(e) 227 | 228 | return None 229 | 230 | 231 | def compare_description(description, note): 232 | result = False 233 | if not note: 234 | note = u'' 235 | 236 | if description != note: 237 | result = True 238 | 239 | return result 240 | -------------------------------------------------------------------------------- /omnifocustokanban/of-to-kb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Omnifocus to Kanban 4 | 5 | Usage: 6 | of-to-kb.py (--trello | --leankit | --kanbanflow | --plane) [--eval] 7 | of-to-kb.py -h | --help 8 | of-to-kb.py --version 9 | 10 | """ 11 | import sys 12 | import os 13 | import logging 14 | import logging.config 15 | from timeit import default_timer as timer 16 | from typing import List, Dict, Any, Union 17 | from docopt import docopt 18 | from kanban_board import KanbanFlow 19 | from omnifocus import Omnifocus 20 | 21 | # Constants 22 | CONFIG_PATH = './config/log.conf' 23 | KANBANFLOW_OPTION = '--kanbanflow' 24 | PLANE_OPTION = '--plane' 25 | 26 | 27 | def setup_logging() -> None: 28 | """Set up logging configuration.""" 29 | logging.config.fileConfig(CONFIG_PATH) 30 | logging.debug(f"sys.path: {sys.path}") 31 | logging.debug(f"sys.executable: {sys.executable}") 32 | logging.debug(f"os.getcwd(): {os.getcwd()}") 33 | 34 | 35 | def connect_to_board(opts: Dict[str, Any]) -> Union[KanbanFlow]: 36 | """Connect to the appropriate Kanban board based on options.""" 37 | if opts[KANBANFLOW_OPTION]: 38 | logging.debug("Connecting to KanbanFlow board") 39 | return KanbanFlow() 40 | else: 41 | raise ValueError(f"Expected {KANBANFLOW_OPTION} or {PLANE_OPTION}") 42 | 43 | 44 | def process_tasks(board: Union[KanbanFlow], omnifocus: Omnifocus, external_ids: List[str]) -> str: 45 | """Process tasks and return a result string.""" 46 | tasks_closed, repeating_tasks_closed = omnifocus.close_tasks(external_ids) 47 | cards_to_add = omnifocus.flagged_tasks().values() 48 | 49 | if isinstance(board, KanbanFlow): 50 | board.remove_comments_from_repeating_tasks(repeating_tasks_closed) 51 | cards_added = board.add_cards(cards_to_add) 52 | 53 | return f"{cards_added} update(s), {len(tasks_closed)} task(s) closed & {len(repeating_tasks_closed)} repeating tasks closed" 54 | 55 | 56 | def main() -> None: 57 | start = timer() 58 | opts = docopt(__doc__) 59 | 60 | try: 61 | board = connect_to_board(opts) 62 | external_ids = board.find_completed_card_ids() 63 | omnifocus = Omnifocus() 64 | 65 | if not opts['--eval']: 66 | result = process_tasks(board, omnifocus, external_ids) 67 | elapsed_time = timer() - start 68 | logging.debug(f"{result} in {elapsed_time:.2f}s") 69 | else: 70 | cards_to_add = omnifocus.flagged_tasks().values() 71 | result = f"To close: {external_ids}\nTo add: {cards_to_add}" 72 | 73 | print(result) 74 | # os._exit(0) 75 | 76 | except ValueError as e: 77 | logging.error(f"Invalid option: {e}") 78 | sys.exit(1) 79 | except IOError as e: 80 | logging.error(f"Failed to connect to kanban board: {e}") 81 | sys.exit(2) 82 | except Exception as e: 83 | logging.error(f"Unexpected error: {e}") 84 | logging.error(f"Exception type: {type(e).__name__}") 85 | logging.error(f"Exception details: {str(e)}") 86 | logging.error("Traceback:", exc_info=True) 87 | sys.exit(3) 88 | 89 | 90 | if __name__ == '__main__': 91 | setup_logging() 92 | main() 93 | -------------------------------------------------------------------------------- /omnifocustokanban/omnifocus.py: -------------------------------------------------------------------------------- 1 | # Based heavily on this: https://github.com/msabramo/PyOmniFocus 2 | 3 | import applescript 4 | import logging 5 | import os 6 | import re 7 | import sqlite3 8 | from datetime import datetime, timezone 9 | from typing import Dict, List, Optional, Tuple, Any 10 | from dataclasses import dataclass 11 | from enum import Enum 12 | 13 | 14 | class TaskStatus(Enum): 15 | DONE = 'done' 16 | DROPPED = 'dropped' 17 | INACTIVE = 'inactive' 18 | 19 | 20 | @dataclass 21 | class Task: 22 | identifier: str 23 | name: str 24 | type: str 25 | note: str 26 | completed: Optional[bool] 27 | uri: str 28 | children: Optional[List['Task']] = None 29 | 30 | 31 | DB_LOCATION = "/Library/Group Containers/34YW5XSRB7.com.omnigroup.OmniFocus/com.omnigroup.OmniFocus4/" \ 32 | "com.omnigroup.OmniFocusModel/OmniFocusDatabase.db" 33 | DB_PREFIX = '' 34 | URI_PREFIX = 'omnifocus:///task/' 35 | DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' 36 | ENCODING = 'utf-8' 37 | 38 | IS_TASK_COMPLETE = """ 39 | on task_completed(task_id) 40 | tell application "OmniFocus" 41 | try 42 | set theTask to task id task_id of default document 43 | return completed of theTask 44 | end try 45 | end tell 46 | end run 47 | """ 48 | CLOSE_TASK = """ 49 | on close_task(task_id) 50 | tell application "OmniFocus" 51 | set completed_task to task id task_id of default document 52 | set task_title to name of completed_task 53 | mark complete completed_task 54 | end tell 55 | end run 56 | """ 57 | 58 | # SQL queries as constants 59 | FLAGGED_TASKS_SQL = """ 60 | SELECT Task.persistentIdentifier, 61 | Task.name, 62 | Task.plainTextNote, 63 | Task.containingProjectInfo, 64 | Task.blocked, 65 | Task.blockedByFutureStartDate, 66 | Task.flagged, 67 | Task.effectiveFlagged, 68 | Task.dateCompleted, 69 | Task.dateDue, 70 | Task.dateToStart, 71 | Task.effectiveDateToStart, 72 | Task.childrenCount, 73 | Task.repetitionMethodString, 74 | Task.containsNextTask, 75 | ProjectInfo.status, 76 | Context.name AS "tag" 77 | FROM Task 78 | JOIN TaskToTag ON TaskToTag.task = Task.persistentIdentifier 79 | JOIN Context ON Context.persistentIdentifier = TaskToTag.tag 80 | JOIN ProjectInfo ON ProjectInfo.task = Task.containingProjectInfo 81 | WHERE Task.flagged = 1 82 | AND Task.effectiveFlagged = 1 83 | AND Task.dateCompleted IS NULL 84 | AND Task.blockedByFutureStartDate IS 0 85 | AND ProjectInfo.status NOT IN (?, ?, ?) 86 | """ 87 | 88 | CHILD_TASKS_SQL = """ 89 | SELECT Task.persistentIdentifier, 90 | Task.name, 91 | Task.plainTextNote, 92 | Task.containingProjectInfo, 93 | Task.blocked, 94 | Task.blockedByFutureStartDate, 95 | Task.flagged, 96 | Task.effectiveFlagged, 97 | Task.dateCompleted, 98 | Task.dateDue, 99 | Task.dateToStart, 100 | Task.effectiveDateToStart, 101 | Task.childrenCount, 102 | Task.repetitionMethodString, 103 | Task.containsNextTask, 104 | ProjectInfo.status, 105 | Context.name AS "tag" 106 | FROM Task 107 | JOIN TaskToTag ON TaskToTag.task = Task.persistentIdentifier 108 | JOIN Context ON Context.persistentIdentifier = TaskToTag.tag 109 | JOIN ProjectInfo ON ProjectInfo.task = Task.containingProjectInfo 110 | WHERE Task.dateCompleted IS NULL 111 | AND Task.blockedByFutureStartDate IS 0 112 | AND ProjectInfo.status NOT IN (?, ?, ?) 113 | AND Task.parent = ? 114 | """ 115 | 116 | 117 | class Omnifocus: 118 | log = logging.getLogger(__name__) 119 | 120 | def __init__(self): 121 | self.of_location = f"{os.path.expanduser('~')}{DB_LOCATION}" 122 | if not os.path.isfile(self.of_location): 123 | self.of_location = re.sub(".OmniFocus3", ".OmniFocus3.MacAppStore", self.of_location) 124 | self.log.debug(f"Using Omnifocus location {self.of_location}") 125 | 126 | try: 127 | self.conn = sqlite3.connect(self.of_location) 128 | self.conn.row_factory = sqlite3.Row 129 | except sqlite3.Error as e: 130 | self.log.error(f"Error connecting to database: {e}") 131 | raise 132 | 133 | def flagged_tasks(self) -> Dict[str, Task]: 134 | self.log.debug("Looking for flagged tasks") 135 | 136 | tasks = {} 137 | 138 | try: 139 | with self.conn: 140 | cursor = self.conn.cursor() 141 | cursor.execute(FLAGGED_TASKS_SQL, (TaskStatus.DONE.value, TaskStatus.DROPPED.value, TaskStatus.INACTIVE.value)) 142 | results = cursor.fetchall() 143 | self.log.debug(f"Found {len(results)} results") 144 | 145 | for row in results: 146 | task_dict = self.task_from_row(row) 147 | child_count = task_dict['child_count'] 148 | name = task_dict['name'] 149 | held = task_dict['is_wf_task'] 150 | _id = task_dict['identifier'] 151 | start_date = task_dict['start_date'] # timestamp in OmniFocus 3.6 152 | 153 | if self.is_deferred(start_date): 154 | self.log.debug(f"Ignoring deferred task '{name}'") 155 | continue 156 | if (child_count and not task_dict['has_next_task']) and (child_count and not held): 157 | self.log.debug(f"Ignoring task '{name}' with {child_count} sub-tasks but doesn't have next task") 158 | continue 159 | if task_dict['blocked'] and not child_count and not held: 160 | self.log.debug(f"Ignoring blocked task '{name}' ({child_count} sub-tasks) that isn't a WF task") 161 | continue 162 | 163 | tasks[_id] = self.init_task(task_dict) 164 | 165 | self.log.debug(f"Found {len(tasks)} flagged tasks") 166 | return tasks 167 | except sqlite3.Error as e: 168 | self.log.error(f"Database error: {e}") 169 | raise 170 | 171 | @staticmethod 172 | def task_from_row(row: sqlite3.Row) -> Dict[str, Any]: 173 | name = row['name'] 174 | blocked = row['blocked'] 175 | child_count = row['childrenCount'] 176 | has_next_task = row['containsNextTask'] 177 | identifier = row['persistentIdentifier'] 178 | tag = row['tag'] 179 | note = row['plainTextNote'] 180 | completed_date = row['dateCompleted'] 181 | is_wf_task = name.startswith('WF') 182 | start_date = row['dateToStart'] 183 | 184 | return { 185 | 'name': name, 186 | 'blocked': blocked, 187 | 'child_count': child_count, 188 | 'has_next_task': has_next_task, 189 | 'start_date': start_date, 190 | 'identifier': identifier, 191 | 'tag': tag, 192 | 'note': note, 193 | 'completed_date': completed_date, 194 | 'is_wf_task': is_wf_task 195 | } 196 | 197 | def close_tasks(self, identifiers: List[Dict[str, str]]) -> Tuple[List[Dict[str, str]], List[Dict[str, str]]]: 198 | tasks_closed = [] 199 | repeating_tasks_closed = [] 200 | for identifier in identifiers: 201 | _id = identifier["id"] 202 | name = identifier["name"] 203 | 204 | close_task_result = self.close_task(_id, name) 205 | 206 | if close_task_result == 1: 207 | tasks_closed.append(identifier) 208 | elif close_task_result == 2: 209 | repeating_tasks_closed.append(identifier) 210 | 211 | return tasks_closed, repeating_tasks_closed 212 | 213 | def close_task(self, _id: str, name: str) -> int: 214 | try: 215 | of_task_name, rep_rule = self.get_task_details(_id) 216 | already_closed = self.task_completed(_id) 217 | 218 | if already_closed: 219 | self.log.debug(f"Ignoring {URI_PREFIX}{_id} ({name}), already completed in Omnifocus") 220 | return 0 221 | 222 | if name != of_task_name: 223 | self.log.debug(f"Ignoring {URI_PREFIX}{_id} ({name}), names don't match ({of_task_name})") 224 | return 0 225 | 226 | rep_type = 1 227 | 228 | if rep_rule is None: 229 | self.log.debug(f"Closing {URI_PREFIX}{_id} ({name})") 230 | else: 231 | self.log.debug(f"Closing repeating task {URI_PREFIX}{_id} ({name})") 232 | rep_type = 2 233 | 234 | scpt = applescript.AppleScript(CLOSE_TASK) 235 | scpt.call('close_task', _id) 236 | 237 | return rep_type 238 | except ValueError as e: 239 | self.log.debug(f"Ignoring {URI_PREFIX}{_id} ({name}), not found in Omnifocus") 240 | self.log.debug(e) 241 | return 0 242 | 243 | def get_task_details(self, _id: str) -> Tuple[str, Optional[str]]: 244 | query = "SELECT * FROM Task WHERE persistentIdentifier = ?" 245 | try: 246 | with self.conn: 247 | cursor = self.conn.cursor() 248 | cursor.execute(query, (_id,)) 249 | result = cursor.fetchone() 250 | 251 | if result is None: 252 | raise ValueError(f"{_id} not found in Omnifocus") 253 | 254 | name = result["name"] 255 | rep_rule = result["repetitionMethodString"] 256 | return name, rep_rule 257 | except sqlite3.Error as e: 258 | self.log.error(f"Database error: {e}") 259 | raise 260 | 261 | def init_task(self, task_dict: Dict[str, Any]) -> Task: 262 | completed = None 263 | 264 | if task_dict['completed_date']: 265 | completed = True 266 | 267 | _id = task_dict['identifier'] 268 | child_count = task_dict['child_count'] 269 | 270 | task_obj = Task( 271 | identifier=_id, 272 | name=task_dict['name'], 273 | type=task_dict['tag'], 274 | note=task_dict['note'], 275 | completed=completed, 276 | uri=f"{URI_PREFIX}{_id}" 277 | ) 278 | 279 | if child_count: 280 | child_tasks = [] 281 | try: 282 | with self.conn: 283 | cursor = self.conn.cursor() 284 | cursor.execute(CHILD_TASKS_SQL, (TaskStatus.DONE.value, TaskStatus.DROPPED.value, TaskStatus.INACTIVE.value, _id)) 285 | results = cursor.fetchall() 286 | self.log.debug(f"Found {len(results)} child tasks") 287 | for child in results: 288 | child_tasks.append(self.init_task(self.task_from_row(child))) 289 | 290 | task_obj.children = child_tasks 291 | except sqlite3.Error as e: 292 | self.log.error(f"Database error: {e}") 293 | raise 294 | 295 | self.log.debug(f"Created task {task_obj}") 296 | return task_obj 297 | 298 | @staticmethod 299 | def task_completed(identifier: str) -> bool: 300 | return applescript.AppleScript(IS_TASK_COMPLETE).call('task_completed', identifier) 301 | 302 | @staticmethod 303 | def deferred_date(date_to_start: Optional[str]) -> Optional[datetime]: 304 | if date_to_start is not None: 305 | if date_to_start[-1:] != "Z": 306 | date_to_start += "Z" 307 | logging.debug(f"Determining task's deferred date: {date_to_start}") 308 | return datetime.strptime(date_to_start, DATETIME_FORMAT) 309 | return None 310 | 311 | @staticmethod 312 | def is_deferred(date_to_start: Optional[str]) -> bool: 313 | now = datetime.now(timezone.utc) 314 | logging.debug(f"Checking if task is deferred based on date_to_start {date_to_start} > {now}") 315 | if date_to_start is not None: 316 | deferred_date = Omnifocus.deferred_date(date_to_start) 317 | return deferred_date is not None and deferred_date.replace(tzinfo=timezone.utc) > now 318 | return False 319 | 320 | 321 | if __name__ == '__main__': 322 | omnifocus = Omnifocus() 323 | print(omnifocus.get_task_details("k83Obd03UWV")) 324 | 325 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | py-trello 3 | docopt 4 | sqlalchemy 5 | py-applescript 6 | pyobjc 7 | PyYaml 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='omnifocus-to-kanban', 5 | version='0.1', 6 | packages=find_packages(''), 7 | package_dir={'': 'src'}, 8 | url='', 9 | license='', 10 | author='rlewis', 11 | author_email='rhyd.lewis@icloud.com', 12 | description='Syncs data from Omnifocus to a kanban board' 13 | ) 14 | --------------------------------------------------------------------------------