Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default defineConfig({
{ text: 'list', link: '/cli/todoist/list' },
{ text: 'add', link: '/cli/todoist/add' },
{ text: 'import', link: '/cli/todoist/import' },
{ text: 'push', link: '/cli/todoist/push' },
{ text: 'sync', link: '/cli/todoist/sync' },
],
},
Expand Down
5 changes: 5 additions & 0 deletions docs/cli/todoist/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Interact with [Todoist](https://www.todoist.com) from the command line.
- [`todoist add`](/cli/todoist/add) - Create tasks in Todoist
- [`todoist import`](/cli/todoist/import) - Import markdown tasks to Todoist
- [`todoist sync`](/cli/todoist/sync) - Sync completion status to markdown
- [`todoist push`](/cli/todoist/push) - Push a markdown file to Todoist as a project

## Requirements

Expand All @@ -27,6 +28,10 @@ md2do todoist import tasks.md:15

# Sync everything
md2do todoist sync

# Push a markdown file as a new Todoist project
md2do todoist push planning.md --dry-run
md2do todoist push planning.md
```

## Related
Expand Down
77 changes: 77 additions & 0 deletions docs/cli/todoist/push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# todoist push

Push a markdown file to Todoist as a new project with sections and tasks.

## Usage

```bash
md2do todoist push <file> [options]
```

## Arguments

| Argument | Description |
| -------- | --------------------- |
| `<file>` | Markdown file to push |

## Options

| Option | Description |
| ----------- | ----------------------------------------------- |
| `--dry-run` | Preview what would be created, without doing it |
| `--force` | Skip confirmation prompt; re-push if ID exists |

## How It Works

The command reads your markdown file's heading structure and maps it to Todoist:

| Markdown | Todoist |
| --------------- | ------------ |
| H1 heading | Project name |
| H2+ headings | Sections |
| `- [ ] tasks` | Tasks |
| Completed tasks | Skipped |

After pushing, it writes `{todoist:ID}` back to each heading so the file records the Todoist IDs:

```markdown
# Q3 Planning {todoist:12345}

## Backend {todoist:67890}

- [ ] Fix auth bug !!
- [ ] Add rate limiting #backend #due/2026-08-01

## Frontend {todoist:11111}

- [ ] Update dashboard
```

Task metadata (priority, tags, due dates) is sent to Todoist automatically.

## Examples

```bash
# Preview without creating anything
md2do todoist push planning.md --dry-run

# Push with confirmation prompt
md2do todoist push planning.md

# Push without prompting
md2do todoist push planning.md --force
```

## Notes

- The file must have an H1 heading (`# Project Name`) — this becomes the Todoist project name
- If the file already has a `{todoist:ID}` on the H1, the command will error unless you pass `--force` (which creates a new project)
- Completed tasks (`- [x]`) are skipped
- All H2, H3, and deeper headings are treated as flat sections (Todoist does not support nested sections)
- This is a **one-time, one-way push** — it does not sync changes back

## Related

- [`todoist import`](/cli/todoist/import) - Import a single task to Todoist
- [`todoist sync`](/cli/todoist/sync) - Pull completion status from Todoist
- [Todoist Integration](/integrations/todoist) - Full guide
1 change: 1 addition & 0 deletions docs/development/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ This page highlights the major upcoming features and their current status.
- **`build_integration` MCP prompt** — Ask Claude to fetch tasks from any external source and write valid ingest files; `mode=provider` appends a TypeScript `SourceProvider` skeleton
- **`sources` in MCP output** — `list_tasks` includes source IDs so Claude can correlate tasks with external systems
- **CI/CD** — GitHub Actions with coverage, type check, lint; npm Trusted Publishing via OIDC (no token)
- **`todoist push`** — One-way push of a markdown file to Todoist as a project with sections and tasks; writes `{todoist:ID}` back to headings

## In Progress

Expand Down
36 changes: 31 additions & 5 deletions docs/integrations/todoist.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ Sync your markdown tasks with Todoist for mobile access, notifications, and cros

md2do integrates with Todoist for task management:

- **Import** - Send markdown tasks to Todoist (one-time)
- **Sync** - Update markdown from Todoist changes (completion status, metadata)
- **Import** - Send a single markdown task to Todoist and link it with `{todoist:ID}`
- **Push** - Push an entire markdown file to Todoist as a project with sections and tasks
- **Sync** - Pull updates from Todoist back to markdown (completion status)

::: info Current Implementation
md2do currently supports **one-way sync** (Todoistmarkdown). You can import tasks to Todoist and pull updates back to markdown.
md2do supports **task-level import** and **document-level push** (markdownTodoist), plus **one-way sync** pulling completion status back from Todoist.

**Coming Soon:** Full bidirectional sync (pushing markdown changes back to Todoist) is planned for a future release.
Full bidirectional sync (pushing markdown edits back to linked Todoist tasks) is planned for a future release.
:::

Your markdown files remain the source of truth, while Todoist provides mobile apps and notifications.
Expand Down Expand Up @@ -99,6 +100,30 @@ md2do todoist import tasks.md:15
md2do todoist import notes.md:42 --project Personal
```

### Push a Markdown File as a Todoist Project

```bash
# Preview what would be created
md2do todoist push planning.md --dry-run

# Push (will prompt for confirmation)
md2do todoist push planning.md

# Push without confirmation
md2do todoist push planning.md --force
```

The `push` command reads the H1 heading as the project name, H2+ headings as sections, and tasks under each heading as section tasks. After pushing, it writes `{todoist:ID}` back to each heading so the file records where it was sent.

```markdown
# Q3 Planning {todoist:12345}

## Backend {todoist:67890}

- [ ] Fix auth bug
- [ ] Add rate limiting
```

## Sync Workflow

### How Sync Works
Expand Down Expand Up @@ -374,7 +399,8 @@ If compromised, regenerate in [Todoist Settings](https://app.todoist.com/app/set

Current limitations (may be addressed in future versions):

- No support for Todoist sections
- `push` creates sections from H2+ headings, but subtask nesting is not yet supported
- Tasks pushed via `push` are not linked individually — only headings get `{todoist:ID}`
- No support for recurring tasks
- No support for task comments
- Subtasks sync as separate tasks
Expand Down
178 changes: 177 additions & 1 deletion packages/cli/src/commands/todoist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import {
md2doToTodoistPriority,
md2doToTodoist,
todoistToMd2do,
pushDocument,
} from '@md2do/todoist';
import { parseTask, updateTask, formatSources } from '@md2do/core';
import { parseTask, updateTask, formatSources, parseDocument, updateHeadings } from '@md2do/core';
import type { Task as TodoistTask } from '@doist/todoist-api-typescript';
import type { Task } from '@md2do/core';
import { scanMarkdownFiles } from '../scanner.js';
Expand All @@ -29,6 +30,11 @@ interface TodoistImportOptions {
project?: string;
}

interface TodoistPushOptions {
dryRun?: boolean;
force?: boolean;
}

interface TodoistSyncOptions {
path?: string;
dryRun?: boolean;
Expand All @@ -48,6 +54,7 @@ export function createTodoistCommand(): Command {
command.addCommand(createTodoistListCommand());
command.addCommand(createTodoistAddCommand());
command.addCommand(createTodoistImportCommand());
command.addCommand(createTodoistPushCommand());
command.addCommand(createTodoistSyncCommand());

return command;
Expand Down Expand Up @@ -539,6 +546,175 @@ async function todoistImportAction(
console.log('');
}

/**
* Create the 'todoist push' subcommand
*/
function createTodoistPushCommand(): Command {
const command = new Command('push');

command
.description(
'Push a markdown file to Todoist as a new project with sections and tasks',
)
.argument('<file>', 'Markdown file to push')
.option('--dry-run', 'Show what would be created without doing it')
.option('--force', 'Skip confirmation prompt')
.action(async (file: string, options: TodoistPushOptions) => {
try {
await todoistPushAction(file, options);
} catch (error) {
console.error(
'Error:',
error instanceof Error ? error.message : String(error),
);
process.exit(1);
}
});

return command;
}

/**
* Action handler for 'todoist push' command
*/
async function todoistPushAction(
file: string,
options: TodoistPushOptions,
): Promise<void> {
// Read the file
let content: string;
try {
content = await fs.readFile(file, 'utf-8');
} catch (error) {
console.error(`Error: Could not read file: ${file}`);
console.error(
` ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}

// Parse document structure
const tree = parseDocument(file, content);

// Validate
if (!tree.projectName) {
console.error('Error: File must have an H1 heading (# Project Name)');
process.exit(1);
}

if (tree.projectTodoistId && !options.force) {
console.error(
`Error: File already has a Todoist project ID: ${tree.projectTodoistId}`,
);
console.error(' Use --force to push anyway (will create a new project)');
process.exit(1);
}

// Count tasks
const rootTaskCount = tree.rootTasks.filter((t) => !t.completed).length;
const sectionTaskCount = tree.sections.reduce(
(sum, s) => sum + s.tasks.filter((t) => !t.completed).length,
0,
);
const totalTasks = rootTaskCount + sectionTaskCount;

// Display summary
console.log('');
console.log(`Project: ${tree.projectName}`);
console.log(
` ${totalTasks} tasks (${rootTaskCount} root, ${sectionTaskCount} in sections)`,
);
console.log(` ${tree.sections.length} sections`);

if (tree.sections.length > 0) {
for (const section of tree.sections) {
const count = section.tasks.filter((t) => !t.completed).length;
console.log(` - ${section.name} (${count} tasks)`);
}
}
console.log('');

if (options.dryRun) {
console.log('Dry run - no changes made');
console.log('');
return;
}

// Confirm unless --force
if (!options.force) {
const p = await import('@clack/prompts');
const confirmed = await p.confirm({
message: `Push "${tree.projectName}" to Todoist?`,
});

if (p.isCancel(confirmed) || !confirmed) {
console.log('Cancelled');
return;
}
}

// Load configuration
const config = await loadConfig();

if (!config.todoist?.apiToken) {
console.error('Error: Todoist API token not configured');
console.error('');
console.error('Please set your API token using one of these methods:');
console.error(
' 1. Environment variable: export TODOIST_API_TOKEN=<token>',
);
console.error(' 2. Global config: ~/.md2do.json or ~/.md2do.yaml');
console.error(' 3. Project config: .md2do.json or .md2do.yaml');
process.exit(1);
}

const client = new TodoistClient({ apiToken: config.todoist.apiToken });

// If --force and already has ID, clear it so pushDocument won't reject
if (tree.projectTodoistId && options.force) {
delete tree.projectTodoistId;
}

// Push to Todoist
console.log('Pushing to Todoist...');
const result = await pushDocument(client, tree);

// Write back Todoist IDs to headings
const headingUpdates: Array<{ line: number; todoistId: string }> = [];

if (tree.projectHeadingLine) {
headingUpdates.push({
line: tree.projectHeadingLine,
todoistId: result.projectId,
});
}

for (const [headingLine, sectionId] of result.sectionIds) {
headingUpdates.push({ line: headingLine, todoistId: sectionId });
}

if (headingUpdates.length > 0) {
const writeResult = await updateHeadings(file, headingUpdates);
if (!writeResult.success) {
console.error(
`Warning: Failed to write IDs back to file: ${writeResult.error}`,
);
}
}

// Display results
console.log('');
console.log('Pushed to Todoist!');
console.log('');
console.log(` Project: ${result.projectName} (ID: ${result.projectId})`);
console.log(` Tasks: ${result.taskCount}`);
console.log(` Sections: ${result.sectionCount}`);
if (headingUpdates.length > 0) {
console.log(` Todoist IDs written back to ${file}`);
}
console.log('');
}

/**
* Create the 'todoist sync' subcommand
*/
Expand Down
Loading
Loading