├── .gitignore ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── README.md ├── empty_things3_inbox_to_notion.py └── thingsnotion.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .Rhistory 4 | node_modules/ 5 | .vscode/ 6 | __pycache__/ 7 | .lastblockid 8 | .ipynb_checkpoints 9 | cron/cron.txt 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | Things3 to Notion workflow to migrate Things3 "notes" (items in the inbox with no title) to Notion. It will migrate notes in the inbox with no title or notes with the `migrate to notion` tag. 4 | 5 | ### Setup 6 | 7 | #### Library installs 8 | - `pip3 install things.py` 9 | - `pip3 install python-dotenv` 10 | - `pip3 install notion-client` 11 | - `pip3 install pyobjc` 12 | #### `.env` setup 13 | - Setup the `.env` file, then you can use the scripts. 14 | - Get a [secret token](https://developers.notion.com/docs/authorization) from an [Notion integration](https://www.notion.so/help/create-integrations-with-the-notion-api) [here](https://www.notion.so/profile/integrations). 15 | - Set variable `NOTION_TOKEN` equal to your secret token. 16 | 17 | You can use this template 18 | 19 | ``` 20 | DB_ID="" 21 | NOTION_TOKEN="" 22 | ALFRED_FILEPATH="" 23 | MOMENT_PAGE_CAPTURE_ID="" 24 | MOMENT_PAGE_WORK_ID="" 25 | ``` 26 | 27 | ### Scripts 28 | 29 | `empty_things3_inbox_to_notion.py`: Takes a block id (i.e. `bf14e6e54b74464db2d2483e114455a6`) and migrates the things3 inbox items to that block (or page). You can get this link with `command + L` or from the end of the Notion URL. 30 | - Setup Instructions 31 | - Enable permissions for the script: `chmod a+x empty_things3_inbox_to_notion.py` 32 | - Run the script: `python3 empty_things3_inbox_to_notion.py [block ID]` 33 | - If no `block ID` given, it will try to infer from last used block ID 34 | 35 | ### Debug 36 | - Make sure your Things3 is up to date. This broke the Things3 python library for me before 37 | 38 | ### References 39 | 40 | - [Things3 python library](https://github.com/thingsapi/things.py#documentation) 41 | - [Notion API docs](https://developers.notion.com/docs/getting-started) 42 | - [Notion python sdk](https://github.com/ramnes/notion-sdk-py) 43 | - Why I like [quick capture](https://culturedcode.com/things/support/articles/2249437/): to [Close open loops](https://notes.andymatuschak.org/z8d4eJNaKrVDGTFpqRnQUPRkexB7K6XbcffAV) 44 | - [Things3's Applescript Support](https://culturedcode.com/things/support/articles/2803572/) 45 | -------------------------------------------------------------------------------- /empty_things3_inbox_to_notion.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import thingsnotion as tn 3 | import things 4 | from notion_client import Client 5 | from dotenv import load_dotenv 6 | from enum import Enum 7 | 8 | load_dotenv() 9 | import os 10 | import re 11 | import sys 12 | 13 | class ExportLocation(Enum): 14 | THINGS_3_DUMP = 1 15 | CAPTURE = 2 16 | WORK = 3 17 | 18 | def addParagraphToBlock(block_id, paragraph_content): 19 | block = tn.create_paragraph(paragraph_content) 20 | notion.blocks.children.append(block_id, children=[block]) 21 | 22 | def addContentToBlock( 23 | block_id, content: list, *, title="", padded=True, blank_header=False, as_type: tn.BlockTypes=None 24 | ): 25 | if as_type == tn.BlockTypes.CALLOUT: 26 | content_ = [tn.create_callout_block(title=title, children=content)] 27 | elif as_type == tn.BlockTypes.TOGGLE: 28 | content_ = [tn.create_toggle_block(title=title, children=content)] 29 | elif as_type: 30 | content_ = [tn.create_block(title=title, children=content, type=type)] 31 | elif padded: 32 | content_ = [tn.create_paragraph("")] + content + [tn.create_paragraph("")] 33 | if blank_header and content and not tn.objIsHeader(content[0]): 34 | content_ = [tn.create_heading("")] + content 35 | try: 36 | notion.blocks.children.append(block_id, children=content_) 37 | except Exception as e: 38 | print(f"failed call to addContentToBlock() where {block_id=} {content_=} because {e=}") 39 | return False 40 | return True 41 | 42 | if __name__ == "__main__": 43 | # setup 44 | my_token = os.getenv("NOTION_TOKEN") 45 | notion = Client(auth=my_token) 46 | inbox = things.inbox() 47 | 48 | query = None 49 | if len(sys.argv) > 1: 50 | query = sys.argv[1] 51 | block_id = "" 52 | while not block_id: 53 | if query: 54 | block_id = query.strip().split("-")[-1] 55 | if not block_id: 56 | block_id = tn.getLastBlockID() 57 | block_id = re.match(r"[a-zA-Z\d]{32}", block_id)[0] 58 | if not block_id: 59 | print(f'Invalid ID: "{block_id}"') 60 | 61 | tn.saveLastBlockID(block_id) 62 | 63 | migrate_empty_titles = True 64 | migrate_full_titles = False 65 | migrate_date_titles = True 66 | 67 | todo_item_ids = [] 68 | TABGS_TO_MIGRATE = set(['migrate to notion']) 69 | itemsToMigrate = [ 70 | todo 71 | for todo in inbox 72 | if ( 73 | (migrate_empty_titles and todo["title"] == "") 74 | or (migrate_full_titles and todo["title"] != "") 75 | or ('tags' in todo and len(list(set(todo['tags']) & TABGS_TO_MIGRATE)) > 0) 76 | ) 77 | ] 78 | 79 | if migrate_full_titles: 80 | notes_raw = [ 81 | "# " + obj["title"] + "\n" + obj["notes"] for obj in itemsToMigrate 82 | ] 83 | else: 84 | notes_raw = [] 85 | for obj in itemsToMigrate: 86 | note_content = "" 87 | if obj["title"]: 88 | note_content += obj["title"] + "\n" 89 | note_content += obj["notes"] 90 | notes_raw.append(note_content) 91 | notes_dict = [tn.obj_from_md(o) for o in notes_raw] 92 | todo_item_ids = [o["uuid"] for o in itemsToMigrate] 93 | 94 | as_callouts = False 95 | add_empty_headers = False 96 | 97 | num_written = 0 98 | # write to notion 99 | for i, (note_content, note_id) in enumerate(zip( 100 | notes_dict, todo_item_ids 101 | )): 102 | block_type = tn.BlockTypes.CALLOUT if as_callouts else tn.BlockTypes.TOGGLE 103 | 104 | # hack: this is fragile way to parse the title from the content, need to refactor to get this -- ideally parse from the raw markdown 105 | try: 106 | bad_title_parsing = note_content[0]['paragraph']['rich_text'][0]['text']['content'] 107 | except (IndexError, KeyError): 108 | bad_title_parsing = "" 109 | 110 | if addContentToBlock( 111 | block_id, 112 | note_content[1:] if bad_title_parsing else note_content, 113 | title=str(bad_title_parsing), 114 | padded=False, 115 | blank_header=add_empty_headers, 116 | as_type=block_type 117 | ): 118 | num_written += 1 119 | tn.deleteTodoItemWithID(note_id) 120 | print(f"{i=} {block_id=}") 121 | if i % 10: 122 | print(f"processing items... [{i=}]") 123 | 124 | returnMessage = f"Wrote {num_written} objects. [{block_id=}]" 125 | print(returnMessage) 126 | -------------------------------------------------------------------------------- /thingsnotion.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | from Foundation import NSAppleScript 4 | from enum import Enum 5 | 6 | load_dotenv() 7 | my_key = os.getenv("DB_ID") 8 | my_token = os.getenv("NOTION_TOKEN") 9 | last_url_filename = '.lastblockid' 10 | alfred_filepath_extension = os.getenv("ALFRED_FILEPATH") 11 | if (alfred_filepath_extension[-1] != '/'): 12 | alfred_filepath_extension += '/' 13 | 14 | class BlockTypes(Enum): 15 | PARAGRAPH="paragraph" 16 | BULLETED_LIST_ITEM="bulleted_list_item" 17 | NUMBERED_LIST_ITEM="numbered_list_item" 18 | TOGGLE="toggle" 19 | TO_DO="to_do" 20 | QUOTE="quote" 21 | CALLOUT="callout" 22 | SYNCED_BLOCK="synced_block" 23 | TEMPLATE="template" 24 | COLUMN="column" 25 | CHILD_PAGE="child_page" 26 | CHILD_DATABASE="child_database" 27 | TABLE="table" 28 | 29 | def create_heading(content, level=1): 30 | level = min(level, 3) 31 | return { 32 | "object": 'block', 33 | "type": f"heading_{level}", 34 | f"heading_{level}": { 35 | "rich_text": [ 36 | { 37 | "type": 'text', 38 | "text": { 39 | "content": content, 40 | }, 41 | }, 42 | ], 43 | }, 44 | } 45 | 46 | def create_callout_block(title='', children=[]): 47 | emoji = '👉' 48 | return { 49 | "object": 'block', 50 | "type": 'callout', 51 | "has_children": True, 52 | "callout": { 53 | "rich_text": [{ 54 | "type": "text", 55 | "text": { 56 | "content": title, 57 | }, 58 | }], 59 | "icon": { 60 | "emoji": emoji 61 | }, 62 | "color": "default", 63 | "children": children 64 | }, 65 | } 66 | 67 | def create_toggle_block(title='', children=[]): 68 | return { 69 | "object": 'block', 70 | "type": 'toggle', 71 | "has_children": True, 72 | "toggle": { 73 | "rich_text": [{ 74 | "type": "text", 75 | "text": { 76 | "content": title, 77 | }, 78 | }], 79 | "color": "default", 80 | "children": children 81 | }, 82 | } 83 | 84 | def create_paragraph(content): 85 | return { 86 | "object": 'block', 87 | "type": 'paragraph', 88 | "paragraph": { 89 | "rich_text": [ 90 | { 91 | "type": 'text', 92 | "text": { 93 | "content": content, 94 | "link": None 95 | }, 96 | }, 97 | ], 98 | }, 99 | } 100 | 101 | def parse_markdown_to_arr(md): 102 | return [x for x in md.split("\n") if x] 103 | 104 | def stringIsHeader(a): 105 | if a: 106 | return a[0] == "#" 107 | return False 108 | 109 | def objIsHeader(obj): 110 | if 'type' in obj: 111 | return "heading" in obj['type'] 112 | return False 113 | 114 | def parse_arr_to_obj(arr): 115 | btype = [] 116 | for a in arr: 117 | if stringIsHeader(a): 118 | btype.append(f"h{a[:3].count('#')}") 119 | else: 120 | btype.append("p") 121 | 122 | obj_ = [] 123 | for c, t in zip(arr, btype): 124 | if t[0] == "h": 125 | obj_.append(create_heading(c, int(t[1]))) 126 | else: 127 | obj_.append(create_paragraph(c)) 128 | return obj_ 129 | 130 | def saveLastBlockID(blockID): 131 | url = alfred_filepath_extension + last_url_filename 132 | with open(url, 'w') as f: 133 | f.write(blockID) 134 | 135 | def getLastBlockID(): 136 | url = alfred_filepath_extension + last_url_filename 137 | with open(url, 'r') as f: 138 | return f.read() 139 | 140 | def obj_from_md(md): 141 | pmd = parse_markdown_to_arr(md) 142 | return parse_arr_to_obj(pmd) 143 | 144 | def deleteTodoItemWithID(id: str, area_names=[], project_names=[]): 145 | """deletes an item in the inbox that has the given id, using applescript""" 146 | 147 | foo = ['list \"Inbox\"'] + [f'area \"{a}\"' for a in area_names] + [f'project \"{p}\"' for p in project_names] 148 | 149 | for bar in foo: 150 | s = NSAppleScript.alloc().initWithSource_(f"""tell application "Things3" 151 | set inboxToDos to to dos of {bar} 152 | repeat with inboxToDo in inboxToDos 153 | if id of inboxToDo equals "{id}" 154 | move inboxToDo to list "Trash" 155 | end if 156 | end repeat 157 | end tell""") 158 | print(f"error(s): {s.executeAndReturnError_(None)}") 159 | 160 | def deleteBlankInboxItems(): 161 | """Deletes all inbox items without a name, using applescripts""" 162 | s = NSAppleScript.alloc().initWithSource_("""tell application "Things3" 163 | set inboxToDos to to dos of list "Inbox" 164 | repeat with inboxToDo in inboxToDos 165 | if name of inboxToDo equals "" 166 | move inboxToDo to list "Trash" 167 | end if 168 | end repeat 169 | end tell""") 170 | print(f"error(s): {s.executeAndReturnError_(None)}") 171 | 172 | def createNewTodo(name="New to do [made by script]"): 173 | """Create a new todo of name `name`, using applescripts""" 174 | s = NSAppleScript.alloc().initWithSource_(f"""tell application "Things3" 175 | set newToDo to make new to do with properties \{name:"{name}"\} 176 | end tell""") 177 | print(f"error(s): {s.executeAndReturnError_(None)}") 178 | --------------------------------------------------------------------------------