@proplandev/mcp 1.0.4 → 1.0.5
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/README.md +96 -0
- package/bin/init.js +61 -6
- package/index.js +1 -1
- package/package.json +2 -4
- package/tools/createProject.js +0 -1
- package/tools/getNextTasks.js +2 -2
- package/adapters/SupabaseAdapter.js +0 -76
- package/auth.js +0 -22
- package/supabase.js +0 -4
- package/tools/getSessionHandoff.js +0 -67
- package/tools/syncProjects.js +0 -99
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# ProPlan MCP
|
|
2
|
+
|
|
3
|
+
Persistent project memory and session continuity for Claude Code. Never lose context between sessions.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- **Remembers your project** — phases, milestones, tasks, and progress survive across sessions
|
|
8
|
+
- **Resumes automatically** — Claude picks up exactly where you left off
|
|
9
|
+
- **Scans your repo** — run `scan_repo` once and Claude understands your codebase
|
|
10
|
+
- **Works offline** — SQLite local mode, no account required
|
|
11
|
+
- **Syncs to dashboard** — optional cloud sync to [proplan.dev](https://project-planner-7zw4.onrender.com)
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @proplandev/mcp init
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That's it. The wizard:
|
|
20
|
+
- Writes `.mcp.json` so Claude Code finds the server
|
|
21
|
+
- Writes `CLAUDE.md` with session resume instructions
|
|
22
|
+
- Updates `.gitignore` to protect local data
|
|
23
|
+
- Auto-adds read-only tools to Claude settings (skips approval prompts)
|
|
24
|
+
|
|
25
|
+
Then restart Claude Code, open your project, and type **start** or **continue**.
|
|
26
|
+
|
|
27
|
+
## Tools
|
|
28
|
+
|
|
29
|
+
| Tool | What it does |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `get_project_status` | List projects or get full status + session handoff |
|
|
32
|
+
| `create_project` | Create a new project with phases, milestones, tasks |
|
|
33
|
+
| `scan_repo` | Analyze codebase structure and auto-generate a roadmap |
|
|
34
|
+
| `get_project_roadmap` | Full roadmap JSON |
|
|
35
|
+
| `get_tasks` | Tasks filtered by status, milestone, or phase |
|
|
36
|
+
| `get_next_tasks` | What to work on next |
|
|
37
|
+
| `update_task_status` | Mark tasks pending / in_progress / completed |
|
|
38
|
+
| `add_note_to_task` | Attach notes to a task |
|
|
39
|
+
| `add_task` | Add a task to a milestone |
|
|
40
|
+
| `edit_task` | Edit task title or description |
|
|
41
|
+
| `delete_task` | Delete a task |
|
|
42
|
+
| `add_milestone` | Add a milestone to a phase |
|
|
43
|
+
| `edit_milestone` | Rename a milestone |
|
|
44
|
+
| `delete_milestone` | Delete a milestone and its tasks |
|
|
45
|
+
| `add_phase` | Add a phase to the roadmap |
|
|
46
|
+
| `edit_phase` | Rename a phase |
|
|
47
|
+
| `delete_phase` | Delete a phase and its milestones |
|
|
48
|
+
| `rename_project` | Rename a project |
|
|
49
|
+
| `delete_project` | Permanently delete a project |
|
|
50
|
+
| `set_project_goal` | Set the north-star goal for a project |
|
|
51
|
+
| `add_session_summary` | Record what was done this session |
|
|
52
|
+
| `export_to_cloud` | Sync local projects to the ProPlan dashboard |
|
|
53
|
+
|
|
54
|
+
## Storage modes
|
|
55
|
+
|
|
56
|
+
**Local (default)** — data stored in `.project-planner/db.sqlite` in your project. No account needed.
|
|
57
|
+
|
|
58
|
+
**Cloud** — projects sync to [proplan.dev](https://project-planner-7zw4.onrender.com). Requires a free account and MCP token from Settings → Claude Code Integration.
|
|
59
|
+
|
|
60
|
+
Switch modes anytime by re-running `npx @proplandev/mcp init`.
|
|
61
|
+
|
|
62
|
+
## Manual setup
|
|
63
|
+
|
|
64
|
+
If you prefer to configure `.mcp.json` yourself:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcpServers": {
|
|
69
|
+
"project-planner": {
|
|
70
|
+
"command": "npx",
|
|
71
|
+
"args": ["-y", "@proplandev/mcp"]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For cloud mode, add your token:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"project-planner": {
|
|
83
|
+
"command": "npx",
|
|
84
|
+
"args": ["-y", "@proplandev/mcp"],
|
|
85
|
+
"env": {
|
|
86
|
+
"MCP_TOKEN": "your-token-here"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Requirements
|
|
94
|
+
|
|
95
|
+
- Node.js 18+
|
|
96
|
+
- Claude Code (or any MCP-compatible client)
|
package/bin/init.js
CHANGED
|
@@ -68,6 +68,43 @@ function buildEntry(mode, mcpToken) {
|
|
|
68
68
|
return entry;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function addToGitignore(projectDir, entries) {
|
|
72
|
+
const gitignorePath = path.join(projectDir, '.gitignore');
|
|
73
|
+
let content = '';
|
|
74
|
+
try { content = fs.readFileSync(gitignorePath, 'utf8'); } catch { /* new file */ }
|
|
75
|
+
const lines = content.split('\n');
|
|
76
|
+
const toAdd = entries.filter(e => !lines.some(l => l.trim() === e));
|
|
77
|
+
if (toAdd.length === 0) return false;
|
|
78
|
+
const addition = (content.endsWith('\n') || content === '' ? '' : '\n') +
|
|
79
|
+
'# ProPlan MCP\n' + toAdd.join('\n') + '\n';
|
|
80
|
+
fs.writeFileSync(gitignorePath, content + addition, 'utf8');
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const CLAUDE_MD_BLOCK = `
|
|
85
|
+
## ProPlan MCP — Session Instructions
|
|
86
|
+
|
|
87
|
+
At the start of every session:
|
|
88
|
+
1. Call \`get_project_status\` with \`include_handoff: true\` on the active project
|
|
89
|
+
2. If projects exist: resume where things left off — say "I see we were working on X..."
|
|
90
|
+
3. If no projects exist: call \`scan_repo\` to read the codebase, then ask "Want me to build a roadmap from this?"
|
|
91
|
+
4. Never ask the user to re-explain context
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
function writeClaudeMd(projectDir) {
|
|
95
|
+
const claudeMdPath = path.join(projectDir, 'CLAUDE.md');
|
|
96
|
+
const marker = 'ProPlan MCP — Session Instructions';
|
|
97
|
+
try {
|
|
98
|
+
const existing = fs.readFileSync(claudeMdPath, 'utf8');
|
|
99
|
+
if (existing.includes(marker)) return 'already';
|
|
100
|
+
fs.writeFileSync(claudeMdPath, existing.trimEnd() + '\n' + CLAUDE_MD_BLOCK, 'utf8');
|
|
101
|
+
return 'appended';
|
|
102
|
+
} catch {
|
|
103
|
+
fs.writeFileSync(claudeMdPath, CLAUDE_MD_BLOCK.trimStart(), 'utf8');
|
|
104
|
+
return 'created';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
71
108
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
72
109
|
|
|
73
110
|
print();
|
|
@@ -106,9 +143,10 @@ print(` 2. User-level ${dim(userLevel)} ${dim('(applies to all your projec
|
|
|
106
143
|
print();
|
|
107
144
|
const locChoice = (await ask(' Your choice [1/2, default 1]: ')).trim() || '1';
|
|
108
145
|
const mcpPath = locChoice === '2' ? userLevel : projectLocal;
|
|
146
|
+
const isProjectLocal = locChoice !== '2';
|
|
109
147
|
print();
|
|
110
148
|
|
|
111
|
-
// 3. Write
|
|
149
|
+
// 3. Write .mcp.json
|
|
112
150
|
const existing = readMcpJson(mcpPath);
|
|
113
151
|
existing.mcpServers = existing.mcpServers || {};
|
|
114
152
|
|
|
@@ -126,14 +164,31 @@ if (alreadyHas) {
|
|
|
126
164
|
|
|
127
165
|
existing.mcpServers['project-planner'] = buildEntry(mode, mcpToken);
|
|
128
166
|
writeMcpJson(mcpPath, existing);
|
|
129
|
-
|
|
130
|
-
// 4. Success
|
|
131
167
|
print(green(' ✓ .mcp.json written') + ' ' + dim(mcpPath));
|
|
168
|
+
|
|
169
|
+
// 4. CLAUDE.md — write or append session instructions
|
|
170
|
+
const claudeResult = writeClaudeMd(process.cwd());
|
|
171
|
+
if (claudeResult === 'created') {
|
|
172
|
+
print(green(' ✓ CLAUDE.md created') + ' ' + dim(path.join(process.cwd(), 'CLAUDE.md')));
|
|
173
|
+
} else if (claudeResult === 'appended') {
|
|
174
|
+
print(green(' ✓ CLAUDE.md updated') + ' ' + dim('ProPlan session instructions appended'));
|
|
175
|
+
} else {
|
|
176
|
+
print(dim(' ✓ CLAUDE.md already has ProPlan instructions — skipped'));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 5. .gitignore — add relevant entries
|
|
180
|
+
const gitignoreEntries = ['.project-planner/'];
|
|
181
|
+
if (isProjectLocal && mode === 'cloud') gitignoreEntries.push('.mcp.json');
|
|
182
|
+
const gitignoreUpdated = addToGitignore(process.cwd(), gitignoreEntries);
|
|
183
|
+
if (gitignoreUpdated) {
|
|
184
|
+
print(green(' ✓ .gitignore updated') + ' ' + dim(gitignoreEntries.join(', ') + ' added'));
|
|
185
|
+
}
|
|
186
|
+
|
|
132
187
|
print();
|
|
133
188
|
print(bold(' Next steps'));
|
|
134
189
|
print(` 1. Restart Claude Code (or reload the MCP server)`);
|
|
135
|
-
print(` 2. Open
|
|
136
|
-
print(` 3. Claude will
|
|
190
|
+
print(` 2. Open your project directory in Claude Code`);
|
|
191
|
+
print(` 3. Type ${bold('start')} or ${bold('continue')} — Claude will scan your project and get to work`);
|
|
137
192
|
print();
|
|
138
193
|
|
|
139
194
|
if (mode === 'local') {
|
|
@@ -145,7 +200,7 @@ if (mode === 'local') {
|
|
|
145
200
|
}
|
|
146
201
|
print();
|
|
147
202
|
|
|
148
|
-
//
|
|
203
|
+
// 6. allowedTools — offer to auto-write
|
|
149
204
|
const ALLOWED_TOOLS = [
|
|
150
205
|
'mcp__project-planner__get_project_status',
|
|
151
206
|
'mcp__project-planner__get_next_tasks',
|
package/index.js
CHANGED
|
@@ -50,7 +50,7 @@ console.error(`[ProPlan MCP] Starting in ${isCloudMode ? 'CLOUD' : 'LOCAL'} mode
|
|
|
50
50
|
// ─── Server ───────────────────────────────────────────────────────────────────
|
|
51
51
|
const server = new McpServer({
|
|
52
52
|
name: 'project-planner',
|
|
53
|
-
version: '1.0.
|
|
53
|
+
version: '1.0.5',
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
server.tool(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proplandev/mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "MCP server for Claude Code — persistent project memory, session continuity, and structural repo analysis",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -22,8 +22,7 @@
|
|
|
22
22
|
"adapters/",
|
|
23
23
|
"tools/",
|
|
24
24
|
"lib/",
|
|
25
|
-
"
|
|
26
|
-
"supabase.js"
|
|
25
|
+
"README.md"
|
|
27
26
|
],
|
|
28
27
|
"keywords": [
|
|
29
28
|
"mcp",
|
|
@@ -45,7 +44,6 @@
|
|
|
45
44
|
},
|
|
46
45
|
"dependencies": {
|
|
47
46
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
48
|
-
"@supabase/supabase-js": "^2.50.0",
|
|
49
47
|
"better-sqlite3": "^12.8.0",
|
|
50
48
|
"zod": "^3.23.0"
|
|
51
49
|
},
|
package/tools/createProject.js
CHANGED
|
@@ -32,7 +32,6 @@ export async function createProject(adapter, args) {
|
|
|
32
32
|
title: taskInput.title,
|
|
33
33
|
status: 'pending',
|
|
34
34
|
order: taskOrder,
|
|
35
|
-
resources: [],
|
|
36
35
|
...(taskInput.description && { description: cap(taskInput.description) }),
|
|
37
36
|
...(taskInput.technology && { technology: taskInput.technology }),
|
|
38
37
|
};
|
package/tools/getNextTasks.js
CHANGED
|
@@ -28,9 +28,9 @@ export async function getNextTasks(adapter, args) {
|
|
|
28
28
|
results.push({
|
|
29
29
|
taskId: task.id,
|
|
30
30
|
title: task.title,
|
|
31
|
-
description: task.description
|
|
31
|
+
...(task.description && { description: task.description }),
|
|
32
32
|
status: task.status,
|
|
33
|
-
technology: task.technology
|
|
33
|
+
...(task.technology && { technology: task.technology }),
|
|
34
34
|
phaseTitle: phase.title,
|
|
35
35
|
milestoneTitle: milestone.title,
|
|
36
36
|
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
// mcp-server/adapters/SupabaseAdapter.js
|
|
2
|
-
|
|
3
|
-
export class SupabaseAdapter {
|
|
4
|
-
constructor(supabase, userId) {
|
|
5
|
-
this._supabase = supabase;
|
|
6
|
-
this._userId = userId;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
getUserId() {
|
|
10
|
-
return this._userId;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
async listProjects() {
|
|
14
|
-
const { data, error } = await this._supabase
|
|
15
|
-
.from('roadmap')
|
|
16
|
-
.select('id, title, content')
|
|
17
|
-
.eq('user_id', this._userId);
|
|
18
|
-
if (error) throw new Error(`Failed to fetch projects: ${error.message}`);
|
|
19
|
-
return data || [];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async getProject(projectId) {
|
|
23
|
-
const { data, error } = await this._supabase
|
|
24
|
-
.from('roadmap')
|
|
25
|
-
.select('id, title, content')
|
|
26
|
-
.eq('user_id', this._userId)
|
|
27
|
-
.eq('id', projectId)
|
|
28
|
-
.single();
|
|
29
|
-
if (error || !data) return null;
|
|
30
|
-
return data;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async saveProject(projectId, title, content, updatedAt) {
|
|
34
|
-
const { error } = await this._supabase
|
|
35
|
-
.from('roadmap')
|
|
36
|
-
.update({ title, content, updated_at: updatedAt })
|
|
37
|
-
.eq('user_id', this._userId)
|
|
38
|
-
.eq('id', projectId);
|
|
39
|
-
if (error) throw new Error(`Failed to save: ${error.message}`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async insertProject(title, content) {
|
|
43
|
-
const now = new Date().toISOString();
|
|
44
|
-
const { data, error } = await this._supabase
|
|
45
|
-
.from('roadmap')
|
|
46
|
-
.insert({
|
|
47
|
-
user_id: this._userId,
|
|
48
|
-
title,
|
|
49
|
-
content,
|
|
50
|
-
created_at: now,
|
|
51
|
-
updated_at: now,
|
|
52
|
-
})
|
|
53
|
-
.select('id')
|
|
54
|
-
.single();
|
|
55
|
-
if (error || !data) throw new Error(`Failed to insert: ${error?.message ?? 'unknown'}`);
|
|
56
|
-
return { id: data.id };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async deleteProject(projectId) {
|
|
60
|
-
const { error } = await this._supabase
|
|
61
|
-
.from('roadmap')
|
|
62
|
-
.delete()
|
|
63
|
-
.eq('user_id', this._userId)
|
|
64
|
-
.eq('id', projectId);
|
|
65
|
-
if (error) throw new Error(`Failed to delete project: ${error.message}`);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
async renameProject(projectId, newTitle, updatedAt) {
|
|
69
|
-
const { error } = await this._supabase
|
|
70
|
-
.from('roadmap')
|
|
71
|
-
.update({ title: newTitle, updated_at: updatedAt })
|
|
72
|
-
.eq('user_id', this._userId)
|
|
73
|
-
.eq('id', projectId);
|
|
74
|
-
if (error) throw new Error(`Failed to rename project: ${error.message}`);
|
|
75
|
-
}
|
|
76
|
-
}
|
package/auth.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// mcp-server/auth.js
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Validates a Personal Access Token against the mcp_tokens table.
|
|
5
|
-
* @param {object} supabase - Supabase client
|
|
6
|
-
* @param {string} token - The MCP_TOKEN value from env
|
|
7
|
-
* @returns {Promise<string>} userId
|
|
8
|
-
* @throws if token not found
|
|
9
|
-
*/
|
|
10
|
-
export async function validatePat(supabase, token) {
|
|
11
|
-
const { data, error } = await supabase
|
|
12
|
-
.from('mcp_tokens')
|
|
13
|
-
.select('user_id')
|
|
14
|
-
.eq('token', token)
|
|
15
|
-
.single();
|
|
16
|
-
|
|
17
|
-
if (error || !data) {
|
|
18
|
-
throw new Error('Invalid MCP_TOKEN. Generate a new one in Project Planner Settings.');
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return data.user_id;
|
|
22
|
-
}
|
package/supabase.js
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// mcp-server/tools/getSessionHandoff.js
|
|
2
|
-
|
|
3
|
-
export async function getSessionHandoff(adapter, args) {
|
|
4
|
-
const data = await adapter.getProject(args.project_id);
|
|
5
|
-
if (!data) throw new Error(`Project ${args.project_id} not found`);
|
|
6
|
-
|
|
7
|
-
const limit = args.last_n_tasks ?? 5;
|
|
8
|
-
|
|
9
|
-
let roadmap;
|
|
10
|
-
try {
|
|
11
|
-
roadmap = JSON.parse(data.content);
|
|
12
|
-
} catch {
|
|
13
|
-
throw new Error(`Project ${data.id} has corrupted roadmap data`);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// Flatten all tasks with their phase/milestone context
|
|
17
|
-
const allTasks = [];
|
|
18
|
-
for (const phase of (roadmap.phases || [])) {
|
|
19
|
-
for (const milestone of (phase.milestones || [])) {
|
|
20
|
-
for (const task of (milestone.tasks || [])) {
|
|
21
|
-
const notes = Array.isArray(task.notes) ? task.notes : [];
|
|
22
|
-
const lastNote = notes.length > 0 ? notes[notes.length - 1] : null;
|
|
23
|
-
allTasks.push({
|
|
24
|
-
id: task.id,
|
|
25
|
-
title: task.title,
|
|
26
|
-
status: task.status || 'pending',
|
|
27
|
-
phase: phase.title,
|
|
28
|
-
milestone: milestone.title,
|
|
29
|
-
lastNote: lastNote ? { text: lastNote.text, createdAt: lastNote.createdAt } : null,
|
|
30
|
-
_lastNoteTime: lastNote ? new Date(lastNote.createdAt).getTime() : 0,
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Sort: in_progress first, then by most recent note descending
|
|
37
|
-
allTasks.sort((a, b) => {
|
|
38
|
-
if (a.status === 'in_progress' && b.status !== 'in_progress') return -1;
|
|
39
|
-
if (b.status === 'in_progress' && a.status !== 'in_progress') return 1;
|
|
40
|
-
return b._lastNoteTime - a._lastNoteTime;
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const recentTasks = allTasks.slice(0, limit).map(({ _lastNoteTime, ...task }) => task);
|
|
44
|
-
|
|
45
|
-
// Summarise overall progress
|
|
46
|
-
const totalTasks = allTasks.length;
|
|
47
|
-
const completedTasks = allTasks.filter(t => t.status === 'completed').length;
|
|
48
|
-
const inProgressTasks = allTasks.filter(t => t.status === 'in_progress').length;
|
|
49
|
-
|
|
50
|
-
// Last session summary (most recent only)
|
|
51
|
-
const sessions = Array.isArray(roadmap.sessions) ? roadmap.sessions : [];
|
|
52
|
-
const lastSession = sessions.length > 0 ? sessions[sessions.length - 1] : null;
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
projectGoal: roadmap.projectGoal || null,
|
|
56
|
-
project: {
|
|
57
|
-
id: data.id,
|
|
58
|
-
title: data.title,
|
|
59
|
-
totalTasks,
|
|
60
|
-
completedTasks,
|
|
61
|
-
inProgressTasks,
|
|
62
|
-
completionPercent: totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0,
|
|
63
|
-
},
|
|
64
|
-
lastSession,
|
|
65
|
-
recentTasks,
|
|
66
|
-
};
|
|
67
|
-
}
|
package/tools/syncProjects.js
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
// mcp-server/tools/syncProjects.js
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Core incremental sync algorithm, extracted for testability.
|
|
5
|
-
*
|
|
6
|
-
* @param {import('../adapters/SqliteAdapter.js').SqliteAdapter} adapter
|
|
7
|
-
* @param {object} supabase - Supabase client
|
|
8
|
-
* @param {string} userId
|
|
9
|
-
* @param {{ delete_removed?: boolean }} opts
|
|
10
|
-
* @returns {Promise<{ inserted: number, updated: number, skipped: number, deleted: number, failed: Array<{title: string, error: string}>, warning?: string }>}
|
|
11
|
-
*/
|
|
12
|
-
export async function syncProjects(adapter, supabase, userId, opts = {}) {
|
|
13
|
-
const { delete_removed = false } = opts;
|
|
14
|
-
|
|
15
|
-
adapter._applyMigrations();
|
|
16
|
-
|
|
17
|
-
const projects = adapter.getProjectsSyncStatus();
|
|
18
|
-
const now = new Date().toISOString();
|
|
19
|
-
|
|
20
|
-
let inserted = 0;
|
|
21
|
-
let updated = 0;
|
|
22
|
-
let skipped = 0;
|
|
23
|
-
let deleted = 0;
|
|
24
|
-
const failed = [];
|
|
25
|
-
|
|
26
|
-
for (const project of projects) {
|
|
27
|
-
if (project.last_synced_at === null) {
|
|
28
|
-
// Never synced → INSERT with local UUID as Supabase row id
|
|
29
|
-
const { error } = await supabase
|
|
30
|
-
.from('roadmap')
|
|
31
|
-
.insert({
|
|
32
|
-
id: project.id,
|
|
33
|
-
user_id: userId,
|
|
34
|
-
title: project.title,
|
|
35
|
-
content: project.content,
|
|
36
|
-
created_at: now,
|
|
37
|
-
updated_at: now,
|
|
38
|
-
})
|
|
39
|
-
.select('id')
|
|
40
|
-
.single();
|
|
41
|
-
|
|
42
|
-
if (error) {
|
|
43
|
-
failed.push({ title: project.title, error: error.message });
|
|
44
|
-
} else {
|
|
45
|
-
adapter.markSynced(project.id, now);
|
|
46
|
-
inserted++;
|
|
47
|
-
}
|
|
48
|
-
} else if (project.updated_at > project.last_synced_at) {
|
|
49
|
-
// Changed since last sync → UPDATE
|
|
50
|
-
const { error } = await supabase
|
|
51
|
-
.from('roadmap')
|
|
52
|
-
.update({ title: project.title, content: project.content, updated_at: now })
|
|
53
|
-
.eq('id', project.id)
|
|
54
|
-
.eq('user_id', userId);
|
|
55
|
-
|
|
56
|
-
if (error) {
|
|
57
|
-
failed.push({ title: project.title, error: error.message });
|
|
58
|
-
} else {
|
|
59
|
-
adapter.markSynced(project.id, now);
|
|
60
|
-
updated++;
|
|
61
|
-
}
|
|
62
|
-
} else {
|
|
63
|
-
skipped++;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (delete_removed) {
|
|
68
|
-
try {
|
|
69
|
-
const localIds = new Set(projects.map(p => p.id));
|
|
70
|
-
const { data: cloudRows, error: fetchErr } = await supabase
|
|
71
|
-
.from('roadmap')
|
|
72
|
-
.select('id')
|
|
73
|
-
.eq('user_id', userId);
|
|
74
|
-
|
|
75
|
-
if (fetchErr) throw fetchErr;
|
|
76
|
-
|
|
77
|
-
const toDelete = cloudRows.map(r => r.id).filter(id => !localIds.has(id));
|
|
78
|
-
if (toDelete.length > 0) {
|
|
79
|
-
const { error: delErr } = await supabase
|
|
80
|
-
.from('roadmap')
|
|
81
|
-
.delete()
|
|
82
|
-
.in('id', toDelete);
|
|
83
|
-
if (delErr) throw delErr;
|
|
84
|
-
deleted = toDelete.length;
|
|
85
|
-
}
|
|
86
|
-
} catch (err) {
|
|
87
|
-
return {
|
|
88
|
-
inserted,
|
|
89
|
-
updated,
|
|
90
|
-
skipped,
|
|
91
|
-
deleted: 0,
|
|
92
|
-
failed,
|
|
93
|
-
warning: `Cloud cleanup failed: ${err.message}. Inserts/updates were committed.`,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return { inserted, updated, skipped, deleted, failed };
|
|
99
|
-
}
|