Skip to content

Instantly share code, notes, and snippets.

@brokosz
Created January 4, 2026 22:39
Show Gist options
  • Select an option

  • Save brokosz/4674002446b9dedf796feeedfbaf4a86 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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;
}
@brokosz
Copy link
Author

brokosz commented Jan 4, 2026

Things 3 to TaskPaper Exporter

Export your Things 3 database to TaskPaper format with full preservation of structure, tags, notes, and scheduling information.

Features

  • Complete Structure Export: Areas, projects, nested projects, and todos with proper hierarchy
  • Smart Tag Handling: Preserves existing tags and adds schedule tags (@today, @upcoming, @recurring)
  • Flexible Archive Options: Include/exclude completed items with configurable tag retention
  • Standalone Task Collection: Automatically collects orphaned tasks from Today/Anytime/Upcoming lists into Inbox
  • Notes & Checklist Support: Exports all notes and checklist items with proper indentation
  • Date Preservation: Includes @due, @start, and @done dates where applicable

Installation

  1. Save the script as things2taskpaper.js
  2. Make it executable: chmod +x things2taskpaper.js
  3. Run it from your terminal

Usage

# Export to stdout
things2taskpaper.js > output.taskpaper

# Export to file directly
things2taskpaper.js output.taskpaper

# Skip archive/completed items
things2taskpaper.js -n output.taskpaper

# Keep all tags in archive (default: minimal tags only)
things2taskpaper.js -a output.taskpaper

# Include @anytime tags (default: skip)
things2taskpaper.js -i output.taskpaper

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, @area only)
  • -i, --include-anytime - Include @anytime tags on active tasks (default: skip)

Output Structure

Inbox:
    - Task from inbox @today
    - Standalone task from Today list @today

Area Name:
    Area notes appear here
    - Direct task in area @tag
    Project Name:
        Project notes appear here
        - Task in project @tag @due(2025-01-15)
        - Recurring task @recurring @today

Archive:
    - Completed task @done(2025-01-04) @project(Project Name) @area(Area Name)

Tag Behavior

Active Tasks

  • Schedule tags: @today, @upcoming (activation date-based)
  • @recurring - Task has recurrence rule
  • @due(YYYY-MM-DD) - Due date
  • @start(YYYY-MM-DD) - Start/activation date
  • All original Things tags preserved
  • Area and project tags inherited

Archive 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 area

Archive Tasks (With -a flag)

  • All original tags preserved
  • Plus @done and @due dates

Notes

  • Someday List: Things doesn't expose "Someday" status via AppleScript. Tasks won't auto-receive @someday tags, but manually-added someday tags are preserved.
  • Blank Lines: Removed from notes to maintain proper TaskPaper folding
  • Project Detection: Projects nested under areas are properly detected and excluded from area's direct task list
  • Unicode Support: Full UTF-8 support for international characters and emoji

Requirements

  • macOS with Things 3 installed
  • JXA/osascript support (built into macOS)

Version

Current version: 1.1.0

License

MIT

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment