prjct-cli 0.21.0 → 0.22.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/core/integrations/notion/client.ts +94 -4
- package/core/integrations/notion/index.ts +4 -1
- package/core/integrations/notion/setup.ts +8 -3
- package/core/integrations/notion/sync.ts +517 -10
- package/core/integrations/notion/templates.ts +36 -24
- package/core/types/storage.ts +8 -0
- package/core/types/task.ts +4 -0
- package/package.json +1 -1
- package/templates/skills/notion-push.md +116 -0
- package/templates/{commands → skills}/notion-setup.md +26 -18
- package/templates/skills/notion-sync.md +290 -0
|
@@ -179,6 +179,95 @@ export class NotionClient {
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Update page content (for dashboard)
|
|
184
|
+
*/
|
|
185
|
+
async updatePageContent(pageId: string, content: string): Promise<boolean> {
|
|
186
|
+
try {
|
|
187
|
+
// Convert markdown content to Notion blocks
|
|
188
|
+
const blocks = this.markdownToBlocks(content)
|
|
189
|
+
|
|
190
|
+
// Clear existing content and add new blocks
|
|
191
|
+
// First, get existing blocks to delete them
|
|
192
|
+
const existingBlocks = await this.apiRequest(
|
|
193
|
+
`/blocks/${pageId}/children`,
|
|
194
|
+
'GET'
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
// Delete existing blocks
|
|
198
|
+
for (const block of existingBlocks.results || []) {
|
|
199
|
+
await this.apiRequest(`/blocks/${block.id}`, 'DELETE')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Add new blocks
|
|
203
|
+
await this.apiRequest(`/blocks/${pageId}/children`, 'PATCH', {
|
|
204
|
+
children: blocks,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
return true
|
|
208
|
+
} catch (error) {
|
|
209
|
+
console.error('[notion] Failed to update page content:', (error as Error).message)
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Convert simple markdown to Notion blocks
|
|
216
|
+
*/
|
|
217
|
+
private markdownToBlocks(content: string): unknown[] {
|
|
218
|
+
const blocks: unknown[] = []
|
|
219
|
+
const lines = content.split('\n')
|
|
220
|
+
|
|
221
|
+
for (const line of lines) {
|
|
222
|
+
if (line.startsWith('# ')) {
|
|
223
|
+
blocks.push({
|
|
224
|
+
type: 'heading_1',
|
|
225
|
+
heading_1: {
|
|
226
|
+
rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
} else if (line.startsWith('## ')) {
|
|
230
|
+
blocks.push({
|
|
231
|
+
type: 'heading_2',
|
|
232
|
+
heading_2: {
|
|
233
|
+
rich_text: [{ type: 'text', text: { content: line.slice(3) } }],
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
} else if (line.startsWith('- ')) {
|
|
237
|
+
blocks.push({
|
|
238
|
+
type: 'bulleted_list_item',
|
|
239
|
+
bulleted_list_item: {
|
|
240
|
+
rich_text: [{ type: 'text', text: { content: line.slice(2) } }],
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
} else if (line.startsWith('|') && line.includes('|')) {
|
|
244
|
+
// Table row - skip header separator
|
|
245
|
+
if (!line.match(/^\|[-|]+\|$/)) {
|
|
246
|
+
const cells = line.split('|').filter(Boolean).map((c) => c.trim())
|
|
247
|
+
if (cells.length > 0) {
|
|
248
|
+
blocks.push({
|
|
249
|
+
type: 'paragraph',
|
|
250
|
+
paragraph: {
|
|
251
|
+
rich_text: [{ type: 'text', text: { content: cells.join(' | ') } }],
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} else if (line.trim() === '---') {
|
|
257
|
+
blocks.push({ type: 'divider', divider: {} })
|
|
258
|
+
} else if (line.trim()) {
|
|
259
|
+
blocks.push({
|
|
260
|
+
type: 'paragraph',
|
|
261
|
+
paragraph: {
|
|
262
|
+
rich_text: [{ type: 'text', text: { content: line } }],
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return blocks
|
|
269
|
+
}
|
|
270
|
+
|
|
182
271
|
/**
|
|
183
272
|
* Query a database
|
|
184
273
|
*/
|
|
@@ -211,11 +300,12 @@ export class NotionClient {
|
|
|
211
300
|
}
|
|
212
301
|
|
|
213
302
|
/**
|
|
214
|
-
* Find page by
|
|
303
|
+
* Find page by name (for upsert)
|
|
304
|
+
* Note: Since each project has its own database, we don't need to filter by project
|
|
215
305
|
*/
|
|
216
306
|
async findPageByProjectAndName(
|
|
217
307
|
databaseId: string,
|
|
218
|
-
|
|
308
|
+
_projectId: string,
|
|
219
309
|
name: string
|
|
220
310
|
): Promise<string | null> {
|
|
221
311
|
try {
|
|
@@ -224,9 +314,9 @@ export class NotionClient {
|
|
|
224
314
|
'POST',
|
|
225
315
|
{
|
|
226
316
|
filter: {
|
|
227
|
-
|
|
228
|
-
{ property: 'Project', rich_text: { equals: projectId } },
|
|
317
|
+
or: [
|
|
229
318
|
{ property: 'Name', title: { equals: name } },
|
|
319
|
+
{ property: 'Idea', title: { equals: name } },
|
|
230
320
|
],
|
|
231
321
|
},
|
|
232
322
|
}
|
|
@@ -28,8 +28,11 @@ export {
|
|
|
28
28
|
syncShippedFeature,
|
|
29
29
|
syncIdea,
|
|
30
30
|
fullSync,
|
|
31
|
+
pullShippedFeatures,
|
|
32
|
+
pullIdeas,
|
|
33
|
+
bidirectionalSync,
|
|
31
34
|
} from './sync'
|
|
32
|
-
export type { SyncResult } from './sync'
|
|
35
|
+
export type { SyncResult, PullResult } from './sync'
|
|
33
36
|
|
|
34
37
|
// Setup
|
|
35
38
|
export {
|
|
@@ -94,16 +94,21 @@ export async function createDatabases(
|
|
|
94
94
|
const databases: NotionIntegrationConfig['databases'] = {}
|
|
95
95
|
|
|
96
96
|
try {
|
|
97
|
-
// Create each database
|
|
97
|
+
// Create each database with project-specific names
|
|
98
98
|
for (const [key, schema] of Object.entries(ALL_DATABASE_SCHEMAS)) {
|
|
99
|
-
|
|
99
|
+
// Override title with project name prefix
|
|
100
|
+
const projectSchema = {
|
|
101
|
+
...schema,
|
|
102
|
+
title: schema.title.replace('prjct:', `${projectName}:`),
|
|
103
|
+
}
|
|
104
|
+
const db = await notionClient.createDatabase(parentPageId, projectSchema)
|
|
100
105
|
if (db) {
|
|
101
106
|
databases[key as keyof typeof databases] = db.id
|
|
102
107
|
} else {
|
|
103
108
|
return {
|
|
104
109
|
success: false,
|
|
105
110
|
databases,
|
|
106
|
-
error: `Failed to create ${
|
|
111
|
+
error: `Failed to create ${projectSchema.title} database`,
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { ShippedFeature, Idea } from '../../types/storage'
|
|
7
7
|
import type { NotionIntegrationConfig } from '../../types/integrations'
|
|
8
8
|
import { notionClient } from './client'
|
|
9
|
+
import { getDashboardContent, type DashboardMetrics } from './templates'
|
|
9
10
|
|
|
10
11
|
// =============================================================================
|
|
11
12
|
// Types
|
|
@@ -25,17 +26,25 @@ export interface SyncResult {
|
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Build Notion properties for a shipped feature
|
|
29
|
+
* Includes ALL fields for comprehensive sync
|
|
28
30
|
*/
|
|
29
31
|
function buildShippedProperties(
|
|
30
32
|
feature: ShippedFeature,
|
|
31
|
-
|
|
33
|
+
_projectId: string
|
|
32
34
|
): Record<string, unknown> {
|
|
35
|
+
const now = new Date().toISOString()
|
|
36
|
+
|
|
37
|
+
// Note: Project field removed - each project now has its own database
|
|
33
38
|
const props: Record<string, unknown> = {
|
|
34
39
|
Name: {
|
|
35
40
|
title: [{ text: { content: feature.name } }],
|
|
36
41
|
},
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
// Sync tracking fields
|
|
43
|
+
prjctId: {
|
|
44
|
+
rich_text: [{ text: { content: feature.id } }],
|
|
45
|
+
},
|
|
46
|
+
'Last Updated': {
|
|
47
|
+
date: { start: now.split('T')[0] },
|
|
39
48
|
},
|
|
40
49
|
}
|
|
41
50
|
|
|
@@ -57,16 +66,24 @@ function buildShippedProperties(
|
|
|
57
66
|
}
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
// Full code metrics
|
|
60
70
|
if (feature.codeMetrics) {
|
|
61
71
|
const metrics = feature.codeMetrics
|
|
62
|
-
props['Lines
|
|
63
|
-
number:
|
|
72
|
+
props['Lines Added'] = {
|
|
73
|
+
number: metrics.linesAdded || 0,
|
|
74
|
+
}
|
|
75
|
+
props['Lines Removed'] = {
|
|
76
|
+
number: metrics.linesRemoved || 0,
|
|
64
77
|
}
|
|
65
78
|
props['Files Changed'] = {
|
|
66
79
|
number: metrics.filesChanged || 0,
|
|
67
80
|
}
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Commit info
|
|
84
|
+
if (feature.commit && typeof feature.commit === 'object') {
|
|
85
|
+
props.Commit = {
|
|
86
|
+
rich_text: [{ text: { content: feature.commit.hash || '' } }],
|
|
70
87
|
}
|
|
71
88
|
}
|
|
72
89
|
|
|
@@ -82,22 +99,37 @@ function buildShippedProperties(
|
|
|
82
99
|
}
|
|
83
100
|
}
|
|
84
101
|
|
|
102
|
+
// Impact level based on metrics
|
|
103
|
+
const impact = feature.codeMetrics?.linesAdded && feature.codeMetrics.linesAdded > 500 ? 'High' :
|
|
104
|
+
feature.codeMetrics?.linesAdded && feature.codeMetrics.linesAdded > 100 ? 'Medium' : 'Low'
|
|
105
|
+
props.Impact = {
|
|
106
|
+
select: { name: impact },
|
|
107
|
+
}
|
|
108
|
+
|
|
85
109
|
return props
|
|
86
110
|
}
|
|
87
111
|
|
|
88
112
|
/**
|
|
89
113
|
* Build Notion properties for an idea
|
|
114
|
+
* Includes ALL fields for comprehensive sync
|
|
90
115
|
*/
|
|
91
116
|
function buildIdeaProperties(
|
|
92
117
|
idea: Idea,
|
|
93
|
-
|
|
118
|
+
_projectId: string
|
|
94
119
|
): Record<string, unknown> {
|
|
120
|
+
const now = new Date().toISOString()
|
|
121
|
+
|
|
122
|
+
// Note: Project field removed - each project now has its own database
|
|
95
123
|
const props: Record<string, unknown> = {
|
|
96
124
|
Idea: {
|
|
97
125
|
title: [{ text: { content: idea.text } }],
|
|
98
126
|
},
|
|
99
|
-
|
|
100
|
-
|
|
127
|
+
// Sync tracking fields
|
|
128
|
+
prjctId: {
|
|
129
|
+
rich_text: [{ text: { content: idea.id } }],
|
|
130
|
+
},
|
|
131
|
+
'Last Updated': {
|
|
132
|
+
date: { start: now.split('T')[0] },
|
|
101
133
|
},
|
|
102
134
|
}
|
|
103
135
|
|
|
@@ -138,9 +170,186 @@ function buildIdeaProperties(
|
|
|
138
170
|
}
|
|
139
171
|
}
|
|
140
172
|
|
|
173
|
+
// Details/notes
|
|
174
|
+
if (idea.details) {
|
|
175
|
+
props.Details = {
|
|
176
|
+
rich_text: [{ text: { content: idea.details.slice(0, 2000) } }],
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Impact/Effort matrix
|
|
181
|
+
if (idea.impactEffort) {
|
|
182
|
+
props.Impact = {
|
|
183
|
+
select: { name: idea.impactEffort.impact },
|
|
184
|
+
}
|
|
185
|
+
props.Effort = {
|
|
186
|
+
select: { name: idea.impactEffort.effort },
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Pain points as text
|
|
191
|
+
if (idea.painPoints && idea.painPoints.length > 0) {
|
|
192
|
+
props['Pain Points'] = {
|
|
193
|
+
rich_text: [{ text: { content: idea.painPoints.join(', ').slice(0, 2000) } }],
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Solutions as text
|
|
198
|
+
if (idea.solutions && idea.solutions.length > 0) {
|
|
199
|
+
props.Solutions = {
|
|
200
|
+
rich_text: [{ text: { content: idea.solutions.join(', ').slice(0, 2000) } }],
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Files affected
|
|
205
|
+
if (idea.filesAffected && idea.filesAffected.length > 0) {
|
|
206
|
+
props['Files Affected'] = {
|
|
207
|
+
rich_text: [{ text: { content: idea.filesAffected.join(', ').slice(0, 2000) } }],
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Risks count
|
|
212
|
+
if (idea.risksCount !== undefined) {
|
|
213
|
+
props.Risks = {
|
|
214
|
+
number: idea.risksCount,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
141
218
|
return props
|
|
142
219
|
}
|
|
143
220
|
|
|
221
|
+
// =============================================================================
|
|
222
|
+
// Property Parsers (Notion → prjct)
|
|
223
|
+
// =============================================================================
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Extract text from Notion rich_text property
|
|
227
|
+
*/
|
|
228
|
+
function extractText(prop: unknown): string | undefined {
|
|
229
|
+
if (!prop || typeof prop !== 'object') return undefined
|
|
230
|
+
const p = prop as { rich_text?: Array<{ plain_text?: string }> }
|
|
231
|
+
if (!p.rich_text || p.rich_text.length === 0) return undefined
|
|
232
|
+
return p.rich_text.map((t) => t.plain_text || '').join('')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Extract title from Notion title property
|
|
237
|
+
*/
|
|
238
|
+
function extractTitle(prop: unknown): string {
|
|
239
|
+
if (!prop || typeof prop !== 'object') return ''
|
|
240
|
+
const p = prop as { title?: Array<{ plain_text?: string }> }
|
|
241
|
+
if (!p.title || p.title.length === 0) return ''
|
|
242
|
+
return p.title.map((t) => t.plain_text || '').join('')
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Extract date from Notion date property
|
|
247
|
+
*/
|
|
248
|
+
function extractDate(prop: unknown): string | undefined {
|
|
249
|
+
if (!prop || typeof prop !== 'object') return undefined
|
|
250
|
+
const p = prop as { date?: { start?: string } }
|
|
251
|
+
return p.date?.start
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Extract select value from Notion select property
|
|
256
|
+
*/
|
|
257
|
+
function extractSelect(prop: unknown): string | undefined {
|
|
258
|
+
if (!prop || typeof prop !== 'object') return undefined
|
|
259
|
+
const p = prop as { select?: { name?: string } }
|
|
260
|
+
return p.select?.name
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Extract status value from Notion status property
|
|
265
|
+
*/
|
|
266
|
+
function extractStatus(prop: unknown): string | undefined {
|
|
267
|
+
if (!prop || typeof prop !== 'object') return undefined
|
|
268
|
+
const p = prop as { status?: { name?: string } }
|
|
269
|
+
return p.status?.name
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Extract number from Notion number property
|
|
274
|
+
*/
|
|
275
|
+
function extractNumber(prop: unknown): number | undefined {
|
|
276
|
+
if (!prop || typeof prop !== 'object') return undefined
|
|
277
|
+
const p = prop as { number?: number | null }
|
|
278
|
+
return p.number ?? undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Extract multi-select as string array
|
|
283
|
+
*/
|
|
284
|
+
function extractMultiSelect(prop: unknown): string[] {
|
|
285
|
+
if (!prop || typeof prop !== 'object') return []
|
|
286
|
+
const p = prop as { multi_select?: Array<{ name?: string }> }
|
|
287
|
+
if (!p.multi_select) return []
|
|
288
|
+
return p.multi_select.map((s) => s.name || '').filter(Boolean)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Parse Notion page to ShippedFeature
|
|
293
|
+
*/
|
|
294
|
+
function parseShippedFeature(
|
|
295
|
+
pageId: string,
|
|
296
|
+
props: Record<string, unknown>
|
|
297
|
+
): Partial<ShippedFeature> & { notionPageId: string } {
|
|
298
|
+
return {
|
|
299
|
+
notionPageId: pageId,
|
|
300
|
+
id: extractText(props.prjctId) || '',
|
|
301
|
+
name: extractTitle(props.Name) || '',
|
|
302
|
+
version: extractText(props.Version) || '',
|
|
303
|
+
shippedAt: extractDate(props['Shipped Date']) || new Date().toISOString(),
|
|
304
|
+
description: extractText(props.Description),
|
|
305
|
+
type: extractSelect(props.Type) as ShippedFeature['type'],
|
|
306
|
+
duration: extractText(props.Duration),
|
|
307
|
+
codeMetrics: {
|
|
308
|
+
linesAdded: extractNumber(props['Lines Added']) || 0,
|
|
309
|
+
linesRemoved: extractNumber(props['Lines Removed']) || 0,
|
|
310
|
+
filesChanged: extractNumber(props['Files Changed']) || 0,
|
|
311
|
+
commits: 0,
|
|
312
|
+
},
|
|
313
|
+
lastSyncedAt: new Date().toISOString(),
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Parse Notion page to Idea
|
|
319
|
+
*/
|
|
320
|
+
function parseIdea(
|
|
321
|
+
pageId: string,
|
|
322
|
+
props: Record<string, unknown>
|
|
323
|
+
): Partial<Idea> & { notionPageId: string } {
|
|
324
|
+
const statusMap: Record<string, Idea['status']> = {
|
|
325
|
+
Pending: 'pending',
|
|
326
|
+
Converted: 'converted',
|
|
327
|
+
Archived: 'archived',
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const priorityMap: Record<string, Idea['priority']> = {
|
|
331
|
+
high: 'high',
|
|
332
|
+
medium: 'medium',
|
|
333
|
+
low: 'low',
|
|
334
|
+
High: 'high',
|
|
335
|
+
Medium: 'medium',
|
|
336
|
+
Low: 'low',
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
notionPageId: pageId,
|
|
341
|
+
id: extractText(props.prjctId) || '',
|
|
342
|
+
text: extractTitle(props.Idea) || '',
|
|
343
|
+
status: statusMap[extractStatus(props.Status) || ''] || 'pending',
|
|
344
|
+
priority: priorityMap[extractSelect(props.Priority) || ''] || 'medium',
|
|
345
|
+
tags: extractMultiSelect(props.Tags),
|
|
346
|
+
details: extractText(props.Details),
|
|
347
|
+
addedAt: extractDate(props.Created) || new Date().toISOString(),
|
|
348
|
+
convertedTo: extractText(props['Converted To']),
|
|
349
|
+
lastSyncedAt: new Date().toISOString(),
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
144
353
|
// =============================================================================
|
|
145
354
|
// Sync Functions
|
|
146
355
|
// =============================================================================
|
|
@@ -309,3 +518,301 @@ export async function fullSync(
|
|
|
309
518
|
|
|
310
519
|
return results
|
|
311
520
|
}
|
|
521
|
+
|
|
522
|
+
// =============================================================================
|
|
523
|
+
// Pull Sync Functions (Notion → prjct)
|
|
524
|
+
// =============================================================================
|
|
525
|
+
|
|
526
|
+
export interface PullResult<T> {
|
|
527
|
+
items: T[]
|
|
528
|
+
newCount: number
|
|
529
|
+
updatedCount: number
|
|
530
|
+
errors: string[]
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Pull shipped features from Notion
|
|
535
|
+
* Returns new and updated features from Notion that don't exist or are newer in Notion
|
|
536
|
+
*/
|
|
537
|
+
export async function pullShippedFeatures(
|
|
538
|
+
config: NotionIntegrationConfig,
|
|
539
|
+
existingFeatures: ShippedFeature[]
|
|
540
|
+
): Promise<PullResult<ShippedFeature>> {
|
|
541
|
+
const result: PullResult<ShippedFeature> = {
|
|
542
|
+
items: [],
|
|
543
|
+
newCount: 0,
|
|
544
|
+
updatedCount: 0,
|
|
545
|
+
errors: [],
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!config.enabled || !config.databases.shipped) {
|
|
549
|
+
return result
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!notionClient.isReady()) {
|
|
553
|
+
result.errors.push('Notion client not ready')
|
|
554
|
+
return result
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
const pages = await notionClient.queryDatabase(config.databases.shipped)
|
|
559
|
+
|
|
560
|
+
// Create lookup map by prjctId and notionPageId
|
|
561
|
+
const existingByPrjctId = new Map(
|
|
562
|
+
existingFeatures.filter((f) => f.id).map((f) => [f.id, f])
|
|
563
|
+
)
|
|
564
|
+
const existingByNotionId = new Map(
|
|
565
|
+
existingFeatures.filter((f) => f.notionPageId).map((f) => [f.notionPageId, f])
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
for (const page of pages) {
|
|
569
|
+
const parsed = parseShippedFeature(page.id, page.properties)
|
|
570
|
+
|
|
571
|
+
// Skip if no name (invalid entry)
|
|
572
|
+
if (!parsed.name) continue
|
|
573
|
+
|
|
574
|
+
// Check if exists locally
|
|
575
|
+
const existingByPrjct = parsed.id ? existingByPrjctId.get(parsed.id) : undefined
|
|
576
|
+
const existingByNotion = existingByNotionId.get(page.id)
|
|
577
|
+
const existing = existingByPrjct || existingByNotion
|
|
578
|
+
|
|
579
|
+
if (existing) {
|
|
580
|
+
// Update existing - merge with local data
|
|
581
|
+
const merged: ShippedFeature = {
|
|
582
|
+
...existing,
|
|
583
|
+
...parsed,
|
|
584
|
+
id: existing.id || parsed.id || crypto.randomUUID(),
|
|
585
|
+
notionPageId: page.id,
|
|
586
|
+
lastSyncedAt: new Date().toISOString(),
|
|
587
|
+
}
|
|
588
|
+
result.items.push(merged)
|
|
589
|
+
result.updatedCount++
|
|
590
|
+
} else {
|
|
591
|
+
// New from Notion - create new entry
|
|
592
|
+
const newFeature: ShippedFeature = {
|
|
593
|
+
id: parsed.id || crypto.randomUUID(),
|
|
594
|
+
name: parsed.name,
|
|
595
|
+
shippedAt: parsed.shippedAt || new Date().toISOString(),
|
|
596
|
+
version: parsed.version || '0.0.0',
|
|
597
|
+
description: parsed.description,
|
|
598
|
+
type: parsed.type,
|
|
599
|
+
duration: parsed.duration,
|
|
600
|
+
codeMetrics: parsed.codeMetrics,
|
|
601
|
+
notionPageId: page.id,
|
|
602
|
+
lastSyncedAt: new Date().toISOString(),
|
|
603
|
+
}
|
|
604
|
+
result.items.push(newFeature)
|
|
605
|
+
result.newCount++
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return result
|
|
610
|
+
} catch (error) {
|
|
611
|
+
result.errors.push((error as Error).message)
|
|
612
|
+
return result
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Pull ideas from Notion
|
|
618
|
+
* Returns new and updated ideas from Notion
|
|
619
|
+
*/
|
|
620
|
+
export async function pullIdeas(
|
|
621
|
+
config: NotionIntegrationConfig,
|
|
622
|
+
existingIdeas: Idea[]
|
|
623
|
+
): Promise<PullResult<Idea>> {
|
|
624
|
+
const result: PullResult<Idea> = {
|
|
625
|
+
items: [],
|
|
626
|
+
newCount: 0,
|
|
627
|
+
updatedCount: 0,
|
|
628
|
+
errors: [],
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (!config.enabled || !config.databases.ideas) {
|
|
632
|
+
return result
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (!notionClient.isReady()) {
|
|
636
|
+
result.errors.push('Notion client not ready')
|
|
637
|
+
return result
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
const pages = await notionClient.queryDatabase(config.databases.ideas)
|
|
642
|
+
|
|
643
|
+
// Create lookup map
|
|
644
|
+
const existingByPrjctId = new Map(
|
|
645
|
+
existingIdeas.filter((i) => i.id).map((i) => [i.id, i])
|
|
646
|
+
)
|
|
647
|
+
const existingByNotionId = new Map(
|
|
648
|
+
existingIdeas.filter((i) => i.notionPageId).map((i) => [i.notionPageId, i])
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
for (const page of pages) {
|
|
652
|
+
const parsed = parseIdea(page.id, page.properties)
|
|
653
|
+
|
|
654
|
+
// Skip if no text (invalid entry)
|
|
655
|
+
if (!parsed.text) continue
|
|
656
|
+
|
|
657
|
+
// Check if exists locally
|
|
658
|
+
const existingByPrjct = parsed.id ? existingByPrjctId.get(parsed.id) : undefined
|
|
659
|
+
const existingByNotion = existingByNotionId.get(page.id)
|
|
660
|
+
const existing = existingByPrjct || existingByNotion
|
|
661
|
+
|
|
662
|
+
if (existing) {
|
|
663
|
+
// Update existing
|
|
664
|
+
const merged: Idea = {
|
|
665
|
+
...existing,
|
|
666
|
+
...parsed,
|
|
667
|
+
id: existing.id || parsed.id || crypto.randomUUID(),
|
|
668
|
+
notionPageId: page.id,
|
|
669
|
+
lastSyncedAt: new Date().toISOString(),
|
|
670
|
+
}
|
|
671
|
+
result.items.push(merged)
|
|
672
|
+
result.updatedCount++
|
|
673
|
+
} else {
|
|
674
|
+
// New from Notion
|
|
675
|
+
const newIdea: Idea = {
|
|
676
|
+
id: parsed.id || crypto.randomUUID(),
|
|
677
|
+
text: parsed.text,
|
|
678
|
+
status: parsed.status || 'pending',
|
|
679
|
+
priority: parsed.priority || 'medium',
|
|
680
|
+
tags: parsed.tags || [],
|
|
681
|
+
addedAt: parsed.addedAt || new Date().toISOString(),
|
|
682
|
+
details: parsed.details,
|
|
683
|
+
convertedTo: parsed.convertedTo,
|
|
684
|
+
notionPageId: page.id,
|
|
685
|
+
lastSyncedAt: new Date().toISOString(),
|
|
686
|
+
}
|
|
687
|
+
result.items.push(newIdea)
|
|
688
|
+
result.newCount++
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return result
|
|
693
|
+
} catch (error) {
|
|
694
|
+
result.errors.push((error as Error).message)
|
|
695
|
+
return result
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Bidirectional sync - combines push and pull
|
|
701
|
+
* Uses "last edit wins" strategy based on lastSyncedAt
|
|
702
|
+
*/
|
|
703
|
+
export async function bidirectionalSync(
|
|
704
|
+
projectId: string,
|
|
705
|
+
config: NotionIntegrationConfig,
|
|
706
|
+
localData: {
|
|
707
|
+
shipped?: ShippedFeature[]
|
|
708
|
+
ideas?: Idea[]
|
|
709
|
+
}
|
|
710
|
+
): Promise<{
|
|
711
|
+
pushed: { shipped: number; ideas: number }
|
|
712
|
+
pulled: { shipped: PullResult<ShippedFeature>; ideas: PullResult<Idea> }
|
|
713
|
+
}> {
|
|
714
|
+
const results = {
|
|
715
|
+
pushed: { shipped: 0, ideas: 0 },
|
|
716
|
+
pulled: {
|
|
717
|
+
shipped: { items: [], newCount: 0, updatedCount: 0, errors: [] } as PullResult<ShippedFeature>,
|
|
718
|
+
ideas: { items: [], newCount: 0, updatedCount: 0, errors: [] } as PullResult<Idea>,
|
|
719
|
+
},
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// 1. Pull from Notion first (to get latest)
|
|
723
|
+
if (localData.shipped) {
|
|
724
|
+
results.pulled.shipped = await pullShippedFeatures(config, localData.shipped)
|
|
725
|
+
}
|
|
726
|
+
if (localData.ideas) {
|
|
727
|
+
results.pulled.ideas = await pullIdeas(config, localData.ideas)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// 2. Push local items that don't have notionPageId (new local items)
|
|
731
|
+
if (localData.shipped && config.databases.shipped) {
|
|
732
|
+
for (const feature of localData.shipped) {
|
|
733
|
+
if (!feature.notionPageId) {
|
|
734
|
+
const result = await syncShippedFeature(projectId, feature, config)
|
|
735
|
+
if (result.success) {
|
|
736
|
+
results.pushed.shipped++
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (localData.ideas && config.databases.ideas) {
|
|
743
|
+
for (const idea of localData.ideas) {
|
|
744
|
+
if (!idea.notionPageId) {
|
|
745
|
+
const result = await syncIdea(projectId, idea, config)
|
|
746
|
+
if (result.success) {
|
|
747
|
+
results.pushed.ideas++
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return results
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// =============================================================================
|
|
757
|
+
// Dashboard Metrics Update
|
|
758
|
+
// =============================================================================
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Update dashboard page with current metrics
|
|
762
|
+
* Called after sync operations
|
|
763
|
+
*/
|
|
764
|
+
export async function updateDashboardMetrics(
|
|
765
|
+
config: NotionIntegrationConfig,
|
|
766
|
+
projectName: string,
|
|
767
|
+
metrics: DashboardMetrics
|
|
768
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
769
|
+
if (!config.enabled || !config.dashboardPageId) {
|
|
770
|
+
return { success: false, error: 'Dashboard not configured' }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (!notionClient.isReady()) {
|
|
774
|
+
return { success: false, error: 'Notion client not ready' }
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
try {
|
|
778
|
+
const content = getDashboardContent(projectName, config.databases, metrics)
|
|
779
|
+
|
|
780
|
+
// Update dashboard page content
|
|
781
|
+
await notionClient.updatePageContent(config.dashboardPageId, content)
|
|
782
|
+
|
|
783
|
+
return { success: true }
|
|
784
|
+
} catch (error) {
|
|
785
|
+
return {
|
|
786
|
+
success: false,
|
|
787
|
+
error: (error as Error).message,
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Calculate metrics from local data
|
|
794
|
+
*/
|
|
795
|
+
export function calculateMetrics(data: {
|
|
796
|
+
shipped?: ShippedFeature[]
|
|
797
|
+
ideas?: Idea[]
|
|
798
|
+
tasks?: Array<{ status?: string; completed?: boolean }>
|
|
799
|
+
roadmap?: Array<{ progress?: number }>
|
|
800
|
+
}): DashboardMetrics {
|
|
801
|
+
const shippedCount = data.shipped?.length || 0
|
|
802
|
+
const ideasPending = data.ideas?.filter((i) => i.status === 'pending').length || 0
|
|
803
|
+
const tasksActive = data.tasks?.filter((t) => !t.completed && t.status !== 'completed').length || 0
|
|
804
|
+
|
|
805
|
+
// Calculate roadmap progress as average of all features
|
|
806
|
+
let roadmapProgress = 0
|
|
807
|
+
if (data.roadmap && data.roadmap.length > 0) {
|
|
808
|
+
const totalProgress = data.roadmap.reduce((sum, f) => sum + (f.progress || 0), 0)
|
|
809
|
+
roadmapProgress = Math.round(totalProgress / data.roadmap.length)
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
shippedCount,
|
|
814
|
+
ideasPending,
|
|
815
|
+
tasksActive,
|
|
816
|
+
roadmapProgress,
|
|
817
|
+
}
|
|
818
|
+
}
|
|
@@ -35,7 +35,6 @@ export const SHIPPED_DATABASE_SCHEMA: NotionDatabaseSchema = {
|
|
|
35
35
|
Commits: { type: 'number', number: { format: 'number' } },
|
|
36
36
|
Duration: { type: 'rich_text', rich_text: {} },
|
|
37
37
|
Description: { type: 'rich_text', rich_text: {} },
|
|
38
|
-
Project: { type: 'rich_text', rich_text: {} },
|
|
39
38
|
},
|
|
40
39
|
}
|
|
41
40
|
|
|
@@ -78,7 +77,6 @@ export const ROADMAP_DATABASE_SCHEMA: NotionDatabaseSchema = {
|
|
|
78
77
|
'Target Date': { type: 'date', date: {} },
|
|
79
78
|
Description: { type: 'rich_text', rich_text: {} },
|
|
80
79
|
Tasks: { type: 'number', number: { format: 'number' } },
|
|
81
|
-
Project: { type: 'rich_text', rich_text: {} },
|
|
82
80
|
},
|
|
83
81
|
}
|
|
84
82
|
|
|
@@ -117,7 +115,6 @@ export const IDEAS_DATABASE_SCHEMA: NotionDatabaseSchema = {
|
|
|
117
115
|
Tags: { type: 'multi_select', multi_select: { options: [] } },
|
|
118
116
|
Created: { type: 'date', date: {} },
|
|
119
117
|
'Converted To': { type: 'rich_text', rich_text: {} },
|
|
120
|
-
Project: { type: 'rich_text', rich_text: {} },
|
|
121
118
|
},
|
|
122
119
|
}
|
|
123
120
|
|
|
@@ -165,7 +162,6 @@ export const TASKS_DATABASE_SCHEMA: NotionDatabaseSchema = {
|
|
|
165
162
|
Feature: { type: 'rich_text', rich_text: {} },
|
|
166
163
|
Created: { type: 'date', date: {} },
|
|
167
164
|
'Completed At': { type: 'date', date: {} },
|
|
168
|
-
Project: { type: 'rich_text', rich_text: {} },
|
|
169
165
|
},
|
|
170
166
|
}
|
|
171
167
|
|
|
@@ -173,8 +169,15 @@ export const TASKS_DATABASE_SCHEMA: NotionDatabaseSchema = {
|
|
|
173
169
|
// Dashboard Template
|
|
174
170
|
// =============================================================================
|
|
175
171
|
|
|
172
|
+
export interface DashboardMetrics {
|
|
173
|
+
shippedCount: number
|
|
174
|
+
ideasPending: number
|
|
175
|
+
tasksActive: number
|
|
176
|
+
roadmapProgress: number // 0-100
|
|
177
|
+
}
|
|
178
|
+
|
|
176
179
|
/**
|
|
177
|
-
* Dashboard page content template
|
|
180
|
+
* Dashboard page content template with metrics
|
|
178
181
|
*/
|
|
179
182
|
export function getDashboardContent(
|
|
180
183
|
projectName: string,
|
|
@@ -183,41 +186,50 @@ export function getDashboardContent(
|
|
|
183
186
|
roadmap?: string
|
|
184
187
|
ideas?: string
|
|
185
188
|
tasks?: string
|
|
186
|
-
}
|
|
189
|
+
},
|
|
190
|
+
metrics?: DashboardMetrics
|
|
187
191
|
): string {
|
|
188
|
-
const sections = []
|
|
192
|
+
const sections: string[] = []
|
|
189
193
|
|
|
190
194
|
sections.push(`# ${projectName} Dashboard`)
|
|
191
195
|
sections.push('')
|
|
192
|
-
sections.push('This dashboard is automatically synced by prjct-cli.')
|
|
193
|
-
sections.push('')
|
|
194
196
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
sections.push(
|
|
197
|
+
// Metrics section
|
|
198
|
+
if (metrics) {
|
|
199
|
+
sections.push('## Metrics')
|
|
200
|
+
sections.push('')
|
|
201
|
+
sections.push('| Metric | Value |')
|
|
202
|
+
sections.push('|--------|-------|')
|
|
203
|
+
sections.push(`| Features Shipped | ${metrics.shippedCount} |`)
|
|
204
|
+
sections.push(`| Ideas Pending | ${metrics.ideasPending} |`)
|
|
205
|
+
sections.push(`| Active Tasks | ${metrics.tasksActive} |`)
|
|
206
|
+
sections.push(`| Roadmap Progress | ${metrics.roadmapProgress}% |`)
|
|
207
|
+
sections.push('')
|
|
208
|
+
} else {
|
|
209
|
+
sections.push('*Metrics will appear after first sync*')
|
|
198
210
|
sections.push('')
|
|
199
211
|
}
|
|
200
212
|
|
|
213
|
+
// Database links
|
|
214
|
+
sections.push('## Databases')
|
|
215
|
+
sections.push('')
|
|
216
|
+
|
|
217
|
+
if (databases.shipped) {
|
|
218
|
+
sections.push(`- **Shipped Features**: Track released features and metrics`)
|
|
219
|
+
}
|
|
201
220
|
if (databases.roadmap) {
|
|
202
|
-
sections.push(
|
|
203
|
-
sections.push(`Track feature progress and planning.`)
|
|
204
|
-
sections.push('')
|
|
221
|
+
sections.push(`- **Roadmap**: Feature planning and progress`)
|
|
205
222
|
}
|
|
206
|
-
|
|
207
223
|
if (databases.ideas) {
|
|
208
|
-
sections.push(
|
|
209
|
-
sections.push(`Captured ideas and their status.`)
|
|
210
|
-
sections.push('')
|
|
224
|
+
sections.push(`- **Ideas**: Captured ideas and status`)
|
|
211
225
|
}
|
|
212
|
-
|
|
213
226
|
if (databases.tasks) {
|
|
214
|
-
sections.push(
|
|
215
|
-
sections.push(`Current task queue and completion status.`)
|
|
216
|
-
sections.push('')
|
|
227
|
+
sections.push(`- **Active Tasks**: Current task queue`)
|
|
217
228
|
}
|
|
218
229
|
|
|
230
|
+
sections.push('')
|
|
219
231
|
sections.push('---')
|
|
220
|
-
sections.push('
|
|
232
|
+
sections.push('Synced by [prjct-cli](https://prjct.app)')
|
|
221
233
|
|
|
222
234
|
return sections.join('\n')
|
|
223
235
|
}
|
package/core/types/storage.ts
CHANGED
|
@@ -43,6 +43,10 @@ export interface ShippedFeature {
|
|
|
43
43
|
quantitativeImpact?: string
|
|
44
44
|
tasksCompleted?: number
|
|
45
45
|
featureId?: string
|
|
46
|
+
/** Notion page ID for bidirectional sync */
|
|
47
|
+
notionPageId?: string
|
|
48
|
+
/** Last sync timestamp for conflict resolution */
|
|
49
|
+
lastSyncedAt?: string
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export interface ShipChange {
|
|
@@ -113,6 +117,10 @@ export interface Idea {
|
|
|
113
117
|
risks?: string[]
|
|
114
118
|
risksCount?: number
|
|
115
119
|
createdAt?: string
|
|
120
|
+
/** Notion page ID for bidirectional sync */
|
|
121
|
+
notionPageId?: string
|
|
122
|
+
/** Last sync timestamp for conflict resolution */
|
|
123
|
+
lastSyncedAt?: string
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
export interface ImpactEffort {
|
package/core/types/task.ts
CHANGED
|
@@ -11,6 +11,10 @@ export interface Task {
|
|
|
11
11
|
completedAt?: string
|
|
12
12
|
duration?: string
|
|
13
13
|
metadata?: TaskMetadata
|
|
14
|
+
/** Notion page ID for bidirectional sync */
|
|
15
|
+
notionPageId?: string
|
|
16
|
+
/** Last sync timestamp for conflict resolution */
|
|
17
|
+
lastSyncedAt?: string
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
export interface TaskMetadata {
|
package/package.json
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
allowed-tools: [Read, Bash]
|
|
3
|
+
description: 'Push prjct data to Notion databases'
|
|
4
|
+
timestamp-rule: 'GetTimestamp() for all timestamps'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /p:notion push - Push Data to Notion
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
|
|
11
|
+
Manually sync all prjct data to Notion databases:
|
|
12
|
+
- Shipped features → Shipped Features database
|
|
13
|
+
- Ideas → Ideas database
|
|
14
|
+
- Queue tasks → Active Tasks database (optional)
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
1. Notion integration configured (`/p:notion setup`)
|
|
19
|
+
2. Token stored in `~/.prjct-cli/config/notion.json`
|
|
20
|
+
|
|
21
|
+
## Flow
|
|
22
|
+
|
|
23
|
+
### Step 1: Check Config
|
|
24
|
+
|
|
25
|
+
READ: `.prjct/prjct.config.json` → extract `projectId`
|
|
26
|
+
READ: `~/.prjct-cli/projects/{projectId}/project.json`
|
|
27
|
+
|
|
28
|
+
IF `integrations.notion.enabled !== true`:
|
|
29
|
+
OUTPUT: "Notion not configured. Run /p:notion setup first."
|
|
30
|
+
STOP
|
|
31
|
+
|
|
32
|
+
### Step 2: Load Token
|
|
33
|
+
|
|
34
|
+
READ: `~/.prjct-cli/config/notion.json` → extract `token`
|
|
35
|
+
|
|
36
|
+
IF no token:
|
|
37
|
+
OUTPUT: "Notion token not found. Run /p:notion setup first."
|
|
38
|
+
STOP
|
|
39
|
+
|
|
40
|
+
### Step 3: Load Data
|
|
41
|
+
|
|
42
|
+
READ: `~/.prjct-cli/projects/{projectId}/storage/shipped.json`
|
|
43
|
+
READ: `~/.prjct-cli/projects/{projectId}/storage/ideas.json`
|
|
44
|
+
|
|
45
|
+
### Step 4: Push to Notion
|
|
46
|
+
|
|
47
|
+
For each shipped feature:
|
|
48
|
+
```bash
|
|
49
|
+
curl -X POST "https://api.notion.com/v1/pages" \
|
|
50
|
+
-H "Authorization: Bearer {token}" \
|
|
51
|
+
-H "Notion-Version: 2022-06-28" \
|
|
52
|
+
-H "Content-Type: application/json" \
|
|
53
|
+
-d '{
|
|
54
|
+
"parent": { "database_id": "{databases.shipped}" },
|
|
55
|
+
"properties": {
|
|
56
|
+
"Name": { "title": [{ "text": { "content": "{feature.name}" } }] },
|
|
57
|
+
"Project": { "select": { "name": "{projectName}" } },
|
|
58
|
+
"Type": { "select": { "name": "{feature.type}" } },
|
|
59
|
+
"Version": { "rich_text": [{ "text": { "content": "{feature.version}" } }] },
|
|
60
|
+
"Shipped Date": { "date": { "start": "{feature.shippedAt}" } }
|
|
61
|
+
}
|
|
62
|
+
}'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For each idea:
|
|
66
|
+
```bash
|
|
67
|
+
curl -X POST "https://api.notion.com/v1/pages" \
|
|
68
|
+
-H "Authorization: Bearer {token}" \
|
|
69
|
+
-H "Notion-Version: 2022-06-28" \
|
|
70
|
+
-H "Content-Type: application/json" \
|
|
71
|
+
-d '{
|
|
72
|
+
"parent": { "database_id": "{databases.ideas}" },
|
|
73
|
+
"properties": {
|
|
74
|
+
"Name": { "title": [{ "text": { "content": "{idea.text}" } }] },
|
|
75
|
+
"Project": { "select": { "name": "{projectName}" } },
|
|
76
|
+
"Status": { "select": { "name": "{idea.status}" } },
|
|
77
|
+
"Created": { "date": { "start": "{idea.addedAt}" } }
|
|
78
|
+
}
|
|
79
|
+
}'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Step 5: Report Results
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
✅ Notion Push Complete
|
|
86
|
+
|
|
87
|
+
Shipped Features: {shipped.count} synced
|
|
88
|
+
Ideas: {ideas.count} synced
|
|
89
|
+
|
|
90
|
+
View in Notion: {dashboardUrl}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Response (Success)
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
✅ Notion Push Complete
|
|
97
|
+
|
|
98
|
+
📦 Shipped: 11 features synced
|
|
99
|
+
💡 Ideas: 1 idea synced
|
|
100
|
+
|
|
101
|
+
Dashboard: https://notion.so/...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Response (No Data)
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
Nothing to push. Add features or ideas first.
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Error Handling
|
|
111
|
+
|
|
112
|
+
| Error | Response |
|
|
113
|
+
|-------|----------|
|
|
114
|
+
| Not configured | "Run /p:notion setup first" |
|
|
115
|
+
| Token invalid | "Token expired. Run /p:notion setup again" |
|
|
116
|
+
| API error | Show error, suggest retry |
|
|
@@ -76,7 +76,7 @@ Ensure the token starts with "ntn_" or "secret_".
|
|
|
76
76
|
```
|
|
77
77
|
Now share a Notion page with your integration:
|
|
78
78
|
|
|
79
|
-
1.
|
|
79
|
+
1. Create a new page in Notion named "prjct: {projectName}"
|
|
80
80
|
2. Click "..." → "Connect to" → select "prjct-cli"
|
|
81
81
|
3. Copy the page URL or ID
|
|
82
82
|
|
|
@@ -87,18 +87,25 @@ Parse page ID from URL:
|
|
|
87
87
|
- `https://notion.so/workspace/Page-Title-abc123...` → `abc123...`
|
|
88
88
|
- Just the 32-character ID is also valid
|
|
89
89
|
|
|
90
|
-
### Step 5: Create Databases
|
|
90
|
+
### Step 5: Create Project Databases
|
|
91
91
|
|
|
92
|
-
Create 4 databases under the parent page
|
|
92
|
+
Create 4 databases + 1 dashboard under the parent page.
|
|
93
|
+
Each project gets its own set of databases (not shared).
|
|
93
94
|
|
|
94
|
-
|
|
|
95
|
-
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
95
|
+
| Created | Name | Description |
|
|
96
|
+
|---------|------|-------------|
|
|
97
|
+
| Dashboard | {projectName}: Dashboard | Metrics + links to all DBs |
|
|
98
|
+
| Database | {projectName}: Shipped Features | Track shipped features with metrics |
|
|
99
|
+
| Database | {projectName}: Roadmap | Feature planning and progress |
|
|
100
|
+
| Database | {projectName}: Ideas | Captured ideas and status |
|
|
101
|
+
| Database | {projectName}: Active Tasks | Current task queue |
|
|
100
102
|
|
|
101
|
-
|
|
103
|
+
The dashboard shows:
|
|
104
|
+
- Total features shipped
|
|
105
|
+
- Ideas pendientes
|
|
106
|
+
- Tareas activas
|
|
107
|
+
- Progreso del roadmap
|
|
108
|
+
- Links a cada database
|
|
102
109
|
|
|
103
110
|
### Step 6: Save Config
|
|
104
111
|
|
|
@@ -150,17 +157,18 @@ Or create `~/.prjct-cli/config/notion.json`:
|
|
|
150
157
|
✅ Notion Connected
|
|
151
158
|
|
|
152
159
|
Workspace: {workspaceName}
|
|
153
|
-
|
|
160
|
+
Project: {projectName}
|
|
161
|
+
Created: 1 dashboard + 4 databases
|
|
154
162
|
|
|
155
|
-
|
|
156
|
-
•
|
|
157
|
-
•
|
|
158
|
-
•
|
|
159
|
-
•
|
|
163
|
+
• {projectName}: Dashboard (with metrics)
|
|
164
|
+
• {projectName}: Shipped Features
|
|
165
|
+
• {projectName}: Roadmap
|
|
166
|
+
• {projectName}: Ideas
|
|
167
|
+
• {projectName}: Active Tasks
|
|
160
168
|
|
|
161
169
|
Auto-sync enabled:
|
|
162
|
-
• On /p:ship → Shipped Features
|
|
163
|
-
• On /p:idea → Ideas
|
|
170
|
+
• On /p:ship → Shipped Features + Dashboard metrics
|
|
171
|
+
• On /p:idea → Ideas + Dashboard metrics
|
|
164
172
|
|
|
165
173
|
To sync all existing data: /p:notion sync
|
|
166
174
|
```
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
---
|
|
2
|
+
allowed-tools: [Read, Bash, Write]
|
|
3
|
+
description: 'Bidirectional sync between prjct and Notion'
|
|
4
|
+
timestamp-rule: 'GetTimestamp() for all timestamps'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /p:notion sync - Bidirectional Sync
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
|
|
11
|
+
Full bidirectional sync between prjct and Notion:
|
|
12
|
+
- **Pull**: Get new items created in Notion → prjct
|
|
13
|
+
- **Push**: Sync local items → Notion
|
|
14
|
+
- **Merge**: Update existing items with latest data
|
|
15
|
+
|
|
16
|
+
Uses "last edit wins" conflict resolution via `lastSyncedAt` timestamps.
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
1. Notion integration configured (`/p:notion setup`)
|
|
21
|
+
2. Token stored in `~/.prjct-cli/config/notion.json`
|
|
22
|
+
3. Databases have `prjctId` and `Last Updated` columns
|
|
23
|
+
|
|
24
|
+
## Context Variables
|
|
25
|
+
|
|
26
|
+
- `{projectId}`: From `.prjct/prjct.config.json`
|
|
27
|
+
- `{globalPath}`: `~/.prjct-cli/projects/{projectId}`
|
|
28
|
+
- `{token}`: From `~/.prjct-cli/config/notion.json`
|
|
29
|
+
|
|
30
|
+
## Flow
|
|
31
|
+
|
|
32
|
+
### Step 1: Check Configuration
|
|
33
|
+
|
|
34
|
+
READ: `.prjct/prjct.config.json` → extract `projectId`
|
|
35
|
+
READ: `{globalPath}/project.json`
|
|
36
|
+
|
|
37
|
+
IF `integrations.notion.enabled !== true`:
|
|
38
|
+
OUTPUT: "Notion not configured. Run /p:notion setup first."
|
|
39
|
+
STOP
|
|
40
|
+
|
|
41
|
+
### Step 2: Load Token
|
|
42
|
+
|
|
43
|
+
READ: `~/.prjct-cli/config/notion.json` → extract `token`
|
|
44
|
+
|
|
45
|
+
IF no token:
|
|
46
|
+
OUTPUT: "Notion token not found. Run /p:notion setup first."
|
|
47
|
+
STOP
|
|
48
|
+
|
|
49
|
+
### Step 3: Load Local Data
|
|
50
|
+
|
|
51
|
+
READ: `{globalPath}/storage/shipped.json`
|
|
52
|
+
READ: `{globalPath}/storage/ideas.json`
|
|
53
|
+
|
|
54
|
+
SET: {localShipped} = shipped.shipped array
|
|
55
|
+
SET: {localIdeas} = ideas.ideas array
|
|
56
|
+
|
|
57
|
+
### Step 4: Initialize Notion Client
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { notionClient } from '../core/integrations/notion/client'
|
|
61
|
+
import { bidirectionalSync } from '../core/integrations/notion/sync'
|
|
62
|
+
|
|
63
|
+
notionClient.initialize(config.integrations.notion, token)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Step 5: Execute Bidirectional Sync
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
const results = await bidirectionalSync(
|
|
70
|
+
projectId,
|
|
71
|
+
config.integrations.notion,
|
|
72
|
+
{
|
|
73
|
+
shipped: localShipped,
|
|
74
|
+
ideas: localIdeas
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Step 6: Merge Pull Results with Local Data
|
|
80
|
+
|
|
81
|
+
For shipped features:
|
|
82
|
+
```typescript
|
|
83
|
+
// Merge pulled items with local
|
|
84
|
+
const mergedShipped = [...localShipped]
|
|
85
|
+
|
|
86
|
+
for (const pulled of results.pulled.shipped.items) {
|
|
87
|
+
const existingIndex = mergedShipped.findIndex(
|
|
88
|
+
f => f.id === pulled.id || f.notionPageId === pulled.notionPageId
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if (existingIndex >= 0) {
|
|
92
|
+
// Update existing
|
|
93
|
+
mergedShipped[existingIndex] = pulled
|
|
94
|
+
} else {
|
|
95
|
+
// Add new
|
|
96
|
+
mergedShipped.push(pulled)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Same logic for ideas.
|
|
102
|
+
|
|
103
|
+
### Step 7: Write Updated Data
|
|
104
|
+
|
|
105
|
+
WRITE: `{globalPath}/storage/shipped.json`
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"shipped": {mergedShipped},
|
|
109
|
+
"lastUpdated": "{GetTimestamp()}"
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
WRITE: `{globalPath}/storage/ideas.json`
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"ideas": {mergedIdeas},
|
|
117
|
+
"lastUpdated": "{GetTimestamp()}"
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Step 8: Regenerate Context
|
|
122
|
+
|
|
123
|
+
After updating storage, regenerate markdown context:
|
|
124
|
+
|
|
125
|
+
WRITE: `{globalPath}/context/shipped.md` (from shipped.json)
|
|
126
|
+
WRITE: `{globalPath}/context/ideas.md` (from ideas.json)
|
|
127
|
+
|
|
128
|
+
### Step 9: Log to Memory
|
|
129
|
+
|
|
130
|
+
APPEND to: `{globalPath}/memory/events.jsonl`
|
|
131
|
+
```json
|
|
132
|
+
{"timestamp":"{GetTimestamp()}","action":"notion_sync","pulled":{"shipped":{shippedPulled},"ideas":{ideasPulled}},"pushed":{"shipped":{shippedPushed},"ideas":{ideasPushed}}}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Response (Success)
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
Notion Sync Complete
|
|
139
|
+
|
|
140
|
+
Pulled from Notion:
|
|
141
|
+
- Shipped: {newShipped} new, {updatedShipped} updated
|
|
142
|
+
- Ideas: {newIdeas} new, {updatedIdeas} updated
|
|
143
|
+
|
|
144
|
+
Pushed to Notion:
|
|
145
|
+
- Shipped: {pushedShipped} features
|
|
146
|
+
- Ideas: {pushedIdeas} ideas
|
|
147
|
+
|
|
148
|
+
Local storage updated.
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Response (No Changes)
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
Notion Sync Complete
|
|
155
|
+
|
|
156
|
+
Already in sync - no changes detected.
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Response (Errors)
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
Notion Sync Complete (with warnings)
|
|
163
|
+
|
|
164
|
+
Pulled: 5 shipped, 2 ideas
|
|
165
|
+
Pushed: 3 features
|
|
166
|
+
|
|
167
|
+
Errors:
|
|
168
|
+
- {error1}
|
|
169
|
+
- {error2}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Database Field Mapping
|
|
173
|
+
|
|
174
|
+
### Shipped Features
|
|
175
|
+
|
|
176
|
+
| prjct Field | Notion Property | Type |
|
|
177
|
+
|-------------|-----------------|------|
|
|
178
|
+
| id | prjctId | rich_text |
|
|
179
|
+
| name | Name | title |
|
|
180
|
+
| version | Version | rich_text |
|
|
181
|
+
| shippedAt | Shipped Date | date |
|
|
182
|
+
| type | Type | select |
|
|
183
|
+
| description | Description | rich_text |
|
|
184
|
+
| duration | Duration | rich_text |
|
|
185
|
+
| codeMetrics.linesAdded | Lines Added | number |
|
|
186
|
+
| codeMetrics.linesRemoved | Lines Removed | number |
|
|
187
|
+
| codeMetrics.filesChanged | Files Changed | number |
|
|
188
|
+
| commit.hash | Commit | rich_text |
|
|
189
|
+
| (calculated) | Impact | select |
|
|
190
|
+
| (auto) | Last Updated | date |
|
|
191
|
+
|
|
192
|
+
### Ideas
|
|
193
|
+
|
|
194
|
+
| prjct Field | Notion Property | Type |
|
|
195
|
+
|-------------|-----------------|------|
|
|
196
|
+
| id | prjctId | rich_text |
|
|
197
|
+
| text | Idea | title |
|
|
198
|
+
| status | Status | status |
|
|
199
|
+
| priority | Priority | select |
|
|
200
|
+
| tags | Tags | multi_select |
|
|
201
|
+
| addedAt | Created | date |
|
|
202
|
+
| details | Details | rich_text |
|
|
203
|
+
| impactEffort.impact | Impact | select |
|
|
204
|
+
| impactEffort.effort | Effort | select |
|
|
205
|
+
| convertedTo | Converted To | rich_text |
|
|
206
|
+
| (auto) | Last Updated | date |
|
|
207
|
+
|
|
208
|
+
## Conflict Resolution
|
|
209
|
+
|
|
210
|
+
**Strategy: Last Edit Wins**
|
|
211
|
+
|
|
212
|
+
When the same item exists in both Notion and prjct:
|
|
213
|
+
1. Compare `lastSyncedAt` (prjct) with `Last Updated` (Notion)
|
|
214
|
+
2. Most recent timestamp wins
|
|
215
|
+
3. Update both sides to match
|
|
216
|
+
|
|
217
|
+
**Matching Logic:**
|
|
218
|
+
1. First match by `prjctId` ↔ `id`
|
|
219
|
+
2. If no prjctId, match by `notionPageId` ↔ Notion page ID
|
|
220
|
+
3. If neither, treat as new item
|
|
221
|
+
|
|
222
|
+
## Error Handling
|
|
223
|
+
|
|
224
|
+
| Error | Response | Action |
|
|
225
|
+
|-------|----------|--------|
|
|
226
|
+
| Not configured | "Run /p:notion setup first" | STOP |
|
|
227
|
+
| Token invalid | "Token expired. Re-run setup" | STOP |
|
|
228
|
+
| API rate limit | "Rate limited. Retry in 1 min" | WARN |
|
|
229
|
+
| Parse error | Log warning | CONTINUE |
|
|
230
|
+
| Network error | "Connection failed" | STOP |
|
|
231
|
+
|
|
232
|
+
## Examples
|
|
233
|
+
|
|
234
|
+
### Example 1: First Sync (Push Only)
|
|
235
|
+
```
|
|
236
|
+
/p:notion sync
|
|
237
|
+
|
|
238
|
+
Output:
|
|
239
|
+
Notion Sync Complete
|
|
240
|
+
|
|
241
|
+
Pulled from Notion:
|
|
242
|
+
- Shipped: 0 new, 0 updated
|
|
243
|
+
- Ideas: 0 new, 0 updated
|
|
244
|
+
|
|
245
|
+
Pushed to Notion:
|
|
246
|
+
- Shipped: 11 features
|
|
247
|
+
- Ideas: 3 ideas
|
|
248
|
+
|
|
249
|
+
Local storage updated.
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Example 2: New Ideas from Notion
|
|
253
|
+
```
|
|
254
|
+
/p:notion sync
|
|
255
|
+
|
|
256
|
+
Output:
|
|
257
|
+
Notion Sync Complete
|
|
258
|
+
|
|
259
|
+
Pulled from Notion:
|
|
260
|
+
- Shipped: 0 new, 0 updated
|
|
261
|
+
- Ideas: 2 new, 0 updated
|
|
262
|
+
|
|
263
|
+
Pushed to Notion:
|
|
264
|
+
- Shipped: 0 features
|
|
265
|
+
- Ideas: 0 ideas
|
|
266
|
+
|
|
267
|
+
Local storage updated.
|
|
268
|
+
|
|
269
|
+
New ideas added:
|
|
270
|
+
- "Add dark mode toggle"
|
|
271
|
+
- "Improve error messages"
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Example 3: Bidirectional Changes
|
|
275
|
+
```
|
|
276
|
+
/p:notion sync
|
|
277
|
+
|
|
278
|
+
Output:
|
|
279
|
+
Notion Sync Complete
|
|
280
|
+
|
|
281
|
+
Pulled from Notion:
|
|
282
|
+
- Shipped: 1 new, 2 updated
|
|
283
|
+
- Ideas: 3 new, 1 updated
|
|
284
|
+
|
|
285
|
+
Pushed to Notion:
|
|
286
|
+
- Shipped: 2 features
|
|
287
|
+
- Ideas: 1 idea
|
|
288
|
+
|
|
289
|
+
Local storage updated.
|
|
290
|
+
```
|