Created
January 4, 2026 22:39
-
-
Save brokosz/4674002446b9dedf796feeedfbaf4a86 to your computer and use it in GitHub Desktop.
Export Things 3 tasks to TaskPaper format with support for areas, projects, tags, notes, and archive. Preserves hierarchy and scheduling information while handling standalone tasks and recurring items.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/osascript -l JavaScript | |
| ObjC.import('stdlib'); | |
| const app = Application('Things3'); | |
| app.includeStandardAdditions = true; | |
| const VERSION = '1.1.0'; | |
| const CONFIG = { | |
| includeArchive: true, | |
| archiveKeepAllTags: false, | |
| skipAnytimeTags: true, | |
| help: false, | |
| version: false | |
| }; | |
| // === Helpers === | |
| function log(msg) { | |
| console.log(msg); | |
| } | |
| function formatDate(date) { | |
| return date ? new Date(date).toISOString().split('T')[0] : ''; | |
| } | |
| function sanitize(text) { | |
| return text.replace(/:$/g, ''); | |
| } | |
| function pluralize(count, singular, plural) { | |
| return count === 1 ? singular : plural; | |
| } | |
| function getTags(item) { | |
| try { | |
| const tagNames = item.tagNames(); | |
| return tagNames ? tagNames.split(',').map(t => `@${t.trim()}`) : []; | |
| } catch(e) { | |
| return []; | |
| } | |
| } | |
| function getNotes(item) { | |
| try { | |
| return item.notes() || null; | |
| } catch(e) { | |
| return null; | |
| } | |
| } | |
| // === Schedule Tags === | |
| function getScheduleTag(todo) { | |
| try { | |
| const activation = todo.activationDate(); | |
| if (!activation) return CONFIG.skipAnytimeTags ? null : '@anytime'; | |
| const today = new Date(); | |
| today.setHours(0, 0, 0, 0); | |
| const activationDate = new Date(activation); | |
| activationDate.setHours(0, 0, 0, 0); | |
| return activationDate <= today ? '@today' : '@upcoming'; | |
| } catch(e) { | |
| return CONFIG.skipAnytimeTags ? null : '@anytime'; | |
| } | |
| } | |
| // === Structure Analysis === | |
| function analyzeThingsStructure() { | |
| log('Analyzing Things structure...'); | |
| const structure = { | |
| areas: new Map(), | |
| standaloneProjects: [], | |
| inbox: null, | |
| logbook: null | |
| }; | |
| const accountedTodos = new Set(); | |
| // Areas | |
| const areas = app.areas(); | |
| log(` Found ${areas.length} ${pluralize(areas.length, 'area', 'areas')}`); | |
| areas.forEach(area => { | |
| const allTodos = area.toDos(); | |
| allTodos.forEach(todo => { | |
| try { accountedTodos.add(todo.id()); } catch(e) {} | |
| }); | |
| structure.areas.set(area.name(), { | |
| name: area.name(), | |
| tags: getTags(area), | |
| notes: getNotes(area), | |
| todos: [], | |
| allTodos: allTodos, | |
| projects: [] | |
| }); | |
| }); | |
| // Projects | |
| const projects = app.projects(); | |
| log(` Found ${projects.length} ${pluralize(projects.length, 'project', 'projects')}`); | |
| const projectNames = new Set(); | |
| projects.forEach(project => { | |
| const projectName = project.name(); | |
| projectNames.add(projectName); | |
| const projectTodos = project.toDos(); | |
| projectTodos.forEach(todo => { | |
| try { accountedTodos.add(todo.id()); } catch(e) {} | |
| }); | |
| const projectData = { | |
| name: projectName, | |
| tags: getTags(project), | |
| notes: getNotes(project), | |
| todos: projectTodos | |
| }; | |
| try { | |
| const area = project.area(); | |
| const areaName = area?.name(); | |
| if (areaName && structure.areas.has(areaName)) { | |
| structure.areas.get(areaName).projects.push(projectData); | |
| } else { | |
| structure.standaloneProjects.push(projectData); | |
| } | |
| } catch(e) { | |
| structure.standaloneProjects.push(projectData); | |
| } | |
| }); | |
| // Filter area todos to exclude project containers | |
| structure.areas.forEach(area => { | |
| area.todos = area.allTodos.filter(todo => { | |
| try { | |
| return !projectNames.has(todo.name()); | |
| } catch(e) { | |
| return true; | |
| } | |
| }); | |
| delete area.allTodos; | |
| }); | |
| // Lists | |
| const lists = app.lists(); | |
| log(` Found ${lists.length} ${pluralize(lists.length, 'list', 'lists')}`); | |
| const inboxTodos = []; | |
| lists.forEach(list => { | |
| const name = list.name(); | |
| if (name === 'Logbook') { | |
| structure.logbook = list; | |
| } else if (name === 'Inbox') { | |
| const todos = list.toDos(); | |
| todos.forEach(todo => { | |
| try { accountedTodos.add(todo.id()); } catch(e) {} | |
| }); | |
| inboxTodos.push(...todos); | |
| } else if (['Today', 'Anytime', 'Upcoming', 'Someday'].includes(name)) { | |
| // Collect standalone tasks from schedule lists | |
| list.toDos().forEach(todo => { | |
| try { | |
| const id = todo.id(); | |
| if (!accountedTodos.has(id)) { | |
| inboxTodos.push(todo); | |
| accountedTodos.add(id); | |
| } | |
| } catch(e) {} | |
| }); | |
| } | |
| }); | |
| if (inboxTodos.length > 0) { | |
| structure.inbox = { name: 'Inbox', todos: inboxTodos }; | |
| } | |
| return structure; | |
| } | |
| // === Todo Export === | |
| function buildTodoTags(todo, inheritedTags, isArchive, projectName, areaName) { | |
| const tags = []; | |
| if (isArchive) { | |
| if (CONFIG.archiveKeepAllTags) { | |
| tags.push(...inheritedTags, ...getTags(todo)); | |
| } else { | |
| if (projectName) tags.push(`@project(${projectName})`); | |
| if (areaName) tags.push(`@area(${areaName})`); | |
| } | |
| try { | |
| const completed = todo.completionDate(); | |
| if (completed) tags.push(`@done(${formatDate(completed)})`); | |
| } catch(e) {} | |
| try { | |
| const due = todo.dueDate(); | |
| if (due) tags.push(`@due(${formatDate(due)})`); | |
| } catch(e) {} | |
| } else { | |
| tags.push(...inheritedTags); | |
| const scheduleTag = getScheduleTag(todo); | |
| if (scheduleTag) tags.push(scheduleTag); | |
| tags.push(...getTags(todo)); | |
| try { | |
| const recurrence = todo.recurrenceRule(); | |
| if (recurrence) tags.push('@recurring'); | |
| } catch(e) {} | |
| try { | |
| const due = todo.dueDate(); | |
| if (due) tags.push(`@due(${formatDate(due)})`); | |
| } catch(e) {} | |
| try { | |
| const start = todo.activationDate(); | |
| if (start) tags.push(`@start(${formatDate(start)})`); | |
| } catch(e) {} | |
| } | |
| return [...new Set(tags)]; | |
| } | |
| function exportTodo(todo, indent = '', inheritedTags = [], isArchive = false, projectName = null, areaName = null) { | |
| const lines = []; | |
| const tags = buildTodoTags(todo, inheritedTags, isArchive, projectName, areaName); | |
| let line = `${indent}- ${sanitize(todo.name())}`; | |
| if (tags.length > 0) line += ` ${tags.join(' ')}`; | |
| lines.push(line); | |
| const notes = getNotes(todo); | |
| if (notes) { | |
| notes.split('\n') | |
| .filter(noteLine => noteLine.trim()) | |
| .forEach(noteLine => { | |
| lines.push(`${indent} ${sanitize(noteLine)}`); | |
| }); | |
| } | |
| try { | |
| todo.toDos().forEach(item => { | |
| lines.push(...exportTodo(item, indent + '\t', tags, isArchive, projectName, areaName)); | |
| }); | |
| } catch(e) {} | |
| return lines; | |
| } | |
| // === Output Generation === | |
| function addNotes(output, notes, indent) { | |
| if (notes) { | |
| notes.split('\n') | |
| .filter(line => line.trim()) | |
| .forEach(line => { | |
| output.push(`${indent}${sanitize(line)}`); | |
| }); | |
| } | |
| } | |
| function exportToTaskPaper(structure) { | |
| const output = []; | |
| let totalCount = 0; | |
| log('\nExporting to TaskPaper format...'); | |
| // Inbox | |
| if (structure.inbox) { | |
| const count = structure.inbox.todos.length; | |
| log(`\nExporting Inbox: ${count} ${pluralize(count, 'task', 'tasks')}`); | |
| output.push(`${structure.inbox.name}:`); | |
| structure.inbox.todos.forEach(todo => { | |
| output.push(...exportTodo(todo, '\t')); | |
| totalCount++; | |
| }); | |
| output.push(''); | |
| } | |
| // Areas | |
| if (structure.areas.size > 0) { | |
| log(`\nExporting ${structure.areas.size} ${pluralize(structure.areas.size, 'area', 'areas')}...`); | |
| Array.from(structure.areas.values()) | |
| .sort((a, b) => a.name.localeCompare(b.name)) | |
| .forEach(area => { | |
| log(` ${area.name}: ${area.todos.length} direct tasks, ${area.projects.length} projects`); | |
| output.push(`${area.name}:`); | |
| addNotes(output, area.notes, '\t'); | |
| area.todos.forEach(todo => { | |
| output.push(...exportTodo(todo, '\t', area.tags)); | |
| totalCount++; | |
| }); | |
| area.projects | |
| .sort((a, b) => a.name.localeCompare(b.name)) | |
| .forEach(project => { | |
| const combinedTags = [...new Set([...area.tags, ...project.tags])]; | |
| output.push(`\t${project.name}:`); | |
| addNotes(output, project.notes, '\t\t'); | |
| log(` ${project.name}: ${project.todos.length} tasks`); | |
| project.todos.forEach(todo => { | |
| output.push(...exportTodo(todo, '\t\t', combinedTags)); | |
| totalCount++; | |
| }); | |
| }); | |
| output.push(''); | |
| }); | |
| } | |
| // Standalone Projects | |
| if (structure.standaloneProjects.length > 0) { | |
| log(`\nExporting ${structure.standaloneProjects.length} standalone projects...`); | |
| structure.standaloneProjects | |
| .sort((a, b) => a.name.localeCompare(b.name)) | |
| .forEach(project => { | |
| log(` ${project.name}: ${project.todos.length} tasks`); | |
| output.push(`${project.name}:`); | |
| addNotes(output, project.notes, '\t'); | |
| project.todos.forEach(todo => { | |
| output.push(...exportTodo(todo, '\t', project.tags)); | |
| totalCount++; | |
| }); | |
| output.push(''); | |
| }); | |
| } | |
| // Archive | |
| if (CONFIG.includeArchive && structure.logbook) { | |
| const completed = structure.logbook.toDos(); | |
| const count = completed.length; | |
| log('\nExporting Archive...'); | |
| log(` ${count} completed ${pluralize(count, 'item', 'items')}`); | |
| log(` Tag mode: ${CONFIG.archiveKeepAllTags ? 'all tags' : 'minimal'}`); | |
| if (count > 0) { | |
| output.push('Archive:'); | |
| const batchSize = 100; | |
| for (let i = 0; i < count; i += batchSize) { | |
| const end = Math.min(i + batchSize, count); | |
| log(` Processing ${i+1}-${end} of ${count}...`); | |
| for (let j = i; j < end; j++) { | |
| const todo = completed[j]; | |
| const projectName = todo.project()?.name() || null; | |
| const areaName = todo.area()?.name() || null; | |
| output.push(...exportTodo(todo, '\t', [], true, projectName, areaName)); | |
| } | |
| } | |
| output.push(''); | |
| } | |
| } | |
| log(`\nExport complete! Total active items: ${totalCount}`); | |
| return output.join('\n'); | |
| } | |
| // === CLI === | |
| function parseArgs() { | |
| const args = $.NSProcessInfo.processInfo.arguments; | |
| const jsArgs = []; | |
| let outputFile = null; | |
| for (let i = 0; i < args.count; i++) { | |
| jsArgs.push(ObjC.unwrap(args.objectAtIndex(i))); | |
| } | |
| for (const arg of jsArgs) { | |
| if (arg.includes('osascript') || arg === '-l' || arg === 'JavaScript' || arg.endsWith('.js')) { | |
| continue; | |
| } | |
| if (!arg.startsWith('-')) { | |
| outputFile = arg; | |
| continue; | |
| } | |
| switch(arg) { | |
| case '-h': case '--help': CONFIG.help = true; break; | |
| case '-v': case '--version': CONFIG.version = true; break; | |
| case '-n': case '--no-archive': CONFIG.includeArchive = false; break; | |
| case '-a': case '--archive-keep-all-tags': CONFIG.archiveKeepAllTags = true; break; | |
| case '-i': case '--include-anytime': CONFIG.skipAnytimeTags = false; break; | |
| default: | |
| console.log(`Unknown option: ${arg}\nRun with --help for usage`); | |
| $.exit(1); | |
| } | |
| } | |
| return outputFile; | |
| } | |
| function showHelp() { | |
| console.log(`Things to TaskPaper Exporter v${VERSION} | |
| Usage: things2tp.js [options] [output-file] | |
| Options: | |
| -h, --help Show this help | |
| -v, --version Show version | |
| -n, --no-archive Skip archived/completed items | |
| -a, --archive-keep-all-tags Keep all tags in archive | |
| -i, --include-anytime Include @anytime tags | |
| Examples: | |
| things2tp.js > things.taskpaper | |
| things2tp.js output.taskpaper | |
| things2tp.js -n active-only.taskpaper`); | |
| } | |
| function showVersion() { | |
| console.log(`Things to TaskPaper Exporter v${VERSION}`); | |
| } | |
| // === Main === | |
| function run() { | |
| const outputFile = parseArgs(); | |
| if (CONFIG.version) { | |
| showVersion(); | |
| return ''; | |
| } | |
| if (CONFIG.help) { | |
| showHelp(); | |
| return ''; | |
| } | |
| const structure = analyzeThingsStructure(); | |
| const output = exportToTaskPaper(structure); | |
| if (outputFile) { | |
| const expandedPath = outputFile.replace(/^~/, $.getenv('HOME')); | |
| const data = $.NSString.alloc.initWithUTF8String(output).dataUsingEncoding($.NSUTF8StringEncoding); | |
| data.writeToFileAtomically(expandedPath, true); | |
| log(`\nWritten to: ${expandedPath}`); | |
| return ''; | |
| } | |
| return output; | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Things 3 to TaskPaper Exporter
Export your Things 3 database to TaskPaper format with full preservation of structure, tags, notes, and scheduling information.
Features
@today,@upcoming,@recurring)@due,@start, and@donedates where applicableInstallation
things2taskpaper.jschmod +x things2taskpaper.jsUsage
Options
-h, --help- Show help message-v, --version- Show version-n, --no-archive- Skip archived/completed items-a, --archive-keep-all-tags- Keep all tags in archive (default:@done,@due,@project,@areaonly)-i, --include-anytime- Include@anytimetags on active tasks (default: skip)Output Structure
Tag Behavior
Active Tasks
@today,@upcoming(activation date-based)@recurring- Task has recurrence rule@due(YYYY-MM-DD)- Due date@start(YYYY-MM-DD)- Start/activation dateArchive Tasks (Default: Minimal)
@done(YYYY-MM-DD)- Completion date@due(YYYY-MM-DD)- Original due date (if existed)@project(Name)- Source project@area(Name)- Source areaArchive Tasks (With
-aflag)@doneand@duedatesNotes
@somedaytags, but manually-added someday tags are preserved.Requirements
Version
Current version: 1.1.0
License
MIT