diff --git a/.eslintrc.json b/.eslintrc.json index 6bd0a83..0cca95f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -38,14 +38,15 @@ ], "overrides": [ { - "files": ["**/tests/e2e/**/*.ts"], + "files": ["**/tests/**/*.ts"], "rules": { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-member-access": "off", "@typescript-eslint/no-unsafe-call": "off", "@typescript-eslint/no-unsafe-return": "off", - "@typescript-eslint/no-unsafe-argument": "off" + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/unbound-method": "off" } } ] diff --git a/README.md b/README.md index 300a155..b1d0b78 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![codecov](https://codecov.io/gh/TeamNickHart/md2do/branch/main/graph/badge.svg)](https://codecov.io/gh/TeamNickHart/md2do) [![CI](https://github.com/TeamNickHart/md2do/workflows/CI/badge.svg)](https://github.com/TeamNickHart/md2do/actions) -Manage TODO items in markdown files with powerful filtering, sorting, and [Todoist](https://www.todoist.com) sync. +Manage TODO items in markdown files with powerful filtering, sorting, native [Todoist](https://www.todoist.com) sync, and open multi-source ingestion. Built with TypeScript, designed for developers who love markdown. ## ✨ Features @@ -22,7 +22,8 @@ Built with TypeScript, designed for developers who love markdown. - ⚡ **Fast** - Built with performance in mind using fast-glob - 🔧 **Flexible** - Output in pretty, table, or JSON formats - 📁 **Context-aware** - Automatically extracts project and person context from folder structure -- 🔄 **Todoist integration** - Import tasks and sync completion status with official [Todoist](https://www.todoist.com) API +- 🔄 **Todoist integration** - Native two-way sync with the official [Todoist](https://www.todoist.com) API (import, sync, list, add) +- 🌐 **Multi-source ingestion** - Bring in tasks from Teams, Outlook, Slack, or any source via JSONL — no API credentials needed in md2do - ⚙️ **Configurable** - Hierarchical config support (global, project, environment) - 🤖 **AI-powered** - MCP server integration for Claude and other AI assistants - 🟣 **Obsidian plugin** - Native task list view, autocomplete, and completion tracking in Obsidian @@ -93,7 +94,8 @@ md2do recognizes standard markdown task syntax with rich metadata: - `#tag` - Tags - `#due/YYYY-MM-DD` - Due date - `{completed:YYYY-MM-DD}` - Completion date -- `{todoist:ID}` - Todoist sync ID +- `{todoist:ID}` - Todoist sync ID (native integration) +- `{slug:ID}` - Any external source link (Teams, Outlook, Slack, etc.) - `- [x]` - Completed task - `- [ ]` - Incomplete task @@ -351,12 +353,13 @@ md2do/ ├── packages/ │ ├── core/ # Core parsing, filtering, and file writing │ │ ├── src/ -│ │ │ ├── parser/ # Markdown task parser +│ │ │ ├── parser/ # Markdown task parser (extractSources, formatSources) │ │ │ ├── scanner/ # File scanner │ │ │ ├── filters/ # Task filtering │ │ │ ├── sorting/ # Task sorting │ │ │ ├── writer/ # File modification (atomic updates) -│ │ │ └── types/ # TypeScript types +│ │ │ ├── ingest/ # JSONL ingest engine (parseJsonl, ingestRecords) +│ │ │ └── types/ # TypeScript types (Task, SourceProvider, IngestRecord) │ │ └── tests/ │ ├── cli/ # CLI interface │ │ ├── src/ @@ -369,10 +372,11 @@ md2do/ │ │ │ ├── schema.ts # Zod schemas for validation │ │ │ └── loader.ts # Hierarchical config loading │ │ └── tests/ -│ ├── todoist/ # Todoist API integration +│ ├── todoist/ # Todoist API integration (native SourceProvider) │ │ ├── src/ │ │ │ ├── client.ts # API client wrapper -│ │ │ └── mapper.ts # Task format conversion +│ │ │ ├── mapper.ts # Task format conversion +│ │ │ └── provider.ts # TodoistProvider implements SourceProvider │ │ └── tests/ │ ├── mcp/ # MCP server for AI integration │ │ ├── src/ @@ -454,8 +458,8 @@ pnpm --filter @md2do/core test:ui ## 📖 Additional Documentation -- [Todoist Setup Guide](docs/todoist-setup.md) - Complete guide to configuring [Todoist](https://www.todoist.com) integration -- [Todoist Implementation Plan](docs/todoist-implementation-plan.md) - Technical roadmap and architecture +- [Todoist Integration](docs/integrations/todoist.md) - Native two-way [Todoist](https://www.todoist.com) sync +- [Multi-Source Ingestion](docs/integrations/ingest.md) - Import tasks from Teams, Outlook, Slack, and more - [Config Package](packages/config/README.md) - Configuration management documentation - [Todoist Package](packages/todoist/README.md) - [Todoist](https://www.todoist.com) API integration documentation - [MCP Package](packages/mcp/README.md) - Model Context Protocol server documentation @@ -502,14 +506,8 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - [x] **MCP (Model Context Protocol) integration** - ✅ Complete! See [MCP docs](packages/mcp/README.md) - [x] **Configuration file support** - ✅ Complete! Hierarchical config with `.md2do.json`/`.yaml` -- [x] **Todoist integration foundation** - ✅ Complete! API client, task mapping, file writer - - [ ] CLI commands (`md2do todoist sync`, `md2do todoist push`, etc.) - - [ ] Bidirectional sync logic - - [ ] Interactive token setup - - [ ] Validation warnings for `{todoist:ID}` markers - - [ ] Detect malformed IDs - - [ ] Verify ID exists in Todoist - - [ ] Warn about orphaned/deleted tasks +- [x] **Todoist native integration** - ✅ Complete! Two-way sync, import, list, add — see [Todoist docs](docs/integrations/todoist.md) +- [x] **Pluggable multi-source ingestion** - ✅ Complete! Open `{slug:ID}` syntax, `md2do ingest` command, `SourceProvider` interface — see [ingest docs](docs/integrations/ingest.md) ### CLI Enhancements @@ -594,12 +592,15 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - [ ] Autocomplete for assignees and tags (learned from workspace) - [ ] Inline suggestions with fuzzy matching -### Integrations +### Native Integrations (via `SourceProvider`) + +Additional first-class integrations built on the same interface as Todoist: - [ ] GitHub Issues integration - [ ] Linear integration - [ ] Jira integration -- [ ] Notion integration + +> **Bring your own source today:** Use `md2do ingest` with JSONL to import from any system — no native integration required. ## 📞 Support diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 39922c4..7dc635f 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -76,6 +76,7 @@ export default defineConfig({ { text: 'list', link: '/cli/list' }, { text: 'stats', link: '/cli/stats' }, { text: 'migrate', link: '/cli/migrate' }, + { text: 'ingest', link: '/cli/ingest' }, { text: 'todoist', collapsed: false, @@ -94,7 +95,9 @@ export default defineConfig({ items: [ { text: 'VSCode Extension', link: '/integrations/vscode' }, { text: 'Obsidian Plugin', link: '/integrations/obsidian' }, - { text: 'Todoist Setup', link: '/integrations/todoist' }, + { text: 'Todoist', link: '/integrations/todoist' }, + { text: 'Multi-Source Ingestion', link: '/integrations/ingest' }, + { text: 'Integration Builder Prompt', link: '/integrations/prompts/build-integration' }, { text: 'MCP (AI Integration)', link: '/integrations/mcp' }, ], }, diff --git a/docs/cli/ingest.md b/docs/cli/ingest.md new file mode 100644 index 0000000..6a2d9d1 --- /dev/null +++ b/docs/cli/ingest.md @@ -0,0 +1,140 @@ +# ingest + +Import tasks from any external source into your markdown vault via a JSONL file. + +```bash +md2do ingest [options] +``` + +## Overview + +`md2do ingest` reads a JSONL file (one task record per line), converts each record into a +markdown task line with full metadata, and writes the result to your vault as a regenerated +markdown file. + +This is the recommended way to bring in tasks from sources that don't have a native md2do +integration — Teams mentions, Outlook flagged emails, Slack saved messages, calendar items, +or any other system an MCP agent can read. + +::: tip Vault files are read-only +Files generated by `ingest` are fully regenerated on every run. Don't hand-edit them — +edit tasks in your regular markdown notes instead. +::: + +## Options + +| Option | Description | +| --------------------- | ---------------------------------------------------------------- | +| `` | Path to the JSONL input file | +| `-o, --output ` | Write output to a specific file (overrides `--vault` derivation) | +| `--vault ` | Vault root directory (default: current directory) | +| `--dry-run` | Print generated markdown to stdout without writing any file | + +## Output Path + +By default, output is written to `//.md`: + +``` +vault/ + todoist/ + inbox.md # md2do ingest todoist-inbox.jsonl --vault vault + teams/ + mentions.md # md2do ingest teams-mentions.jsonl --vault vault + outlook/ + flagged.md # md2do ingest outlook-flagged.jsonl --vault vault +``` + +Use `--output` to override: + +```bash +md2do ingest my-tasks.jsonl --output notes/imported.md +``` + +## JSONL Format + +Each line in the input file is a JSON object representing one task: + +```jsonl +{"source":"teams","externalId":"msg-789","text":"Follow up on PR review","completed":false,"priority":"normal","tags":["eng"]} +{"source":"teams","externalId":"msg-790","text":"Update onboarding doc","completed":false,"priority":"high","dueDate":"2026-08-10","assignee":"nick"} +{"source":"teams","externalId":"msg-100","text":"Old action item","completed":true} +``` + +### Required Fields + +| Field | Type | Description | +| ------------ | --------- | ---------------------------------------------------------------------------------------------- | +| `source` | `string` | Source slug (e.g. `teams`, `outlook`, `slack`). Used in `{source:ID}` markers and output path. | +| `externalId` | `string` | Unique ID in the source system. | +| `text` | `string` | Task description. | +| `completed` | `boolean` | Whether the task is done. | + +### Optional Fields + +| Field | Type | Description | +| ---------- | ---------- | ------------------------------------------------------------------- | +| `priority` | `string` | `urgent`, `high`, `normal`, or `low` | +| `dueDate` | `string` | Due date in `YYYY-MM-DD` format | +| `tags` | `string[]` | Array of tag names (without `#`) | +| `assignee` | `string` | Username (without `@`) | +| `metadata` | `object` | Arbitrary extra data — ignored by md2do but preserved for reference | + +## Generated Markdown + +Given this JSONL: + +```jsonl +{"source":"outlook","externalId":"AAMk-abc","text":"Review Q3 budget","completed":false,"priority":"high","dueDate":"2026-08-10","tags":["finance"],"assignee":"nick"} +{"source":"outlook","externalId":"AAMk-def","text":"Reply to procurement","completed":true} +``` + +`md2do ingest outlook-flagged.jsonl --vault vault --dry-run` produces: + +```markdown +# Outlook + +- [ ] Review Q3 budget @nick !! #finance #due/2026-08-10 {outlook:AAMk-abc} + +## Completed + +- [x] Reply to procurement {outlook:AAMk-def} {completed:2026-08-02} +``` + +The generated lines use all standard md2do metadata syntax: + +- `@assignee`, priority markers, `#tags`, `#due/DATE` — fully parsed by `md2do list` +- `{source:externalId}` — source link, deduplicated by scanner +- `{completed:DATE}` — set to today's date on ingest + +## Examples + +```bash +# Preview without writing +md2do ingest teams-mentions.jsonl --dry-run + +# Write to vault +md2do ingest teams-mentions.jsonl --vault ~/notes + +# Specify output file directly +md2do ingest slack-saved.jsonl --output ~/notes/slack/saved.md + +# Ingest from a temporary file produced by an MCP agent +md2do ingest /tmp/outlook-flagged.jsonl --vault ~/obsidian-vault +``` + +## Mixed Sources + +If a JSONL file contains records from more than one source, md2do warns you and uses the +first record's source slug for the output path derivation: + +``` +⚠️ Warning: Mixed sources detected: teams, outlook. Using first source "teams" for output path derivation. +``` + +Use separate JSONL files per source to avoid ambiguity. + +## Next Steps + +- [Multi-Source Ingestion Guide](/integrations/ingest) — conceptual overview and MCP agent workflow +- [Task Format](/guide/task-format) — how `{slug:ID}` source links work +- [Todoist Integration](/integrations/todoist) — native two-way Todoist sync diff --git a/docs/cli/overview.md b/docs/cli/overview.md index 706565e..08a2cb5 100644 --- a/docs/cli/overview.md +++ b/docs/cli/overview.md @@ -193,6 +193,35 @@ md2do migrate --path ./work-notes See [migrate command](/cli/migrate) for details. +### `ingest` + +Import tasks from any external source into your vault via a JSONL file. + +```bash +md2do ingest [options] +``` + +**Options:** + +- `-o, --output ` - Write to a specific output file +- `--vault ` - Vault root directory (default: current directory) +- `--dry-run` - Print generated markdown without writing + +**Examples:** + +```bash +# Preview output +md2do ingest teams-mentions.jsonl --dry-run + +# Write to vault (output: vault/teams/teams-mentions.md) +md2do ingest teams-mentions.jsonl --vault ~/notes + +# Specify output path directly +md2do ingest outlook-flagged.jsonl --output ~/notes/outlook/inbox.md +``` + +See [ingest command](/cli/ingest) for the full JSONL format spec and vault convention. + ## Todoist Commands Sync with [Todoist](https://www.todoist.com). Requires API token configuration. @@ -566,5 +595,6 @@ md2do todoist sync --help - [Task Format](/guide/task-format) - Learn task syntax - [Filtering](/guide/filtering) - Advanced filtering - [Configuration](/guide/configuration) - Set up config files -- [Todoist Integration](/integrations/todoist) - Sync with Todoist +- [Todoist Integration](/integrations/todoist) - Native two-way Todoist sync +- [Multi-Source Ingestion](/integrations/ingest) - Import from Teams, Outlook, Slack, and more - [Examples](/guide/examples) - Real-world usage diff --git a/docs/development/roadmap.md b/docs/development/roadmap.md index 7b54879..1d367e3 100644 --- a/docs/development/roadmap.md +++ b/docs/development/roadmap.md @@ -4,30 +4,31 @@ See the complete roadmap at [ROADMAP.md](https://github.com/TeamNickHart/md2do/b This page highlights the major upcoming features and their current status. -## Current Version: v0.2.x +## Current Version: v0.7.x ### Completed - Core task parsing and filtering - CLI commands (list, stats, config, migrate) -- Todoist integration (import, sync, list, add) -- MCP server for AI +- **Todoist integration** (native — import, sync, list, add with full two-way sync) +- MCP server for AI assistants - Hierarchical configuration - VitePress docs site at [md2do.com](https://md2do.com) -- **Syntax migration** - New `#due/YYYY-MM-DD`, `{completed:YYYY-MM-DD}`, `{todoist:NNN}` format with backward-compatible legacy parsing -- **`md2do migrate` command** - Automated migration from legacy bracket syntax -- **VS Code extension** (v0.2.1) - Task Explorer, CodeLens, diagnostics, dashboard, smart `#due/` autocomplete -- **Obsidian plugin** (beta) - Task list view, grouping, sorting, commands +- **Syntax migration** — New `#due/YYYY-MM-DD`, `{completed:YYYY-MM-DD}`, `{slug:NNN}` format with backward-compatible legacy parsing +- **`md2do migrate` command** — Automated migration from legacy bracket syntax +- **VS Code extension** — Task Explorer, CodeLens, diagnostics, dashboard, smart `#due/` autocomplete +- **Obsidian plugin** — Task list view, grouping, sorting, commands, autocomplete +- **Pluggable multi-source ingestion** — Open `{slug:ID}` source link pattern; `md2do ingest` command for JSONL-based import from any source (Teams, Outlook, Slack, etc.) +- **`SourceProvider` interface** — Typed contract for native integrations (Todoist implements this today) ## In Progress ### Obsidian Plugin Polish -- [ ] Syntax migration updates (suggest provider, task writer) - [ ] Community plugin submission - [ ] Auto-completion for `#due/`, `@`, `#` -### Advanced Sync Logic +### Advanced Todoist Sync - [x] Basic bidirectional sync - [ ] Advanced conflict detection @@ -43,6 +44,14 @@ This page highlights the major upcoming features and their current status. ## Near-Term +### MCP Agent Workflows + +AI-powered task ingestion from M365 and more + +- Claude + M365 MCP tools → JSONL → `md2do ingest` +- Prompt templates for Teams, Outlook, calendar +- Scheduled agent runs (cron + MCP) + ### Watch Mode Real-time monitoring and auto-sync @@ -59,20 +68,19 @@ Real-time monitoring and auto-sync ## Mid-Term -### MCP + Todoist - -AI-powered hybrid workflows +### Native Integrations (via `SourceProvider`) -- Todoist operations via MCP -- Unified queries -- Smart sync suggestions +Additional first-class integrations built on the same interface as Todoist: -### GitHub Issues +- **GitHub Issues** — bidirectional sync, issue linking +- **Linear** — issue sync with priority and cycle mapping +- **Jira** — ticket sync -Sync with GitHub Issues +### Multi-Source Unified View -- Bidirectional sync -- Issue linking +- Filter tasks by source (`--source teams`, `--source todoist`) +- Cross-source stats (`md2do stats --by source`) +- Conflict detection across sources (same task imported twice) ## Long-Term diff --git a/docs/guide/task-format.md b/docs/guide/task-format.md index 12e4e47..5361c7f 100644 --- a/docs/guide/task-format.md +++ b/docs/guide/task-format.md @@ -111,18 +111,27 @@ The older bracket syntax is still parsed: ::: -### Todoist Integration +### Source Links -When syncing with [Todoist](https://www.todoist.com), md2do links tasks using `{todoist:}` brace syntax: +md2do uses `{slug:ID}` brace tokens to link tasks to external systems: ```markdown - [ ] Review pull request #due/2026-01-25 {todoist:123456789} +- [ ] Follow up on Teams mention {teams:msg-789} +- [ ] Action item from flagged email {outlook:AAMk-abc} ``` -The `{todoist:ID}` marker links the task to Todoist for sync. +The slug identifies the source system; the ID is the record's unique identifier in that system. +Any `{word:value}` token is treated as a source link — the only reserved word is `completed`. + +**Todoist** is md2do's native integration with full two-way sync. The `{todoist:ID}` marker +is written automatically when you run `md2do todoist import` or `md2do todoist sync`. + +For other sources (Teams, Outlook, Slack, etc.), use `md2do ingest` to generate vault files +from JSONL. See [Multi-Source Ingestion](/integrations/ingest) for details. ::: details Legacy Syntax (Backward Compatible) -The older bracket syntax is still parsed: +The older bracket syntax for Todoist is still parsed: ```markdown - [ ] Review pull request [todoist: 123456789] @@ -178,7 +187,7 @@ md2do recognizes metadata in this order on each line: 5. **Tags**: All `#hashtags` found 6. **Due date**: `#due/YYYY-MM-DD` tag syntax 7. **Completion date**: `{completed:YYYY-MM-DD}` (if checked) -8. **Todoist ID**: `{todoist:ID}` (if present) +8. **Source links**: `{slug:ID}` tokens (any non-reserved slug) Both new and legacy syntax are parsed during the transition period. See [Syntax Migration](#syntax-migration) for details. @@ -195,17 +204,18 @@ Extracts: - Priority: `"urgent"` - Tags: `["backend", "urgent"]` - Due: `2026-01-25` -- Todoist ID: `"123"` +- Sources: `{ todoist: "123" }` ## Syntax Migration md2do has migrated from bracket syntax to a hybrid tag/brace syntax. Both old and new syntax are parsed during the transition, but the new syntax is recommended for all new tasks. -| Metadata | New Syntax | Legacy Syntax | -| ---------- | ------------------------ | ------------------------- | -| Due date | `#due/2026-01-25` | `[due: 2026-01-25]` | -| Completion | `{completed:2026-01-25}` | `[completed: 2026-01-25]` | -| Todoist ID | `{todoist:123}` | `[todoist: 123]` | +| Metadata | New Syntax | Legacy Syntax | +| ------------ | ---------------------------- | ------------------------- | +| Due date | `#due/2026-01-25` | `[due: 2026-01-25]` | +| Completion | `{completed:2026-01-25}` | `[completed: 2026-01-25]` | +| Todoist ID | `{todoist:123}` | `[todoist: 123]` | +| Other source | `{teams:msg-789}` (any slug) | — | **What changed:** @@ -290,5 +300,6 @@ Wrong - [Filtering & Sorting](/guide/filtering) - Query your tasks - [Configuration](/guide/configuration) - Customize md2do -- [Todoist Integration](/integrations/todoist) - Sync with Todoist +- [Todoist Integration](/integrations/todoist) - Native two-way Todoist sync +- [Multi-Source Ingestion](/integrations/ingest) - Bring in tasks from Teams, Outlook, Slack, and more - [Examples](/guide/examples) - Real-world usage patterns diff --git a/docs/integrations/ingest.md b/docs/integrations/ingest.md new file mode 100644 index 0000000..eb1b686 --- /dev/null +++ b/docs/integrations/ingest.md @@ -0,0 +1,204 @@ +# Multi-Source Ingestion + +Bring tasks from any external system into your markdown vault — Teams, Outlook, Slack, +calendar apps, or anything else — using a simple JSONL intermediate format. + +## Overview + +md2do has a **native Todoist integration** with full two-way sync. For everything else, the +**ingest system** provides an open, source-agnostic pipeline: + +1. An agent (MCP, script, cron job) fetches tasks from an external system +2. It writes them to a JSONL file — one task record per line +3. `md2do ingest` converts the JSONL into a markdown vault file +4. The vault file is fully parsed by `md2do list`, filters, Obsidian plugin, etc. + +No API credentials needed in md2do. No hardcoded integrations. Any system that can emit +JSON can feed into your vault. + +## Source Links: `{slug:ID}` + +The core primitive that makes this work is the **source link** — a `{slug:ID}` brace token +in the task line: + +```markdown +- [ ] Follow up on PR review {teams:msg-789} +- [ ] Review Q3 budget {outlook:AAMk-abc} +- [ ] Ship the release {todoist:123456789} +``` + +The slug identifies the source system, and the ID is the record's unique identifier in that +system. md2do parses every `{word:value}` token on a task line (except reserved words like +`completed`) and stores them in `task.sources`: + +```json +{ + "text": "Follow up on PR review", + "sources": { "teams": "msg-789" } +} +``` + +This is the same mechanism Todoist uses — `{todoist:ID}` is just the native integration's +instance of the general pattern. + +::: tip Reserved slugs +`completed` is reserved for completion dates (`{completed:2026-08-02}`). All other slugs +are open and treated as source links. +::: + +## JSONL Format + +The intermediate format is newline-delimited JSON (JSONL). Each line is one task: + +```jsonl +{"source":"teams","externalId":"msg-789","text":"Follow up on PR review","completed":false,"priority":"normal","tags":["eng"]} +{"source":"outlook","externalId":"AAMk-abc","text":"Review Q3 budget","completed":false,"priority":"high","dueDate":"2026-08-10","tags":["finance"],"assignee":"nick","metadata":{"from":"boss@company.com"}} +{"source":"slack","externalId":"C01234-1722556800","text":"Respond to thread in #releases","completed":false} +``` + +See the [ingest command reference](/cli/ingest#jsonl-format) for the full field spec. + +## Vault Layout + +Ingested files live under a source-named subdirectory in your vault: + +``` +vault/ + teams/ + mentions.md # md2do ingest teams-mentions.jsonl --vault vault + outlook/ + flagged.md # md2do ingest outlook-flagged.jsonl --vault vault + slack/ + saved.md # md2do ingest slack-saved.jsonl --vault vault + todoist/ # (if using ingest for Todoist — usually use native sync instead) + inbox.md +``` + +These files are **fully regenerated** on every ingest run. Don't hand-edit them. + +## MCP Agent Workflow + +The most powerful use case: an MCP agent (e.g. Claude with M365 access) reads from +external APIs and emits JSONL that md2do consumes. + +### Example: Teams + Outlook with Claude + +``` +Claude (with M365 MCP tools) + └── reads Teams mentions, Outlook flagged emails + └── writes /tmp/teams-mentions.jsonl + └── writes /tmp/outlook-flagged.jsonl + +md2do ingest /tmp/teams-mentions.jsonl --vault ~/notes +md2do ingest /tmp/outlook-flagged.jsonl --vault ~/notes +``` + +The agent handles authentication and API access. md2do handles the markdown conversion +and vault management. Neither needs to know about the other's internals. + +### Prompt template + +You can use this as a starting point with any MCP-capable agent that has calendar/mail/chat access: + +``` +Fetch all flagged emails from my Outlook inbox and any unread @mentions from Teams +from the last 7 days. For each item, output a JSONL record with fields: +source, externalId, text, completed (false), priority (urgent/high/normal/low), +dueDate (YYYY-MM-DD if applicable), tags, assignee, metadata. + +Write the Teams records to /tmp/teams.jsonl and Outlook records to /tmp/outlook.jsonl. +``` + +Then run: + +```bash +md2do ingest /tmp/teams.jsonl --vault ~/notes +md2do ingest /tmp/outlook.jsonl --vault ~/notes +``` + +## The Full Pipeline + +``` +External System JSONL File Markdown Vault +────────────── ────────────────── ────────────────────────── +Teams mentions ──► teams.jsonl ──► vault/teams/mentions.md +Outlook email ──► outlook.jsonl ──► vault/outlook/flagged.md +Slack saved ──► slack.jsonl ──► vault/slack/saved.md +Todoist (native) ──► (direct sync) ──► your existing notes + │ + ▼ + md2do ingest ... + │ + ▼ + md2do list / Obsidian plugin + (all sources unified) +``` + +Once in the vault, all tasks — regardless of source — are queryable with the same tools: + +```bash +# Tasks from Teams due this week +md2do list --tag eng --due-this-week + +# All urgent tasks across all sources +md2do list --priority urgent --incomplete + +# Tasks from a specific source +md2do list --path vault/outlook +``` + +## Comparing: Native Todoist vs Ingest + +| | Todoist (native) | Ingest system | +| ------------------ | --------------------- | ------------------------ | +| **Setup** | API token in config | No md2do config needed | +| **Sync direction** | Two-way (pull + push) | One-way (source → vault) | +| **Live sync** | `md2do todoist sync` | Re-run `md2do ingest` | +| **Source link** | `{todoist:ID}` | `{slug:ID}` (any slug) | +| **Best for** | Todoist power users | Everything else | + +For Todoist specifically, use the [native integration](/integrations/todoist) — it gives you +full two-way sync, priority mapping, label sync, and more. Use `ingest` for sources that +don't have a native md2do integration yet. + +## Building a Custom Provider + +If you want programmatic integration (vs. agent-generated JSONL), implement the +`SourceProvider` interface from `@md2do/core`: + +```typescript +import type { SourceProvider, SourceTask, FetchOptions } from '@md2do/core'; + +export class SlackProvider implements SourceProvider { + readonly slug = 'slack'; + readonly name = 'Slack'; + + async fetchTasks(options?: FetchOptions): Promise { + // fetch saved messages from Slack API + const messages = await this.slackClient.getSavedMessages(); + return messages.map((msg) => ({ + externalId: msg.ts, + text: msg.text, + completed: false, + tags: [msg.channel], + })); + } +} +``` + +Then use `ingestRecords()` from `@md2do/core` to convert to markdown: + +```typescript +import { ingestRecords } from '@md2do/core'; + +const provider = new SlackProvider(slackClient); +const tasks = await provider.fetchTasks(); +const records = tasks.map((t) => ({ source: provider.slug, ...t })); +const markdown = ingestRecords(records); +``` + +## Next Steps + +- [ingest command reference](/cli/ingest) — full CLI options and JSONL spec +- [Todoist Integration](/integrations/todoist) — native two-way sync with Todoist +- [Task Format](/guide/task-format) — how `{slug:ID}` source links are parsed diff --git a/docs/integrations/mcp.md b/docs/integrations/mcp.md index d97f256..2084745 100644 --- a/docs/integrations/mcp.md +++ b/docs/integrations/mcp.md @@ -194,6 +194,9 @@ Filter and query tasks: - **By project:** `--project acme-corp` - **By completion:** `--completed` / `--incomplete` +Tasks include a `sources` field when present (e.g. `{ "teams": "msg-789" }`), so Claude can +see external IDs for tasks ingested from Teams, Outlook, Slack, or other sources. + ### `get_task_stats` Aggregate statistics: @@ -253,6 +256,24 @@ Generates: - Prioritized by urgency - Suggestions for re-scheduling +### Build Integration + +``` +Claude: Use the build_integration prompt with source=teams +``` + +Generates a prompt you can pass to any Claude agent that has access to an external system +(Teams, Outlook, Slack, Google Calendar, etc.). The agent will fetch tasks and write a valid +JSONL file that `md2do ingest` can consume. + +Arguments: + +- `source` (required) — source slug, e.g. `teams`, `outlook`, `slack`, `gcal` +- `mode` (optional) — `jsonl` (default) or `provider` (appends a TypeScript `SourceProvider` skeleton) + +See [Integration Builder Prompt](/integrations/prompts/build-integration) for the full prompt +text and a worked example. + ## Resources Claude can access task data via URIs: @@ -294,7 +315,11 @@ Ask naturally - Claude translates to md2do filters: ## Advanced Usage -### Combining with Todoist +### Combining with External Sources + +md2do tasks carry a `sources` field for each external system they were ingested from +(e.g. `{ "teams": "msg-789", "todoist": "12345" }`). Claude can see these IDs in +`list_tasks` output and use them to correlate tasks across systems. If you have [Todoist](https://www.todoist.com) integration enabled, Claude can help sync: @@ -312,6 +337,10 @@ md2do todoist import features.md:15 md2do todoist import bugs.md:12" ``` +For other sources (Teams, Outlook, Slack, etc.), use the `build_integration` prompt to +generate a fetching prompt for any Claude agent with access to that system. See +[Building Integrations](#building-integrations) below. + ### Code Analysis ``` @@ -349,6 +378,27 @@ Claude: "📝 Code Review Queue All tasks tagged #code-review and due by Friday" ``` +## Building Integrations + +Use the `build_integration` MCP prompt to connect md2do to any external system. It generates +a ready-to-use prompt for a Claude agent that has access to that system: + +``` +Claude: Use the build_integration prompt with source=teams +``` + +The agent will: + +1. Fetch tasks from the source (mentions, flagged items, saved messages, etc.) +2. Write a JSONL file in the md2do ingest format +3. Run `md2do ingest` to populate your vault + +The resulting tasks appear alongside everything else in your vault and are fully queryable +via `md2do list`, the Obsidian plugin, and — back through MCP — by Claude itself. + +See [Integration Builder Prompt](/integrations/prompts/build-integration) for the full prompt +text, field reference, and a worked Teams example. + ## Configuration ### Custom Working Directory diff --git a/docs/integrations/prompts/build-integration.md b/docs/integrations/prompts/build-integration.md new file mode 100644 index 0000000..1f926d8 --- /dev/null +++ b/docs/integrations/prompts/build-integration.md @@ -0,0 +1,122 @@ +# Build an md2do Integration (Prompt) + +Use this prompt to help any Claude agent — one with access to Teams, Outlook, Slack, Google +Calendar, or any other system — understand the md2do JSONL format well enough to produce +correct ingest files. + +## How to Use + +- **Paste it into any Claude conversation** that has access to your source system +- **Use it via MCP:** invoke the `build_integration` prompt from the md2do MCP server + (`source=`, e.g. `source=teams`) +- **Save it as a slash command** in Claude Code for quick reuse + +## Prompt + +> Copy everything between the START and END markers (replace `{source}` with your source slug, +> e.g. `teams`, `outlook`, `slack`, `gcal`). + +---PROMPT START--- + +You are helping build a new md2do source integration for: {source}. + +md2do ingests external tasks via a JSONL file — one JSON record per line. +Your job: fetch tasks from {source}, write valid JSONL, then run the ingest command. + +## JSONL Format + +Required fields (every record must have all four): + +source string — always "{source}" +externalId string — stable, unique ID for this item in {source} +text string — task description (plain text) +completed boolean — true if already done/resolved + +Optional fields: + +priority string — "urgent" | "high" | "normal" | "low" +dueDate string — YYYY-MM-DD (ISO date only, no time) +tags string[] — tag names, no # prefix +assignee string — username, no @ prefix +metadata object — any extra data (preserved, ignored by md2do) + +## Example + +{"source":"{source}","externalId":"abc-123","text":"Review Q3 budget","completed":false,"priority":"high","dueDate":"2026-08-15","tags":["finance"],"assignee":"nick"} +{"source":"{source}","externalId":"abc-456","text":"Old action item","completed":true} + +## Instructions + +1. Fetch all relevant items from {source} (unread @mentions, flagged emails, saved items, etc.) +2. Write one JSONL line per item to /tmp/{source}-tasks.jsonl +3. Choose externalId carefully — use the most stable unique identifier in {source} + (message-id, event-id, thread-id — NOT a list index or timestamp alone) +4. Map priorities to md2do levels: + - Critical / P0 / urgent → "urgent" + - Important / P1 / high → "high" + - Normal / P2 / medium → "normal" + - Low / P3 / no priority → "low" (or omit) +5. Set completed: true only when explicitly done/resolved/closed in {source} + +After writing the file, run: + +md2do ingest /tmp/{source}-tasks.jsonl --vault ~/notes + +This creates vault/{source}/{source}-tasks.md with all tasks in md2do format, +queryable via `md2do list`, the Obsidian plugin, and the MCP server. + +---PROMPT END--- + +## Fields Reference + +| Field | Type | Required | Description | +| ------------ | -------- | -------- | ---------------------------------------------------------------------------------------- | +| `source` | string | yes | Source system slug (e.g. `teams`, `outlook`) — must match across all records in the file | +| `externalId` | string | yes | Stable unique ID for the item in the source system | +| `text` | string | yes | Task description in plain text | +| `completed` | boolean | yes | `true` if the item is done/resolved/closed | +| `priority` | string | no | `"urgent"` \| `"high"` \| `"normal"` \| `"low"` | +| `dueDate` | string | no | ISO date: `YYYY-MM-DD` (no time component) | +| `tags` | string[] | no | Tag names without `#` prefix | +| `assignee` | string | no | Username without `@` prefix | +| `metadata` | object | no | Arbitrary extra data — preserved in output, ignored by md2do | + +## Example: Microsoft Teams + +Fetching unread @mentions and flagged messages from Teams: + +```jsonl +{"source":"teams","externalId":"msg-1AABcd","text":"Follow up on the deployment plan","completed":false,"priority":"high","tags":["eng","infra"],"assignee":"nick","metadata":{"channel":"#releases","from":"alice@company.com"}} +{"source":"teams","externalId":"msg-2XYZef","text":"Review Q3 budget proposal","completed":false,"priority":"normal","dueDate":"2026-08-15","tags":["finance"]} +{"source":"teams","externalId":"msg-3GHIjk","text":"Approve onboarding docs","completed":true} +``` + +After writing to `/tmp/teams-tasks.jsonl`: + +```bash +md2do ingest /tmp/teams-tasks.jsonl --vault ~/notes +# Creates: ~/notes/teams/teams-tasks.md +``` + +The resulting markdown: + +```markdown +# teams-tasks + +- [ ] Follow up on the deployment plan !high @nick #eng #infra {teams:msg-1AABcd} +- [ ] Review Q3 budget proposal #due/2026-08-15 #finance {teams:msg-2XYZef} +- [x] Approve onboarding docs {completed:2026-08-02} {teams:msg-3GHIjk} +``` + +Tasks are now queryable alongside everything else in your vault: + +```bash +md2do list --path ~/notes/teams --incomplete +md2do list --priority high --due-this-week +``` + +## Next Steps + +- [Multi-Source Ingestion](/integrations/ingest) — architecture overview and vault layout +- [ingest command reference](/cli/ingest) — full CLI options and JSONL spec +- [Task Format](/guide/task-format) — how `{source:ID}` tokens are parsed diff --git a/docs/integrations/todoist.md b/docs/integrations/todoist.md index 356a31b..7cdb504 100644 --- a/docs/integrations/todoist.md +++ b/docs/integrations/todoist.md @@ -103,16 +103,22 @@ md2do todoist import notes.md:42 --project Personal ### How Sync Works -md2do links tasks using IDs: +md2do links tasks using source link tokens: ```markdown - [ ] Review PR #due/2026-01-25 {todoist:123456789} ``` -The `{todoist:ID}` marker connects your markdown to Todoist. +The `{todoist:ID}` marker connects your markdown task to Todoist. This uses md2do's general +`{slug:ID}` source link syntax — `todoist` is the slug for the native Todoist integration. > **Note:** Legacy bracket syntax (`[todoist: ID]`, `[due: ...]`) is still parsed for backward compatibility. +::: tip Other integrations +Need to bring in tasks from Teams, Outlook, or Slack? md2do's `ingest` command supports any +source via a simple JSONL format. See [Multi-Source Ingestion](/integrations/ingest). +::: + ### Sync Commands ```bash @@ -404,6 +410,7 @@ Not automatically. Use `--project` flag when importing tasks to specify the dest - [Configuration](/guide/configuration) - Advanced config options - [MCP Integration](/integrations/mcp) - Use with Claude Code AI - [CLI Reference](/cli/todoist/overview) - Complete command reference +- [Multi-Source Ingestion](/integrations/ingest) - Import from Teams, Outlook, Slack, and more ## Get Help diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9189979..ad82a03 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,6 +9,7 @@ import { createTodoistCommand, createConfigCommand, createMigrateCommand, + createIngestCommand, } from './commands/index.js'; // Read version from package.json @@ -39,6 +40,7 @@ program.addCommand(createStatsCommand()); program.addCommand(createTodoistCommand()); program.addCommand(createConfigCommand()); program.addCommand(createMigrateCommand()); +program.addCommand(createIngestCommand()); // Show help if no command specified if (process.argv.length === 2) { diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index e933340..c065f6d 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -237,7 +237,7 @@ async function configInitAction(options: ConfigInitOptions): Promise { 'relative-date-no-context': 'warn', 'missing-due-date': 'warn', 'missing-completed-date': 'warn', - 'duplicate-todoist-id': 'error', + 'duplicate-source-id': 'error', 'file-read-error': 'error', }, }; @@ -274,7 +274,7 @@ async function configInitAction(options: ConfigInitOptions): Promise { 'relative-date-no-context': 'warn', 'missing-due-date': 'warn', 'missing-completed-date': 'warn', - 'duplicate-todoist-id': 'error', + 'duplicate-source-id': 'error', 'file-read-error': 'error', }, }; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 1fc97eb..68f7932 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -5,3 +5,4 @@ export { createStatsCommand } from './stats.js'; export { createTodoistCommand } from './todoist.js'; export { createConfigCommand } from './config.js'; export { createMigrateCommand } from './migrate.js'; +export { createIngestCommand } from './ingest.js'; diff --git a/packages/cli/src/commands/ingest.ts b/packages/cli/src/commands/ingest.ts new file mode 100644 index 0000000..a265189 --- /dev/null +++ b/packages/cli/src/commands/ingest.ts @@ -0,0 +1,114 @@ +import { Command } from 'commander'; +import { parseJsonl, ingestRecords } from '@md2do/core'; +import fs from 'fs/promises'; +import path from 'path'; + +interface IngestOptions { + output?: string; + vault?: string; + dryRun?: boolean; +} + +/** + * Create the 'ingest' command + */ +export function createIngestCommand(): Command { + const command = new Command('ingest'); + + command + .description('Ingest tasks from a JSONL file into a markdown vault file') + .argument('', 'Path to JSONL file (one task record per line)') + .option( + '-o, --output ', + 'Output file path (overrides --vault derivation)', + ) + .option( + '--vault ', + 'Vault root directory (default: current directory)', + ) + .option('--dry-run', 'Print generated markdown without writing') + .action(async (file: string, options: IngestOptions) => { + try { + await ingestAction(file, options); + } catch (error) { + console.error( + 'Error:', + error instanceof Error ? error.message : String(error), + ); + process.exit(1); + } + }); + + return command; +} + +async function ingestAction( + file: string, + options: IngestOptions, +): Promise { + // Read JSONL 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 records + const records = parseJsonl(content); + + if (records.length === 0) { + console.log('No records found in JSONL file.'); + return; + } + + // Warn if mixed sources + const sources = new Set(records.map((r) => r.source)); + if (sources.size > 1) { + console.warn( + `⚠️ Warning: Mixed sources detected: ${[...sources].join(', ')}. Using first source "${records[0]!.source}" for output path derivation.`, + ); + } + + // Generate markdown + const today = new Date().toISOString().slice(0, 10); + const markdown = ingestRecords(records, undefined, today); + + if (options.dryRun) { + console.log(markdown); + return; + } + + // Derive output path + let outputPath: string; + if (options.output) { + outputPath = path.resolve(options.output); + } else { + const vaultRoot = options.vault + ? path.resolve(options.vault) + : process.cwd(); + const sourceSlug = records[0]!.source; + const basename = path.basename(file, path.extname(file)) + '.md'; + outputPath = path.join(vaultRoot, sourceSlug, basename); + } + + // Ensure output directory exists + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + + // Atomic write: write to temp file then rename + const tmpPath = `${outputPath}.tmp.${process.pid}`; + try { + await fs.writeFile(tmpPath, markdown, 'utf-8'); + await fs.rename(tmpPath, outputPath); + } catch (error) { + // Clean up temp file on failure + await fs.unlink(tmpPath).catch(() => undefined); + throw error; + } + + console.log(`✅ Wrote ${records.length} tasks to ${outputPath}`); +} diff --git a/packages/cli/src/commands/todoist.ts b/packages/cli/src/commands/todoist.ts index 6cc8ae3..e5ab860 100644 --- a/packages/cli/src/commands/todoist.ts +++ b/packages/cli/src/commands/todoist.ts @@ -6,7 +6,7 @@ import { md2doToTodoist, todoistToMd2do, } from '@md2do/todoist'; -import { parseTask, updateTask } from '@md2do/core'; +import { parseTask, updateTask, formatSources } from '@md2do/core'; import type { Task as TodoistTask } from '@doist/todoist-api-typescript'; import type { Task } from '@md2do/core'; import { scanMarkdownFiles } from '../scanner.js'; @@ -464,8 +464,10 @@ async function todoistImportAction( const task = parseResult.task; // Check if already has Todoist ID - if (task.todoistId) { - console.error(`❌ Error: Task already has a Todoist ID: ${task.todoistId}`); + if (task.sources?.['todoist']) { + console.error( + `❌ Error: Task already has a Todoist ID: ${task.sources['todoist']}`, + ); console.error(" Use 'md2do todoist sync' to sync existing tasks"); process.exit(1); } @@ -499,11 +501,13 @@ async function todoistImportAction( const todoistTask = await client.createTask(todoistParams); // Update markdown file with Todoist ID + // Merge any existing sources with the new todoist id + const updatedSources = { ...(task.sources ?? {}), todoist: todoistTask.id }; const updateResult = await updateTask({ file: file, line, updates: { - text: `${task.text} [todoist:${todoistTask.id}]`, + text: `${task.text} ${formatSources(updatedSources)}`, }, }); @@ -600,7 +604,9 @@ async function todoistSyncAction(options: TodoistSyncOptions): Promise { }); // Filter tasks with Todoist IDs - const tasksWithTodoist = scanResult.tasks.filter((t) => t.todoistId); + const tasksWithTodoist = scanResult.tasks.filter( + (t) => t.sources?.['todoist'], + ); if (tasksWithTodoist.length === 0) { console.log(''); @@ -620,12 +626,13 @@ async function todoistSyncAction(options: TodoistSyncOptions): Promise { const notFoundIds: string[] = []; for (const task of tasksWithTodoist) { + const todoistId = task.sources!['todoist']!; try { - const todoistTask = await client.getTask(task.todoistId!); - todoistTasks.set(task.todoistId!, todoistTask); + const todoistTask = await client.getTask(todoistId); + todoistTasks.set(todoistId, todoistTask); } catch (error) { // Task not found in Todoist (deleted) - notFoundIds.push(task.todoistId!); + notFoundIds.push(todoistId); } } @@ -644,7 +651,7 @@ async function todoistSyncAction(options: TodoistSyncOptions): Promise { }> = []; for (const mdTask of tasksWithTodoist) { - const todoistTask = todoistTasks.get(mdTask.todoistId!); + const todoistTask = todoistTasks.get(mdTask.sources!['todoist']!); if (!todoistTask) { // Task deleted in Todoist @@ -729,12 +736,18 @@ async function todoistSyncAction(options: TodoistSyncOptions): Promise { errorCount++; } } else if (update.type === 'pull' && !update.todoistTask) { - // Remove Todoist ID from deleted task + // Remove Todoist ID from deleted task — reconstruct sources without todoist + const remainingSources = { ...(update.task.sources ?? {}) }; + delete remainingSources['todoist']; + const sourcesStr = + Object.keys(remainingSources).length > 0 + ? ` ${formatSources(remainingSources)}` + : ''; const result = await updateTask({ file: update.task.file, line: update.task.line, updates: { - text: update.task.text.replace(/\s*\[todoist:\d+\]/, ''), + text: `${update.task.text}${sourcesStr}`, }, }); diff --git a/packages/cli/src/formatters/json.ts b/packages/cli/src/formatters/json.ts index 04e323e..925d6f8 100644 --- a/packages/cli/src/formatters/json.ts +++ b/packages/cli/src/formatters/json.ts @@ -13,7 +13,7 @@ export interface JsonOutput { priority?: string; dueDate?: string; // ISO string tags: string[]; - todoistId?: string; + sources?: Record; completedDate?: string; // ISO string }>; metadata: { @@ -43,7 +43,7 @@ export function formatAsJson(tasks: Task[]): string { ...(task.priority && { priority: task.priority }), ...(task.dueDate && { dueDate: task.dueDate.toISOString() }), tags: task.tags, - ...(task.todoistId && { todoistId: task.todoistId }), + ...(task.sources && { sources: task.sources }), ...(task.completedDate && { completedDate: task.completedDate.toISOString(), }), diff --git a/packages/cli/tests/e2e/migrate.test.ts b/packages/cli/tests/e2e/migrate.test.ts index f20ccf3..b51ef20 100644 --- a/packages/cli/tests/e2e/migrate.test.ts +++ b/packages/cli/tests/e2e/migrate.test.ts @@ -379,7 +379,7 @@ describe('E2E: md2do migrate', () => { assignee?: string; priority?: string; tags: string[]; - todoistId?: string; + sources?: Record; dueDate?: string; completed: boolean; completedDate?: string; @@ -396,7 +396,7 @@ describe('E2E: md2do migrate', () => { expect(tasks[0]?.assignee).toBe('alice'); expect(tasks[0]?.priority).toBe('high'); expect(tasks[0]?.tags).toEqual(['backend']); - expect(tasks[0]?.todoistId).toBe('123'); + expect(tasks[0]?.sources?.['todoist']).toBe('123'); expect(tasks[0]?.dueDate).toBeDefined(); // Second task: completion preserved diff --git a/packages/cli/tests/e2e/output-formats.test.ts b/packages/cli/tests/e2e/output-formats.test.ts index 634bdb8..5c53e16 100644 --- a/packages/cli/tests/e2e/output-formats.test.ts +++ b/packages/cli/tests/e2e/output-formats.test.ts @@ -133,7 +133,7 @@ describe.skip('E2E: JSON Format', () => { expect(task.priority).toBe('urgent'); // May include optional fields - // assignee, tags[], dueDate, todoistId + // assignee, tags[], dueDate, sources } expect(json).toMatchSnapshot(); diff --git a/packages/cli/tests/e2e/warnings.test.ts b/packages/cli/tests/e2e/warnings.test.ts index 2b44c19..43e1366 100644 --- a/packages/cli/tests/e2e/warnings.test.ts +++ b/packages/cli/tests/e2e/warnings.test.ts @@ -84,7 +84,7 @@ describe('E2E: Warning Configuration Profiles', () => { 'relative-date-no-context': 'error', 'missing-due-date': 'warn', 'missing-completed-date': 'warn', - 'duplicate-todoist-id': 'error', + 'duplicate-source-id': 'error', 'file-read-error': 'error', }, }, @@ -127,7 +127,7 @@ describe('E2E: Warning Configuration Profiles', () => { 'relative-date-no-context': 'off', 'missing-due-date': 'off', 'missing-completed-date': 'off', - 'duplicate-todoist-id': 'error', + 'duplicate-source-id': 'error', 'file-read-error': 'error', }, }, diff --git a/packages/config/src/presets.ts b/packages/config/src/presets.ts index 485b3d2..8baeaeb 100644 --- a/packages/config/src/presets.ts +++ b/packages/config/src/presets.ts @@ -30,7 +30,7 @@ export const PRESET_STRICT: WarningPreset = { 'missing-completed-date': 'warn', // Critical issues - errors - 'duplicate-todoist-id': 'error', + 'duplicate-source-id': 'error', 'file-read-error': 'error', }, }; @@ -65,7 +65,7 @@ export const PRESET_RECOMMENDED: WarningPreset = { 'missing-completed-date': 'off', // Critical errors - always shown - 'duplicate-todoist-id': 'error', + 'duplicate-source-id': 'error', 'file-read-error': 'error', }, }; diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 087b4ee..f1b2bed 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -44,7 +44,7 @@ export const WarningConfigSchema = z 'relative-date-no-context', 'missing-due-date', 'missing-completed-date', - 'duplicate-todoist-id', + 'duplicate-source-id', 'file-read-error', ]), z.enum(['error', 'warn', 'info', 'off']), @@ -97,7 +97,7 @@ export type WarningPreset = { | 'relative-date-no-context' | 'missing-due-date' | 'missing-completed-date' - | 'duplicate-todoist-id' + | 'duplicate-source-id' | 'file-read-error', 'error' | 'warn' | 'info' | 'off' >; @@ -137,7 +137,7 @@ export const DEFAULT_CONFIG: Config = { 'missing-completed-date': 'off', // Errors - always shown - 'duplicate-todoist-id': 'error', + 'duplicate-source-id': 'error', 'file-read-error': 'error', }, }, diff --git a/packages/config/tests/presets.test.ts b/packages/config/tests/presets.test.ts index bdd0673..4ea76e6 100644 --- a/packages/config/tests/presets.test.ts +++ b/packages/config/tests/presets.test.ts @@ -25,7 +25,7 @@ describe('Warning Presets', () => { }); it('should have critical rules set to error', () => { - expect(PRESET_STRICT.rules['duplicate-todoist-id']).toBe('error'); + expect(PRESET_STRICT.rules['duplicate-source-id']).toBe('error'); expect(PRESET_STRICT.rules['file-read-error']).toBe('error'); }); @@ -53,7 +53,7 @@ describe('Warning Presets', () => { }); it('should have critical rules set to error', () => { - expect(PRESET_RECOMMENDED.rules['duplicate-todoist-id']).toBe('error'); + expect(PRESET_RECOMMENDED.rules['duplicate-source-id']).toBe('error'); expect(PRESET_RECOMMENDED.rules['file-read-error']).toBe('error'); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2b4c063..91dfabe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,6 +25,9 @@ export * from './warnings/filter.js'; // Migrator export * from './migrator/index.js'; +// Ingest +export * from './ingest/index.js'; + // Utilities export * from './utils/dates.js'; export * from './utils/id.js'; diff --git a/packages/core/src/ingest/index.ts b/packages/core/src/ingest/index.ts new file mode 100644 index 0000000..920634d --- /dev/null +++ b/packages/core/src/ingest/index.ts @@ -0,0 +1,178 @@ +import type { IngestRecord } from '../types/index.js'; + +/** + * Parse JSONL content into IngestRecord array + * + * @param content - JSONL string (one JSON object per line) + * @returns Array of validated IngestRecord objects + * @throws Error with line number on malformed JSON or missing required fields + */ +export function parseJsonl(content: string): IngestRecord[] { + const records: IngestRecord[] = []; + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trim(); + if (!line) continue; // skip blank lines + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + throw new Error(`Line ${i + 1}: Invalid JSON: ${line}`); + } + + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + throw new Error(`Line ${i + 1}: Expected a JSON object`); + } + + const obj = parsed as Record; + + // Validate required fields + if (typeof obj['source'] !== 'string' || !obj['source']) { + throw new Error(`Line ${i + 1}: Missing required field "source"`); + } + if (typeof obj['externalId'] !== 'string' || !obj['externalId']) { + throw new Error(`Line ${i + 1}: Missing required field "externalId"`); + } + if (typeof obj['text'] !== 'string' || !obj['text']) { + throw new Error(`Line ${i + 1}: Missing required field "text"`); + } + if (typeof obj['completed'] !== 'boolean') { + throw new Error( + `Line ${i + 1}: Missing required field "completed" (must be boolean)`, + ); + } + + const record: IngestRecord = { + source: obj['source'], + externalId: obj['externalId'], + text: obj['text'], + completed: obj['completed'], + }; + + if (typeof obj['priority'] === 'string') record.priority = obj['priority']; + if (typeof obj['dueDate'] === 'string') record.dueDate = obj['dueDate']; + if ( + Array.isArray(obj['tags']) && + obj['tags'].every((t) => typeof t === 'string') + ) { + record.tags = obj['tags']; + } + if (typeof obj['assignee'] === 'string') record.assignee = obj['assignee']; + if ( + typeof obj['metadata'] === 'object' && + obj['metadata'] !== null && + !Array.isArray(obj['metadata']) + ) { + record.metadata = obj['metadata'] as Record; + } + + records.push(record); + } + + return records; +} + +/** + * Convert a priority string to exclamation mark notation + */ +function priorityMarker(priority: string | undefined): string { + switch (priority) { + case 'urgent': + return ' !!!'; + case 'high': + return ' !!'; + case 'normal': + return ' !'; + default: + return ''; + } +} + +/** + * Convert a single IngestRecord to a markdown task line + * + * Format: - [ ] text @assignee PRIORITY #tags #due/DATE {source:externalId} {completed:DATE} + * + * @param record - The ingest record to convert + * @param today - Today's date string (YYYY-MM-DD) used for completed date; defaults to current date + * @returns Markdown task line string + */ +export function ingestRecordToLine( + record: IngestRecord, + today?: string, +): string { + const checkbox = record.completed ? '- [x]' : '- [ ]'; + let line = `${checkbox} ${record.text}`; + + if (record.assignee) { + line += ` @${record.assignee}`; + } + + line += priorityMarker(record.priority); + + if (record.tags && record.tags.length > 0) { + line += ' ' + record.tags.map((t) => `#${t}`).join(' '); + } + + if (record.dueDate) { + line += ` #due/${record.dueDate}`; + } + + line += ` {${record.source}:${record.externalId}}`; + + if (record.completed) { + const completedDate = today ?? new Date().toISOString().slice(0, 10); + line += ` {completed:${completedDate}}`; + } + + return line; +} + +/** + * Generate a markdown document from an array of IngestRecord objects + * + * Groups incomplete tasks first, then a "## Completed" section. + * H1 title defaults to title-cased source slug from first record. + * + * @param records - Array of ingest records + * @param title - Optional override for the H1 title + * @param today - Today's date string (YYYY-MM-DD) for completed dates + * @returns Full markdown document string + */ +export function ingestRecords( + records: IngestRecord[], + title?: string, + today?: string, +): string { + if (records.length === 0) return ''; + + const sourceSlug = records[0]!.source; + const heading = + title ?? sourceSlug.charAt(0).toUpperCase() + sourceSlug.slice(1); + + const incomplete = records.filter((r) => !r.completed); + const completed = records.filter((r) => r.completed); + + const lines: string[] = [`# ${heading}`, '']; + + for (const record of incomplete) { + lines.push(ingestRecordToLine(record, today)); + } + + if (completed.length > 0) { + if (incomplete.length > 0) lines.push(''); + lines.push('## Completed', ''); + for (const record of completed) { + lines.push(ingestRecordToLine(record, today)); + } + } + + lines.push(''); + return lines.join('\n'); +} diff --git a/packages/core/src/parser/index.ts b/packages/core/src/parser/index.ts index 61b2f10..a4063f2 100644 --- a/packages/core/src/parser/index.ts +++ b/packages/core/src/parser/index.ts @@ -68,6 +68,7 @@ export function extractTags(text: string): string[] { * @example * extractTodoistId("Task {todoist:123456}") // => "123456" * extractTodoistId("Task [todoist:123456]") // => "123456" (legacy) + * @deprecated Use extractSources() instead */ export function extractTodoistId(text: string): string | undefined { const match = text.match(PATTERNS.TODOIST_ID); @@ -75,6 +76,61 @@ export function extractTodoistId(text: string): string | undefined { return match[1] || match[2]; } +/** + * Extract all source links from task text as a slug → id map + * + * Matches {slug:value} tokens, skipping reserved slugs (e.g. "completed"). + * Also handles legacy [todoist:NNN] bracket syntax. + * + * @param text - Task text + * @returns Record of slug → externalId, or undefined if none found + * + * @example + * extractSources("Task {todoist:123} {teams:msg-456}") // => { todoist: "123", teams: "msg-456" } + * extractSources("Task [todoist:123]") // => { todoist: "123" } (legacy) + * extractSources("Task {completed:2026-01-18}") // => undefined (reserved) + */ +export function extractSources( + text: string, +): Record | undefined { + const sources: Record = {}; + + // Match all {slug:value} tokens + const bracePattern = new RegExp(PATTERNS.BRACE_TOKEN.source, 'g'); + for (const match of text.matchAll(bracePattern)) { + const slug = match[1]; + const value = match[2]; + if (slug && value && !PATTERNS.RESERVED_SLUGS.has(slug)) { + sources[slug] = value; + } + } + + // Handle legacy [todoist:NNN] if not already captured via brace syntax + if (!('todoist' in sources)) { + const legacyMatch = text.match(PATTERNS.TODOIST_ID_LEGACY); + if (legacyMatch?.[1]) { + sources['todoist'] = legacyMatch[1]; + } + } + + return Object.keys(sources).length > 0 ? sources : undefined; +} + +/** + * Format sources record as space-separated {slug:id} tokens + * + * @param sources - Record of slug → externalId + * @returns Space-separated string of {slug:id} tokens + * + * @example + * formatSources({ todoist: "123", teams: "msg-456" }) // => "{todoist:123} {teams:msg-456}" + */ +export function formatSources(sources: Record): string { + return Object.entries(sources) + .map(([slug, id]) => `{${slug}:${id}}`) + .join(' '); +} + /** * Extract completion date from task text * @@ -198,8 +254,11 @@ export function cleanTaskText(text: string): string { .replace(PATTERNS.DUE_DATE_ABSOLUTE, '') .replace(PATTERNS.DUE_DATE_RELATIVE, '') .replace(PATTERNS.DUE_DATE_SHORT, '') - // Remove todoist ID and completed date (new and legacy) - .replace(PATTERNS.TODOIST_ID, '') + // Remove non-reserved brace tokens (source links like {todoist:ID}, {teams:ID}) + .replace(new RegExp(PATTERNS.BRACE_TOKEN_NON_RESERVED.source, 'g'), '') + // Remove legacy [todoist:NNN] bracket syntax + .replace(PATTERNS.TODOIST_ID_LEGACY, '') + // Remove completed date (new and legacy) .replace(PATTERNS.COMPLETED_DATE, '') // Remove assignee .replace(PATTERNS.ASSIGNEE, '') @@ -321,7 +380,7 @@ export function parseTask( const assignee = extractAssignee(fullText); const priority = extractPriority(fullText); const tags = extractTags(fullText); - const todoistId = extractTodoistId(fullText); + const sources = extractSources(fullText); const completedDate = extractCompletedDate(fullText); // Extract due date (may produce warning) @@ -384,7 +443,7 @@ export function parseTask( if (assignee !== undefined) task.assignee = assignee; if (priority !== undefined) task.priority = priority; if (dueDateResult.date !== undefined) task.dueDate = dueDateResult.date; - if (todoistId !== undefined) task.todoistId = todoistId; + if (sources !== undefined) task.sources = sources; if (completedDate !== undefined) task.completedDate = completedDate; if (context.project !== undefined) task.project = context.project; if (context.person !== undefined) task.person = context.person; diff --git a/packages/core/src/parser/patterns.ts b/packages/core/src/parser/patterns.ts index e7befbd..fea36fd 100644 --- a/packages/core/src/parser/patterns.ts +++ b/packages/core/src/parser/patterns.ts @@ -141,6 +141,46 @@ export const TAG = /#(?!due\/)([\w-]+)/g; */ export const TODOIST_ID = /\{todoist:(\d+)\}|\[todoist:\s*(\d+)\]/i; +/** + * Matches any {slug:value} brace token (global) + * + * Examples: + * "{todoist:123}" → slug: "todoist", value: "123" + * "{teams:msg-456}" → slug: "teams", value: "msg-456" + * "{completed:2026-01-18}" → slug: "completed", value: "2026-01-18" + * + * Groups: + * [1] - Slug (starts with letter, alphanumeric) + * [2] - Value (anything up to closing brace) + */ +export const BRACE_TOKEN = /\{([a-zA-Z][a-zA-Z0-9]*):([^}]+)\}/g; + +/** + * Matches {slug:value} brace tokens that are NOT reserved slugs (global) + * Reserved slugs: completed + * + * Examples: + * "{todoist:123}" → match + * "{teams:msg-456}" → match + * "{completed:2026-01-18}" → no match (reserved) + */ +export const BRACE_TOKEN_NON_RESERVED = + /\{(?!completed:)[a-zA-Z][a-zA-Z0-9]*:[^}]+\}/g; + +/** + * Legacy bracket-only form for Todoist ID + * + * Examples: + * "[todoist:123456]" → group 1: "123456" + * "[todoist: 987654321]" → group 1: "987654321" + */ +export const TODOIST_ID_LEGACY = /\[todoist:\s*(\d+)\]/i; + +/** + * Reserved slug names that cannot be used as source identifiers + */ +export const RESERVED_SLUGS = new Set(['completed']); + /** * Matches completion date in new syntax ({completed:YYYY-MM-DD}) or legacy bracket syntax * @@ -172,5 +212,9 @@ export const PATTERNS = { DUE_DATE_SHORT, TAG, TODOIST_ID, + TODOIST_ID_LEGACY, COMPLETED_DATE, + BRACE_TOKEN, + BRACE_TOKEN_NON_RESERVED, + RESERVED_SLUGS, } as const; diff --git a/packages/core/src/scanner/index.ts b/packages/core/src/scanner/index.ts index 3f48596..568f134 100644 --- a/packages/core/src/scanner/index.ts +++ b/packages/core/src/scanner/index.ts @@ -79,8 +79,8 @@ export class MarkdownScanner { const tasks: Task[] = []; const warnings: Warning[] = []; - // Track Todoist IDs to detect duplicates - const todoistIds = new Map(); + // Track source IDs (composite "slug:id" key) to detect duplicates + const sourceIds = new Map(); // Initialize context from file path const context: ParsingContext = {}; @@ -117,25 +117,25 @@ export class MarkdownScanner { if (result.task) { tasks.push(result.task); - // Check for duplicate Todoist IDs - if (result.task.todoistId) { - const existing = todoistIds.get(result.task.todoistId); - if (existing) { - warnings.push({ - severity: 'error', - source: 'md2do', - ruleId: 'duplicate-todoist-id', - file: filePath, - line: lineNumber, - text: result.task.text, - message: `Duplicate Todoist ID {todoist:${result.task.todoistId}}. Also found at ${existing.file}:${existing.line}.`, - reason: `Duplicate Todoist ID {todoist:${result.task.todoistId}}. Also found at ${existing.file}:${existing.line}.`, - }); - } else { - todoistIds.set(result.task.todoistId, { - file: filePath, - line: lineNumber, - }); + // Check for duplicate source IDs + if (result.task.sources) { + for (const [slug, id] of Object.entries(result.task.sources)) { + const compositeKey = `${slug}:${id}`; + const existing = sourceIds.get(compositeKey); + if (existing) { + warnings.push({ + severity: 'error', + source: 'md2do', + ruleId: 'duplicate-source-id', + file: filePath, + line: lineNumber, + text: result.task.text, + message: `Duplicate {${slug}:${id}}. Also found at ${existing.file}:${existing.line}.`, + reason: `Duplicate {${slug}:${id}}. Also found at ${existing.file}:${existing.line}.`, + }); + } else { + sourceIds.set(compositeKey, { file: filePath, line: lineNumber }); + } } } } @@ -164,36 +164,36 @@ export class MarkdownScanner { const allTasks: Task[] = []; const allWarnings: Warning[] = []; - // Track Todoist IDs across all files - const todoistIds = new Map(); + // Track source IDs (composite "slug:id" key) across all files + const sourceIds = new Map(); for (const file of files) { const result = this.scanFile(file.path, file.content); allTasks.push(...result.tasks); allWarnings.push(...result.warnings); - // Check for duplicate Todoist IDs across files + // Check for duplicate source IDs across files for (const task of result.tasks) { - if (task.todoistId) { - const existing = todoistIds.get(task.todoistId); - if (existing && existing.file !== task.file) { - // Only add warning if duplicate is in a different file - // (same-file duplicates are already caught by scanFile) - allWarnings.push({ - severity: 'error', - source: 'md2do', - ruleId: 'duplicate-todoist-id', - file: task.file, - line: task.line, - text: task.text, - message: `Duplicate Todoist ID {todoist:${task.todoistId}} across files. Also found at ${existing.file}:${existing.line}.`, - reason: `Duplicate Todoist ID {todoist:${task.todoistId}} across files. Also found at ${existing.file}:${existing.line}.`, - }); - } else if (!existing) { - todoistIds.set(task.todoistId, { - file: task.file, - line: task.line, - }); + if (task.sources) { + for (const [slug, id] of Object.entries(task.sources)) { + const compositeKey = `${slug}:${id}`; + const existing = sourceIds.get(compositeKey); + if (existing && existing.file !== task.file) { + // Only add warning if duplicate is in a different file + // (same-file duplicates are already caught by scanFile) + allWarnings.push({ + severity: 'error', + source: 'md2do', + ruleId: 'duplicate-source-id', + file: task.file, + line: task.line, + text: task.text, + message: `Duplicate {${slug}:${id}} across files. Also found at ${existing.file}:${existing.line}.`, + reason: `Duplicate {${slug}:${id}} across files. Also found at ${existing.file}:${existing.line}.`, + }); + } else if (!existing) { + sourceIds.set(compositeKey, { file: task.file, line: task.line }); + } } } } diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index bca733e..f29435d 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -24,8 +24,8 @@ export interface Task { priority?: Priority; tags: string[]; - // Optional Todoist sync - todoistId?: string; + // Optional external source links (slug → externalId) + sources?: Record; completedDate?: Date; } @@ -75,7 +75,7 @@ export type WarningCode = | 'relative-date-no-context' // [due: tomorrow] — relative dates no longer supported | 'missing-due-date' // Incomplete task with no due date | 'missing-completed-date' // [x] without [completed: date] - | 'duplicate-todoist-id' // Same Todoist ID in multiple tasks + | 'duplicate-source-id' // Same source:id in multiple tasks | 'file-read-error'; // Failed to read file export interface Warning { @@ -100,3 +100,40 @@ export interface Warning { /** @deprecated Use message instead */ reason?: string; } + +export interface SourceTask { + externalId: string; + text: string; + completed: boolean; + priority?: 'urgent' | 'high' | 'normal' | 'low'; + dueDate?: string; // YYYY-MM-DD + tags?: string[]; + assignee?: string; + metadata?: Record; +} + +export interface FetchOptions { + since?: Date; + limit?: number; + filter?: Record; +} + +export interface SourceProvider { + readonly slug: string; + readonly name: string; + fetchTasks(options?: FetchOptions): Promise; + completeTask?(externalId: string): Promise; + reopenTask?(externalId: string): Promise; +} + +export interface IngestRecord { + source: string; + externalId: string; + text: string; + completed: boolean; + priority?: string; + dueDate?: string; + tags?: string[]; + assignee?: string; + metadata?: Record; +} diff --git a/packages/core/src/warnings/filter.ts b/packages/core/src/warnings/filter.ts index 0c5e491..cfa2cda 100644 --- a/packages/core/src/warnings/filter.ts +++ b/packages/core/src/warnings/filter.ts @@ -21,7 +21,7 @@ export interface WarningFilterConfig { * enabled: true, * rules: { * 'missing-due-date': 'off', - * 'duplicate-todoist-id': 'error', + * 'duplicate-source-id': 'error', * }, * }; * diff --git a/packages/core/tests/ingest/index.test.ts b/packages/core/tests/ingest/index.test.ts new file mode 100644 index 0000000..1df7a82 --- /dev/null +++ b/packages/core/tests/ingest/index.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect } from 'vitest'; +import { + parseJsonl, + ingestRecordToLine, + ingestRecords, +} from '../../src/ingest/index.js'; +import type { IngestRecord } from '../../src/types/index.js'; + +describe('parseJsonl', () => { + it('should parse a single valid record', () => { + const line = JSON.stringify({ + source: 'teams', + externalId: 'msg-1', + text: 'Review PR', + completed: false, + }); + const records = parseJsonl(line); + expect(records).toHaveLength(1); + expect(records[0]).toEqual({ + source: 'teams', + externalId: 'msg-1', + text: 'Review PR', + completed: false, + }); + }); + + it('should parse multiple records', () => { + const content = [ + JSON.stringify({ + source: 'teams', + externalId: 'a', + text: 'Task A', + completed: false, + }), + JSON.stringify({ + source: 'teams', + externalId: 'b', + text: 'Task B', + completed: true, + }), + ].join('\n'); + const records = parseJsonl(content); + expect(records).toHaveLength(2); + }); + + it('should skip blank lines', () => { + const content = [ + JSON.stringify({ + source: 'teams', + externalId: 'a', + text: 'Task A', + completed: false, + }), + '', + JSON.stringify({ + source: 'teams', + externalId: 'b', + text: 'Task B', + completed: false, + }), + ' ', + ].join('\n'); + const records = parseJsonl(content); + expect(records).toHaveLength(2); + }); + + it('should parse optional fields', () => { + const line = JSON.stringify({ + source: 'outlook', + externalId: 'AAMk', + text: 'Budget review', + completed: false, + priority: 'high', + dueDate: '2026-08-10', + tags: ['finance'], + assignee: 'nick', + metadata: { from: 'boss@co.com' }, + }); + const records = parseJsonl(line); + expect(records[0]).toEqual({ + source: 'outlook', + externalId: 'AAMk', + text: 'Budget review', + completed: false, + priority: 'high', + dueDate: '2026-08-10', + tags: ['finance'], + assignee: 'nick', + metadata: { from: 'boss@co.com' }, + }); + }); + + it('should throw on invalid JSON', () => { + expect(() => parseJsonl('not valid json')).toThrow('Line 1: Invalid JSON'); + }); + + it('should throw with line number on invalid JSON in line 2', () => { + const content = [ + JSON.stringify({ + source: 'teams', + externalId: 'a', + text: 'Task A', + completed: false, + }), + 'oops', + ].join('\n'); + expect(() => parseJsonl(content)).toThrow('Line 2: Invalid JSON'); + }); + + it('should throw on missing "source" field', () => { + expect(() => + parseJsonl( + JSON.stringify({ externalId: 'a', text: 'Task', completed: false }), + ), + ).toThrow('source'); + }); + + it('should throw on missing "externalId" field', () => { + expect(() => + parseJsonl( + JSON.stringify({ source: 'teams', text: 'Task', completed: false }), + ), + ).toThrow('externalId'); + }); + + it('should throw on missing "text" field', () => { + expect(() => + parseJsonl( + JSON.stringify({ source: 'teams', externalId: 'a', completed: false }), + ), + ).toThrow('text'); + }); + + it('should throw on missing "completed" field', () => { + expect(() => + parseJsonl( + JSON.stringify({ source: 'teams', externalId: 'a', text: 'Task' }), + ), + ).toThrow('completed'); + }); + + it('should return empty array for empty string', () => { + expect(parseJsonl('')).toEqual([]); + }); +}); + +describe('ingestRecordToLine', () => { + const baseRecord: IngestRecord = { + source: 'teams', + externalId: 'msg-1', + text: 'Review PR', + completed: false, + }; + + it('should produce an incomplete task line', () => { + const line = ingestRecordToLine(baseRecord); + expect(line).toBe('- [ ] Review PR {teams:msg-1}'); + }); + + it('should produce a completed task line with today date', () => { + const line = ingestRecordToLine( + { ...baseRecord, completed: true }, + '2026-08-02', + ); + expect(line).toBe('- [x] Review PR {teams:msg-1} {completed:2026-08-02}'); + }); + + it('should include assignee', () => { + const line = ingestRecordToLine({ ...baseRecord, assignee: 'nick' }); + expect(line).toContain('@nick'); + }); + + it('should include priority markers', () => { + expect(ingestRecordToLine({ ...baseRecord, priority: 'urgent' })).toContain( + '!!!', + ); + expect(ingestRecordToLine({ ...baseRecord, priority: 'high' })).toContain( + '!!', + ); + expect(ingestRecordToLine({ ...baseRecord, priority: 'normal' })).toContain( + '!', + ); + expect( + ingestRecordToLine({ ...baseRecord, priority: 'low' }), + ).not.toContain('!'); + }); + + it('should include tags', () => { + const line = ingestRecordToLine({ ...baseRecord, tags: ['eng', 'review'] }); + expect(line).toContain('#eng'); + expect(line).toContain('#review'); + }); + + it('should include due date', () => { + const line = ingestRecordToLine({ ...baseRecord, dueDate: '2026-08-10' }); + expect(line).toContain('#due/2026-08-10'); + }); + + it('should include all metadata in correct order', () => { + const record: IngestRecord = { + source: 'outlook', + externalId: 'AAMk', + text: 'Budget review', + completed: false, + priority: 'high', + dueDate: '2026-08-10', + tags: ['finance'], + assignee: 'nick', + }; + const line = ingestRecordToLine(record); + expect(line).toBe( + '- [ ] Budget review @nick !! #finance #due/2026-08-10 {outlook:AAMk}', + ); + }); +}); + +describe('ingestRecords', () => { + it('should return empty string for empty records', () => { + expect(ingestRecords([])).toBe(''); + }); + + it('should generate markdown with H1 from source slug', () => { + const records: IngestRecord[] = [ + { + source: 'teams', + externalId: 'msg-1', + text: 'Task A', + completed: false, + }, + ]; + const md = ingestRecords(records, undefined, '2026-08-02'); + expect(md).toContain('# Teams'); + expect(md).toContain('- [ ] Task A {teams:msg-1}'); + }); + + it('should use custom title', () => { + const records: IngestRecord[] = [ + { + source: 'teams', + externalId: 'msg-1', + text: 'Task A', + completed: false, + }, + ]; + const md = ingestRecords(records, 'My Custom Title', '2026-08-02'); + expect(md).toContain('# My Custom Title'); + }); + + it('should separate incomplete and completed tasks', () => { + const records: IngestRecord[] = [ + { + source: 'teams', + externalId: 'a', + text: 'Incomplete', + completed: false, + }, + { source: 'teams', externalId: 'b', text: 'Done', completed: true }, + ]; + const md = ingestRecords(records, undefined, '2026-08-02'); + expect(md).toContain('- [ ] Incomplete'); + expect(md).toContain('## Completed'); + expect(md).toContain('- [x] Done'); + // Incomplete should appear before Completed section + expect(md.indexOf('- [ ] Incomplete')).toBeLessThan( + md.indexOf('## Completed'), + ); + }); + + it('should omit Completed section if no completed records', () => { + const records: IngestRecord[] = [ + { + source: 'teams', + externalId: 'a', + text: 'Incomplete', + completed: false, + }, + ]; + const md = ingestRecords(records, undefined, '2026-08-02'); + expect(md).not.toContain('## Completed'); + }); + + it('should end with a trailing newline', () => { + const records: IngestRecord[] = [ + { source: 'teams', externalId: 'a', text: 'Task', completed: false }, + ]; + const md = ingestRecords(records, undefined, '2026-08-02'); + expect(md.endsWith('\n')).toBe(true); + }); +}); diff --git a/packages/core/tests/parser/extractSources.test.ts b/packages/core/tests/parser/extractSources.test.ts new file mode 100644 index 0000000..0a697f4 --- /dev/null +++ b/packages/core/tests/parser/extractSources.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { extractSources, formatSources } from '../../src/parser/index.js'; + +describe('extractSources', () => { + it('should return undefined for text with no brace tokens', () => { + expect(extractSources('Fix bug @nick !! #backend')).toBeUndefined(); + }); + + it('should extract a single todoist brace token', () => { + expect(extractSources('Fix bug {todoist:123456}')).toEqual({ + todoist: '123456', + }); + }); + + it('should extract multiple source tokens', () => { + expect(extractSources('Fix bug {todoist:123} {teams:msg-456}')).toEqual({ + todoist: '123', + teams: 'msg-456', + }); + }); + + it('should skip reserved slug "completed"', () => { + expect(extractSources('Fix bug {completed:2026-01-18}')).toBeUndefined(); + }); + + it('should extract non-reserved tokens and skip completed', () => { + expect( + extractSources('Fix bug {todoist:123} {completed:2026-01-18}'), + ).toEqual({ todoist: '123' }); + }); + + it('should handle legacy [todoist:NNN] bracket syntax', () => { + expect(extractSources('Fix bug [todoist:123456]')).toEqual({ + todoist: '123456', + }); + }); + + it('should handle legacy [todoist: NNN] with space', () => { + expect(extractSources('Fix bug [todoist: 987654]')).toEqual({ + todoist: '987654', + }); + }); + + it('should prefer brace syntax over legacy bracket syntax for todoist', () => { + // Both present: brace wins, legacy ignored + expect(extractSources('Fix bug {todoist:111} [todoist:222]')).toEqual({ + todoist: '111', + }); + }); + + it('should extract non-todoist brace tokens alongside legacy todoist', () => { + expect(extractSources('Fix bug {teams:msg-1} [todoist:222]')).toEqual({ + teams: 'msg-1', + todoist: '222', + }); + }); + + it('should return undefined for only reserved tokens', () => { + expect(extractSources('Task {completed:2026-08-01}')).toBeUndefined(); + }); + + it('should handle slug with numbers', () => { + expect(extractSources('Task {source2:abc123}')).toEqual({ + source2: 'abc123', + }); + }); +}); + +describe('formatSources', () => { + it('should format a single source', () => { + expect(formatSources({ todoist: '123' })).toBe('{todoist:123}'); + }); + + it('should format multiple sources', () => { + const result = formatSources({ todoist: '123', teams: 'msg-456' }); + // Order may vary (object key order), check both slugs are present + expect(result).toContain('{todoist:123}'); + expect(result).toContain('{teams:msg-456}'); + }); + + it('should return empty string for empty object', () => { + expect(formatSources({})).toBe(''); + }); +}); diff --git a/packages/core/tests/parser/index.test.ts b/packages/core/tests/parser/index.test.ts index 43a4a5e..642474b 100644 --- a/packages/core/tests/parser/index.test.ts +++ b/packages/core/tests/parser/index.test.ts @@ -362,12 +362,12 @@ describe('parseTask', () => { it('should extract Todoist ID with new syntax', () => { const result = parseTask('- [ ] Task {todoist:123456}', 1, file, {}); - expect(result.task?.todoistId).toBe('123456'); + expect(result.task?.sources?.['todoist']).toBe('123456'); }); it('should extract Todoist ID with legacy syntax', () => { const result = parseTask('- [ ] Task [todoist:123456]', 1, file, {}); - expect(result.task?.todoistId).toBe('123456'); + expect(result.task?.sources?.['todoist']).toBe('123456'); }); it('should extract completion date with new syntax', () => { @@ -399,7 +399,7 @@ describe('parseTask', () => { expect(result.task?.priority).toBe('high'); expect(result.task?.tags).toEqual(['backend', 'urgent']); expect(result.task?.dueDate).toBeInstanceOf(Date); - expect(result.task?.todoistId).toBe('123'); + expect(result.task?.sources?.['todoist']).toBe('123'); expect(result.task?.text).toBe('Fix bug'); }); @@ -412,7 +412,7 @@ describe('parseTask', () => { expect(result.task?.priority).toBe('high'); expect(result.task?.tags).toEqual(['backend', 'urgent']); expect(result.task?.dueDate).toBeInstanceOf(Date); - expect(result.task?.todoistId).toBe('123'); + expect(result.task?.sources?.['todoist']).toBe('123'); expect(result.task?.text).toBe('Fix bug'); }); }); @@ -504,7 +504,7 @@ describe('parseTask', () => { expect(task).not.toHaveProperty('assignee'); expect(task).not.toHaveProperty('priority'); expect(task).not.toHaveProperty('dueDate'); - expect(task).not.toHaveProperty('todoistId'); + expect(task).not.toHaveProperty('sources'); expect(task).not.toHaveProperty('completedDate'); expect(task).not.toHaveProperty('project'); expect(task).not.toHaveProperty('person'); @@ -566,7 +566,7 @@ describe('parseTask', () => { expect(result.task?.completed).toBe(true); expect(result.task?.text).toBe('Fix payment bug'); - expect(result.task?.todoistId).toBe('987654'); + expect(result.task?.sources?.['todoist']).toBe('987654'); expect(result.task?.completedDate).toBeInstanceOf(Date); }); @@ -577,7 +577,7 @@ describe('parseTask', () => { expect(result.task?.completed).toBe(true); expect(result.task?.text).toBe('Fix payment bug'); - expect(result.task?.todoistId).toBe('987654'); + expect(result.task?.sources?.['todoist']).toBe('987654'); expect(result.task?.completedDate).toBeInstanceOf(Date); }); }); diff --git a/packages/core/tests/scanner/index.test.ts b/packages/core/tests/scanner/index.test.ts index bde4c3b..9ff037e 100644 --- a/packages/core/tests/scanner/index.test.ts +++ b/packages/core/tests/scanner/index.test.ts @@ -312,8 +312,8 @@ Line 4 expect(result.tasks[0]?.tags).toEqual(['backend', 'urgent']); // Task with Todoist ID - expect(result.tasks[1]?.todoistId).toBe('123456'); - expect(result.tasks[3]?.todoistId).toBe('789012'); + expect(result.tasks[1]?.sources?.['todoist']).toBe('123456'); + expect(result.tasks[3]?.sources?.['todoist']).toBe('789012'); }); }); @@ -456,10 +456,7 @@ Line 4 expect(result.tasks).toHaveLength(2); expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]?.reason).toContain( - 'Duplicate Todoist ID {todoist:', - ); - expect(result.warnings[0]?.reason).toContain('123456'); + expect(result.warnings[0]?.reason).toContain('{todoist:123456}'); expect(result.warnings[0]?.line).toBe(2); }); @@ -506,9 +503,7 @@ Line 4 expect(result.tasks).toHaveLength(2); expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]?.reason).toContain( - 'Duplicate Todoist ID {todoist:', - ); + expect(result.warnings[0]?.reason).toContain('{todoist:123456}'); expect(result.warnings[0]?.reason).toContain('across files'); expect(result.warnings[0]?.file).toBe('file2.md'); }); diff --git a/packages/mcp/src/prompts/templates.ts b/packages/mcp/src/prompts/templates.ts index de7c403..f836589 100644 --- a/packages/mcp/src/prompts/templates.ts +++ b/packages/mcp/src/prompts/templates.ts @@ -53,6 +53,25 @@ export function registerPrompts(server: Server) { }, ], }, + { + name: PROMPT_TEMPLATES.BUILD_INTEGRATION, + description: + 'Generate a prompt to help a Claude agent fetch tasks from an external source and write valid md2do JSONL ingest files', + arguments: [ + { + name: 'source', + description: + 'Source system slug (e.g. teams, outlook, slack, gcal)', + required: true, + }, + { + name: 'mode', + description: + 'Output mode: "jsonl" (default) or "provider" (appends TypeScript SourceProvider skeleton)', + required: false, + }, + ], + }, ], })); @@ -70,6 +89,9 @@ export function registerPrompts(server: Server) { case PROMPT_TEMPLATES.OVERDUE_REVIEW: return getOverdueReviewPrompt(args); + case PROMPT_TEMPLATES.BUILD_INTEGRATION: + return getBuildIntegrationPrompt(args); + default: throw new Error(`Unknown prompt: ${name}`); } @@ -156,6 +178,107 @@ Format the report with: }; } +/** + * Build integration prompt + */ +function getBuildIntegrationPrompt(args?: Record) { + const source = args?.source ?? 'your-source'; + + const corePrompt = `You are helping build a new md2do source integration for: ${source}. + +md2do ingests external tasks via a JSONL file — one JSON record per line. +Your job: fetch tasks from ${source}, write valid JSONL, then run the ingest command. + +## JSONL Format + +Required fields (every record must have all four): + + source string — always "${source}" + externalId string — stable, unique ID for this item in ${source} + text string — task description (plain text) + completed boolean — true if already done/resolved + +Optional fields: + + priority string — "urgent" | "high" | "normal" | "low" + dueDate string — YYYY-MM-DD (ISO date only, no time) + tags string[] — tag names, no # prefix + assignee string — username, no @ prefix + metadata object — any extra data (preserved, ignored by md2do) + +## Example + +{"source":"${source}","externalId":"abc-123","text":"Review Q3 budget","completed":false,"priority":"high","dueDate":"2026-08-15","tags":["finance"],"assignee":"nick"} +{"source":"${source}","externalId":"abc-456","text":"Old action item","completed":true} + +## Instructions + +1. Fetch all relevant items from ${source} (unread @mentions, flagged emails, saved items, etc.) +2. Write one JSONL line per item to /tmp/${source}-tasks.jsonl +3. Choose externalId carefully — use the most stable unique identifier in ${source} + (message-id, event-id, thread-id — NOT a list index or timestamp alone) +4. Map priorities to md2do levels: + - Critical / P0 / urgent → "urgent" + - Important / P1 / high → "high" + - Normal / P2 / medium → "normal" + - Low / P3 / no priority → "low" (or omit) +5. Set completed: true only when explicitly done/resolved/closed in ${source} + +After writing the file, run: + + md2do ingest /tmp/${source}-tasks.jsonl --vault ~/notes + +This creates vault/${source}/${source}-tasks.md with all tasks in md2do format, +queryable via \`md2do list\`, the Obsidian plugin, and the MCP server.`; + + const providerSkeleton = ` + +## TypeScript SourceProvider Skeleton + +If you want a programmatic integration instead of agent-generated JSONL, implement this interface: + +\`\`\`typescript +import type { SourceProvider, SourceTask, FetchOptions } from '@md2do/core'; + +export class ${source.charAt(0).toUpperCase() + source.slice(1)}Provider implements SourceProvider { + readonly slug = '${source}'; + readonly name = '${source.charAt(0).toUpperCase() + source.slice(1)}'; + + async fetchTasks(options?: FetchOptions): Promise { + // TODO: fetch items from ${source} API + return []; + } +} +\`\`\` + +Then use \`ingestRecords()\` from \`@md2do/core\` to convert to markdown: + +\`\`\`typescript +import { ingestRecords } from '@md2do/core'; + +const provider = new ${source.charAt(0).toUpperCase() + source.slice(1)}Provider(client); +const tasks = await provider.fetchTasks(); +const records = tasks.map((t) => ({ source: provider.slug, ...t })); +const markdown = ingestRecords(records); +\`\`\``; + + const text = + args?.mode === 'provider' ? corePrompt + providerSkeleton : corePrompt; + + return { + description: `Integration builder prompt for ${source}`, + messages: [ + { + role: 'user' as const, + content: { + type: 'text' as const, + text, + }, + }, + ], + }; +} + /** * Overdue review prompt */ diff --git a/packages/mcp/src/tools/list-tasks.ts b/packages/mcp/src/tools/list-tasks.ts index dec97af..9863ccf 100644 --- a/packages/mcp/src/tools/list-tasks.ts +++ b/packages/mcp/src/tools/list-tasks.ts @@ -136,5 +136,7 @@ function formatTask(task: Task) { if (task.dueDate) formatted.dueDate = task.dueDate.toISOString(); if (task.completedDate) formatted.completedDate = task.completedDate.toISOString(); + if (task.sources && Object.keys(task.sources).length > 0) + formatted.sources = task.sources; return formatted; } diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index dd840ca..83c79ad 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -82,6 +82,7 @@ export const PROMPT_TEMPLATES = { DAILY_STANDUP: 'daily_standup', SPRINT_SUMMARY: 'sprint_summary', OVERDUE_REVIEW: 'overdue_review', + BUILD_INTEGRATION: 'build_integration', } as const; export type PromptTemplateName = diff --git a/packages/todoist/src/index.ts b/packages/todoist/src/index.ts index d0fc630..f32ecff 100644 --- a/packages/todoist/src/index.ts +++ b/packages/todoist/src/index.ts @@ -9,3 +9,4 @@ export { todoistToMd2do, } from './mapper.js'; export type { TodoistTaskParams, Md2doTaskUpdate } from './mapper.js'; +export { TodoistProvider } from './provider.js'; diff --git a/packages/todoist/src/mapper.ts b/packages/todoist/src/mapper.ts index 43840ac..9141866 100644 --- a/packages/todoist/src/mapper.ts +++ b/packages/todoist/src/mapper.ts @@ -1,4 +1,5 @@ import type { Task } from '@md2do/core'; +import { formatSources } from '@md2do/core'; import type { Task as TodoistTask } from '@doist/todoist-api-typescript'; /** @@ -75,7 +76,7 @@ export function formatTaskContent( priority?: string; tags?: string[]; due?: Date; - todoistId?: string; + sources?: Record; }, ): string { let result = content; @@ -108,9 +109,9 @@ export function formatTaskContent( result += ` #due/${year}-${month}-${day}`; } - // Add Todoist ID - if (options.todoistId) { - result += ` {todoist:${options.todoistId}}`; + // Add source links + if (options.sources && Object.keys(options.sources).length > 0) { + result += ` ${formatSources(options.sources)}`; } return result; @@ -165,7 +166,7 @@ export function md2doToTodoist( export interface Md2doTaskUpdate { text: string; completed: boolean; - todoistId: string; + sources: Record; priority?: string; tags?: string[]; due?: Date; @@ -187,9 +188,9 @@ export function todoistToMd2do( priority?: string; tags?: string[]; due?: Date; - todoistId?: string; + sources?: Record; } = { - todoistId: todoistTask.id, + sources: { todoist: todoistTask.id }, }; if (assignee !== undefined) { @@ -211,7 +212,7 @@ export function todoistToMd2do( const update: Md2doTaskUpdate = { text: formatTaskContent(todoistTask.content, formatOptions), completed: todoistTask.isCompleted ?? false, - todoistId: todoistTask.id, + sources: { todoist: todoistTask.id }, }; // Add optional properties only if they have values diff --git a/packages/todoist/src/provider.ts b/packages/todoist/src/provider.ts new file mode 100644 index 0000000..7838cf2 --- /dev/null +++ b/packages/todoist/src/provider.ts @@ -0,0 +1,51 @@ +import type { SourceProvider, SourceTask, FetchOptions } from '@md2do/core'; +import { todoistToMd2doPriority } from './mapper.js'; +import type { TodoistClient } from './client.js'; + +/** + * Todoist implementation of the SourceProvider interface + */ +export class TodoistProvider implements SourceProvider { + readonly slug = 'todoist'; + readonly name = 'Todoist'; + + constructor(private client: TodoistClient) {} + + async fetchTasks(options?: FetchOptions): Promise { + const todoistTasks = await this.client.getTasks( + options?.filter as { projectId?: string; labelId?: string } | undefined, + ); + + return todoistTasks.map((task) => { + const sourceTask: SourceTask = { + externalId: task.id, + text: task.content, + completed: task.isCompleted ?? false, + }; + + const priority = todoistToMd2doPriority(task.priority); + if ( + priority === 'urgent' || + priority === 'high' || + priority === 'normal' || + priority === 'low' + ) { + sourceTask.priority = priority; + } + + if (task.due?.date) sourceTask.dueDate = task.due.date; + + if (task.labels.length > 0) sourceTask.tags = task.labels; + + return sourceTask; + }); + } + + async completeTask(externalId: string): Promise { + await this.client.completeTask(externalId); + } + + async reopenTask(externalId: string): Promise { + await this.client.reopenTask(externalId); + } +} diff --git a/packages/todoist/tests/mapper.test.ts b/packages/todoist/tests/mapper.test.ts index b583ec1..768f838 100644 --- a/packages/todoist/tests/mapper.test.ts +++ b/packages/todoist/tests/mapper.test.ts @@ -142,7 +142,9 @@ describe('formatTaskContent', () => { }); it('should format content with Todoist ID', () => { - const result = formatTaskContent('Fix bug', { todoistId: '123456' }); + const result = formatTaskContent('Fix bug', { + sources: { todoist: '123456' }, + }); expect(result).toBe('Fix bug {todoist:123456}'); }); @@ -152,7 +154,7 @@ describe('formatTaskContent', () => { priority: 'urgent', tags: ['backend'], due: new Date('2026-01-20T00:00:00.000Z'), - todoistId: '123456', + sources: { todoist: '123456' }, }); expect(result).toBe( 'Fix bug @nick !!! #backend #due/2026-01-20 {todoist:123456}', @@ -244,7 +246,7 @@ describe('todoistToMd2do', () => { priority: 'urgent', tags: ['backend'], due: new Date('2026-01-20T00:00:00.000Z'), - todoistId: '123456', + sources: { todoist: '123456' }, }); }); @@ -292,7 +294,7 @@ describe('todoistToMd2do', () => { text: 'Fix bug {todoist:123456}', completed: false, priority: 'low', - todoistId: '123456', + sources: { todoist: '123456' }, }); }); }); diff --git a/packages/todoist/tests/provider.test.ts b/packages/todoist/tests/provider.test.ts new file mode 100644 index 0000000..6125a0e --- /dev/null +++ b/packages/todoist/tests/provider.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TodoistProvider } from '../src/provider.js'; +import type { Task as TodoistTask } from '@doist/todoist-api-typescript'; + +function makeTodoistTask(overrides: Partial = {}): TodoistTask { + return { + id: 'task-1', + content: 'Test task', + description: '', + projectId: 'proj-1', + sectionId: null, + parentId: null, + order: 1, + priority: 1, + labels: [], + due: null, + url: 'https://todoist.com/task/1', + commentCount: 0, + isCompleted: false, + createdAt: '2026-08-01T00:00:00Z', + creatorId: 'user-1', + assigneeId: null, + assignerId: null, + duration: null, + ...overrides, + }; +} + +describe('TodoistProvider', () => { + const getTasks = vi.fn().mockResolvedValue([]); + const completeTask = vi.fn().mockResolvedValue(true); + const reopenTask = vi.fn().mockResolvedValue(true); + + const mockClient = { + getTasks, + getTask: vi.fn(), + createTask: vi.fn(), + updateTask: vi.fn(), + completeTask, + reopenTask, + deleteTask: vi.fn(), + getProjects: vi.fn(), + getProject: vi.fn(), + findProjectByName: vi.fn(), + getLabels: vi.fn(), + ensureLabel: vi.fn(), + test: vi.fn(), + }; + + let provider: TodoistProvider; + + beforeEach(() => { + vi.clearAllMocks(); + getTasks.mockResolvedValue([]); + provider = new TodoistProvider(mockClient as any); + }); + + it('has slug "todoist" and name "Todoist"', () => { + expect(provider.slug).toBe('todoist'); + expect(provider.name).toBe('Todoist'); + }); + + describe('fetchTasks', () => { + it('returns empty array when no tasks', async () => { + const tasks = await provider.fetchTasks(); + expect(tasks).toEqual([]); + }); + + it('maps basic task fields', async () => { + getTasks.mockResolvedValue([ + makeTodoistTask({ id: 'abc', content: 'Do the thing' }), + ]); + + const tasks = await provider.fetchTasks(); + + expect(tasks).toHaveLength(1); + expect(tasks[0]!.externalId).toBe('abc'); + expect(tasks[0]!.text).toBe('Do the thing'); + expect(tasks[0]!.completed).toBe(false); + }); + + it('maps completed status', async () => { + getTasks.mockResolvedValue([makeTodoistTask({ isCompleted: true })]); + const tasks = await provider.fetchTasks(); + expect(tasks[0]!.completed).toBe(true); + }); + + it('maps priority 4 → urgent', async () => { + getTasks.mockResolvedValue([makeTodoistTask({ priority: 4 })]); + const tasks = await provider.fetchTasks(); + expect(tasks[0]!.priority).toBe('urgent'); + }); + + it('maps priority 3 → high', async () => { + getTasks.mockResolvedValue([makeTodoistTask({ priority: 3 })]); + const tasks = await provider.fetchTasks(); + expect(tasks[0]!.priority).toBe('high'); + }); + + it('maps priority 2 → normal', async () => { + getTasks.mockResolvedValue([makeTodoistTask({ priority: 2 })]); + const tasks = await provider.fetchTasks(); + expect(tasks[0]!.priority).toBe('normal'); + }); + + it('maps priority 1 → low', async () => { + getTasks.mockResolvedValue([makeTodoistTask({ priority: 1 })]); + const tasks = await provider.fetchTasks(); + expect(tasks[0]!.priority).toBe('low'); + }); + + it('maps dueDate when present', async () => { + getTasks.mockResolvedValue([ + makeTodoistTask({ + due: { + date: '2026-08-15', + isRecurring: false, + string: 'Aug 15', + timezone: null, + datetime: null, + }, + }), + ]); + const tasks = await provider.fetchTasks(); + expect(tasks[0]!.dueDate).toBe('2026-08-15'); + }); + + it('omits dueDate when not present', async () => { + getTasks.mockResolvedValue([makeTodoistTask({ due: null })]); + const tasks = await provider.fetchTasks(); + expect(tasks[0]!.dueDate).toBeUndefined(); + }); + + it('maps labels as tags', async () => { + getTasks.mockResolvedValue([ + makeTodoistTask({ labels: ['eng', 'backend'] }), + ]); + const tasks = await provider.fetchTasks(); + expect(tasks[0]!.tags).toEqual(['eng', 'backend']); + }); + + it('omits tags when labels is empty', async () => { + getTasks.mockResolvedValue([makeTodoistTask({ labels: [] })]); + const tasks = await provider.fetchTasks(); + expect(tasks[0]!.tags).toBeUndefined(); + }); + + it('passes options.filter to client.getTasks', async () => { + await provider.fetchTasks({ filter: { projectId: 'proj-42' } }); + expect(getTasks).toHaveBeenCalledWith({ projectId: 'proj-42' }); + }); + + it('maps multiple tasks', async () => { + getTasks.mockResolvedValue([ + makeTodoistTask({ id: '1', content: 'First' }), + makeTodoistTask({ id: '2', content: 'Second' }), + ]); + const tasks = await provider.fetchTasks(); + expect(tasks).toHaveLength(2); + expect(tasks[0]!.externalId).toBe('1'); + expect(tasks[1]!.externalId).toBe('2'); + }); + }); + + describe('completeTask', () => { + it('delegates to client.completeTask', async () => { + await provider.completeTask('task-99'); + expect(completeTask).toHaveBeenCalledWith('task-99'); + }); + }); + + describe('reopenTask', () => { + it('delegates to client.reopenTask', async () => { + await provider.reopenTask('task-99'); + expect(reopenTask).toHaveBeenCalledWith('task-99'); + }); + }); +}); diff --git a/packages/todoist/vitest.config.ts b/packages/todoist/vitest.config.ts index 21c8b36..070a672 100644 --- a/packages/todoist/vitest.config.ts +++ b/packages/todoist/vitest.config.ts @@ -13,12 +13,13 @@ export default defineConfig({ '**/*.d.ts', '**/*.config.*', '**/dist/**', + '**/client.ts', // thin API wrapper, no unit-testable logic ], thresholds: { - lines: 50, // TODO: Increase to 80% - client.ts needs tests - functions: 70, + lines: 80, + functions: 80, branches: 90, - statements: 50, + statements: 80, }, }, }, diff --git a/packages/vscode/src/commands/treeActions.ts b/packages/vscode/src/commands/treeActions.ts index e95adf3..111d0ae 100644 --- a/packages/vscode/src/commands/treeActions.ts +++ b/packages/vscode/src/commands/treeActions.ts @@ -105,8 +105,10 @@ export async function copyTaskAsMarkdown(task: Task): Promise { markdown += ` ${task.tags.map((t) => `#${t}`).join(' ')}`; } - if (task.todoistId) { - markdown += ` {todoist:${task.todoistId}}`; + if (task.sources && Object.keys(task.sources).length > 0) { + for (const [slug, id] of Object.entries(task.sources)) { + markdown += ` {${slug}:${id}}`; + } } await vscode.env.clipboard.writeText(markdown); diff --git a/packages/vscode/src/providers/codeLensProvider.ts b/packages/vscode/src/providers/codeLensProvider.ts index cee6667..0efcba1 100644 --- a/packages/vscode/src/providers/codeLensProvider.ts +++ b/packages/vscode/src/providers/codeLensProvider.ts @@ -109,14 +109,16 @@ export class TaskCodeLensProvider implements vscode.CodeLensProvider { codeLenses.push(priorityLens); } - // Todoist sync status - if (task.todoistId) { - const todoistLens = new vscode.CodeLens(range, { - title: '🔄 Synced', - command: '', - tooltip: `Synced with Todoist (ID: ${task.todoistId})`, - }); - codeLenses.push(todoistLens); + // Source sync status + if (task.sources) { + for (const [slug, id] of Object.entries(task.sources)) { + const syncLens = new vscode.CodeLens(range, { + title: '🔄 Synced', + command: '', + tooltip: `Synced with ${slug} (ID: ${id})`, + }); + codeLenses.push(syncLens); + } } // Delete action diff --git a/packages/vscode/src/providers/hoverProvider.ts b/packages/vscode/src/providers/hoverProvider.ts index 3b0804e..452702e 100644 --- a/packages/vscode/src/providers/hoverProvider.ts +++ b/packages/vscode/src/providers/hoverProvider.ts @@ -88,9 +88,11 @@ export class TaskHoverProvider implements vscode.HoverProvider { sections.push(`👥 **Person**: ${task.person}`); } - // Todoist sync - if (task.todoistId) { - sections.push(`🔄 **Todoist ID**: ${task.todoistId}`); + // Source links + if (task.sources) { + for (const [slug, id] of Object.entries(task.sources)) { + sections.push(`🔄 **${slug} ID**: ${id}`); + } } // Add all sections