planko-mcp-server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,3 @@
1
+ Looks like there's already a license file for this project.
2
+ [?25l? Do you want to replace it? › (y/N)✔ Do you want to replace it? … no
3
+ [?25hExiting...
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # planko-mcp-server
2
+
3
+ MCP server for syncing [Planko](https://planko.io) tasks with local Markdown files. Works with Claude Code, Cursor, and any MCP-compatible AI agent.
4
+
5
+ ## Features
6
+
7
+ - **3 tools**: setup, sync preview, sync
8
+ - **Multi-folder**: sync multiple projects to different local folders
9
+ - **Bidirectional sync**: pull remote changes, push local changes
10
+ - **Delete sync**: deleted local files remove tasks on server; deleted tasks remove local files
11
+ - **User-scoped API key**: one key works across all your projects
12
+
13
+ ## Getting Started
14
+
15
+ ### Step 1 — Get your API key
16
+
17
+ 1. Open [app.planko.io/integrations](https://app.planko.io/integrations)
18
+ 2. Find the **Model Context Protocol (MCP)** card
19
+ 3. Click **"Ativar"** to generate your API key
20
+ 4. Copy the key (you can only see it once — regenerate if you lose it)
21
+
22
+ ### Step 2 — Add to your MCP client
23
+
24
+ **Claude Code** — add to `.mcp.json` in your project root (or `~/.claude/settings.local.json` for global):
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "planko": {
30
+ "command": "npx",
31
+ "args": ["-y", "planko-mcp-server"],
32
+ "env": {
33
+ "PLANKO_API_KEY": "your-api-key"
34
+ }
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ **Cursor** — add to `.cursor/mcp.json`:
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "planko": {
46
+ "command": "npx",
47
+ "args": ["-y", "planko-mcp-server"],
48
+ "env": {
49
+ "PLANKO_API_KEY": "your-api-key"
50
+ }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ No `npm install` or cloning needed. Requires Node.js 18+.
57
+
58
+ ### Step 3 — Restart your tool
59
+
60
+ Restart Claude Code, Cursor, or whichever MCP client you use so it picks up the new server.
61
+
62
+ ### Step 4 — Setup a project folder
63
+
64
+ Ask your AI agent to run:
65
+
66
+ ```
67
+ planko_setup(projectName: "My Project", folderPath: "/path/to/folder", email: "you@email.com")
68
+ ```
69
+
70
+ This maps a Planko project to a local folder. You can set up multiple projects pointing to different folders.
71
+
72
+ ### Step 5 — Sync
73
+
74
+ ```
75
+ planko_sync()
76
+ ```
77
+
78
+ Tasks are pulled as `.md` files into your folder. Local changes are pushed back to Planko.
79
+
80
+ ## Tools
81
+
82
+ | Tool | Description | Parameters |
83
+ |---|---|---|
84
+ | `planko_setup` | Set up sync between a project and a local folder | `projectName`, `folderPath`, `email` |
85
+ | `planko_sync_preview` | Preview what would be synced (read-only) | `projectName` (optional) |
86
+ | `planko_sync` | Execute bidirectional sync with delete support | `projectName` (optional) |
87
+
88
+ ### Sync preview
89
+
90
+ ```
91
+ planko_sync_preview() # Preview all projects
92
+ planko_sync_preview(projectName: "My Project") # Preview one project
93
+ ```
94
+
95
+ ### Sync
96
+
97
+ ```
98
+ planko_sync() # Sync all projects
99
+ planko_sync(projectName: "My Project") # Sync one project
100
+ ```
101
+
102
+ When `projectName` is omitted, all configured projects are synced.
103
+
104
+ Sync order: delete, pull, push. Local changes overwrite remote on conflict.
105
+
106
+ ## Environment Variables
107
+
108
+ | Variable | Required | Description |
109
+ |---|---|---|
110
+ | `PLANKO_API_KEY` | Yes | Your MCP API key from app.planko.io/integrations |
111
+ | `PLANKO_API_BASE` | No | API base URL override (defaults to production) |
112
+
113
+ ## How it works
114
+
115
+ - Each Planko task maps to a local `.md` file (task name becomes filename)
116
+ - Task descriptions are converted between BlockNote and Markdown automatically
117
+ - Sync state is tracked in `.planko-mcp-sync.json` per folder
118
+ - Global config is stored at `~/.planko-mcp/config.json`
119
+ - API keys are never written to any file
120
+
121
+ ## License
122
+
123
+ MIT
package/index.js ADDED
@@ -0,0 +1,574 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * planko-mcp-server — MCP server for syncing Planko tasks with local .md files.
5
+ *
6
+ * Environment variables:
7
+ * PLANKO_API_KEY (required) — user-scoped MCP API key
8
+ * PLANKO_API_BASE — optional API base URL override
9
+ *
10
+ * Tools:
11
+ * planko_setup — Configure sync for a project folder
12
+ * planko_sync_preview — Preview what would be synced
13
+ * planko_sync — Execute bidirectional sync with delete support
14
+ */
15
+
16
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
18
+ import { z } from 'zod';
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+
22
+ import { createApiClient } from './src/api.js';
23
+ import {
24
+ blockNoteToMarkdown,
25
+ markdownToBlockNote,
26
+ descriptionToMarkdown,
27
+ } from './src/converters.js';
28
+ import {
29
+ readConfig,
30
+ writeConfig,
31
+ readSyncState,
32
+ writeSyncState,
33
+ createSyncState,
34
+ listLocalFiles,
35
+ getFileMtimeMs,
36
+ deleteLocalFile,
37
+ toFileName,
38
+ toTaskName,
39
+ buildIndexes,
40
+ SYNC_FILE,
41
+ } from './src/sync-state.js';
42
+
43
+ // --- Startup validation ---
44
+
45
+ const API_KEY = process.env.PLANKO_API_KEY;
46
+ if (!API_KEY) {
47
+ console.error(
48
+ 'Fatal: PLANKO_API_KEY environment variable is required.'
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ const api = createApiClient({ apiKey: API_KEY });
54
+
55
+ // --- Tool helpers ---
56
+
57
+ function toolOk(text) {
58
+ return { content: [{ type: 'text', text }] };
59
+ }
60
+
61
+ function toolError(text) {
62
+ return { content: [{ type: 'text', text }], isError: true };
63
+ }
64
+
65
+ // --- Sync engine (shared by preview and sync) ---
66
+
67
+ /**
68
+ * Compute sync diff for a single project folder.
69
+ * Returns { wouldPush, wouldPull, wouldDeleteLocal, wouldDeleteRemote, conflicts }
70
+ */
71
+ async function computeSyncDiff(projectId, folder, sync) {
72
+ const localFiles = listLocalFiles(folder);
73
+ const { byFileName, byId } = buildIndexes(sync.tasks);
74
+
75
+ // Get ALL tasks from server (no mcpLastSyncDate filter) to detect deletions
76
+ const statusData = await api.status(projectId);
77
+ const remoteTasks = statusData.tasks || [];
78
+ const remoteIdSet = new Set(remoteTasks.map((t) => t._id.toString()));
79
+
80
+ // --- Local changes (would be pushed) ---
81
+ const wouldPush = [];
82
+ const localMtimes = {};
83
+
84
+ for (const fname of localFiles) {
85
+ const filePath = join(folder, fname);
86
+ const mtimeMs = Math.floor(getFileMtimeMs(filePath));
87
+ localMtimes[fname] = mtimeMs;
88
+
89
+ if (fname in byFileName) {
90
+ const task = sync.tasks[byFileName[fname]];
91
+ const lastKnownMtime = task.mcpLastLocalUpdate;
92
+ let isModified = false;
93
+ if (task._id == null) {
94
+ isModified = true; // new task
95
+ } else if (lastKnownMtime != null) {
96
+ isModified = mtimeMs > lastKnownMtime;
97
+ } else if (sync.mcpLastSyncDate == null || mtimeMs > sync.mcpLastSyncDate) {
98
+ isModified = true;
99
+ }
100
+ if (isModified) {
101
+ wouldPush.push({ name: toTaskName(fname), fileName: fname, _id: task._id });
102
+ }
103
+ } else {
104
+ // New local file — will be created on server
105
+ wouldPush.push({ name: toTaskName(fname), fileName: fname, _id: null });
106
+ }
107
+ }
108
+
109
+ // --- Remote changes (would be pulled) ---
110
+ const wouldPull = [];
111
+ for (const rt of remoteTasks) {
112
+ if (
113
+ rt.mcpSyncDate &&
114
+ sync.mcpLastSyncDate &&
115
+ new Date(rt.mcpSyncDate).getTime() > sync.mcpLastSyncDate
116
+ ) {
117
+ wouldPull.push({ name: rt.name, _id: rt._id.toString() });
118
+ } else if (!sync.mcpLastSyncDate) {
119
+ // First sync — pull everything not already local
120
+ const fname = toFileName(rt.name);
121
+ if (fname && !(fname in byFileName)) {
122
+ wouldPull.push({ name: rt.name, _id: rt._id.toString() });
123
+ }
124
+ }
125
+ }
126
+
127
+ // --- Detect deletions ---
128
+ const localFileSet = new Set(localFiles);
129
+
130
+ // Tasks in sync state that have an _id but are no longer on the server → deleted remotely
131
+ const wouldDeleteLocal = [];
132
+ for (const task of sync.tasks) {
133
+ if (task._id && !remoteIdSet.has(task._id.toString())) {
134
+ wouldDeleteLocal.push({ name: task.name, fileName: task.fileName, _id: task._id });
135
+ }
136
+ }
137
+
138
+ // Tasks in sync state that have an _id and fileName but the local file is gone → deleted locally
139
+ const wouldDeleteRemote = [];
140
+ for (const task of sync.tasks) {
141
+ if (task._id && task.fileName && !localFileSet.has(task.fileName)) {
142
+ // Only if the task still exists on server
143
+ if (remoteIdSet.has(task._id.toString())) {
144
+ wouldDeleteRemote.push({ name: task.name, fileName: task.fileName, _id: task._id });
145
+ }
146
+ }
147
+ }
148
+
149
+ // --- Conflicts ---
150
+ const conflicts = [];
151
+ const pushNames = new Set(wouldPush.map((p) => p.name));
152
+ for (const pull of wouldPull) {
153
+ if (pushNames.has(pull.name)) {
154
+ conflicts.push({
155
+ name: pull.name,
156
+ localMtime: localMtimes[toFileName(pull.name)]
157
+ ? new Date(localMtimes[toFileName(pull.name)]).toISOString()
158
+ : 'unknown',
159
+ });
160
+ }
161
+ }
162
+
163
+ return { wouldPush, wouldPull, wouldDeleteLocal, wouldDeleteRemote, conflicts };
164
+ }
165
+
166
+ /**
167
+ * Execute sync for a single project folder.
168
+ */
169
+ async function executeSync(projectId, folder, sync) {
170
+ const localFiles = listLocalFiles(folder);
171
+ const prePullSyncDate = sync.mcpLastSyncDate;
172
+ const results = [];
173
+
174
+ // Get ALL tasks to detect deletions
175
+ const allStatusData = await api.status(projectId);
176
+ const allRemoteTasks = allStatusData.tasks || [];
177
+ const remoteIdSet = new Set(allRemoteTasks.map((t) => t._id.toString()));
178
+
179
+ // --- DELETE phase (remote → local): remove local files for tasks deleted on server ---
180
+ const localFileSet = new Set(localFiles);
181
+ let deletedLocalCount = 0;
182
+ const tasksToRemoveFromState = [];
183
+
184
+ for (const task of sync.tasks) {
185
+ if (task._id && !remoteIdSet.has(task._id.toString())) {
186
+ // Task was deleted on server — remove local file
187
+ if (task.fileName && deleteLocalFile(folder, task.fileName)) {
188
+ deletedLocalCount++;
189
+ }
190
+ tasksToRemoveFromState.push(task._id);
191
+ }
192
+ }
193
+
194
+ // Remove deleted tasks from sync state
195
+ if (tasksToRemoveFromState.length > 0) {
196
+ const removeSet = new Set(tasksToRemoveFromState.map((id) => id.toString()));
197
+ sync.tasks = sync.tasks.filter((t) => !t._id || !removeSet.has(t._id.toString()));
198
+ }
199
+
200
+ if (deletedLocalCount > 0) {
201
+ results.push(`Deleted locally: ${deletedLocalCount} file(s) (removed on server)`);
202
+ }
203
+
204
+ // --- DELETE phase (local → remote): delete tasks on server for locally deleted files ---
205
+ const currentLocalFiles = new Set(listLocalFiles(folder));
206
+ const toDeleteRemote = [];
207
+
208
+ for (const task of sync.tasks) {
209
+ if (task._id && task.fileName && !currentLocalFiles.has(task.fileName)) {
210
+ if (remoteIdSet.has(task._id.toString())) {
211
+ toDeleteRemote.push({ _id: task._id, name: task.name, deleted: true });
212
+ }
213
+ }
214
+ }
215
+
216
+ if (toDeleteRemote.length > 0) {
217
+ const deleteResponse = await api.push(projectId, toDeleteRemote);
218
+ const deletedRemote = (deleteResponse.tasks || []).filter((t) => t.deleted);
219
+ // Remove deleted tasks from sync state
220
+ const deletedIds = new Set(deletedRemote.map((t) => t._id.toString()));
221
+ sync.tasks = sync.tasks.filter((t) => !t._id || !deletedIds.has(t._id.toString()));
222
+ results.push(`Deleted remotely: ${deletedRemote.length} task(s) (removed locally)`);
223
+ }
224
+
225
+ // --- PULL phase ---
226
+ const pullData = await api.pull(projectId, prePullSyncDate);
227
+ const pulledTasks = pullData.tasks || [];
228
+
229
+ const { byId } = buildIndexes(sync.tasks);
230
+ const pulledNames = [];
231
+
232
+ for (const pt of pulledTasks) {
233
+ const fileName = toFileName(pt.name);
234
+ const fileContent = descriptionToMarkdown(pt.description);
235
+
236
+ const entry = {
237
+ _id: pt._id,
238
+ mcpSyncDate: pt.mcpSyncDate || null,
239
+ fileName,
240
+ name: pt.name,
241
+ mcpLastLocalUpdate: null,
242
+ };
243
+
244
+ if (pt._id in byId) {
245
+ sync.tasks[byId[pt._id]] = entry;
246
+ } else {
247
+ sync.tasks.push(entry);
248
+ }
249
+ pulledNames.push(pt.name);
250
+
251
+ if (fileName) {
252
+ writeFileSync(join(folder, fileName), fileContent);
253
+ }
254
+ }
255
+
256
+ sync.mcpLastSyncPullChanges = pulledNames;
257
+ results.push(`Pulled: ${pulledTasks.length} task(s)`);
258
+ if (pulledNames.length > 0) {
259
+ pulledNames.forEach((n) => results.push(` - ${n}`));
260
+ }
261
+
262
+ // --- PUSH phase ---
263
+ const { byFileName: fnIdx2 } = buildIndexes(sync.tasks);
264
+ const refreshedFiles = listLocalFiles(folder);
265
+
266
+ for (const fname of refreshedFiles) {
267
+ const filePath = join(folder, fname);
268
+ const mtimeMs = Math.floor(getFileMtimeMs(filePath));
269
+
270
+ if (fname in fnIdx2) {
271
+ sync.tasks[fnIdx2[fname]].mcpLastLocalUpdate = mtimeMs;
272
+ } else {
273
+ sync.tasks.push({
274
+ _id: null,
275
+ mcpSyncDate: null,
276
+ fileName: fname,
277
+ name: toTaskName(fname),
278
+ mcpLastLocalUpdate: mtimeMs,
279
+ });
280
+ }
281
+ }
282
+
283
+ const tasksToPush = sync.tasks.filter((t) => {
284
+ if (t.mcpLastLocalUpdate == null) return false;
285
+ if (t._id == null) return true;
286
+ if (prePullSyncDate == null) return true;
287
+ return t.mcpLastLocalUpdate > prePullSyncDate;
288
+ });
289
+
290
+ if (tasksToPush.length === 0) {
291
+ results.push('Pushed: 0 task(s) (no local changes)');
292
+ } else {
293
+ const pushItems = [];
294
+ for (const t of tasksToPush) {
295
+ if (!t.fileName) continue;
296
+ const filePath = join(folder, t.fileName);
297
+ const content = existsSync(filePath)
298
+ ? readFileSync(filePath, 'utf-8')
299
+ : null;
300
+ const description = content
301
+ ? JSON.stringify(markdownToBlockNote(content))
302
+ : null;
303
+
304
+ pushItems.push({
305
+ _id: t._id,
306
+ name: toTaskName(t.fileName),
307
+ description,
308
+ });
309
+ }
310
+
311
+ const pushResponse = await api.push(projectId, pushItems);
312
+ const responseTasks = (pushResponse.tasks || []).filter((t) => !t.deleted);
313
+
314
+ const { byId: pushIdIdx, byFileName: pushFnIdx } = buildIndexes(sync.tasks);
315
+ const pushedNames = [];
316
+
317
+ for (const rt of responseTasks) {
318
+ pushedNames.push(rt.name);
319
+ const rtFileName = toFileName(rt.name);
320
+
321
+ if (rt._id in pushIdIdx) {
322
+ const idx = pushIdIdx[rt._id];
323
+ sync.tasks[idx].mcpSyncDate = rt.mcpSyncDate;
324
+ sync.tasks[idx].name = rt.name;
325
+ if (rtFileName) sync.tasks[idx].fileName = rtFileName;
326
+ } else if (rtFileName && rtFileName in pushFnIdx) {
327
+ const idx = pushFnIdx[rtFileName];
328
+ sync.tasks[idx]._id = rt._id;
329
+ sync.tasks[idx].mcpSyncDate = rt.mcpSyncDate;
330
+ sync.tasks[idx].name = rt.name;
331
+ sync.tasks[idx].fileName = rtFileName;
332
+ }
333
+ }
334
+
335
+ sync.mcpLastSyncPushChanges = pushedNames;
336
+ results.push(`Pushed: ${responseTasks.length} task(s)`);
337
+ if (pushedNames.length > 0) {
338
+ pushedNames.forEach((n) => results.push(` - ${n}`));
339
+ }
340
+
341
+ const errors = pushResponse.errors || [];
342
+ if (errors.length > 0) {
343
+ results.push(
344
+ `${errors.length} push error(s):`,
345
+ ...errors.map((e) => ` - Task ${e._id || e.index}: ${e.reason}`)
346
+ );
347
+ }
348
+ }
349
+
350
+ sync.mcpLastSyncDate = Date.now();
351
+ writeSyncState(folder, sync);
352
+
353
+ return results;
354
+ }
355
+
356
+ // --- MCP Server ---
357
+
358
+ const server = new McpServer({
359
+ name: 'planko-mcp-server',
360
+ version: '0.2.0',
361
+ });
362
+
363
+ // ---- planko_setup ----
364
+ server.tool(
365
+ 'planko_setup',
366
+ 'Set up sync between a Planko project and a local folder. Supports multiple project-folder mappings. The user must provide the project name, local folder path, and email.',
367
+ {
368
+ projectName: z.string().describe('Name of the Planko project to sync'),
369
+ folderPath: z.string().describe('Absolute path to the local folder for .md task files'),
370
+ email: z.string().email().describe('User email for task attribution'),
371
+ },
372
+ async ({ projectName, folderPath, email }) => {
373
+ try {
374
+ // List user's projects to find the matching one
375
+ const { projects } = await api.projects();
376
+
377
+ const match = projects.find(
378
+ (p) => p.name.toLowerCase() === projectName.toLowerCase()
379
+ );
380
+
381
+ if (!match) {
382
+ const available = projects.map((p) => ` - ${p.name}`).join('\n');
383
+ return toolError(
384
+ `Project "${projectName}" not found.\n\nAvailable projects:\n${available}`
385
+ );
386
+ }
387
+
388
+ // Ensure folder exists
389
+ if (!existsSync(folderPath)) {
390
+ mkdirSync(folderPath, { recursive: true });
391
+ }
392
+
393
+ // Save to global config
394
+ const config = readConfig();
395
+ config.projects = config.projects || {};
396
+ config.projects[match.name] = {
397
+ projectId: match._id,
398
+ folder: folderPath,
399
+ email,
400
+ isWorkspace: match.isWorkspace,
401
+ };
402
+ writeConfig(config);
403
+
404
+ // Initialize sync state for this folder
405
+ const syncState = createSyncState(match._id, match.name);
406
+ writeSyncState(folderPath, syncState);
407
+
408
+ return toolOk(
409
+ `Setup complete for project "${match.name}".\n` +
410
+ ` Project ID: ${match._id}\n` +
411
+ ` Folder: ${folderPath}\n` +
412
+ ` Email: ${email}\n` +
413
+ ` Workspace: ${match.isWorkspace ? 'Yes' : 'No (personal)'}\n\n` +
414
+ `Run planko_sync to pull tasks into this folder.`
415
+ );
416
+ } catch (err) {
417
+ return toolError(`Setup failed: ${err.message}`);
418
+ }
419
+ }
420
+ );
421
+
422
+ // ---- planko_sync_preview ----
423
+ server.tool(
424
+ 'planko_sync_preview',
425
+ 'Preview what would be synced (read-only, no writes). Shows files that would be pushed, pulled, deleted, and any conflicts. If projectName is omitted, previews all configured projects.',
426
+ {
427
+ projectName: z.string().optional().describe('Project name to preview (optional — omit to preview all)'),
428
+ },
429
+ async ({ projectName }) => {
430
+ try {
431
+ const config = readConfig();
432
+ const projectEntries = config.projects || {};
433
+
434
+ if (Object.keys(projectEntries).length === 0) {
435
+ return toolError('No projects configured. Run planko_setup first.');
436
+ }
437
+
438
+ // Filter to specific project or all
439
+ let targets;
440
+ if (projectName) {
441
+ const key = Object.keys(projectEntries).find(
442
+ (k) => k.toLowerCase() === projectName.toLowerCase()
443
+ );
444
+ if (!key) {
445
+ const available = Object.keys(projectEntries).map((k) => ` - ${k}`).join('\n');
446
+ return toolError(
447
+ `Project "${projectName}" not configured.\n\nConfigured projects:\n${available}`
448
+ );
449
+ }
450
+ targets = [{ name: key, ...projectEntries[key] }];
451
+ } else {
452
+ targets = Object.entries(projectEntries).map(([name, cfg]) => ({ name, ...cfg }));
453
+ }
454
+
455
+ const allLines = ['Sync Preview (read-only, no changes made)', ''];
456
+
457
+ for (const target of targets) {
458
+ const sync = readSyncState(target.folder);
459
+ if (!sync) {
460
+ allLines.push(`--- ${target.name} ---`);
461
+ allLines.push('No sync state found. Run planko_sync to initialize.', '');
462
+ continue;
463
+ }
464
+
465
+ let diff;
466
+ try {
467
+ diff = await computeSyncDiff(target.projectId, target.folder, sync);
468
+ } catch (err) {
469
+ allLines.push(`--- ${target.name} ---`);
470
+ allLines.push(`Error: ${err.message}`, '');
471
+ continue;
472
+ }
473
+
474
+ allLines.push(`--- ${target.name} (${target.folder}) ---`);
475
+ allLines.push('');
476
+ allLines.push(`Would push: ${diff.wouldPush.length} task(s)`);
477
+ diff.wouldPush.forEach((p) => allLines.push(` - ${p.name}${p._id ? '' : ' (new)'}`));
478
+
479
+ allLines.push(`Would pull: ${diff.wouldPull.length} task(s)`);
480
+ diff.wouldPull.forEach((p) => allLines.push(` - ${p.name}`));
481
+
482
+ allLines.push(`Would delete locally: ${diff.wouldDeleteLocal.length} file(s) (removed on server)`);
483
+ diff.wouldDeleteLocal.forEach((d) => allLines.push(` - ${d.name}`));
484
+
485
+ allLines.push(`Would delete remotely: ${diff.wouldDeleteRemote.length} task(s) (removed locally)`);
486
+ diff.wouldDeleteRemote.forEach((d) => allLines.push(` - ${d.name}`));
487
+
488
+ if (diff.conflicts.length > 0) {
489
+ allLines.push('');
490
+ allLines.push(`Conflicts: ${diff.conflicts.length} (modified both locally and remotely)`);
491
+ diff.conflicts.forEach((c) => allLines.push(` - ${c.name} (local: ${c.localMtime})`));
492
+ allLines.push('Note: sync uses pull-then-push order — local changes overwrite remote on conflict.');
493
+ }
494
+
495
+ allLines.push('');
496
+ }
497
+
498
+ return toolOk(allLines.join('\n'));
499
+ } catch (err) {
500
+ return toolError(`Sync preview failed: ${err.message}`);
501
+ }
502
+ }
503
+ );
504
+
505
+ // ---- planko_sync ----
506
+ server.tool(
507
+ 'planko_sync',
508
+ 'Execute bidirectional sync with delete support: deletes, then pulls remote changes, then pushes local changes. If projectName is omitted, syncs all configured projects.',
509
+ {
510
+ projectName: z.string().optional().describe('Project name to sync (optional — omit to sync all)'),
511
+ },
512
+ async ({ projectName }) => {
513
+ try {
514
+ const config = readConfig();
515
+ const projectEntries = config.projects || {};
516
+
517
+ if (Object.keys(projectEntries).length === 0) {
518
+ return toolError('No projects configured. Run planko_setup first.');
519
+ }
520
+
521
+ // Filter to specific project or all
522
+ let targets;
523
+ if (projectName) {
524
+ const key = Object.keys(projectEntries).find(
525
+ (k) => k.toLowerCase() === projectName.toLowerCase()
526
+ );
527
+ if (!key) {
528
+ const available = Object.keys(projectEntries).map((k) => ` - ${k}`).join('\n');
529
+ return toolError(
530
+ `Project "${projectName}" not configured.\n\nConfigured projects:\n${available}`
531
+ );
532
+ }
533
+ targets = [{ name: key, ...projectEntries[key] }];
534
+ } else {
535
+ targets = Object.entries(projectEntries).map(([name, cfg]) => ({ name, ...cfg }));
536
+ }
537
+
538
+ const allResults = [];
539
+
540
+ for (const target of targets) {
541
+ let sync = readSyncState(target.folder);
542
+ if (!sync) {
543
+ sync = createSyncState(target.projectId, target.name);
544
+ }
545
+
546
+ allResults.push(`--- ${target.name} (${target.folder}) ---`);
547
+
548
+ try {
549
+ const results = await executeSync(target.projectId, target.folder, sync);
550
+ allResults.push(...results);
551
+ } catch (err) {
552
+ allResults.push(`Error: ${err.message}`);
553
+ }
554
+ allResults.push('');
555
+ }
556
+
557
+ return toolOk('Sync complete.\n\n' + allResults.join('\n'));
558
+ } catch (err) {
559
+ return toolError(`Sync failed: ${err.message}`);
560
+ }
561
+ }
562
+ );
563
+
564
+ // --- Start server ---
565
+
566
+ async function main() {
567
+ const transport = new StdioServerTransport();
568
+ await server.connect(transport);
569
+ }
570
+
571
+ main().catch((err) => {
572
+ console.error('Fatal:', err.message);
573
+ process.exit(1);
574
+ });
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "planko-mcp-server",
3
+ "version": "0.2.0",
4
+ "description": "MCP server for syncing Planko tasks with local Markdown files",
5
+ "type": "module",
6
+ "bin": {
7
+ "planko-mcp-server": "./index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "scripts": {
19
+ "test": "vitest run",
20
+ "prepublishOnly": "node --check index.js && node --check src/api.js && node --check src/converters.js && node --check src/sync-state.js"
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.12.1"
24
+ },
25
+ "devDependencies": {
26
+ "vitest": "^3.1.1"
27
+ },
28
+ "keywords": [
29
+ "planko",
30
+ "mcp",
31
+ "model-context-protocol",
32
+ "task-sync",
33
+ "markdown"
34
+ ],
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/planko-io/planko-mcp-server"
39
+ }
40
+ }
package/src/api.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * API client for Planko MCP Project Sync endpoints.
3
+ * Uses native fetch (Node 18+).
4
+ *
5
+ * All external endpoints require projectId (user-scoped API key, PL012).
6
+ */
7
+
8
+ const DEFAULT_API_BASE = 'https://planko-426622.ue.r.appspot.com/v1';
9
+
10
+ export function createApiClient({ apiKey, apiBase }) {
11
+ const base = apiBase || process.env.PLANKO_API_BASE || DEFAULT_API_BASE;
12
+
13
+ async function request(method, path, body) {
14
+ const url = `${base}${path}`;
15
+ const headers = { 'x-api-key': apiKey };
16
+
17
+ const options = { method, headers };
18
+
19
+ if (body) {
20
+ headers['Content-Type'] = 'application/json';
21
+ options.body = JSON.stringify(body);
22
+ }
23
+
24
+ const res = await fetch(url, options);
25
+ const text = await res.text();
26
+
27
+ let data;
28
+ try {
29
+ data = JSON.parse(text);
30
+ } catch {
31
+ data = text;
32
+ }
33
+
34
+ if (!res.ok) {
35
+ const msg =
36
+ typeof data === 'object' && data.message
37
+ ? data.message
38
+ : `API returned HTTP ${res.status}`;
39
+ throw new Error(msg);
40
+ }
41
+
42
+ return data;
43
+ }
44
+
45
+ return {
46
+ /**
47
+ * GET /mcp-project-sync/projects — list all projects accessible by the user
48
+ */
49
+ async projects() {
50
+ return request('GET', '/mcp-project-sync/projects');
51
+ },
52
+
53
+ /**
54
+ * GET /mcp-project-sync/status?projectId=...&mcpLastSyncDate=...
55
+ */
56
+ async status(projectId, mcpLastSyncDate) {
57
+ let qs = `projectId=${projectId}`;
58
+ if (mcpLastSyncDate != null) {
59
+ qs += `&mcpLastSyncDate=${mcpLastSyncDate}`;
60
+ }
61
+ return request('GET', `/mcp-project-sync/status?${qs}`);
62
+ },
63
+
64
+ /**
65
+ * GET /mcp-project-sync/pull?projectId=...&mcpLastSyncDate=...
66
+ */
67
+ async pull(projectId, mcpLastSyncDate) {
68
+ let qs = `projectId=${projectId}`;
69
+ if (mcpLastSyncDate != null) {
70
+ qs += `&mcpLastSyncDate=${mcpLastSyncDate}`;
71
+ }
72
+ return request('GET', `/mcp-project-sync/pull?${qs}`);
73
+ },
74
+
75
+ /**
76
+ * POST /mcp-project-sync/push
77
+ */
78
+ async push(projectId, tasks) {
79
+ return request('POST', '/mcp-project-sync/push', {
80
+ projectId,
81
+ tasks,
82
+ });
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,330 @@
1
+ /**
2
+ * BlockNote <-> Markdown converters.
3
+ *
4
+ * Key fix from POC: markdownToBlockNote handles indented lines as nested
5
+ * children instead of flattening them into top-level paragraphs.
6
+ */
7
+
8
+ // --- HTML entity decoding and tag conversion ---
9
+
10
+ const HTML_ENTITIES = {
11
+ '&lt;': '<',
12
+ '&gt;': '>',
13
+ '&amp;': '&',
14
+ '&quot;': '"',
15
+ '&#39;': "'",
16
+ '&apos;': "'",
17
+ '&nbsp;': ' ',
18
+ };
19
+
20
+ function decodeHtmlEntities(text) {
21
+ return text.replace(/&(?:lt|gt|amp|quot|#39|apos|nbsp);/g, (match) => HTML_ENTITIES[match] || match);
22
+ }
23
+
24
+ /**
25
+ * Convert HTML tags embedded in BlockNote text content to Markdown.
26
+ * Handles common tags: h1-h6, p, strong, em, code, a, br, ul/ol/li, table.
27
+ */
28
+ function htmlToMarkdown(html) {
29
+ let md = html;
30
+
31
+ // Headings
32
+ md = md.replace(/<h([1-6])[^>]*>([\s\S]*?)<\/h[1-6]>/gi, (_, level, content) => {
33
+ return '#'.repeat(Number(level)) + ' ' + content.trim();
34
+ });
35
+
36
+ // Bold
37
+ md = md.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**');
38
+ md = md.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**');
39
+
40
+ // Italic
41
+ md = md.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, '*$1*');
42
+ md = md.replace(/<i[^>]*>([\s\S]*?)<\/i>/gi, '*$1*');
43
+
44
+ // Code
45
+ md = md.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');
46
+
47
+ // Links
48
+ md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)');
49
+
50
+ // Line breaks
51
+ md = md.replace(/<br\s*\/?>/gi, '\n');
52
+
53
+ // List items
54
+ md = md.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1');
55
+
56
+ // Table conversion (basic)
57
+ md = md.replace(/<thead>([\s\S]*?)<\/thead>/gi, (_, content) => {
58
+ const headers = [];
59
+ content.replace(/<th[^>]*>([\s\S]*?)<\/th>/gi, (_, cell) => {
60
+ headers.push(cell.trim());
61
+ });
62
+ if (headers.length === 0) return '';
63
+ return '| ' + headers.join(' | ') + ' |\n| ' + headers.map(() => '---').join(' | ') + ' |';
64
+ });
65
+ md = md.replace(/<tr[^>]*>([\s\S]*?)<\/tr>/gi, (_, content) => {
66
+ const cells = [];
67
+ content.replace(/<td[^>]*>([\s\S]*?)<\/td>/gi, (_, cell) => {
68
+ cells.push(cell.trim());
69
+ });
70
+ if (cells.length === 0) return '';
71
+ return '\n| ' + cells.join(' | ') + ' |';
72
+ });
73
+
74
+ // Strip remaining tags
75
+ md = md.replace(/<\/?(?:p|div|span|ul|ol|table|tbody|thead|tr|th|td|section|article|header|footer|nav|main|aside|figure|figcaption|blockquote)[^>]*>/gi, '');
76
+
77
+ return md.trim();
78
+ }
79
+
80
+ /**
81
+ * Check if text contains HTML tags (after entity decoding).
82
+ */
83
+ function containsHtmlTags(text) {
84
+ return /<[a-z][a-z0-9]*[\s>\/]/i.test(text);
85
+ }
86
+
87
+ // --- BlockNote -> Markdown ---
88
+
89
+ export function inlineToMarkdown(content) {
90
+ if (!content || !Array.isArray(content)) return '';
91
+ return content
92
+ .map((item) => {
93
+ let text = item.text || '';
94
+ if (!text) return '';
95
+
96
+ // Decode HTML entities first
97
+ text = decodeHtmlEntities(text);
98
+
99
+ // If text contains HTML tags, convert them to Markdown
100
+ if (containsHtmlTags(text)) {
101
+ text = htmlToMarkdown(text);
102
+ }
103
+
104
+ const s = item.styles || {};
105
+ if (s.code) text = `\`${text}\``;
106
+ if (s.strikethrough) text = `~~${text}~~`;
107
+ if (s.italic) text = `*${text}*`;
108
+ if (s.bold) text = `**${text}**`;
109
+ return text;
110
+ })
111
+ .join('');
112
+ }
113
+
114
+ export function blockNoteToMarkdown(blocks, indent = 0) {
115
+ if (!blocks || !Array.isArray(blocks)) return '';
116
+ const prefix = ' '.repeat(indent);
117
+ const lines = [];
118
+
119
+ for (const block of blocks) {
120
+ const text = inlineToMarkdown(block.content);
121
+
122
+ switch (block.type) {
123
+ case 'heading': {
124
+ const level = block.props?.level || 1;
125
+ lines.push(`${prefix}${'#'.repeat(level)} ${text}`);
126
+ break;
127
+ }
128
+ case 'bulletListItem':
129
+ lines.push(`${prefix}- ${text}`);
130
+ break;
131
+ case 'numberedListItem':
132
+ lines.push(`${prefix}1. ${text}`);
133
+ break;
134
+ case 'checkListItem': {
135
+ const checked = block.props?.checked ? 'x' : ' ';
136
+ lines.push(`${prefix}- [${checked}] ${text}`);
137
+ break;
138
+ }
139
+ case 'paragraph':
140
+ default:
141
+ lines.push(`${prefix}${text}`);
142
+ break;
143
+ }
144
+
145
+ if (block.children && block.children.length > 0) {
146
+ const childMd = blockNoteToMarkdown(block.children, indent + 1);
147
+ lines.push(childMd);
148
+ }
149
+ }
150
+
151
+ return lines.join('\n');
152
+ }
153
+
154
+ // --- Markdown -> BlockNote ---
155
+
156
+ export function parseInlineMarkdown(text) {
157
+ const content = [];
158
+ // Order: bold (**), italic (*), strikethrough (~~), code (`)
159
+ const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|~~(.+?)~~|`(.+?)`|([^*~`]+))/g;
160
+ let match;
161
+ while ((match = regex.exec(text)) !== null) {
162
+ if (match[2]) {
163
+ content.push({ type: 'text', text: match[2], styles: { bold: true } });
164
+ } else if (match[3]) {
165
+ content.push({ type: 'text', text: match[3], styles: { italic: true } });
166
+ } else if (match[4]) {
167
+ content.push({
168
+ type: 'text',
169
+ text: match[4],
170
+ styles: { strikethrough: true },
171
+ });
172
+ } else if (match[5]) {
173
+ content.push({ type: 'text', text: match[5], styles: { code: true } });
174
+ } else if (match[6]) {
175
+ content.push({ type: 'text', text: match[6], styles: {} });
176
+ }
177
+ }
178
+ return content.length > 0 ? content : [{ type: 'text', text, styles: {} }];
179
+ }
180
+
181
+ function makeBlock(type, text, props = {}) {
182
+ return {
183
+ type,
184
+ props: {
185
+ backgroundColor: 'default',
186
+ textColor: 'default',
187
+ textAlignment: 'left',
188
+ ...props,
189
+ },
190
+ content: parseInlineMarkdown(text),
191
+ children: [],
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Determine the indentation level of a line (number of 2-space indents).
197
+ */
198
+ function indentLevel(line) {
199
+ const match = line.match(/^( *)/);
200
+ if (!match) return 0;
201
+ return Math.floor(match[1].length / 2);
202
+ }
203
+
204
+ /**
205
+ * Parse a trimmed line into a block (without children).
206
+ */
207
+ function parseLine(trimmed) {
208
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
209
+ if (headingMatch) {
210
+ return makeBlock('heading', headingMatch[2], {
211
+ level: headingMatch[1].length,
212
+ isToggleable: false,
213
+ });
214
+ }
215
+
216
+ const checkMatch = trimmed.match(/^-\s+\[([ xX])\]\s+(.*)$/);
217
+ if (checkMatch) {
218
+ return makeBlock('checkListItem', checkMatch[2], {
219
+ checked: checkMatch[1] !== ' ',
220
+ });
221
+ }
222
+
223
+ const bulletMatch = trimmed.match(/^-\s+(.*)$/);
224
+ if (bulletMatch) {
225
+ return makeBlock('bulletListItem', bulletMatch[1]);
226
+ }
227
+
228
+ const numberedMatch = trimmed.match(/^\d+\.\s+(.*)$/);
229
+ if (numberedMatch) {
230
+ return makeBlock('numberedListItem', numberedMatch[1]);
231
+ }
232
+
233
+ return makeBlock('paragraph', trimmed);
234
+ }
235
+
236
+ /**
237
+ * Convert markdown string to BlockNote blocks, preserving nesting.
238
+ *
239
+ * Indented lines (2-space increments) become children of the preceding
240
+ * block at the parent indent level. This fixes the POC which flattened
241
+ * all lines into top-level blocks.
242
+ */
243
+ export function markdownToBlockNote(md) {
244
+ if (!md) return [];
245
+ const lines = md.split('\n');
246
+
247
+ // Recursive helper: parse lines[start..end) at the given base indent level.
248
+ function parseRange(start, end, baseIndent) {
249
+ const blocks = [];
250
+ let i = start;
251
+
252
+ while (i < end) {
253
+ const line = lines[i];
254
+ const level = indentLevel(line);
255
+ const trimmed = line.trimStart();
256
+
257
+ if (trimmed === '') {
258
+ // Empty lines become empty paragraphs at base level
259
+ blocks.push(makeBlock('paragraph', ''));
260
+ i++;
261
+ continue;
262
+ }
263
+
264
+ if (level < baseIndent) {
265
+ // Shouldn't happen in well-formed input, but handle gracefully
266
+ break;
267
+ }
268
+
269
+ if (level > baseIndent) {
270
+ // This line is indented deeper than expected — attach as children
271
+ // of the last block at baseIndent level.
272
+ const childStart = i;
273
+ while (i < end) {
274
+ const currentTrimmed = lines[i].trim();
275
+ const currentLevel = currentTrimmed === '' ? -1 : indentLevel(lines[i]);
276
+
277
+ if (currentTrimmed === '') {
278
+ // Blank line: look ahead to determine if nesting continues
279
+ let nextNonBlank = i + 1;
280
+ while (nextNonBlank < end && lines[nextNonBlank].trim() === '') nextNonBlank++;
281
+ if (nextNonBlank >= end || indentLevel(lines[nextNonBlank]) <= baseIndent) {
282
+ break; // blank line(s) followed by non-indented or EOF = end of nesting
283
+ }
284
+ i++; // blank line within nested content — include it
285
+ } else if (currentLevel <= baseIndent) {
286
+ break; // back to parent level
287
+ } else {
288
+ i++;
289
+ }
290
+ }
291
+
292
+ if (blocks.length > 0) {
293
+ blocks[blocks.length - 1].children = parseRange(childStart, i, baseIndent + 1);
294
+ } else {
295
+ // No parent block — create blocks at the deeper level as top-level
296
+ const deeper = parseRange(childStart, i, level);
297
+ blocks.push(...deeper);
298
+ }
299
+ continue;
300
+ }
301
+
302
+ // level === baseIndent
303
+ blocks.push(parseLine(trimmed));
304
+ i++;
305
+ }
306
+
307
+ return blocks;
308
+ }
309
+
310
+ return parseRange(0, lines.length, 0);
311
+ }
312
+
313
+ // --- Convenience helpers ---
314
+
315
+ export function descriptionToMarkdown(description) {
316
+ if (!description) return '';
317
+ try {
318
+ const blocks =
319
+ typeof description === 'string' ? JSON.parse(description) : description;
320
+ return blockNoteToMarkdown(blocks);
321
+ } catch (_) {
322
+ return typeof description === 'string' ? description : '';
323
+ }
324
+ }
325
+
326
+ export function markdownToDescription(md) {
327
+ if (!md) return null;
328
+ const blocks = markdownToBlockNote(md);
329
+ return JSON.stringify(blocks);
330
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Sync state management for planko-mcp-server.
3
+ *
4
+ * Two types of state:
5
+ * 1. Global config (~/.planko-mcp/config.json) — maps project names to folders
6
+ * 2. Per-folder sync state (planko-mcp-sync.json) — task mappings and timestamps
7
+ *
8
+ * Credentials are NEVER written to any state file.
9
+ * Task names are stored WITHOUT the .md extension.
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, unlinkSync } from 'node:fs';
13
+ import { join, dirname } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+
16
+ export const SYNC_FILE = '.planko-mcp-sync.json';
17
+ export const CONFIG_DIR = join(homedir(), '.planko-mcp');
18
+ export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
19
+ const IGNORED_FILES = ['.DS_Store', 'Thumbs.db', '.gitkeep'];
20
+
21
+ export function isIgnoredFile(filename) {
22
+ return IGNORED_FILES.includes(filename) || filename.startsWith('.');
23
+ }
24
+
25
+ // --- File name / task name conversion ---
26
+
27
+ export function toFileName(name) {
28
+ if (!name) return null;
29
+ return name.endsWith('.md') ? name : `${name}.md`;
30
+ }
31
+
32
+ export function toTaskName(fileName) {
33
+ if (!fileName) return null;
34
+ return fileName.endsWith('.md') ? fileName.slice(0, -3) : fileName;
35
+ }
36
+
37
+ // --- Global config (multi-folder) ---
38
+
39
+ export function readConfig() {
40
+ if (!existsSync(CONFIG_FILE)) return { projects: {} };
41
+ try {
42
+ const raw = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
43
+ // Strip any accidentally-stored credentials
44
+ delete raw.apiKey;
45
+ return raw;
46
+ } catch {
47
+ return { projects: {} };
48
+ }
49
+ }
50
+
51
+ export function writeConfig(config) {
52
+ const safe = { ...config };
53
+ delete safe.apiKey;
54
+ if (!existsSync(CONFIG_DIR)) {
55
+ mkdirSync(CONFIG_DIR, { recursive: true });
56
+ }
57
+ writeFileSync(CONFIG_FILE, JSON.stringify(safe, null, 2));
58
+ }
59
+
60
+ // --- Per-folder sync state ---
61
+
62
+ export function readSyncState(folder) {
63
+ const syncPath = join(folder, SYNC_FILE);
64
+ if (!existsSync(syncPath)) return null;
65
+ const raw = JSON.parse(readFileSync(syncPath, 'utf-8'));
66
+ delete raw.apiKey;
67
+ delete raw.userEmail;
68
+ return raw;
69
+ }
70
+
71
+ export function writeSyncState(folder, data) {
72
+ const safe = { ...data };
73
+ delete safe.apiKey;
74
+ delete safe.apiSecret;
75
+ delete safe.password;
76
+ delete safe.userEmail;
77
+ writeFileSync(join(folder, SYNC_FILE), JSON.stringify(safe, null, 2));
78
+ }
79
+
80
+ export function createSyncState(projectId, projectName) {
81
+ return {
82
+ project: {
83
+ _id: projectId,
84
+ name: projectName,
85
+ },
86
+ mcpLastSyncDate: null,
87
+ mcpLastSyncPullChanges: [],
88
+ mcpLastSyncPushChanges: [],
89
+ tasks: [],
90
+ };
91
+ }
92
+
93
+ // --- Local file operations ---
94
+
95
+ export function listLocalFiles(folder) {
96
+ if (!existsSync(folder)) return [];
97
+ return readdirSync(folder).filter((f) => {
98
+ if (f === SYNC_FILE) return false;
99
+ if (isIgnoredFile(f)) return false;
100
+ const fullPath = join(folder, f);
101
+ return statSync(fullPath).isFile() && f.endsWith('.md');
102
+ });
103
+ }
104
+
105
+ export function getFileMtimeMs(filePath) {
106
+ return statSync(filePath).mtimeMs;
107
+ }
108
+
109
+ export function deleteLocalFile(folder, fileName) {
110
+ const filePath = join(folder, fileName);
111
+ if (existsSync(filePath)) {
112
+ unlinkSync(filePath);
113
+ return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ // --- Index helpers ---
119
+
120
+ export function buildIndexes(tasks) {
121
+ const byId = {};
122
+ const byFileName = {};
123
+ tasks.forEach((t, i) => {
124
+ if (t._id) byId[t._id] = i;
125
+ if (t.fileName) byFileName[t.fileName] = i;
126
+ });
127
+ return { byId, byFileName };
128
+ }