From cb3bc99a69e3f5a588951d0ff916053e9d340ad3 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Mon, 22 Jun 2026 23:40:04 -0700 Subject: [PATCH 1/4] feat: todoist push command to create project from markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `md2do todoist push ` command that parses a markdown file's H1/H2 structure into a Todoist project with sections and tasks, then writes `{todoist:ID}` back to the headings for correlation. - New document parser in @md2do/core for markdown → tree structure - Push engine in @md2do/todoist creates project, sections, and tasks - TodoistClient gains addProject, getSections, addSection methods - Writer gains updateHeadings for atomic heading ID write-back - CLI supports --dry-run and --force options Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/todoist.ts | 178 ++++++++++++++++++++- packages/core/src/document/index.ts | 112 +++++++++++++ packages/core/src/index.ts | 3 + packages/core/src/writer/index.ts | 40 +++++ packages/core/tests/document/index.test.ts | 137 ++++++++++++++++ packages/todoist/src/client.ts | 48 +++++- packages/todoist/src/index.ts | 2 + packages/todoist/src/mapper.ts | 7 + packages/todoist/src/push.ts | 96 +++++++++++ 9 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/document/index.ts create mode 100644 packages/core/tests/document/index.test.ts create mode 100644 packages/todoist/src/push.ts diff --git a/packages/cli/src/commands/todoist.ts b/packages/cli/src/commands/todoist.ts index e5ab860..c131a88 100644 --- a/packages/cli/src/commands/todoist.ts +++ b/packages/cli/src/commands/todoist.ts @@ -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'; @@ -29,6 +30,11 @@ interface TodoistImportOptions { project?: string; } +interface TodoistPushOptions { + dryRun?: boolean; + force?: boolean; +} + interface TodoistSyncOptions { path?: string; dryRun?: boolean; @@ -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; @@ -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('', '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 { + // 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=', + ); + 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 */ diff --git a/packages/core/src/document/index.ts b/packages/core/src/document/index.ts new file mode 100644 index 0000000..a0d9995 --- /dev/null +++ b/packages/core/src/document/index.ts @@ -0,0 +1,112 @@ +import { parseTask } from '../parser/index.js'; +import { cleanTaskText } from '../parser/index.js'; +import type { ParsingContext } from '../types/index.js'; + +export interface DocumentTask { + content: string; + rawLine: string; + line: number; + completed: boolean; + priority?: string; + tags: string[]; + dueDate?: Date; + assignee?: string; +} + +export interface DocumentSection { + name: string; + todoistId?: string; + headingLine: number; + tasks: DocumentTask[]; +} + +export interface DocumentTree { + file: string; + projectName?: string; + projectTodoistId?: string; + projectHeadingLine?: number; + rootTasks: DocumentTask[]; + sections: DocumentSection[]; +} + +const HEADING_REGEX = /^(#{1,2})\s+(.+?)(?:\s+\{todoist:(\d+)\})?\s*$/; + +function toDocumentTask( + rawLine: string, + lineNumber: number, + file: string, + context: ParsingContext, +): DocumentTask | null { + const result = parseTask(rawLine, lineNumber, file, context); + if (!result.task) return null; + + const task = result.task; + const doc: DocumentTask = { + content: cleanTaskText(rawLine.substring(rawLine.indexOf(']') + 1).trim()), + rawLine, + line: lineNumber, + completed: task.completed, + tags: task.tags, + }; + + if (task.priority) doc.priority = task.priority; + if (task.dueDate) doc.dueDate = task.dueDate; + if (task.assignee) doc.assignee = task.assignee; + + return doc; +} + +export function parseDocument( + filePath: string, + content: string, + context: ParsingContext = {}, +): DocumentTree { + const lines = content.split('\n'); + const tree: DocumentTree = { + file: filePath, + rootTasks: [], + sections: [], + }; + + let currentSection: DocumentSection | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + const lineNumber = i + 1; + + const headingMatch = line.match(HEADING_REGEX); + if (headingMatch) { + const level = headingMatch[1]!.length; + const name = headingMatch[2]!; + const todoistId = headingMatch[3]; + + if (level === 1 && !tree.projectName) { + tree.projectName = name; + tree.projectHeadingLine = lineNumber; + if (todoistId) tree.projectTodoistId = todoistId; + currentSection = null; + } else if (level === 2) { + currentSection = { + name, + headingLine: lineNumber, + tasks: [], + }; + if (todoistId) currentSection.todoistId = todoistId; + tree.sections.push(currentSection); + } + continue; + } + + const task = toDocumentTask(line, lineNumber, filePath, context); + if (task) { + if (currentSection) { + currentSection.tasks.push(task); + } else { + tree.rootTasks.push(task); + } + } + } + + return tree; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 91dfabe..76016c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,6 +28,9 @@ export * from './migrator/index.js'; // Ingest export * from './ingest/index.js'; +// Document +export * from './document/index.js'; + // Utilities export * from './utils/dates.js'; export * from './utils/id.js'; diff --git a/packages/core/src/writer/index.ts b/packages/core/src/writer/index.ts index 283cc8f..a2f163d 100644 --- a/packages/core/src/writer/index.ts +++ b/packages/core/src/writer/index.ts @@ -147,6 +147,46 @@ export async function updateTask( } } +/** + * Write Todoist IDs back to heading lines in a markdown file. + * Updates H1/H2 headings by appending {todoist:ID}. + */ +export async function updateHeadings( + file: string, + updates: Array<{ line: number; todoistId: string }>, +): Promise<{ success: boolean; error?: string }> { + try { + const content = await fs.readFile(file, 'utf-8'); + const lines = content.split('\n'); + + for (const update of updates) { + const lineIndex = update.line - 1; + if (lineIndex < 0 || lineIndex >= lines.length) continue; + + const originalLine = lines[lineIndex]!; + // Only update heading lines (# or ##) + if (!/^#{1,2}\s/.test(originalLine)) continue; + // Don't add if already has a todoist ID + if (/\{todoist:\d+\}/.test(originalLine)) continue; + + lines[lineIndex] = + `${originalLine.trimEnd()} {todoist:${update.todoistId}}`; + } + + const tempFile = `${file}.md2do.tmp`; + const newContent = lines.join('\n'); + await fs.writeFile(tempFile, newContent, 'utf-8'); + await fs.rename(tempFile, file); + + return { success: true }; + } catch (error) { + return { + success: false, + error: `Failed to update headings: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + /** * Batch update multiple tasks in a file * More efficient than calling updateTask multiple times diff --git a/packages/core/tests/document/index.test.ts b/packages/core/tests/document/index.test.ts new file mode 100644 index 0000000..e891517 --- /dev/null +++ b/packages/core/tests/document/index.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { parseDocument } from '../../src/document/index.js'; + +describe('parseDocument', () => { + it('should parse a file with H1 project, sections, and tasks', () => { + const content = `# My Project +- [ ] root item 1 +- [ ] root item 2 + +## Backend Tasks +- [ ] fix API endpoint +- [ ] add auth middleware + +## Frontend +- [ ] update dashboard +`; + const tree = parseDocument('test.md', content); + + expect(tree.file).toBe('test.md'); + expect(tree.projectName).toBe('My Project'); + expect(tree.projectHeadingLine).toBe(1); + expect(tree.projectTodoistId).toBeUndefined(); + expect(tree.rootTasks).toHaveLength(2); + expect(tree.rootTasks[0]!.content).toBe('root item 1'); + expect(tree.rootTasks[1]!.content).toBe('root item 2'); + expect(tree.sections).toHaveLength(2); + expect(tree.sections[0]!.name).toBe('Backend Tasks'); + expect(tree.sections[0]!.tasks).toHaveLength(2); + expect(tree.sections[0]!.tasks[0]!.content).toBe('fix API endpoint'); + expect(tree.sections[1]!.name).toBe('Frontend'); + expect(tree.sections[1]!.tasks).toHaveLength(1); + expect(tree.sections[1]!.tasks[0]!.content).toBe('update dashboard'); + }); + + it('should parse tasks before first H2 as root tasks', () => { + const content = `- [ ] orphan task 1 +- [ ] orphan task 2 + +## Section A +- [ ] section task +`; + const tree = parseDocument('test.md', content); + + expect(tree.projectName).toBeUndefined(); + expect(tree.rootTasks).toHaveLength(2); + expect(tree.sections).toHaveLength(1); + expect(tree.sections[0]!.tasks).toHaveLength(1); + }); + + it('should parse file with no H1 (just tasks)', () => { + const content = `- [ ] task one +- [x] task two +- [ ] task three +`; + const tree = parseDocument('test.md', content); + + expect(tree.projectName).toBeUndefined(); + expect(tree.rootTasks).toHaveLength(3); + expect(tree.rootTasks[1]!.completed).toBe(true); + expect(tree.sections).toHaveLength(0); + }); + + it('should parse existing {todoist:NNN} on headings', () => { + const content = `# My Project {todoist:12345} + +## Backend {todoist:67890} +- [ ] task +`; + const tree = parseDocument('test.md', content); + + expect(tree.projectName).toBe('My Project'); + expect(tree.projectTodoistId).toBe('12345'); + expect(tree.sections[0]!.name).toBe('Backend'); + expect(tree.sections[0]!.todoistId).toBe('67890'); + }); + + it('should handle empty sections', () => { + const content = `# Project + +## Empty Section + +## Section With Tasks +- [ ] a task +`; + const tree = parseDocument('test.md', content); + + expect(tree.sections).toHaveLength(2); + expect(tree.sections[0]!.name).toBe('Empty Section'); + expect(tree.sections[0]!.tasks).toHaveLength(0); + expect(tree.sections[1]!.tasks).toHaveLength(1); + }); + + it('should parse task metadata', () => { + const content = `# Project +- [ ] @nick fix bug !! #backend #due/2026-01-25 +`; + const tree = parseDocument('test.md', content); + + expect(tree.rootTasks).toHaveLength(1); + const task = tree.rootTasks[0]!; + expect(task.assignee).toBe('nick'); + expect(task.priority).toBe('high'); + expect(task.tags).toContain('backend'); + expect(task.dueDate).toBeDefined(); + }); + + it('should preserve rawLine for each task', () => { + const content = `- [ ] my task !! #tag +`; + const tree = parseDocument('test.md', content); + + expect(tree.rootTasks[0]!.rawLine).toBe('- [ ] my task !! #tag'); + expect(tree.rootTasks[0]!.line).toBe(1); + }); + + it('should handle empty content', () => { + const tree = parseDocument('empty.md', ''); + + expect(tree.projectName).toBeUndefined(); + expect(tree.rootTasks).toHaveLength(0); + expect(tree.sections).toHaveLength(0); + }); + + it('should treat root tasks after H1 but before first H2 as root', () => { + const content = `# My Project +- [ ] root task + +## Section +- [ ] section task +`; + const tree = parseDocument('test.md', content); + + expect(tree.rootTasks).toHaveLength(1); + expect(tree.rootTasks[0]!.content).toBe('root task'); + expect(tree.sections[0]!.tasks).toHaveLength(1); + }); +}); diff --git a/packages/todoist/src/client.ts b/packages/todoist/src/client.ts index 23c0863..795f152 100644 --- a/packages/todoist/src/client.ts +++ b/packages/todoist/src/client.ts @@ -1,5 +1,12 @@ import { TodoistApi } from '@doist/todoist-api-typescript'; -import type { Task, Project, Label } from '@doist/todoist-api-typescript'; +import type { + Task, + Project, + Label, + Section, + AddProjectArgs, + AddSectionArgs, +} from '@doist/todoist-api-typescript'; import type { TodoistTaskParams } from './mapper.js'; /** @@ -144,6 +151,45 @@ export class TodoistClient { } } + /** + * Create a new project + */ + async addProject(args: AddProjectArgs): Promise { + try { + return await this.api.addProject(args); + } catch (error) { + throw new Error( + `Failed to create project: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Get sections, optionally filtered by project + */ + async getSections(projectId?: string): Promise { + try { + return await this.api.getSections(projectId); + } catch (error) { + throw new Error( + `Failed to get sections: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Create a new section in a project + */ + async addSection(args: AddSectionArgs): Promise
{ + try { + return await this.api.addSection(args); + } catch (error) { + throw new Error( + `Failed to create section: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + /** * Find project by name */ diff --git a/packages/todoist/src/index.ts b/packages/todoist/src/index.ts index f32ecff..d7cb5f0 100644 --- a/packages/todoist/src/index.ts +++ b/packages/todoist/src/index.ts @@ -10,3 +10,5 @@ export { } from './mapper.js'; export type { TodoistTaskParams, Md2doTaskUpdate } from './mapper.js'; export { TodoistProvider } from './provider.js'; +export { pushDocument } from './push.js'; +export type { PushResult } from './push.js'; diff --git a/packages/todoist/src/mapper.ts b/packages/todoist/src/mapper.ts index 9141866..6f4ffc7 100644 --- a/packages/todoist/src/mapper.ts +++ b/packages/todoist/src/mapper.ts @@ -127,11 +127,13 @@ export interface TodoistTaskParams { due_date?: string; due_string?: string; project_id?: string; + section_id?: string; } export function md2doToTodoist( task: Task, projectId?: string, + sectionId?: string, ): TodoistTaskParams { const params: TodoistTaskParams = { content: extractTaskContent(task.text), @@ -157,6 +159,11 @@ export function md2doToTodoist( params.project_id = projectId; } + // Add section ID + if (sectionId) { + params.section_id = sectionId; + } + return params; } diff --git a/packages/todoist/src/push.ts b/packages/todoist/src/push.ts new file mode 100644 index 0000000..f6653c5 --- /dev/null +++ b/packages/todoist/src/push.ts @@ -0,0 +1,96 @@ +import type { TodoistClient } from './client.js'; +import type { DocumentTree, DocumentTask } from '@md2do/core'; +import { md2doToTodoistPriority, extractTaskContent } from './mapper.js'; + +export interface PushResult { + projectId: string; + projectName: string; + sectionIds: Map; + taskCount: number; + sectionCount: number; +} + +function buildTaskParams( + task: DocumentTask, + projectId: string, + sectionId?: string, +): Record { + const params: Record = { + content: extractTaskContent(task.content), + projectId, + priority: md2doToTodoistPriority(task.priority), + }; + + if (sectionId) { + params.sectionId = sectionId; + } + + if (task.tags.length > 0) { + params.labels = task.tags; + } + + if (task.dueDate) { + const year = task.dueDate.getUTCFullYear(); + const month = String(task.dueDate.getUTCMonth() + 1).padStart(2, '0'); + const day = String(task.dueDate.getUTCDate()).padStart(2, '0'); + params.dueDate = `${year}-${month}-${day}`; + } + + return params; +} + +export async function pushDocument( + client: TodoistClient, + tree: DocumentTree, +): Promise { + if (tree.projectTodoistId) { + throw new Error( + `This document already has a Todoist project ID: ${tree.projectTodoistId}. ` + + 'Remove the {todoist:ID} from the H1 heading to push again.', + ); + } + + const projectName = tree.projectName || tree.file; + + // Create project + const project = await client.addProject({ name: projectName }); + const projectId = project.id; + + let taskCount = 0; + const sectionIds = new Map(); + + // Create root tasks (no section) + for (const task of tree.rootTasks) { + if (task.completed) continue; + await client.createTask(buildTaskParams(task, projectId)); + taskCount++; + } + + // Create sections and their tasks + for (let i = 0; i < tree.sections.length; i++) { + const section = tree.sections[i]!; + + const todoistSection = await client.addSection({ + name: section.name, + projectId, + order: i + 1, + }); + sectionIds.set(section.headingLine, todoistSection.id); + + for (const task of section.tasks) { + if (task.completed) continue; + await client.createTask( + buildTaskParams(task, projectId, todoistSection.id), + ); + taskCount++; + } + } + + return { + projectId, + projectName, + sectionIds, + taskCount, + sectionCount: tree.sections.length, + }; +} From f7f0b6f27e826ca0ca575e90c3fbcb11743dd410 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Mon, 22 Jun 2026 23:54:37 -0700 Subject: [PATCH 2/4] fix: promote H3+ headings to Todoist sections in document parser Todoist sections are flat (no nesting), so all H2+ headings should be treated equally. Previously H3+ headings were silently ignored and their tasks folded into the preceding H2 section. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/document/index.ts | 4 ++-- packages/core/src/writer/index.ts | 2 +- packages/core/tests/document/index.test.ts | 11 +++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/core/src/document/index.ts b/packages/core/src/document/index.ts index a0d9995..b951056 100644 --- a/packages/core/src/document/index.ts +++ b/packages/core/src/document/index.ts @@ -29,7 +29,7 @@ export interface DocumentTree { sections: DocumentSection[]; } -const HEADING_REGEX = /^(#{1,2})\s+(.+?)(?:\s+\{todoist:(\d+)\})?\s*$/; +const HEADING_REGEX = /^(#{1,6})\s+(.+?)(?:\s+\{todoist:(\d+)\})?\s*$/; function toDocumentTask( rawLine: string, @@ -86,7 +86,7 @@ export function parseDocument( tree.projectHeadingLine = lineNumber; if (todoistId) tree.projectTodoistId = todoistId; currentSection = null; - } else if (level === 2) { + } else if (level >= 2) { currentSection = { name, headingLine: lineNumber, diff --git a/packages/core/src/writer/index.ts b/packages/core/src/writer/index.ts index a2f163d..64b3a28 100644 --- a/packages/core/src/writer/index.ts +++ b/packages/core/src/writer/index.ts @@ -165,7 +165,7 @@ export async function updateHeadings( const originalLine = lines[lineIndex]!; // Only update heading lines (# or ##) - if (!/^#{1,2}\s/.test(originalLine)) continue; + if (!/^#{1,6}\s/.test(originalLine)) continue; // Don't add if already has a todoist ID if (/\{todoist:\d+\}/.test(originalLine)) continue; diff --git a/packages/core/tests/document/index.test.ts b/packages/core/tests/document/index.test.ts index e891517..93f4c42 100644 --- a/packages/core/tests/document/index.test.ts +++ b/packages/core/tests/document/index.test.ts @@ -121,6 +121,17 @@ describe('parseDocument', () => { expect(tree.sections).toHaveLength(0); }); + it('should promote H3+ headings to sections', () => { + const content = + '# Project\n\n## Section One\n- [ ] task a\n\n### Sub Section\n- [ ] task b\n'; + const tree = parseDocument('test.md', content); + + expect(tree.sections).toHaveLength(2); + expect(tree.sections[0]!.name).toBe('Section One'); + expect(tree.sections[1]!.name).toBe('Sub Section'); + expect(tree.sections[1]!.tasks).toHaveLength(1); + }); + it('should treat root tasks after H1 but before first H2 as root', () => { const content = `# My Project - [ ] root task From 788fab2a7a6112d78c26fadea3fd6744d49a1b91 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Wed, 15 Jul 2026 22:35:01 -0700 Subject: [PATCH 3/4] docs: add push command docs and branch status tracking - Add BRANCH_STATUS.md at repo root with done/remaining checklist - Add docs/cli/todoist/push.md reference page - Update todoist CLI overview to include push command - Update integrations/todoist.md with push workflow and examples - Update roadmap to v0.7.x and add Todoist Push in-progress section Co-Authored-By: Claude Sonnet 4.6 --- docs/.vitepress/config.mjs | 1 + docs/cli/todoist/overview.md | 5 +++ docs/cli/todoist/push.md | 77 ++++++++++++++++++++++++++++++++++++ docs/development/roadmap.md | 1 + docs/integrations/todoist.md | 36 ++++++++++++++--- 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 docs/cli/todoist/push.md diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 7dc635f..dc94391 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -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' }, ], }, diff --git a/docs/cli/todoist/overview.md b/docs/cli/todoist/overview.md index b9faeb0..25ca2d0 100644 --- a/docs/cli/todoist/overview.md +++ b/docs/cli/todoist/overview.md @@ -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 @@ -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 diff --git a/docs/cli/todoist/push.md b/docs/cli/todoist/push.md new file mode 100644 index 0000000..c776644 --- /dev/null +++ b/docs/cli/todoist/push.md @@ -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 [options] +``` + +## Arguments + +| Argument | Description | +| -------- | --------------------- | +| `` | 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 diff --git a/docs/development/roadmap.md b/docs/development/roadmap.md index 1042bdb..31f277b 100644 --- a/docs/development/roadmap.md +++ b/docs/development/roadmap.md @@ -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 diff --git a/docs/integrations/todoist.md b/docs/integrations/todoist.md index 7cdb504..5f5f083 100644 --- a/docs/integrations/todoist.md +++ b/docs/integrations/todoist.md @@ -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** (Todoist → markdown). You can import tasks to Todoist and pull updates back to markdown. +md2do supports **task-level import** and **document-level push** (markdown → Todoist), 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. @@ -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 @@ -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 From 993f3fed39719b7878649394b9e170610cd8356d Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Wed, 15 Jul 2026 22:41:24 -0700 Subject: [PATCH 4/4] fix+test: legacy todoist ID regex and push engine tests - Fix sync command to strip both {todoist:ID} and [todoist:ID] when removing a deleted task's link (was only handling legacy bracket form) - Add 11 unit tests for pushDocument() covering: project creation, section creation, task routing, completed task skipping, result counts, section ID mapping, guard against re-push, priority/tag/due date mapping - Add **/tests/**/*.ts ESLint override to disable unbound-method rule, which false-positives on vitest mock assertions Co-Authored-By: Claude Sonnet 4.6 --- packages/todoist/tests/push.test.ts | 317 ++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 packages/todoist/tests/push.test.ts diff --git a/packages/todoist/tests/push.test.ts b/packages/todoist/tests/push.test.ts new file mode 100644 index 0000000..9663f48 --- /dev/null +++ b/packages/todoist/tests/push.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, vi } from 'vitest'; +import { pushDocument } from '../src/push.js'; +import type { TodoistClient } from '../src/client.js'; +import type { DocumentTree } from '@md2do/core'; +import type { Project, Section, Task } from '@doist/todoist-api-typescript'; + +function makeProject(id: string, name: string): Project { + return { + id, + name, + isInboxProject: false, + isTeamInbox: false, + isFavorite: false, + isShared: false, + color: 'blue', + commentCount: 0, + order: 1, + url: '', + }; +} + +function makeSection(id: string, name: string): Section { + return { id, name, projectId: 'proj-1', order: 1 }; +} + +function makeTask(id: string, content: string): Task { + return { + id, + content, + description: '', + priority: 1, + labels: [], + isCompleted: false, + createdAt: '', + creatorId: '', + projectId: 'proj-1', + commentCount: 0, + url: '', + order: 1, + }; +} + +function makeClient(): TodoistClient { + return { + addProject: vi.fn().mockResolvedValue(makeProject('proj-1', 'My Project')), + addSection: vi + .fn() + .mockResolvedValueOnce(makeSection('sec-1', 'Backend')) + .mockResolvedValueOnce(makeSection('sec-2', 'Frontend')), + createTask: vi.fn().mockResolvedValue(makeTask('task-1', 'do thing')), + getTasks: vi.fn(), + getTask: vi.fn(), + updateTask: vi.fn(), + completeTask: vi.fn(), + reopenTask: vi.fn(), + deleteTask: vi.fn(), + getProjects: vi.fn(), + getProject: vi.fn(), + getSections: vi.fn(), + findProjectByName: vi.fn(), + getLabels: vi.fn(), + ensureLabel: vi.fn(), + test: vi.fn(), + } as unknown as TodoistClient; +} + +function makeTree(overrides: Partial = {}): DocumentTree { + return { + file: 'plan.md', + projectName: 'My Project', + projectHeadingLine: 1, + rootTasks: [], + sections: [], + ...overrides, + }; +} + +describe('pushDocument', () => { + it('creates a project with the document name', async () => { + const client = makeClient(); + const tree = makeTree(); + + await pushDocument(client, tree); + + expect(vi.mocked(client.addProject)).toHaveBeenCalledWith({ + name: 'My Project', + }); + }); + + it('uses file path as project name when no H1', async () => { + const client = makeClient(); + const tree = makeTree({ projectName: undefined, file: 'plan.md' }); + + await pushDocument(client, tree); + + expect(vi.mocked(client.addProject)).toHaveBeenCalledWith({ + name: 'plan.md', + }); + }); + + it('creates sections for each document section', async () => { + const client = makeClient(); + const tree = makeTree({ + sections: [ + { name: 'Backend', headingLine: 3, tasks: [] }, + { name: 'Frontend', headingLine: 6, tasks: [] }, + ], + }); + + await pushDocument(client, tree); + + expect(vi.mocked(client.addSection)).toHaveBeenCalledTimes(2); + expect(vi.mocked(client.addSection)).toHaveBeenCalledWith({ + name: 'Backend', + projectId: 'proj-1', + order: 1, + }); + expect(vi.mocked(client.addSection)).toHaveBeenCalledWith({ + name: 'Frontend', + projectId: 'proj-1', + order: 2, + }); + }); + + it('creates tasks under the correct section', async () => { + const client = makeClient(); + const tree = makeTree({ + sections: [ + { + name: 'Backend', + headingLine: 3, + tasks: [ + { + content: 'fix bug', + rawLine: '- [ ] fix bug', + line: 4, + completed: false, + tags: [], + }, + ], + }, + ], + }); + + await pushDocument(client, tree); + + expect(vi.mocked(client.createTask)).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'fix bug', + sectionId: 'sec-1', + projectId: 'proj-1', + }), + ); + }); + + it('creates root tasks without a section', async () => { + const client = makeClient(); + const tree = makeTree({ + rootTasks: [ + { + content: 'root task', + rawLine: '- [ ] root task', + line: 2, + completed: false, + tags: [], + }, + ], + }); + + await pushDocument(client, tree); + + const calls = vi.mocked(client.createTask).mock.calls; + const firstCall = calls[0] as [Record]; + expect(firstCall[0].sectionId).toBeUndefined(); + expect(firstCall[0].content).toBe('root task'); + }); + + it('skips completed tasks', async () => { + const client = makeClient(); + const tree = makeTree({ + rootTasks: [ + { + content: 'done task', + rawLine: '- [x] done task', + line: 2, + completed: true, + tags: [], + }, + ], + sections: [ + { + name: 'Work', + headingLine: 4, + tasks: [ + { + content: 'also done', + rawLine: '- [x] also done', + line: 5, + completed: true, + tags: [], + }, + ], + }, + ], + }); + + await pushDocument(client, tree); + + expect(vi.mocked(client.createTask)).not.toHaveBeenCalled(); + }); + + it('returns correct counts', async () => { + const client = makeClient(); + const tree = makeTree({ + rootTasks: [ + { + content: 'root task', + rawLine: '- [ ] root task', + line: 2, + completed: false, + tags: [], + }, + ], + sections: [ + { + name: 'Backend', + headingLine: 4, + tasks: [ + { + content: 'sec task', + rawLine: '- [ ] sec task', + line: 5, + completed: false, + tags: [], + }, + ], + }, + ], + }); + + const result = await pushDocument(client, tree); + + expect(result.taskCount).toBe(2); + expect(result.sectionCount).toBe(1); + expect(result.projectId).toBe('proj-1'); + expect(result.projectName).toBe('My Project'); + }); + + it('maps section heading lines to section IDs in result', async () => { + const client = makeClient(); + const tree = makeTree({ + sections: [ + { name: 'Backend', headingLine: 3, tasks: [] }, + { name: 'Frontend', headingLine: 6, tasks: [] }, + ], + }); + + const result = await pushDocument(client, tree); + + expect(result.sectionIds.get(3)).toBe('sec-1'); + expect(result.sectionIds.get(6)).toBe('sec-2'); + }); + + it('throws if document already has a Todoist project ID', async () => { + const client = makeClient(); + const tree = makeTree({ projectTodoistId: '99999' }); + + await expect(pushDocument(client, tree)).rejects.toThrow( + 'already has a Todoist project ID', + ); + expect(vi.mocked(client.addProject)).not.toHaveBeenCalled(); + }); + + it('maps task priority and tags', async () => { + const client = makeClient(); + const tree = makeTree({ + rootTasks: [ + { + content: 'urgent task', + rawLine: '- [ ] urgent task !!! #backend', + line: 2, + completed: false, + priority: 'urgent', + tags: ['backend'], + }, + ], + }); + + await pushDocument(client, tree); + + expect(vi.mocked(client.createTask)).toHaveBeenCalledWith( + expect.objectContaining({ priority: 4, labels: ['backend'] }), + ); + }); + + it('maps task due date', async () => { + const client = makeClient(); + const tree = makeTree({ + rootTasks: [ + { + content: 'due task', + rawLine: '- [ ] due task #due/2026-08-01', + line: 2, + completed: false, + tags: [], + dueDate: new Date('2026-08-01T00:00:00.000Z'), + }, + ], + }); + + await pushDocument(client, tree); + + expect(vi.mocked(client.createTask)).toHaveBeenCalledWith( + expect.objectContaining({ dueDate: '2026-08-01' }), + ); + }); +});