todo-harvest

Command-line tool · Python · MIT

Every task list you have, in one file you own.

Work is in Jira, the team board is in Plane, the family list is in Microsoft To Do, notes with checkboxes are in Notion, and the self-hosted Vikunja was supposed to replace them all. todo-harvest pulls every one into one file in one schema, resolves conflicts by timestamp, and pushes the result back to the services that accept writes — no daemon, no account, one command at a time.

5sources: Vikunja, Plane, Jira, Microsoft To Do, Notion
2push targets: Vikunja and Plane
1schema, one JSON file, one SQLite mapping
Five trackers on the left, the local state file in the middle, the unified schema on the right

Nothing polls and nothing runs in the background. Local state is the source of truth: a pull never overwrites a newer local edit, and a push never invents a task on a service that has no mapping unless you set a default project.

One schema

id, title, status, priority, dates, tags, category, url — the same record whatever the source, and the service's own payload kept whole under raw.

Pull from five, push to two

Vikunja, Plane, Jira, Microsoft To Do and Notion in; Vikunja and Plane out. Jira and To Do pushes are not implemented yet, Notion is pull-only by design.

Conflicts by timestamp

An incoming task with a newer updated_date replaces the local copy; an older one is ignored. No merge dialogs.

Stable ids across services

mapping.db assigns one local_id on first sight and remembers which id the task has on each service, so a task created by a push is recognised on the next pull.

Your vocabulary

status_map, priority_map and Notion field_map for instances with their own status names, priorities and column titles.

Explicit runs

pull, push and sync do exactly what they say; inspect and export never touch the network.

How a sync runs

A pull, then a push; each step does one thing.

  1. Fetch

    Each configured source is queried through its own client — REST with three retries and exponential backoff. Jira uses the configured JQL; Notion, the listed databases; Plane, the listed or all projects.

  2. Normalise and map

    Every item becomes the unified record, status and priority mapped to the common values. mapping.db assigns a stable local_id on first sight.

  3. Merge

    A newer updated_date than the local copy replaces it; an older one is ignored. The result is written to output/todos.json.

  4. Push

    Tasks with a mapping on the target are updated in place; tasks from other sources are created there only if default_project_id is set. A new task's service id goes into mapping.db.

Quick start

Python 3; ./ctl creates a virtual environment on first run and installs the dependencies. On Windows, harvest.ps1 takes the same arguments from PowerShell.

Run it

git clone https://github.com/mmdemirbas/todo-harvest.git && cd todo-harvest
cp config.example.yaml config.yaml    # fill in the services you use
./ctl pull                            # everything into output/todos.json
./ctl push vikunja                    # local state back to Vikunja
./ctl sync                            # pull all, then push all

Only configure the services you use; the rest are skipped. Each needs one token — the README walks through each service.

config.yaml, the shape

vikunja:
  base_url: "http://localhost:3456"
  api_token: "YOUR_API_TOKEN"
  # default_project_id: 1   # to create cross-source tasks here

jira:
  base_url: "https://YOUR_SUBDOMAIN.atlassian.net"
  email: "your@email.com"
  api_token: "YOUR_API_TOKEN"
  # status_map: { "Custom Status": "in_progress" }

notion:
  token: "YOUR_INTEGRATION_SECRET"
  database_ids: ["DATABASE_ID_1"]
  # field_map: { status: "Status", due_date: "Due Date" }

config.yaml holds the tokens and is git-ignored; config.example.yaml is the template.

Commands

CommandNetworkDoes
./ctl pull [source…]yesPull from every configured service, or the named ones, and merge
./ctl push vikunja / planeyesPush local state to one target
./ctl syncyesPull all, then push all
./ctl inspect projects [source]noProject / list / database ids — for default_project_id
./ctl inspect statsnoTask counts, field coverage, date ranges
./ctl inspect fields jiranoThe status, priority and tag values a source uses
./ctl export [--output-dir DIR]noSnapshot local state to JSON and CSV
./ctl testnoTests with a coverage report

The unified schema

Every task becomes the same record; the source's full payload stays under raw.

FieldTypeMeaning
id, local_idstring{source}-{source_id}, and the stable UUID assigned on first pull
sourcestringvikunja, plane, jira, mstodo or notion
title, descriptionstringThe task and its body
statusenumtodo, in_progress, done, cancelled
priorityenumcritical, high, medium, low, none
created_date, due_date, updated_date, completed_dateISO 8601Timestamps; completed_date from each service's own field, null for Notion
tags, category, urllist, object, stringLabels or list names; the organisational container; a link back to the original
rawobjectThe original API payload — Jira's ADF description and custom fields, To Do's checklist items, every Notion property

What pushes back

rw is pull and push; pull is pull only.

Fieldvikunjaplanejiramstodonotion
title, descriptionrwrwpullpullpull
priority, due_daterwrwpullpullpull
statusrwpullpullpullpull
tags, categoryrwpullpullpullpull

What lives where

FileHoldsCommit it?
config.yamlTokens and per-service optionsNo — it is in .gitignore
output/todos.jsonEvery task in the unified schema, the local source of truthYour call; it is your task list
mapping.dbSQLite: local id ↔ service id per sourceKeep it with todos.json; deleting it re-maps everything on the next pull
output/*.csv, *.jsonExport snapshots for spreadsheets or other toolsNo