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 +3 -0
- package/README.md +123 -0
- package/index.js +574 -0
- package/package.json +40 -0
- package/src/api.js +85 -0
- package/src/converters.js +330 -0
- package/src/sync-state.js +128 -0
package/LICENSE
ADDED
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
|
+
'<': '<',
|
|
12
|
+
'>': '>',
|
|
13
|
+
'&': '&',
|
|
14
|
+
'"': '"',
|
|
15
|
+
''': "'",
|
|
16
|
+
''': "'",
|
|
17
|
+
' ': ' ',
|
|
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
|
+
}
|