├── .gitignore ├── README.md ├── TODO.taskpaper ├── app-screenshot.png ├── app.py ├── dialog-control-finder.png ├── dialog-control-system-events.png ├── export_things.py ├── icon.acorn ├── icon.icns ├── list_styles.py ├── makefile ├── requirements.txt ├── t2tp.sh ├── test-data ├── Things-legacy-testdb.sqlite3 ├── Things-testdb.sqlite ├── Things-testdb.thingsdatabase │ └── main.sqlite └── test-database-export.taskpaper └── test-t2tp.sh /.gitignore: -------------------------------------------------------------------------------- 1 | /export_data 2 | /export.log 3 | /Things.sqlite3 4 | /icon.png 5 | /things2taskpaper.spec 6 | /build 7 | /things2taskpaper-debug.spec 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Export your Things 3 database to TaskPaper 2 | 3 | Here's a simple App to export from [Things 3 (by Cultured Code)](https://culturedcode.com/things/) to [TaskPaper](https://www.taskpaper.com). 4 | 5 | I created this app because I discovered that for me Things works great for some use cases, but for others I want to use a plain text format: I use Things for capturing taks, ideas and bookmarks, for planning my day and for reminding me about all the stuff that I don't care about, but need to do anyway. For detailed planning, for managing ideas and things like reading lists, I am much more effective using TaskPaper files. So I have set up an area "Export" with several projects that act as buckets for certain topics like ideas, reading, research etc., and roughly every two weeks I use this app to export my Things Inbox and the "Export" area with this app and delete all those tasks in Thinks. This keeps my Things database nice and manageable. 6 | 7 | Currently, the exporter has the following features: 8 | 9 | - export tasks, projects (including the inbox) and areas 10 | - headers, checklists, notes 11 | - tags for tasks, projects and areas 12 | - done and trashed items are excluded 13 | - due dates, start dates, today and someday are added as tags to projects and tasks 14 | 15 | Repeating tasks are currently not supported. The exporter was tested with Things 3.13.6. 16 | 17 | ## Usage 18 | 19 | Download the latest version of the app from the [releases page](https://github.com/bboc/things3-export/releases). 20 | 21 | **Warning: Make sure to close Things before you run the app!!** 22 | 23 | Run the app, you will see this screen: 24 | 25 | ![](app-screenshot.png) 26 | 27 | Depending on your system, MacOS may present two dialogs to you, where it asks you for the permission to control Finder and System-Events. You have to grant these permissions for the app to work: 28 | 29 | ![](dialog-control-finder.png) 30 | 31 | ![](dialog-control-system-events.png) 32 | 33 | If you just click the button "EXPORT", the app will export your Things 3 database to a folder "Things 3 export" in your Downloads folder, with one Taskpaper-file per are. 34 | 35 | Here's a brief explanation of the options and other interface elements, from top to bottom: 36 | 37 | - **Output as**: Select one of three output formats – all in one Taskpaper file, one Taskpaper file per area (default), or one Taskpapaer file for each project 38 | - **EXPORT**: exports your things database with the selected options 39 | - **Output file**: (optional) this will be used as a name of the export directory in your Downloads folder (or as a filename in case you want all in one file). If you enter nothing, the default will be "Things 3 export" 40 | - **Select custom database**: (optional) Leave this empty if you want to use the default Things 3 database location. Otherwise click "Select File" to select another Things 3 database, you need to locate the file `main.sqklite` inside the thingsdatabase-bundle. 41 | - **Exporter Output**: Here you will see what's happening during the export, sometimes helpful if things are not going according to plan. 42 | - **Quit**: Close the app. 43 | 44 | That's it. Go to your Downloads folder to see the export. 45 | 46 | 47 | ## Command line Tools 48 | 49 | In addition to the GUI-Version, the exporter can also be run from the command line. 50 | 51 | By default, the exporter will create a folder for each area that contains a TaskPaper file per project. One file per area and one file with everything is also possible. To see what the exporter can do, open the Terminal app and run 52 | 53 | `$ python3 export_things.py -h` 54 | 55 | 56 | ### Setup 57 | 58 | The exporter is Python program run from the command line, download `export_things.py` and `t2tp.sh` to a directory on your Mac, and then navigate to that directory in the Terminal app. 59 | 60 | 61 | #### Installing Python 3 62 | 63 | While the app comes bundled with a Python interpreter, you need to have Python 3 installed to run the command line version. 64 | 65 | If you don't have it installed, here's what you need to do: 66 | 67 | $ xcode-select --install 68 | $ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" 69 | $ brew install python3 70 | 71 | If you have Xcode or the Command Line Tools (CLT) for Xcode already installed, skip step 1. If you have [Homebrew](https://brew.sh) installed, skip step 2. If you have Python 3 already installed, why are you reading this anyway? 72 | 73 | 74 | ### Usage: 75 | 76 | 77 | #### Work on the live database 78 | 79 | It's definitely a good idea to close things before you do that: 80 | 81 | `$ source t2tp.sh` 82 | 83 | You will find all your tasks in a folder called `export data`, one TaskPaper file per project, areas are grouped in subfolders. 84 | 85 | If you prefer one TaskPaper file per area, you can add the option `--format area`, if you prefer one file with everything, just add `--format all`. 86 | 87 | 88 | #### Work on a copy of the database 89 | 90 | Copy the things database to the same folder where you downloaded the script: 91 | 92 | `$ cp ~/Library/Group\ Containers/JLMPQHK86H.com.culturedcode.ThingsMac/Things\ Database.thingsdatabase/main.sqlite .` 93 | 94 | then run the exporter: 95 | 96 | `$ python3 export_things.py` 97 | 98 | 99 | ### Restore a database backup in Things 3 100 | 101 | If you, like me, play around with your Things database and accidentally sync changes you don't want back to the Things cloud, [here's how to restore a backup database](https://support.culturedcode.com/customer/en/portal/articles/2803595-restoring-from-a-backup) 102 | 103 | ## Changelog 104 | 105 | - v 1.0.2. (2021-01-08): 106 | - re-designed the GUI for better usability 107 | - default Things 3 database is now automatically selected 108 | - fixed a bug that prevented opening the file dialog for selecting the database 109 | - v1.0.1 (2020-12-20) 110 | - added support for new Database location in Things 3.13+ 111 | -------------------------------------------------------------------------------- /TODO.taskpaper: -------------------------------------------------------------------------------- 1 | Things3 Export: 2 | - add automated test runs export and compares to test export 3 | -------------------------------------------------------------------------------- /app-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bboc/things3-export/f38225bec4cea605e1d865fbe85e920c4d3ae6a9/app-screenshot.png -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Tkinter wrapper for things2takspaper. 4 | 5 | Note: When building a native app with pyinstaller, the working directory of 6 | the app (and of the export script) will be system root ("/"), which is NOT writable, at 7 | least on MacOS. When the app is invoked from the terminal via "open", or 8 | app.py is run with "python app.py", the working directory is the shell's 9 | current directory. 10 | 11 | """ 12 | 13 | from argparse import Namespace 14 | import logging 15 | import os 16 | from pathlib import Path 17 | import queue 18 | from textwrap import dedent 19 | import tkinter as tk 20 | from tkinter.scrolledtext import ScrolledText 21 | from tkinter import filedialog 22 | from tkinter import ttk 23 | 24 | import traceback 25 | 26 | import export_things 27 | 28 | # from setup import VERSION 29 | VERSION = '1.0.2' 30 | 31 | logger = logging.getLogger("t2tp") 32 | 33 | 34 | DATABASE_DIR = '~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/Things Database.thingsdatabase/' 35 | DATABASE_NAME = 'main.sqlite' 36 | DEFAULT_TARGET = 'Things 3 export' 37 | 38 | BG_COL_1 = "#524790" 39 | TEXT_COL = '#4a4a7c' 40 | 41 | class App: 42 | 43 | FMT_PROJECT = ("one file per project", "project") 44 | FMT_AREA = ("one file per area", "area") 45 | FMT_ALL = ("all in one", "all") 46 | 47 | FORMATS = (FMT_AREA, FMT_PROJECT, FMT_ALL) 48 | 49 | LABEL_TARGET_FORMAT = "Output as:" 50 | 51 | HEADER_TEXT = dedent("""\ 52 | Export your data from the Things 3 database to TaskPaper files. 53 | """) 54 | EXPLANATION_TEXT = dedent("""\ 55 | Please close Things 3 before exporting the database! 56 | You will find all exported data in your Downloads folder. 57 | """) 58 | 59 | def __init__(self, master): 60 | 61 | self.source_type = None 62 | master.geometry('600x600') 63 | self.setup_styles() 64 | self.build_gui(master) 65 | 66 | def setup_styles(self): 67 | 68 | s = ttk.Style() 69 | s.theme_use('aqua') 70 | s.configure('Export.TButton', padding=5, foreground=BG_COL_1, font=("Helvetica", 16, "normal")) 71 | s.configure('Quit.TButton', padding=5, foreground="#444444", font=("Helvetica", 14, "normal")) 72 | s.configure('Header.TLabel', font=("Helvetica", 18, "normal")) 73 | s.configure('TLabelframe.Label', font=("Helvetica", 15, "normal")) 74 | s.configure('TLabelframe.Label', foreground=TEXT_COL) 75 | s.configure('TLabel', foreground=TEXT_COL) 76 | s.configure('TButton', foreground=TEXT_COL) 77 | s.configure('TRadioButton', foreground=TEXT_COL) 78 | 79 | def build_gui(self, master): 80 | master.title("Export Things 3 to Taskpaper v%s" % VERSION) 81 | container = ttk.Frame(master, style="Container.TFrame") 82 | container.pack(fill=tk.BOTH, expand=True) 83 | 84 | # convert button and basic explanation: convert 85 | upper_frame = ttk.Frame(container) 86 | upper_frame.pack(fill=tk.BOTH, expand=False) 87 | 88 | T = ttk.Label(upper_frame, text=self.HEADER_TEXT, style='Header.TLabel', foreground=TEXT_COL) 89 | T.pack(anchor=tk.NW, padx=10, pady=5) 90 | T.config(state='disabled') 91 | 92 | T = ttk.Label(upper_frame, text=self.EXPLANATION_TEXT) 93 | T.pack(anchor=tk.NW, expand=False, fill=tk.NONE, padx=10, pady=5) 94 | 95 | # output format 96 | self.format = tk.StringVar() 97 | ff = ttk.Frame(upper_frame) 98 | ff.pack(anchor=tk.NW, padx=10, pady=5) 99 | self.output_format_frame(ff, self.LABEL_TARGET_FORMAT, self.format, self.FMT_AREA[1], self.FORMATS) 100 | 101 | export = ttk.Button(upper_frame, text="EXPORT", command=self.cmd_things2tp, style="Export.TButton") 102 | export.pack(anchor=tk.NW, side=tk.LEFT, padx=10, pady=5) 103 | 104 | quit = ttk.Button(upper_frame, text="QUIT", command=master.quit, style='Quit.TButton') 105 | quit.pack(anchor=tk.NE, side=tk.RIGHT, padx=5, pady=5) 106 | 107 | ttk.Separator(container).pack(fill=tk.X, padx=5, pady=5) 108 | 109 | options_frame = ttk.LabelFrame(container, text='More options:') 110 | options_frame.pack(anchor=tk.NW, fill=tk.X, expand=False, padx=10, pady=5) 111 | 112 | self.more_options_frame(options_frame) 113 | 114 | ttk.Separator(container).pack(fill=tk.X, padx=5, pady=5) 115 | 116 | logger_frame = ttk.LabelFrame(container, text="Exporter Output:") 117 | logger_frame.pack(anchor=tk.NW, fill=tk.X, padx=10, pady=10) 118 | self.console = ConsoleUi(logger_frame, container) 119 | 120 | def more_options_frame(self, frame): 121 | # source file 122 | self.filename = tk.StringVar() 123 | 124 | # output file 125 | self.output_file = tk.StringVar() 126 | output_frame = ttk.Frame(frame) 127 | output_frame.pack(anchor=tk.NW, padx=0, pady=5) 128 | ttk.Label(output_frame, text="Output file ('%s' if empty):" % DEFAULT_TARGET).pack(side=tk.LEFT) 129 | self.entry_target_file = ttk.Entry(output_frame, text="foobar", textvariable=self.output_file) 130 | self.entry_target_file.pack(side=tk.LEFT, padx=0, pady=5) 131 | 132 | source_frame = ttk.Frame(frame) 133 | source_frame.pack(anchor=tk.NW, padx=0, pady=10) 134 | 135 | ttk.Label(source_frame, text="Select custom databases:").pack(side=tk.LEFT) 136 | ttk.Entry(source_frame, text="foobar", textvariable=self.filename).pack(side=tk.LEFT) 137 | ttk.Button(source_frame, text="Select File", command=self.cb_select_file).pack(side=tk.LEFT) 138 | 139 | def output_format_frame(self, frame, label, variable, default, available_formats): 140 | """Set available output formats.""" 141 | variable.set(default) 142 | ttk.Label(frame, text=label).pack(side=tk.LEFT, padx=0, pady=10) 143 | for text, mode in available_formats: 144 | ttk.Radiobutton(frame, text=text, variable=variable, value=mode).pack(side=tk.LEFT) 145 | 146 | def clean_frame(self, frame): 147 | for widget in frame.winfo_children(): 148 | widget.destroy() 149 | 150 | def cb_select_file(self): 151 | filename = tk.filedialog.askopenfilename(initialdir=os.path.join(Path.home(), "Documents"), 152 | title="Select file", 153 | defaultextension='*.sqlite3', 154 | filetypes=(("SQLite3 Files", "*.sqlite3"), 155 | ("SQLite Files", "*.sqlite"))) 156 | self.filename.set(filename) 157 | 158 | def cmd_things2tp(self): 159 | """Export the database. This is called when pressing the Export button""" 160 | logger.setLevel('INFO') 161 | # logger.setLevel('DEBUG') 162 | logger.info("starting conversion...") 163 | 164 | database = self.filename.get() 165 | if not database: 166 | database = os.path.join(os.path.expanduser(DATABASE_DIR), DATABASE_NAME) 167 | logger.info("no databases selected, setting default database") 168 | 169 | if not os.path.exists(database): 170 | logger.error("database '%s' does not exist!" % database) 171 | return 172 | logger.info("database: %s" % database) 173 | 174 | output_format = self.format.get() 175 | logger.info("target format: %s" % output_format) 176 | if output_format not in [self.FMT_ALL[1], self.FMT_PROJECT[1], self.FMT_AREA[1]]: 177 | logger.error("unknown output format: %s" % output_format) 178 | return 179 | 180 | target = self.output_file.get() 181 | if not target: 182 | target = DEFAULT_TARGET 183 | # capture stdout if all-in-one 184 | if output_format == self.FMT_ALL[1]: 185 | target = export_things.RowObject.FILE_TMPL % target 186 | target = os.path.join(Path.home(), "Downloads", target) 187 | logger.info("target: %s" % target) 188 | 189 | args = Namespace(database=database, 190 | target=target, 191 | format=output_format, 192 | stdout=False, 193 | called_from_gui=True) 194 | try: 195 | export_things.export(args) 196 | except Exception: 197 | tb = traceback.format_exc() 198 | logger.error(tb) 199 | else: 200 | logger.info("export finished") 201 | 202 | logger.info("ready") 203 | 204 | 205 | class QueueHandler(logging.Handler): 206 | """Class to send logging records to a queue 207 | 208 | It can be used from different threads 209 | """ 210 | def __init__(self, log_queue): 211 | super(QueueHandler, self).__init__() 212 | self.log_queue = log_queue 213 | 214 | def emit(self, record): 215 | self.log_queue.put(record) 216 | 217 | 218 | class ConsoleUi: 219 | """Poll messages from a logging queue and display them in a scrolled text widget""" 220 | 221 | def __init__(self, frame, master): 222 | self.frame = frame 223 | # Create a ScrolledText wdiget 224 | self.scrolled_text = ScrolledText(frame, state='disabled', height=12, background="#E8E8E8") 225 | self.scrolled_text.grid(row=0, column=0, sticky=(tk.N, tk.S, tk.W, tk.E)) 226 | self.scrolled_text.configure(font='TkFixedFont') 227 | self.scrolled_text.tag_config('INFO', foreground='black') 228 | self.scrolled_text.tag_config('DEBUG', foreground='gray') 229 | self.scrolled_text.tag_config('WARNING', foreground='orange') 230 | self.scrolled_text.tag_config('ERROR', foreground='red') 231 | self.scrolled_text.tag_config('CRITICAL', foreground='red', underline=1) 232 | # Create a logging handler using a queue 233 | self.log_queue = queue.Queue() 234 | self.queue_handler = QueueHandler(self.log_queue) 235 | formatter = logging.Formatter('%(message)s') 236 | self.queue_handler.setFormatter(formatter) 237 | logger.addHandler(self.queue_handler) 238 | # Start polling messages from the queue 239 | self.frame.after(100, self.poll_log_queue) 240 | self.scrolled_text.pack(fill=tk.BOTH, expand=1) 241 | 242 | def display(self, record): 243 | msg = self.queue_handler.format(record) 244 | self.scrolled_text.configure(state='normal') 245 | self.scrolled_text.insert(tk.END, msg + '\n', record.levelname) 246 | self.scrolled_text.configure(state='disabled') 247 | # Autoscroll to the bottom 248 | self.scrolled_text.yview(tk.END) 249 | 250 | def poll_log_queue(self): 251 | # Check every 100ms if there is a new message in the queue to display 252 | while True: 253 | try: 254 | record = self.log_queue.get(block=False) 255 | except queue.Empty: 256 | break 257 | else: 258 | self.display(record) 259 | self.frame.after(100, self.poll_log_queue) 260 | 261 | 262 | def main(): 263 | root = tk.Tk() 264 | App(root) 265 | 266 | # begin workaround code 267 | def fix_macos_mojave_button_issue(): 268 | """See https://stackoverflow.com/questions/52529403/button-text-of-tkinter-does-not-work-in-mojave""" 269 | a = root.winfo_geometry().split('+')[0] 270 | b = a.split('x') 271 | w = int(b[0]) 272 | h = int(b[1]) 273 | root.geometry('%dx%d' % (w + 1, h + 1)) 274 | root.update() 275 | root.after(0, fix_macos_mojave_button_issue) 276 | # end of workaround code 277 | 278 | os.system('''/usr/bin/osascript -e 'tell app "Finder" to set frontmost of process "python" to true' ''') 279 | root.mainloop() 280 | root.destroy() 281 | 282 | 283 | if __name__ == "__main__": 284 | main() 285 | -------------------------------------------------------------------------------- /dialog-control-finder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bboc/things3-export/f38225bec4cea605e1d865fbe85e920c4d3ae6a9/dialog-control-finder.png -------------------------------------------------------------------------------- /dialog-control-system-events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bboc/things3-export/f38225bec4cea605e1d865fbe85e920c4d3ae6a9/dialog-control-system-events.png -------------------------------------------------------------------------------- /export_things.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from datetime import datetime 3 | import logging 4 | import os 5 | import re 6 | import sqlite3 7 | import sys 8 | 9 | """ 10 | Export Things 3 database to TaskPaper files 11 | 12 | Things 3 database can be found at: 13 | ~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/Things Database.thingsdatabase/main.sqlite 14 | 15 | before Things 3.13 it was at 16 | ~/Library/Containers/com.culturedcode.ThingsMac/Data/Library/Application Support/Cultured Code/Things/Things.sqlite3 17 | 18 | Database Structure: 19 | 20 | - TMArea contains areas 21 | - TMTasks contains Projects (type=1), Tasks (type=0) and "ActionGroups" (type=2) 22 | - Tasks are sometimes goruped by actionGroup (i.e. headers) 23 | - some tasks have checklists (which consit of TMChecklistitems) 24 | 25 | """ 26 | 27 | DEFAULT_TARGET = 'Things3 export' 28 | 29 | 30 | def export(args): 31 | try: 32 | args.called_from_gui 33 | except: 34 | # log to file only if not called from guo 35 | logging.basicConfig(filename='export.log', level=logging.ERROR) 36 | 37 | if args.format not in [RowObject.FMT_ALL, RowObject.FMT_PROJECT, RowObject.FMT_AREA]: 38 | raise Exception("unknown format %s" % args.format) 39 | 40 | con = sqlite3.connect(args.database) 41 | 42 | con.row_factory = sqlite3.Row 43 | 44 | # reroute stdout 45 | if args.format == RowObject.FMT_ALL and not args.stdout: 46 | filename = args.target 47 | if not filename.endswith('.taskpaper'): 48 | filename = RowObject.FILE_TMPL % filename 49 | reroute_stdout(filename) 50 | c = con.cursor() 51 | no_area = Area(dict(uuid='NULL', title='no area'), con, args) 52 | no_area.export() 53 | for row in c.execute(Area.QUERY): 54 | a = Area(row, con, args) 55 | a.export() 56 | con.close() 57 | 58 | if args.format == RowObject.FMT_ALL and not args.stdout: 59 | sys.stdout = sys.__stdout__ 60 | 61 | 62 | def reroute_stdout(filename, path_prefix=''): 63 | print("rerouting standardout to", filename) 64 | if path_prefix: 65 | filename = filename.replace(r'/', '|') 66 | filename = os.path.join(path_prefix, filename) 67 | sys.stdout = open(filename, 'w') 68 | 69 | 70 | class RowObject(object): 71 | PROJECT_TEMPLATE = "\n%(indent)s%(title)s:%(tags)s" 72 | FMT_ALL = 'all' 73 | FMT_PROJECT = 'project' 74 | FMT_AREA = 'area' 75 | 76 | def __init__(self, row, con, args, level=0): 77 | self.row = row 78 | self.con = con 79 | self.args = args 80 | self.level = level 81 | 82 | def __getattr__(self, name): 83 | return self.row[name] 84 | 85 | def __getitem__(self, name): 86 | return getattr(self, name) 87 | 88 | TEMPLATE = '%s%s' 89 | 90 | def indent_(self, level): 91 | return "\t" * level 92 | 93 | @property 94 | def indent(self): 95 | return self.indent_(self.level) 96 | 97 | @property 98 | def notes_indent(self): 99 | return self.indent_(self.level + 1) 100 | 101 | @property 102 | def tags(self): 103 | return '' # tags are empty for some items 104 | 105 | URL = re.compile("\.*)?\"\>.*?\<\/a\>") 106 | 107 | def print_notes(self): 108 | notes = self.notes 109 | if notes.startswith(""): 110 | notes = notes[27:-7] 111 | for line in notes.split("\n"): 112 | line = self.URL.sub(lambda m: m.group('url'), line) 113 | print('%s%s' % (self.notes_indent, line)) 114 | 115 | def find_and_export_items(self, klass, query): 116 | c = self.con.cursor() 117 | for row in c.execute(query): 118 | item = klass(row, self.con, self.args, self.level + 1) 119 | item.export() 120 | 121 | FILE_TMPL = "%s.taskpaper" 122 | 123 | def reroute_stdout(self, path_prefix): 124 | filename = self.FILE_TMPL % self.title 125 | reroute_stdout(filename, path_prefix) 126 | 127 | def makedirs(self): 128 | if not os.path.exists(self.path): 129 | os.makedirs(self.path) 130 | 131 | 132 | class RowObjectWithTags(RowObject): 133 | 134 | TAGS_QUERY = """ 135 | SELECT tag.title AS title FROM TMTaskTag AS tt, TMTag AS tag 136 | WHERE tt.tasks = '%s' 137 | AND tt.tags = tag.uuid; 138 | """ 139 | 140 | def __init__(self, row, con, args, level=0): 141 | super().__init__(row, con, args, level) 142 | self._tags = [] 143 | 144 | @property 145 | def tags(self): 146 | if len(self._tags) == 0: 147 | return '' 148 | return ' ' + ' '.join(self._tags) 149 | 150 | def add_tag(self, tag): 151 | if tag not in self._tags: 152 | self._tags.append(tag) 153 | 154 | def load_tags_from_db(self): 155 | def make_tag(title): 156 | return '@' + title.replace(' ', '_').replace('-', '_') 157 | 158 | c = self.con.cursor() 159 | for row in c.execute(self.TAGS_QUERY % self.uuid): 160 | self.add_tag(make_tag(row['title'])) 161 | 162 | 163 | class TaskObjects(RowObjectWithTags): 164 | 165 | task_fields = """ 166 | SELECT uuid, status, title, type, notes, area, dueDate, startDate, todayIndex, checklistItemsCount, stopDate 167 | FROM TMTask 168 | """ 169 | 170 | def add_attributes(self): 171 | """Add all attributes (due date, start date, today, someday etc.) as tags.""" 172 | if self.dueDate: 173 | self.add_tag('@due(%s)' % datetime.fromtimestamp(self.dueDate).strftime("%Y-%m-%d")) 174 | if self.todayIndex: 175 | if self.startDate: 176 | self.add_tag('@today') 177 | else: 178 | self.add_tag('@someday') 179 | elif self.startDate: 180 | self.add_tag('@startDate(%s)' % datetime.fromtimestamp(self.startDate).strftime("%Y-%m-%d")) 181 | if self.stopDate: 182 | self.add_tag('@done(%s)' % datetime.fromtimestamp(self.stopDate).strftime("%Y-%m-%d")) 183 | 184 | 185 | class Area(RowObjectWithTags): 186 | QUERY = """ 187 | SELECT uuid, title FROM TMArea ORDER BY "index"; 188 | """ 189 | 190 | TAGS_QUERY = """ 191 | SELECT tag.title AS title FROM TMAreaTag AS at, TMTag AS tag 192 | WHERE at.areas = '%s' 193 | AND at.tags = tag.uuid; 194 | """ 195 | 196 | def export(self): 197 | logging.debug("Area: %s (%s)", self.title, self.uuid) 198 | self.load_tags_from_db() 199 | if self.args.format == RowObject.FMT_ALL: 200 | next_level = 1 201 | print(self.PROJECT_TEMPLATE % self) 202 | elif self.args.format == RowObject.FMT_AREA: 203 | # reroute stdout to a file for this area 204 | self.path = self.args.target 205 | self.makedirs() 206 | self.reroute_stdout(self.args.target) 207 | next_level = 0 208 | else: 209 | # set path and make folder for area 210 | self.path = os.path.join(self.args.target, self.title) 211 | self.makedirs() 212 | next_level = 0 213 | 214 | c = self.con.cursor() 215 | 216 | if self.uuid == 'NULL': 217 | inbox = Project(dict(uuid='NULL', title='Inbox', 218 | dueDate=None, startDate=None, stopDate=None, todayIndex=None, notes=None), 219 | self.con, self.args, self.level + 1, self) 220 | inbox.export() 221 | query = Project.PROJECTS_WITHOUT_AREA 222 | else: 223 | self.find_and_export_items(Task, Task.TASKS_IN_AREA_WITHOUT_PROJECT % self.uuid) 224 | query = Project.PROJECTS_IN_AREA % self.uuid 225 | 226 | for row in c.execute(query): 227 | p = Project(row, self.con, self.args, next_level, self) 228 | p.export() 229 | 230 | if self.args.format == RowObject.FMT_AREA: 231 | sys.stdout = sys.__stdout__ 232 | 233 | 234 | class Project(TaskObjects): 235 | PROJECTS_IN_AREA = TaskObjects.task_fields + """ 236 | WHERE type=1 237 | AND area="%s" 238 | AND trashed = 0 239 | AND status < 2 -- not canceled 240 | ORDER BY "index"; 241 | """ 242 | PROJECTS_WITHOUT_AREA = TaskObjects.task_fields + """ 243 | WHERE type=1 244 | AND area is NULL 245 | AND trashed = 0 246 | AND status < 2 -- not canceled 247 | ORDER BY "index"; 248 | """ 249 | 250 | def __init__(self, row, con, args, level, area): 251 | super().__init__(row, con, args, level) 252 | self.area = area 253 | 254 | def export(self): 255 | logging.debug("Project: %s (%s)", self.title, self.uuid) 256 | self.load_tags_from_db() 257 | self.add_attributes() 258 | if self.args.format == RowObject.FMT_PROJECT: 259 | self.reroute_stdout(self.area.path) 260 | else: 261 | print(self.PROJECT_TEMPLATE % self) 262 | 263 | if self.notes: 264 | self.print_notes() 265 | 266 | if self.uuid == 'NULL': 267 | self.find_and_export_items(Task, Task.TASKS_IN_INBOX) 268 | else: 269 | self.find_and_export_items(Task, Task.TASKS_IN_PROJECT % self.uuid) 270 | 271 | if self.args.format == RowObject.FMT_PROJECT: 272 | sys.stdout = sys.__stdout__ 273 | 274 | 275 | class Task(TaskObjects): 276 | 277 | TASKS_IN_PROJECT = TaskObjects.task_fields + """ 278 | WHERE type != 1 -- find tasks and action groups 279 | AND project="%s" 280 | AND trashed = 0 281 | AND status < 2 -- whatever "1" means 282 | ORDER BY type, "index"; -- tasks without headers come first 283 | """ 284 | TASKS_IN_AREA_WITHOUT_PROJECT = TaskObjects.task_fields + """ 285 | WHERE type != 1 -- find tasks and action groups 286 | AND area="%s" 287 | AND project is NULL 288 | AND trashed = 0 289 | AND status < 2 -- whatever "1" means 290 | ORDER BY type, "index"; -- tasks without headers come first 291 | """ 292 | TASKS_IN_INBOX = TaskObjects.task_fields + """ 293 | WHERE type != 1 -- find tasks and action groups 294 | AND project IS NULL 295 | AND area IS NULL 296 | AND actionGroup IS NULL 297 | AND trashed = 0 298 | AND status < 2 -- whatever "1" means 299 | ORDER BY "index"; 300 | """ 301 | TASKS_IN_ACTION_GROUPS = TaskObjects.task_fields + """ 302 | WHERE type = 0 303 | AND actionGroup="%s" 304 | AND trashed = 0 305 | AND status < 2 -- whatever "1" means 306 | ORDER BY "index"; 307 | """ 308 | ACTIONGROUP = 2 309 | TASK_TEMPLATE = '%(indent)s- %(title)s%(tags)s' 310 | ACTIONGROUP_TEMPLATE = '%(indent)s%(title)s:' 311 | 312 | def export(self): 313 | logging.debug("Task: %s (%s) Level: %s Status: %s Type: %s", self.title, self.uuid, self.level, self.status, self.type) 314 | self.load_tags_from_db() 315 | self.add_attributes() 316 | if self.type == self.ACTIONGROUP: 317 | # process action group (which have no notes!) 318 | print(self.ACTIONGROUP_TEMPLATE % self) 319 | self.find_and_export_items(Task, Task.TASKS_IN_ACTION_GROUPS % self.uuid) 320 | else: 321 | print(self.TASK_TEMPLATE % self) 322 | if self.notes: 323 | self.print_notes() 324 | 325 | if self.checkListItemsCount: 326 | self.find_and_export_items(CheckListItem, CheckListItem.items_of_task % self.uuid) 327 | 328 | 329 | class CheckListItem(RowObject): 330 | items_of_task = """ 331 | SELECT uuid, title, status 332 | FROM TMChecklistItem 333 | WHERE task = '%s' 334 | ORDER BY "index" 335 | """ 336 | 337 | def export(self): 338 | print(Task.TASK_TEMPLATE % self) 339 | 340 | 341 | if __name__ == "__main__": 342 | 343 | parser = argparse.ArgumentParser(description='Export tasks from Things3 database to TaskPaper.') 344 | parser.add_argument('--target', dest='target', action='store', 345 | default=DEFAULT_TARGET, 346 | help='output folder (default: export_data') 347 | parser.add_argument('--db', dest='database', action='store', 348 | default='main.sqlite', 349 | help='path to the Things3 database (default: main.sqlite)') 350 | parser.add_argument('--format', dest='format', action='store', 351 | default='project', 352 | help='Define output format(area|project|all): what will be exported into one taskpaper file (default: project') 353 | parser.add_argument('--stdout', dest='stdout', action='store_true', 354 | default=False, 355 | help='output to standard output instead of file (only works with format=all)') 356 | 357 | args = parser.parse_args() 358 | export(args) 359 | -------------------------------------------------------------------------------- /icon.acorn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bboc/things3-export/f38225bec4cea605e1d865fbe85e920c4d3ae6a9/icon.acorn -------------------------------------------------------------------------------- /icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bboc/things3-export/f38225bec4cea605e1d865fbe85e920c4d3ae6a9/icon.icns -------------------------------------------------------------------------------- /list_styles.py: -------------------------------------------------------------------------------- 1 | """ 2 | Print config options and layout tree of ea ttk widget. 3 | 4 | From https://stackoverflow.com/questions/45389166/how-to-know-all-style-options-of-a-ttk-widget 5 | """ 6 | 7 | import tkinter.ttk as ttk 8 | import tkinter as tk 9 | 10 | 11 | def iter_layout(layout, tab_amnt=0, elements=[]): 12 | """Recursively prints the layout children.""" 13 | el_tabs = ' ' * tab_amnt 14 | val_tabs = ' ' * (tab_amnt + 1) 15 | 16 | for element, child in layout: 17 | elements.append(element) 18 | print(el_tabs + '\'{}\': {}'.format(element, '{')) 19 | for key, value in child.items(): 20 | if type(value) == str: 21 | print(val_tabs + '\'{}\' : \'{}\','.format(key, value)) 22 | else: 23 | print(val_tabs + '\'{}\' : [('.format(key)) 24 | iter_layout(value, tab_amnt=tab_amnt + 3) 25 | print(val_tabs + ')]') 26 | 27 | print(el_tabs + '{}{}'.format('} // ', element)) 28 | 29 | return elements 30 | 31 | 32 | def stylename_elements_options(stylename, widget): 33 | """Function to expose the options of every element associated to a widget stylename.""" 34 | 35 | try: 36 | # Get widget elements 37 | style = ttk.Style() 38 | layout = style.layout(stylename) 39 | config = widget.configure() 40 | 41 | print('{:*^50}\n'.format('Style = {stylename}')) 42 | 43 | print('{:*^50}'.format('Config')) 44 | for key, value in config.items(): 45 | print('{:<15}{:^10}{}'.format(key, '=>', value)) 46 | 47 | print('\n{:*^50}'.format('Layout')) 48 | elements = iter_layout(layout) 49 | 50 | # Get options of widget elements 51 | print('\n{:*^50}'.format('element options')) 52 | for element in elements: 53 | print('{0:30} options: {1}'.format( 54 | element, style.element_options(element))) 55 | 56 | except tk.TclError: 57 | print('_tkinter.TclError: "{0}" in function' 58 | 'widget_elements_options({0}) is not a regonised stylename.' 59 | .format(stylename)) 60 | 61 | 62 | if __name__ == "__main__": 63 | widget = ttk.Label(None) 64 | class_ = widget.winfo_class() 65 | stylename_elements_options(class_, widget) 66 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | app: 2 | echo "if the app doesn't build, delete the cache folders in ~/Library/Application Support/pyinstaller" 3 | pyinstaller --onefile --log-level WARN --icon=icon.icns --name things2taskpaper --noconfirm --windowed app.py 4 | open dist/things2taskpaper.app/Contents/MacOS/things2taskpaper 5 | debug: 6 | -rm -r dist/things2taskpaper-debug.app 7 | -rm dist/things2taskpaper-debug 8 | pyinstaller --onefile --log-level DEBUG --icon=icon.icns --name things2taskpaper-debug --debug all --windowed app.py 9 | open dist/things2taskpaper-debug.app/Contents/MacOS/things2taskpaper-debug 10 | apptest: 11 | python --version 12 | echo "make sure this is Python 3!" 13 | python app.py 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyInstaller 2 | 3 | -------------------------------------------------------------------------------- /t2tp.sh: -------------------------------------------------------------------------------- 1 | python3 export_things.py --db ~/Library/Group\ Containers/JLMPQHK86H.com.culturedcode.ThingsMac/Things\ Database.thingsdatabase/main.sqlite $1 $2 $3 2 | -------------------------------------------------------------------------------- /test-data/Things-legacy-testdb.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bboc/things3-export/f38225bec4cea605e1d865fbe85e920c4d3ae6a9/test-data/Things-legacy-testdb.sqlite3 -------------------------------------------------------------------------------- /test-data/Things-testdb.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bboc/things3-export/f38225bec4cea605e1d865fbe85e920c4d3ae6a9/test-data/Things-testdb.sqlite -------------------------------------------------------------------------------- /test-data/Things-testdb.thingsdatabase/main.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bboc/things3-export/f38225bec4cea605e1d865fbe85e920c4d3ae6a9/test-data/Things-testdb.thingsdatabase/main.sqlite -------------------------------------------------------------------------------- /test-data/test-database-export.taskpaper: -------------------------------------------------------------------------------- 1 | 2 | no area: 3 | 4 | Inbox: 5 | - a task in my inbox 6 | - Exporting your Data (with Link) 7 | https://culturedcode.com/things/support/articles/2982272/ 8 | Here’s how export your data from Things 3 on Mac and iOS. 9 | 10 | 11 | a free floating project: @foo 12 | this project has a tag and a note 13 | 14 | - here's a task in this project 15 | here's a heading: 16 | - and another task with is tagged @bar 17 | - this task is done 18 | - this task has a checklist 19 | - one 20 | - two 21 | - three 22 | - 23 | - task with note 24 | this is a note 25 | with some lines 26 | of text 27 | 28 | another headline: 29 | - this task has a deadline @due(2019-07-02) 30 | - task for today @today 31 | - task for this evening @startDate(2019-06-21) 32 | - task for a specific date @today 33 | - a someday task @someday 34 | 35 | this is my area: @area_tag 36 | - this is a task right inside an area 37 | 38 | first project in area: 39 | some notes 40 | - task1 41 | - task2 42 | 43 | second project in area: @today 44 | this one is set to "today" 45 | - task in today project 46 | 47 | this is another area: 48 | - 49 | 50 | a project: 51 | - a taks that has it all @Errand @due(2019-07-09) @today 52 | there's a note 53 | and a link 54 | https://culturedcode.com/things/support/articles/2981402/ 55 | - one 56 | - two 57 | -------------------------------------------------------------------------------- /test-t2tp.sh: -------------------------------------------------------------------------------- 1 | python3 export_things.py --db test-data/Things-testdb.thingsdatabase/main.sqlite --format all --stdout 2 | --------------------------------------------------------------------------------