kanban-lite 1.0.4
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/.editorconfig +9 -0
- package/.github/workflows/ci.yml +59 -0
- package/.github/workflows/release.yml +75 -0
- package/.prettierignore +6 -0
- package/.prettierrc.yaml +4 -0
- package/.vscode/extensions.json +3 -0
- package/.vscode/launch.json +17 -0
- package/.vscode/settings.json +21 -0
- package/.vscode/tasks.json +22 -0
- package/.vscodeignore +11 -0
- package/CHANGELOG.md +184 -0
- package/CLAUDE.md +58 -0
- package/CONTRIBUTING.md +114 -0
- package/LICENSE +22 -0
- package/README.md +482 -0
- package/SKILL.md +237 -0
- package/dist/cli.js +8716 -0
- package/dist/extension.js +8463 -0
- package/dist/mcp-server.js +1327 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/standalone-webview/index.js +85 -0
- package/dist/standalone-webview/index.js.map +1 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/standalone-webview/style.css +1 -0
- package/dist/standalone.js +7513 -0
- package/dist/webview/icons-Dx9MGYqN.js +180 -0
- package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/webview/index.js +85 -0
- package/dist/webview/index.js.map +1 -0
- package/dist/webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/webview/style.css +1 -0
- package/docs/images/board-overview.png +0 -0
- package/docs/images/editor-view.png +0 -0
- package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
- package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
- package/eslint.config.mjs +31 -0
- package/package.json +161 -0
- package/postcss.config.js +6 -0
- package/resources/icon-light.png +0 -0
- package/resources/icon-light.svg +105 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +105 -0
- package/resources/kanban-dark.svg +21 -0
- package/resources/kanban-light.svg +21 -0
- package/resources/kanban.svg +21 -0
- package/src/cli/index.ts +846 -0
- package/src/extension/FeatureHeaderProvider.ts +370 -0
- package/src/extension/KanbanPanel.ts +973 -0
- package/src/extension/SidebarViewProvider.ts +507 -0
- package/src/extension/featureFileUtils.ts +82 -0
- package/src/extension/index.ts +234 -0
- package/src/mcp-server/index.ts +632 -0
- package/src/sdk/KanbanSDK.ts +349 -0
- package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
- package/src/sdk/__tests__/parser.test.ts +170 -0
- package/src/sdk/fileUtils.ts +76 -0
- package/src/sdk/index.ts +6 -0
- package/src/sdk/parser.ts +70 -0
- package/src/sdk/types.ts +15 -0
- package/src/shared/config.ts +113 -0
- package/src/shared/editorTypes.ts +14 -0
- package/src/shared/types.ts +120 -0
- package/src/standalone/__tests__/server.integration.test.ts +1916 -0
- package/src/standalone/__tests__/webhooks.test.ts +357 -0
- package/src/standalone/fileUtils.ts +70 -0
- package/src/standalone/index.ts +71 -0
- package/src/standalone/server.ts +1046 -0
- package/src/standalone/webhooks.ts +135 -0
- package/src/webview/App.tsx +469 -0
- package/src/webview/assets/main.css +329 -0
- package/src/webview/assets/standalone-theme.css +130 -0
- package/src/webview/components/ColumnDialog.tsx +119 -0
- package/src/webview/components/CreateFeatureDialog.tsx +524 -0
- package/src/webview/components/DatePicker.tsx +185 -0
- package/src/webview/components/FeatureCard.tsx +186 -0
- package/src/webview/components/FeatureEditor.tsx +623 -0
- package/src/webview/components/KanbanBoard.tsx +144 -0
- package/src/webview/components/KanbanColumn.tsx +159 -0
- package/src/webview/components/MarkdownEditor.tsx +291 -0
- package/src/webview/components/PrioritySelect.tsx +39 -0
- package/src/webview/components/QuickAddInput.tsx +72 -0
- package/src/webview/components/SettingsPanel.tsx +284 -0
- package/src/webview/components/Toolbar.tsx +175 -0
- package/src/webview/components/UndoToast.tsx +70 -0
- package/src/webview/index.html +12 -0
- package/src/webview/lib/utils.ts +6 -0
- package/src/webview/main.tsx +11 -0
- package/src/webview/standalone-main.tsx +13 -0
- package/src/webview/standalone-shim.ts +132 -0
- package/src/webview/standalone.html +12 -0
- package/src/webview/store/index.ts +241 -0
- package/tailwind.config.js +53 -0
- package/tsconfig.json +22 -0
- package/vite.config.ts +36 -0
- package/vite.standalone.config.ts +62 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
import * as http from 'http'
|
|
2
|
+
import * as fs from 'fs'
|
|
3
|
+
import * as path from 'path'
|
|
4
|
+
import { WebSocketServer, WebSocket } from 'ws'
|
|
5
|
+
import chokidar from 'chokidar'
|
|
6
|
+
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'
|
|
7
|
+
import { getTitleFromContent, generateFeatureFilename, extractNumericId } from '../shared/types'
|
|
8
|
+
import type { Feature, FeatureStatus, Priority, KanbanColumn, FeatureFrontmatter, CardDisplaySettings } from '../shared/types'
|
|
9
|
+
import { readConfig, writeConfig, configToSettings, settingsToConfig, allocateCardId, syncCardIdCounter } from '../shared/config'
|
|
10
|
+
import type { KanbanConfig } from '../shared/config'
|
|
11
|
+
import { parseFeatureFile, serializeFeature } from '../sdk/parser'
|
|
12
|
+
import { ensureStatusSubfolders, moveFeatureFile, renameFeatureFile, getFeatureFilePath, getStatusFromPath } from './fileUtils'
|
|
13
|
+
import { fireWebhooks, loadWebhooks, createWebhook, deleteWebhook } from './webhooks'
|
|
14
|
+
|
|
15
|
+
interface CreateFeatureData {
|
|
16
|
+
status: FeatureStatus
|
|
17
|
+
priority: Priority
|
|
18
|
+
content: string
|
|
19
|
+
assignee: string | null
|
|
20
|
+
dueDate: string | null
|
|
21
|
+
labels: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MIME_TYPES: Record<string, string> = {
|
|
25
|
+
'.html': 'text/html',
|
|
26
|
+
'.js': 'text/javascript',
|
|
27
|
+
'.css': 'text/css',
|
|
28
|
+
'.json': 'application/json',
|
|
29
|
+
'.png': 'image/png',
|
|
30
|
+
'.svg': 'image/svg+xml',
|
|
31
|
+
'.ico': 'image/x-icon',
|
|
32
|
+
'.map': 'application/json'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
export function startServer(featuresDir: string, port: number, webviewDir?: string): http.Server {
|
|
37
|
+
const absoluteFeaturesDir = path.resolve(featuresDir)
|
|
38
|
+
let features: Feature[] = []
|
|
39
|
+
let migrating = false
|
|
40
|
+
let currentEditingFeatureId: string | null = null
|
|
41
|
+
let lastWrittenContent = ''
|
|
42
|
+
|
|
43
|
+
// Resolve webview static files directory
|
|
44
|
+
const resolvedWebviewDir = webviewDir || path.join(__dirname, 'standalone-webview')
|
|
45
|
+
|
|
46
|
+
// Derive workspace root from features directory
|
|
47
|
+
const workspaceRoot = path.dirname(absoluteFeaturesDir)
|
|
48
|
+
|
|
49
|
+
function getConfig(): KanbanConfig {
|
|
50
|
+
return readConfig(workspaceRoot)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function saveConfigFile(config: KanbanConfig): void {
|
|
54
|
+
writeConfig(workspaceRoot, config)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Helpers ---
|
|
58
|
+
|
|
59
|
+
function sanitizeFeature(feature: Feature): Omit<Feature, 'filePath'> {
|
|
60
|
+
const { filePath: _, ...rest } = feature
|
|
61
|
+
return rest
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const chunks: Buffer[] = []
|
|
67
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
68
|
+
req.on('end', () => {
|
|
69
|
+
try {
|
|
70
|
+
const text = Buffer.concat(chunks).toString('utf-8')
|
|
71
|
+
resolve(text ? JSON.parse(text) : {})
|
|
72
|
+
} catch (err) {
|
|
73
|
+
reject(err)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
req.on('error', reject)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function matchRoute(
|
|
81
|
+
expectedMethod: string,
|
|
82
|
+
actualMethod: string,
|
|
83
|
+
pathname: string,
|
|
84
|
+
pattern: string
|
|
85
|
+
): Record<string, string> | null {
|
|
86
|
+
if (expectedMethod !== actualMethod) return null
|
|
87
|
+
const patternParts = pattern.split('/')
|
|
88
|
+
const pathParts = pathname.split('/')
|
|
89
|
+
if (patternParts.length !== pathParts.length) return null
|
|
90
|
+
const params: Record<string, string> = {}
|
|
91
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
92
|
+
if (patternParts[i].startsWith(':')) {
|
|
93
|
+
params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i])
|
|
94
|
+
} else if (patternParts[i] !== pathParts[i]) {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return params
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function jsonOk(res: http.ServerResponse, data: unknown, status = 200): void {
|
|
102
|
+
res.writeHead(status, {
|
|
103
|
+
'Content-Type': 'application/json',
|
|
104
|
+
'Access-Control-Allow-Origin': '*'
|
|
105
|
+
})
|
|
106
|
+
res.end(JSON.stringify({ ok: true, data }))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function jsonError(res: http.ServerResponse, status: number, error: string): void {
|
|
110
|
+
res.writeHead(status, {
|
|
111
|
+
'Content-Type': 'application/json',
|
|
112
|
+
'Access-Control-Allow-Origin': '*'
|
|
113
|
+
})
|
|
114
|
+
res.end(JSON.stringify({ ok: false, error }))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- HTML template ---
|
|
118
|
+
const indexHtml = `<!DOCTYPE html>
|
|
119
|
+
<html lang="en">
|
|
120
|
+
<head>
|
|
121
|
+
<meta charset="UTF-8">
|
|
122
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
123
|
+
<link href="/style.css" rel="stylesheet">
|
|
124
|
+
<title>Kanban Board</title>
|
|
125
|
+
</head>
|
|
126
|
+
<body>
|
|
127
|
+
<div id="root"></div>
|
|
128
|
+
<script type="module" src="/index.js"></script>
|
|
129
|
+
</body>
|
|
130
|
+
</html>`
|
|
131
|
+
|
|
132
|
+
// --- Feature loading ---
|
|
133
|
+
|
|
134
|
+
function loadFeatures(): void {
|
|
135
|
+
fs.mkdirSync(absoluteFeaturesDir, { recursive: true })
|
|
136
|
+
ensureStatusSubfolders(absoluteFeaturesDir, getConfig().columns.map(c => c.id))
|
|
137
|
+
|
|
138
|
+
// Phase 1: Migrate flat root .md files into their status subfolder
|
|
139
|
+
migrating = true
|
|
140
|
+
try {
|
|
141
|
+
const rootEntries = fs.readdirSync(absoluteFeaturesDir, { withFileTypes: true })
|
|
142
|
+
for (const entry of rootEntries) {
|
|
143
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
144
|
+
const filePath = path.join(absoluteFeaturesDir, entry.name)
|
|
145
|
+
try {
|
|
146
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
147
|
+
const feature = parseFeatureFile(content, filePath)
|
|
148
|
+
if (feature) {
|
|
149
|
+
moveFeatureFile(filePath, absoluteFeaturesDir, feature.status, feature.attachments)
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// skip
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
migrating = false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Phase 2: Load .md files from ALL subdirectories
|
|
160
|
+
const loaded: Feature[] = []
|
|
161
|
+
const topEntries = fs.readdirSync(absoluteFeaturesDir, { withFileTypes: true })
|
|
162
|
+
for (const entry of topEntries) {
|
|
163
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
164
|
+
const subdir = path.join(absoluteFeaturesDir, entry.name)
|
|
165
|
+
try {
|
|
166
|
+
const subEntries = fs.readdirSync(subdir, { withFileTypes: true })
|
|
167
|
+
for (const sub of subEntries) {
|
|
168
|
+
if (!sub.isFile() || !sub.name.endsWith('.md')) continue
|
|
169
|
+
const filePath = path.join(subdir, sub.name)
|
|
170
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
171
|
+
const feature = parseFeatureFile(content, filePath)
|
|
172
|
+
if (feature) loaded.push(feature)
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// skip unreadable directories
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Phase 3: Reconcile status ↔ folder mismatches
|
|
180
|
+
migrating = true
|
|
181
|
+
try {
|
|
182
|
+
for (const feature of loaded) {
|
|
183
|
+
const pathStatus = getStatusFromPath(feature.filePath, absoluteFeaturesDir)
|
|
184
|
+
if (pathStatus !== null && pathStatus !== feature.status) {
|
|
185
|
+
try {
|
|
186
|
+
feature.filePath = moveFeatureFile(feature.filePath, absoluteFeaturesDir, feature.status, feature.attachments)
|
|
187
|
+
} catch { /* retry next load */ }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
migrating = false
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Migrate legacy integer order → fractional indices
|
|
195
|
+
const hasLegacyOrder = loaded.some(f => /^\d+$/.test(f.order))
|
|
196
|
+
if (hasLegacyOrder) {
|
|
197
|
+
const byStatus = new Map<string, Feature[]>()
|
|
198
|
+
for (const f of loaded) {
|
|
199
|
+
const list = byStatus.get(f.status) || []
|
|
200
|
+
list.push(f)
|
|
201
|
+
byStatus.set(f.status, list)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const columnFeatures of byStatus.values()) {
|
|
205
|
+
columnFeatures.sort((a, b) => parseInt(a.order) - parseInt(b.order))
|
|
206
|
+
const keys = generateNKeysBetween(null, null, columnFeatures.length)
|
|
207
|
+
for (let i = 0; i < columnFeatures.length; i++) {
|
|
208
|
+
columnFeatures[i].order = keys[i]
|
|
209
|
+
const content = serializeFeature(columnFeatures[i])
|
|
210
|
+
fs.writeFileSync(columnFeatures[i].filePath, content, 'utf-8')
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Sync ID counter with existing cards
|
|
216
|
+
const numericIds = loaded
|
|
217
|
+
.map(f => parseInt(f.id, 10))
|
|
218
|
+
.filter(n => !Number.isNaN(n))
|
|
219
|
+
if (numericIds.length > 0) {
|
|
220
|
+
syncCardIdCounter(workspaceRoot, numericIds)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
features = loaded.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- Message building & broadcast ---
|
|
227
|
+
|
|
228
|
+
function buildInitMessage(): unknown {
|
|
229
|
+
const config = getConfig()
|
|
230
|
+
const settings = configToSettings(config)
|
|
231
|
+
settings.showBuildWithAI = false
|
|
232
|
+
settings.markdownEditorMode = false
|
|
233
|
+
return {
|
|
234
|
+
type: 'init',
|
|
235
|
+
features,
|
|
236
|
+
columns: config.columns,
|
|
237
|
+
settings
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function broadcast(message: unknown): void {
|
|
242
|
+
const json = JSON.stringify(message)
|
|
243
|
+
for (const client of wss.clients) {
|
|
244
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
245
|
+
client.send(json)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// --- Mutation functions ---
|
|
251
|
+
// Shared by both WebSocket handlers and REST API routes.
|
|
252
|
+
|
|
253
|
+
function doCreateFeature(data: CreateFeatureData): Feature {
|
|
254
|
+
fs.mkdirSync(absoluteFeaturesDir, { recursive: true })
|
|
255
|
+
ensureStatusSubfolders(absoluteFeaturesDir, getConfig().columns.map(c => c.id))
|
|
256
|
+
|
|
257
|
+
const title = getTitleFromContent(data.content)
|
|
258
|
+
const numericId = allocateCardId(workspaceRoot)
|
|
259
|
+
const filename = generateFeatureFilename(numericId, title)
|
|
260
|
+
const now = new Date().toISOString()
|
|
261
|
+
const featuresInStatus = features
|
|
262
|
+
.filter(f => f.status === data.status)
|
|
263
|
+
.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
264
|
+
const lastOrder = featuresInStatus.length > 0 ? featuresInStatus[featuresInStatus.length - 1].order : null
|
|
265
|
+
|
|
266
|
+
const feature: Feature = {
|
|
267
|
+
id: String(numericId),
|
|
268
|
+
status: data.status,
|
|
269
|
+
priority: data.priority,
|
|
270
|
+
assignee: data.assignee,
|
|
271
|
+
dueDate: data.dueDate,
|
|
272
|
+
created: now,
|
|
273
|
+
modified: now,
|
|
274
|
+
completedAt: data.status === 'done' ? now : null,
|
|
275
|
+
labels: data.labels,
|
|
276
|
+
attachments: [],
|
|
277
|
+
order: generateKeyBetween(lastOrder, null),
|
|
278
|
+
content: data.content,
|
|
279
|
+
filePath: getFeatureFilePath(absoluteFeaturesDir, data.status, filename)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fs.mkdirSync(path.dirname(feature.filePath), { recursive: true })
|
|
283
|
+
const content = serializeFeature(feature)
|
|
284
|
+
fs.writeFileSync(feature.filePath, content, 'utf-8')
|
|
285
|
+
|
|
286
|
+
features.push(feature)
|
|
287
|
+
broadcast(buildInitMessage())
|
|
288
|
+
fireWebhooks(workspaceRoot, 'task.created', sanitizeFeature(feature))
|
|
289
|
+
return feature
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function doMoveFeature(featureId: string, newStatus: string, newOrder: number): Feature | null {
|
|
293
|
+
const feature = features.find(f => f.id === featureId)
|
|
294
|
+
if (!feature) return null
|
|
295
|
+
|
|
296
|
+
const oldStatus = feature.status
|
|
297
|
+
const statusChanged = oldStatus !== newStatus
|
|
298
|
+
|
|
299
|
+
feature.status = newStatus as FeatureStatus
|
|
300
|
+
feature.modified = new Date().toISOString()
|
|
301
|
+
if (statusChanged) {
|
|
302
|
+
feature.completedAt = newStatus === 'done' ? new Date().toISOString() : null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const targetColumnFeatures = features
|
|
306
|
+
.filter(f => f.status === newStatus && f.id !== featureId)
|
|
307
|
+
.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
308
|
+
|
|
309
|
+
const clampedOrder = Math.max(0, Math.min(newOrder, targetColumnFeatures.length))
|
|
310
|
+
const before = clampedOrder > 0 ? targetColumnFeatures[clampedOrder - 1].order : null
|
|
311
|
+
const after = clampedOrder < targetColumnFeatures.length ? targetColumnFeatures[clampedOrder].order : null
|
|
312
|
+
feature.order = generateKeyBetween(before, after)
|
|
313
|
+
|
|
314
|
+
const content = serializeFeature(feature)
|
|
315
|
+
fs.writeFileSync(feature.filePath, content, 'utf-8')
|
|
316
|
+
|
|
317
|
+
if (statusChanged) {
|
|
318
|
+
migrating = true
|
|
319
|
+
try {
|
|
320
|
+
feature.filePath = moveFeatureFile(feature.filePath, absoluteFeaturesDir, newStatus, feature.attachments)
|
|
321
|
+
} catch {
|
|
322
|
+
// retry next load
|
|
323
|
+
} finally {
|
|
324
|
+
migrating = false
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
broadcast(buildInitMessage())
|
|
329
|
+
fireWebhooks(workspaceRoot, 'task.moved', {
|
|
330
|
+
...sanitizeFeature(feature),
|
|
331
|
+
previousStatus: oldStatus
|
|
332
|
+
})
|
|
333
|
+
return feature
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function doUpdateFeature(featureId: string, updates: Partial<Feature>): Feature | null {
|
|
337
|
+
const feature = features.find(f => f.id === featureId)
|
|
338
|
+
if (!feature) return null
|
|
339
|
+
|
|
340
|
+
const oldStatus = feature.status
|
|
341
|
+
const oldTitle = getTitleFromContent(feature.content)
|
|
342
|
+
const { filePath: _fp, id: _id, ...safeUpdates } = updates
|
|
343
|
+
Object.assign(feature, safeUpdates)
|
|
344
|
+
feature.modified = new Date().toISOString()
|
|
345
|
+
if (oldStatus !== feature.status) {
|
|
346
|
+
feature.completedAt = feature.status === 'done' ? new Date().toISOString() : null
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const content = serializeFeature(feature)
|
|
350
|
+
fs.writeFileSync(feature.filePath, content, 'utf-8')
|
|
351
|
+
|
|
352
|
+
// Rename file if title changed (numeric-ID cards only)
|
|
353
|
+
const newTitle = getTitleFromContent(feature.content)
|
|
354
|
+
const numId = extractNumericId(feature.id)
|
|
355
|
+
if (numId !== null && newTitle !== oldTitle) {
|
|
356
|
+
const newFilename = generateFeatureFilename(numId, newTitle)
|
|
357
|
+
migrating = true
|
|
358
|
+
try {
|
|
359
|
+
feature.filePath = renameFeatureFile(feature.filePath, newFilename)
|
|
360
|
+
} catch { /* retry next load */ } finally { migrating = false }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (oldStatus !== feature.status) {
|
|
364
|
+
migrating = true
|
|
365
|
+
try {
|
|
366
|
+
feature.filePath = moveFeatureFile(feature.filePath, absoluteFeaturesDir, feature.status, feature.attachments)
|
|
367
|
+
} catch {
|
|
368
|
+
// retry next load
|
|
369
|
+
} finally {
|
|
370
|
+
migrating = false
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
broadcast(buildInitMessage())
|
|
375
|
+
fireWebhooks(workspaceRoot, 'task.updated', sanitizeFeature(feature))
|
|
376
|
+
return feature
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function doDeleteFeature(featureId: string): boolean {
|
|
380
|
+
const feature = features.find(f => f.id === featureId)
|
|
381
|
+
if (!feature) return false
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
fs.unlinkSync(feature.filePath)
|
|
385
|
+
const deleted = sanitizeFeature(feature)
|
|
386
|
+
features = features.filter(f => f.id !== featureId)
|
|
387
|
+
broadcast(buildInitMessage())
|
|
388
|
+
fireWebhooks(workspaceRoot, 'task.deleted', deleted)
|
|
389
|
+
return true
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.error('Failed to delete feature:', err)
|
|
392
|
+
return false
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function doAddColumn(name: string, color: string): KanbanColumn {
|
|
397
|
+
const config = getConfig()
|
|
398
|
+
const columns = config.columns
|
|
399
|
+
const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
400
|
+
let uniqueId = id
|
|
401
|
+
let counter = 1
|
|
402
|
+
while (columns.some(c => c.id === uniqueId)) {
|
|
403
|
+
uniqueId = `${id}-${counter++}`
|
|
404
|
+
}
|
|
405
|
+
const column: KanbanColumn = { id: uniqueId, name, color }
|
|
406
|
+
columns.push(column)
|
|
407
|
+
saveConfigFile({ ...config, columns })
|
|
408
|
+
broadcast(buildInitMessage())
|
|
409
|
+
fireWebhooks(workspaceRoot, 'column.created', column)
|
|
410
|
+
return column
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function doEditColumn(columnId: string, updates: { name: string; color: string }): KanbanColumn | null {
|
|
414
|
+
const config = getConfig()
|
|
415
|
+
const col = config.columns.find(c => c.id === columnId)
|
|
416
|
+
if (!col) return null
|
|
417
|
+
const columns = config.columns.map(c =>
|
|
418
|
+
c.id === columnId ? { ...c, name: updates.name, color: updates.color } : c
|
|
419
|
+
)
|
|
420
|
+
saveConfigFile({ ...config, columns })
|
|
421
|
+
const updated = columns.find(c => c.id === columnId) ?? col
|
|
422
|
+
broadcast(buildInitMessage())
|
|
423
|
+
fireWebhooks(workspaceRoot, 'column.updated', updated)
|
|
424
|
+
return updated
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function doRemoveColumn(columnId: string): { removed: boolean; error?: string } {
|
|
428
|
+
const hasFeatures = features.some(f => f.status === columnId)
|
|
429
|
+
if (hasFeatures) return { removed: false, error: 'Column has tasks' }
|
|
430
|
+
const config = getConfig()
|
|
431
|
+
const col = config.columns.find(c => c.id === columnId)
|
|
432
|
+
if (!col) return { removed: false, error: 'Column not found' }
|
|
433
|
+
const columns = config.columns.filter(c => c.id !== columnId)
|
|
434
|
+
if (columns.length === 0) return { removed: false, error: 'Cannot remove last column' }
|
|
435
|
+
saveConfigFile({ ...config, columns })
|
|
436
|
+
broadcast(buildInitMessage())
|
|
437
|
+
fireWebhooks(workspaceRoot, 'column.deleted', col)
|
|
438
|
+
return { removed: true }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function doSaveSettings(newSettings: CardDisplaySettings): void {
|
|
442
|
+
const config = getConfig()
|
|
443
|
+
saveConfigFile(settingsToConfig(config, newSettings))
|
|
444
|
+
broadcast(buildInitMessage())
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function doAddAttachment(featureId: string, filename: string, fileData: Buffer): boolean {
|
|
448
|
+
const feature = features.find(f => f.id === featureId)
|
|
449
|
+
if (!feature) return false
|
|
450
|
+
|
|
451
|
+
const featureDir = path.dirname(feature.filePath)
|
|
452
|
+
const destPath = path.join(featureDir, filename)
|
|
453
|
+
fs.writeFileSync(destPath, fileData)
|
|
454
|
+
|
|
455
|
+
if (!feature.attachments) feature.attachments = []
|
|
456
|
+
if (!feature.attachments.includes(filename)) {
|
|
457
|
+
feature.attachments.push(filename)
|
|
458
|
+
}
|
|
459
|
+
feature.modified = new Date().toISOString()
|
|
460
|
+
const content = serializeFeature(feature)
|
|
461
|
+
lastWrittenContent = content
|
|
462
|
+
fs.writeFileSync(feature.filePath, content, 'utf-8')
|
|
463
|
+
return true
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function doRemoveAttachment(featureId: string, attachment: string): Feature | null {
|
|
467
|
+
const feature = features.find(f => f.id === featureId)
|
|
468
|
+
if (!feature) return null
|
|
469
|
+
|
|
470
|
+
feature.attachments = (feature.attachments || []).filter(a => a !== attachment)
|
|
471
|
+
feature.modified = new Date().toISOString()
|
|
472
|
+
const fileContent = serializeFeature(feature)
|
|
473
|
+
lastWrittenContent = fileContent
|
|
474
|
+
fs.writeFileSync(feature.filePath, fileContent, 'utf-8')
|
|
475
|
+
|
|
476
|
+
broadcast(buildInitMessage())
|
|
477
|
+
return feature
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// --- WebSocket message handling ---
|
|
481
|
+
|
|
482
|
+
async function handleMessage(ws: WebSocket, message: unknown): Promise<void> {
|
|
483
|
+
const msg = message as Record<string, unknown>
|
|
484
|
+
switch (msg.type) {
|
|
485
|
+
case 'ready':
|
|
486
|
+
loadFeatures()
|
|
487
|
+
ws.send(JSON.stringify(buildInitMessage()))
|
|
488
|
+
break
|
|
489
|
+
|
|
490
|
+
case 'createFeature':
|
|
491
|
+
doCreateFeature(msg.data as CreateFeatureData)
|
|
492
|
+
break
|
|
493
|
+
|
|
494
|
+
case 'moveFeature':
|
|
495
|
+
doMoveFeature(msg.featureId as string, msg.newStatus as string, msg.newOrder as number)
|
|
496
|
+
break
|
|
497
|
+
|
|
498
|
+
case 'deleteFeature':
|
|
499
|
+
doDeleteFeature(msg.featureId as string)
|
|
500
|
+
break
|
|
501
|
+
|
|
502
|
+
case 'updateFeature':
|
|
503
|
+
doUpdateFeature(msg.featureId as string, msg.updates as Partial<Feature>)
|
|
504
|
+
break
|
|
505
|
+
|
|
506
|
+
case 'openFeature': {
|
|
507
|
+
const featureId = msg.featureId as string
|
|
508
|
+
const feature = features.find(f => f.id === featureId)
|
|
509
|
+
if (!feature) break
|
|
510
|
+
|
|
511
|
+
currentEditingFeatureId = featureId
|
|
512
|
+
const frontmatter: FeatureFrontmatter = {
|
|
513
|
+
id: feature.id, status: feature.status, priority: feature.priority,
|
|
514
|
+
assignee: feature.assignee, dueDate: feature.dueDate, created: feature.created,
|
|
515
|
+
modified: feature.modified, completedAt: feature.completedAt,
|
|
516
|
+
labels: feature.labels, attachments: feature.attachments, order: feature.order
|
|
517
|
+
}
|
|
518
|
+
ws.send(JSON.stringify({ type: 'featureContent', featureId: feature.id, content: feature.content, frontmatter }))
|
|
519
|
+
break
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
case 'saveFeatureContent': {
|
|
523
|
+
const featureId = msg.featureId as string
|
|
524
|
+
const newContent = msg.content as string
|
|
525
|
+
const fm = msg.frontmatter as FeatureFrontmatter
|
|
526
|
+
const feature = features.find(f => f.id === featureId)
|
|
527
|
+
if (!feature) break
|
|
528
|
+
|
|
529
|
+
const oldStatus = feature.status
|
|
530
|
+
const oldTitle = getTitleFromContent(feature.content)
|
|
531
|
+
feature.content = newContent
|
|
532
|
+
feature.status = fm.status
|
|
533
|
+
feature.priority = fm.priority
|
|
534
|
+
feature.assignee = fm.assignee
|
|
535
|
+
feature.dueDate = fm.dueDate
|
|
536
|
+
feature.labels = fm.labels
|
|
537
|
+
feature.attachments = fm.attachments || feature.attachments || []
|
|
538
|
+
feature.modified = new Date().toISOString()
|
|
539
|
+
if (oldStatus !== feature.status) {
|
|
540
|
+
feature.completedAt = feature.status === 'done' ? new Date().toISOString() : null
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const fileContent = serializeFeature(feature)
|
|
544
|
+
lastWrittenContent = fileContent
|
|
545
|
+
fs.writeFileSync(feature.filePath, fileContent, 'utf-8')
|
|
546
|
+
|
|
547
|
+
// Rename file if title changed (numeric-ID cards only)
|
|
548
|
+
const saveNewTitle = getTitleFromContent(feature.content)
|
|
549
|
+
const saveNumId = extractNumericId(feature.id)
|
|
550
|
+
if (saveNumId !== null && saveNewTitle !== oldTitle) {
|
|
551
|
+
const newFilename = generateFeatureFilename(saveNumId, saveNewTitle)
|
|
552
|
+
migrating = true
|
|
553
|
+
try {
|
|
554
|
+
feature.filePath = renameFeatureFile(feature.filePath, newFilename)
|
|
555
|
+
} catch { /* retry next load */ } finally { migrating = false }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (oldStatus !== feature.status) {
|
|
559
|
+
migrating = true
|
|
560
|
+
try {
|
|
561
|
+
feature.filePath = moveFeatureFile(feature.filePath, absoluteFeaturesDir, feature.status, feature.attachments)
|
|
562
|
+
} catch { /* retry next load */ } finally { migrating = false }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
broadcast(buildInitMessage())
|
|
566
|
+
fireWebhooks(workspaceRoot, 'task.updated', sanitizeFeature(feature))
|
|
567
|
+
break
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
case 'closeFeature':
|
|
571
|
+
currentEditingFeatureId = null
|
|
572
|
+
break
|
|
573
|
+
|
|
574
|
+
case 'openSettings': {
|
|
575
|
+
const config = getConfig()
|
|
576
|
+
const settings = configToSettings(config)
|
|
577
|
+
settings.showBuildWithAI = false
|
|
578
|
+
settings.markdownEditorMode = false
|
|
579
|
+
ws.send(JSON.stringify({ type: 'showSettings', settings }))
|
|
580
|
+
break
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
case 'saveSettings':
|
|
584
|
+
doSaveSettings(msg.settings as CardDisplaySettings)
|
|
585
|
+
break
|
|
586
|
+
|
|
587
|
+
case 'addColumn': {
|
|
588
|
+
const col = msg.column as { name: string; color: string }
|
|
589
|
+
doAddColumn(col.name, col.color)
|
|
590
|
+
break
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
case 'editColumn':
|
|
594
|
+
doEditColumn(msg.columnId as string, msg.updates as { name: string; color: string })
|
|
595
|
+
break
|
|
596
|
+
|
|
597
|
+
case 'removeColumn':
|
|
598
|
+
doRemoveColumn(msg.columnId as string)
|
|
599
|
+
break
|
|
600
|
+
|
|
601
|
+
case 'removeAttachment': {
|
|
602
|
+
const featureId = msg.featureId as string
|
|
603
|
+
const feature = doRemoveAttachment(featureId, msg.attachment as string)
|
|
604
|
+
if (feature && currentEditingFeatureId === featureId) {
|
|
605
|
+
const frontmatter: FeatureFrontmatter = {
|
|
606
|
+
id: feature.id, status: feature.status, priority: feature.priority,
|
|
607
|
+
assignee: feature.assignee, dueDate: feature.dueDate, created: feature.created,
|
|
608
|
+
modified: feature.modified, completedAt: feature.completedAt,
|
|
609
|
+
labels: feature.labels, attachments: feature.attachments, order: feature.order
|
|
610
|
+
}
|
|
611
|
+
ws.send(JSON.stringify({ type: 'featureContent', featureId: feature.id, content: feature.content, frontmatter }))
|
|
612
|
+
}
|
|
613
|
+
break
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// VSCode-specific actions — no-ops in standalone
|
|
617
|
+
case 'openFile':
|
|
618
|
+
case 'focusMenuBar':
|
|
619
|
+
case 'startWithAI':
|
|
620
|
+
case 'addAttachment':
|
|
621
|
+
case 'openAttachment':
|
|
622
|
+
break
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// --- HTTP server ---
|
|
627
|
+
|
|
628
|
+
const server = http.createServer(async (req, res) => {
|
|
629
|
+
const url = new URL(req.url || '/', `http://${req.headers.host}`)
|
|
630
|
+
const { pathname } = url
|
|
631
|
+
const method = req.method || 'GET'
|
|
632
|
+
|
|
633
|
+
// CORS preflight
|
|
634
|
+
if (method === 'OPTIONS') {
|
|
635
|
+
res.writeHead(204, {
|
|
636
|
+
'Access-Control-Allow-Origin': '*',
|
|
637
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
|
638
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
639
|
+
'Access-Control-Max-Age': '86400'
|
|
640
|
+
})
|
|
641
|
+
res.end()
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Route helper: match and extract params, then handle
|
|
646
|
+
const route = (expectedMethod: string, pattern: string): Record<string, string> | null =>
|
|
647
|
+
matchRoute(expectedMethod, method, pathname, pattern)
|
|
648
|
+
|
|
649
|
+
// ==================== TASKS API ====================
|
|
650
|
+
|
|
651
|
+
let params = route('GET', '/api/tasks')
|
|
652
|
+
if (params) {
|
|
653
|
+
loadFeatures()
|
|
654
|
+
let result = features.map(sanitizeFeature)
|
|
655
|
+
const status = url.searchParams.get('status')
|
|
656
|
+
if (status) result = result.filter(f => f.status === status)
|
|
657
|
+
const priority = url.searchParams.get('priority')
|
|
658
|
+
if (priority) result = result.filter(f => f.priority === priority)
|
|
659
|
+
const assignee = url.searchParams.get('assignee')
|
|
660
|
+
if (assignee) result = result.filter(f => f.assignee === assignee)
|
|
661
|
+
const label = url.searchParams.get('label')
|
|
662
|
+
if (label) result = result.filter(f => f.labels.includes(label))
|
|
663
|
+
return jsonOk(res, result)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
params = route('POST', '/api/tasks')
|
|
667
|
+
if (params) {
|
|
668
|
+
try {
|
|
669
|
+
const body = await readBody(req)
|
|
670
|
+
const data: CreateFeatureData = {
|
|
671
|
+
content: (body.content as string) || '',
|
|
672
|
+
status: (body.status as FeatureStatus) || 'backlog',
|
|
673
|
+
priority: (body.priority as Priority) || 'medium',
|
|
674
|
+
assignee: (body.assignee as string) || null,
|
|
675
|
+
dueDate: (body.dueDate as string) || null,
|
|
676
|
+
labels: (body.labels as string[]) || []
|
|
677
|
+
}
|
|
678
|
+
if (!data.content) return jsonError(res, 400, 'content is required')
|
|
679
|
+
const feature = doCreateFeature(data)
|
|
680
|
+
return jsonOk(res, sanitizeFeature(feature), 201)
|
|
681
|
+
} catch (err) {
|
|
682
|
+
return jsonError(res, 400, String(err))
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
params = route('GET', '/api/tasks/:id')
|
|
687
|
+
if (params) {
|
|
688
|
+
const { id } = params
|
|
689
|
+
const feature = features.find(f => f.id === id)
|
|
690
|
+
if (!feature) return jsonError(res, 404, 'Task not found')
|
|
691
|
+
return jsonOk(res, sanitizeFeature(feature))
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
params = route('PUT', '/api/tasks/:id')
|
|
695
|
+
if (params) {
|
|
696
|
+
try {
|
|
697
|
+
const { id } = params
|
|
698
|
+
const body = await readBody(req)
|
|
699
|
+
const feature = doUpdateFeature(id, body as Partial<Feature>)
|
|
700
|
+
if (!feature) return jsonError(res, 404, 'Task not found')
|
|
701
|
+
return jsonOk(res, sanitizeFeature(feature))
|
|
702
|
+
} catch (err) {
|
|
703
|
+
return jsonError(res, 400, String(err))
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
params = route('PATCH', '/api/tasks/:id/move')
|
|
708
|
+
if (params) {
|
|
709
|
+
try {
|
|
710
|
+
const { id } = params
|
|
711
|
+
const body = await readBody(req)
|
|
712
|
+
const newStatus = body.status as string
|
|
713
|
+
const position = body.position as number ?? 0
|
|
714
|
+
if (!newStatus) return jsonError(res, 400, 'status is required')
|
|
715
|
+
const feature = doMoveFeature(id, newStatus, position)
|
|
716
|
+
if (!feature) return jsonError(res, 404, 'Task not found')
|
|
717
|
+
return jsonOk(res, sanitizeFeature(feature))
|
|
718
|
+
} catch (err) {
|
|
719
|
+
return jsonError(res, 400, String(err))
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
params = route('DELETE', '/api/tasks/:id')
|
|
724
|
+
if (params) {
|
|
725
|
+
const { id } = params
|
|
726
|
+
const ok = doDeleteFeature(id)
|
|
727
|
+
if (!ok) return jsonError(res, 404, 'Task not found')
|
|
728
|
+
return jsonOk(res, { deleted: true })
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ==================== ATTACHMENTS API ====================
|
|
732
|
+
|
|
733
|
+
params = route('POST', '/api/tasks/:id/attachments')
|
|
734
|
+
if (params) {
|
|
735
|
+
try {
|
|
736
|
+
const { id } = params
|
|
737
|
+
const body = await readBody(req)
|
|
738
|
+
const files = body.files as { name: string; data: string }[]
|
|
739
|
+
if (!Array.isArray(files)) return jsonError(res, 400, 'files array is required')
|
|
740
|
+
for (const file of files) {
|
|
741
|
+
const buf = Buffer.from(file.data, 'base64')
|
|
742
|
+
doAddAttachment(id, file.name, buf)
|
|
743
|
+
}
|
|
744
|
+
broadcast(buildInitMessage())
|
|
745
|
+
const feature = features.find(f => f.id === id)
|
|
746
|
+
if (!feature) return jsonError(res, 404, 'Task not found')
|
|
747
|
+
return jsonOk(res, sanitizeFeature(feature))
|
|
748
|
+
} catch (err) {
|
|
749
|
+
return jsonError(res, 400, String(err))
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
params = route('GET', '/api/tasks/:id/attachments/:filename')
|
|
754
|
+
if (params) {
|
|
755
|
+
const { id, filename: attachName } = params
|
|
756
|
+
const feature = features.find(f => f.id === id)
|
|
757
|
+
if (!feature) return jsonError(res, 404, 'Task not found')
|
|
758
|
+
const featureDir = path.dirname(feature.filePath)
|
|
759
|
+
const attachmentPath = path.resolve(featureDir, attachName)
|
|
760
|
+
if (!attachmentPath.startsWith(absoluteFeaturesDir)) {
|
|
761
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
762
|
+
res.end('Forbidden')
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
const ext = path.extname(attachName)
|
|
766
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream'
|
|
767
|
+
fs.readFile(attachmentPath, (err, data) => {
|
|
768
|
+
if (err) { res.writeHead(404); res.end('File not found'); return }
|
|
769
|
+
res.writeHead(200, {
|
|
770
|
+
'Content-Type': contentType,
|
|
771
|
+
'Content-Disposition': `inline; filename="${attachName}"`,
|
|
772
|
+
'Access-Control-Allow-Origin': '*'
|
|
773
|
+
})
|
|
774
|
+
res.end(data)
|
|
775
|
+
})
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
params = route('DELETE', '/api/tasks/:id/attachments/:filename')
|
|
780
|
+
if (params) {
|
|
781
|
+
const { id, filename: attachName } = params
|
|
782
|
+
const feature = doRemoveAttachment(id, attachName)
|
|
783
|
+
if (!feature) return jsonError(res, 404, 'Task not found')
|
|
784
|
+
return jsonOk(res, sanitizeFeature(feature))
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ==================== COLUMNS API ====================
|
|
788
|
+
|
|
789
|
+
params = route('GET', '/api/columns')
|
|
790
|
+
if (params) {
|
|
791
|
+
return jsonOk(res, getConfig().columns)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
params = route('POST', '/api/columns')
|
|
795
|
+
if (params) {
|
|
796
|
+
try {
|
|
797
|
+
const body = await readBody(req)
|
|
798
|
+
const name = body.name as string
|
|
799
|
+
const color = body.color as string
|
|
800
|
+
if (!name) return jsonError(res, 400, 'name is required')
|
|
801
|
+
const column = doAddColumn(name, color || '#6b7280')
|
|
802
|
+
return jsonOk(res, column, 201)
|
|
803
|
+
} catch (err) {
|
|
804
|
+
return jsonError(res, 400, String(err))
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
params = route('PUT', '/api/columns/:id')
|
|
809
|
+
if (params) {
|
|
810
|
+
try {
|
|
811
|
+
const { id } = params
|
|
812
|
+
const body = await readBody(req)
|
|
813
|
+
const column = doEditColumn(id, { name: body.name as string, color: body.color as string })
|
|
814
|
+
if (!column) return jsonError(res, 404, 'Column not found')
|
|
815
|
+
return jsonOk(res, column)
|
|
816
|
+
} catch (err) {
|
|
817
|
+
return jsonError(res, 400, String(err))
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
params = route('DELETE', '/api/columns/:id')
|
|
822
|
+
if (params) {
|
|
823
|
+
const { id } = params
|
|
824
|
+
const result = doRemoveColumn(id)
|
|
825
|
+
if (!result.removed) return jsonError(res, 400, result.error || 'Cannot remove column')
|
|
826
|
+
return jsonOk(res, { deleted: true })
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ==================== SETTINGS API ====================
|
|
830
|
+
|
|
831
|
+
params = route('GET', '/api/settings')
|
|
832
|
+
if (params) {
|
|
833
|
+
const config = getConfig()
|
|
834
|
+
const settings = configToSettings(config)
|
|
835
|
+
settings.showBuildWithAI = false
|
|
836
|
+
settings.markdownEditorMode = false
|
|
837
|
+
return jsonOk(res, settings)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
params = route('PUT', '/api/settings')
|
|
841
|
+
if (params) {
|
|
842
|
+
try {
|
|
843
|
+
const body = await readBody(req)
|
|
844
|
+
doSaveSettings(body as unknown as CardDisplaySettings)
|
|
845
|
+
const config = getConfig()
|
|
846
|
+
return jsonOk(res, configToSettings(config))
|
|
847
|
+
} catch (err) {
|
|
848
|
+
return jsonError(res, 400, String(err))
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ==================== WEBHOOKS API ====================
|
|
853
|
+
|
|
854
|
+
params = route('GET', '/api/webhooks')
|
|
855
|
+
if (params) {
|
|
856
|
+
return jsonOk(res, loadWebhooks(workspaceRoot))
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
params = route('POST', '/api/webhooks')
|
|
860
|
+
if (params) {
|
|
861
|
+
try {
|
|
862
|
+
const body = await readBody(req)
|
|
863
|
+
const webhookUrl = body.url as string
|
|
864
|
+
const events = body.events as string[]
|
|
865
|
+
const secret = body.secret as string | undefined
|
|
866
|
+
if (!webhookUrl) return jsonError(res, 400, 'url is required')
|
|
867
|
+
if (!events || !Array.isArray(events) || events.length === 0) {
|
|
868
|
+
return jsonError(res, 400, 'events array is required')
|
|
869
|
+
}
|
|
870
|
+
const webhook = createWebhook(workspaceRoot, { url: webhookUrl, events, secret })
|
|
871
|
+
return jsonOk(res, webhook, 201)
|
|
872
|
+
} catch (err) {
|
|
873
|
+
return jsonError(res, 400, String(err))
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
params = route('DELETE', '/api/webhooks/:id')
|
|
878
|
+
if (params) {
|
|
879
|
+
const { id } = params
|
|
880
|
+
const ok = deleteWebhook(workspaceRoot, id)
|
|
881
|
+
if (!ok) return jsonError(res, 404, 'Webhook not found')
|
|
882
|
+
return jsonOk(res, { deleted: true })
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ==================== WORKSPACE API ====================
|
|
886
|
+
|
|
887
|
+
params = route('GET', '/api/workspace')
|
|
888
|
+
if (params) {
|
|
889
|
+
return jsonOk(res, { path: workspaceRoot })
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ==================== LEGACY API (backwards compat) ====================
|
|
893
|
+
|
|
894
|
+
if (method === 'POST' && pathname === '/api/upload-attachment') {
|
|
895
|
+
try {
|
|
896
|
+
const body = await readBody(req)
|
|
897
|
+
const featureId = body.featureId as string
|
|
898
|
+
const files = body.files as { name: string; data: string }[]
|
|
899
|
+
if (!featureId || !Array.isArray(files)) return jsonError(res, 400, 'Missing featureId or files')
|
|
900
|
+
|
|
901
|
+
for (const file of files) {
|
|
902
|
+
const buf = Buffer.from(file.data, 'base64')
|
|
903
|
+
doAddAttachment(featureId, file.name, buf)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
broadcast(buildInitMessage())
|
|
907
|
+
const feature = features.find(f => f.id === featureId)
|
|
908
|
+
if (feature && currentEditingFeatureId === featureId) {
|
|
909
|
+
const frontmatter: FeatureFrontmatter = {
|
|
910
|
+
id: feature.id, status: feature.status, priority: feature.priority,
|
|
911
|
+
assignee: feature.assignee, dueDate: feature.dueDate, created: feature.created,
|
|
912
|
+
modified: feature.modified, completedAt: feature.completedAt,
|
|
913
|
+
labels: feature.labels, attachments: feature.attachments, order: feature.order
|
|
914
|
+
}
|
|
915
|
+
broadcast({ type: 'featureContent', featureId: feature.id, content: feature.content, frontmatter })
|
|
916
|
+
}
|
|
917
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
918
|
+
res.end(JSON.stringify({ ok: true }))
|
|
919
|
+
} catch (err) {
|
|
920
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
921
|
+
res.end(JSON.stringify({ error: String(err) }))
|
|
922
|
+
}
|
|
923
|
+
return
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (method === 'GET' && pathname === '/api/attachment') {
|
|
927
|
+
const featureId = url.searchParams.get('featureId')
|
|
928
|
+
const filename = url.searchParams.get('filename')
|
|
929
|
+
if (!featureId || !filename) {
|
|
930
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Missing featureId or filename'); return
|
|
931
|
+
}
|
|
932
|
+
const feature = features.find(f => f.id === featureId)
|
|
933
|
+
if (!feature) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Feature not found'); return }
|
|
934
|
+
const featureDir = path.dirname(feature.filePath)
|
|
935
|
+
const attachmentPath = path.resolve(featureDir, filename)
|
|
936
|
+
if (!attachmentPath.startsWith(absoluteFeaturesDir)) {
|
|
937
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Forbidden'); return
|
|
938
|
+
}
|
|
939
|
+
const ext = path.extname(filename)
|
|
940
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream'
|
|
941
|
+
fs.readFile(attachmentPath, (err, data) => {
|
|
942
|
+
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('File not found'); return }
|
|
943
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Content-Disposition': `inline; filename="${filename}"` })
|
|
944
|
+
res.end(data)
|
|
945
|
+
})
|
|
946
|
+
return
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Catch-all for unmatched /api/* routes
|
|
950
|
+
if (pathname.startsWith('/api/')) {
|
|
951
|
+
return jsonError(res, 404, 'Not found')
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ==================== STATIC FILES ====================
|
|
955
|
+
|
|
956
|
+
const filePath = path.join(resolvedWebviewDir, pathname === '/' ? 'index.html' : pathname)
|
|
957
|
+
|
|
958
|
+
if (!path.extname(filePath)) {
|
|
959
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
960
|
+
res.end(indexHtml)
|
|
961
|
+
return
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const ext = path.extname(filePath)
|
|
965
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream'
|
|
966
|
+
|
|
967
|
+
fs.readFile(filePath, (err, data) => {
|
|
968
|
+
if (err) {
|
|
969
|
+
res.writeHead(200, { 'Content-Type': 'text/html' })
|
|
970
|
+
res.end(indexHtml)
|
|
971
|
+
return
|
|
972
|
+
}
|
|
973
|
+
res.writeHead(200, { 'Content-Type': contentType })
|
|
974
|
+
res.end(data)
|
|
975
|
+
})
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
// --- WebSocket server ---
|
|
979
|
+
|
|
980
|
+
const wss = new WebSocketServer({ server, path: '/ws' })
|
|
981
|
+
|
|
982
|
+
wss.on('connection', (ws) => {
|
|
983
|
+
ws.on('message', (data) => {
|
|
984
|
+
try {
|
|
985
|
+
const message = JSON.parse(data.toString())
|
|
986
|
+
handleMessage(ws, message)
|
|
987
|
+
} catch (err) {
|
|
988
|
+
console.error('Failed to handle message:', err)
|
|
989
|
+
}
|
|
990
|
+
})
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
// --- File watcher ---
|
|
994
|
+
|
|
995
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
996
|
+
|
|
997
|
+
fs.mkdirSync(absoluteFeaturesDir, { recursive: true })
|
|
998
|
+
|
|
999
|
+
const watcher = chokidar.watch(absoluteFeaturesDir, {
|
|
1000
|
+
ignoreInitial: true,
|
|
1001
|
+
awaitWriteFinish: { stabilityThreshold: 100 }
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
const handleFileChange = (changedPath?: string) => {
|
|
1005
|
+
if (changedPath && !changedPath.endsWith('.md')) return
|
|
1006
|
+
if (migrating) return
|
|
1007
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
1008
|
+
debounceTimer = setTimeout(() => {
|
|
1009
|
+
loadFeatures()
|
|
1010
|
+
broadcast(buildInitMessage())
|
|
1011
|
+
|
|
1012
|
+
if (currentEditingFeatureId && changedPath) {
|
|
1013
|
+
const editingFeature = features.find(f => f.id === currentEditingFeatureId)
|
|
1014
|
+
if (editingFeature && editingFeature.filePath === changedPath) {
|
|
1015
|
+
const currentContent = serializeFeature(editingFeature)
|
|
1016
|
+
if (currentContent !== lastWrittenContent) {
|
|
1017
|
+
const frontmatter: FeatureFrontmatter = {
|
|
1018
|
+
id: editingFeature.id, status: editingFeature.status, priority: editingFeature.priority,
|
|
1019
|
+
assignee: editingFeature.assignee, dueDate: editingFeature.dueDate, created: editingFeature.created,
|
|
1020
|
+
modified: editingFeature.modified, completedAt: editingFeature.completedAt,
|
|
1021
|
+
labels: editingFeature.labels, attachments: editingFeature.attachments, order: editingFeature.order
|
|
1022
|
+
}
|
|
1023
|
+
broadcast({ type: 'featureContent', featureId: editingFeature.id, content: editingFeature.content, frontmatter })
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}, 100)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
watcher.on('change', handleFileChange)
|
|
1031
|
+
watcher.on('add', handleFileChange)
|
|
1032
|
+
watcher.on('unlink', handleFileChange)
|
|
1033
|
+
|
|
1034
|
+
server.on('close', () => {
|
|
1035
|
+
watcher.close()
|
|
1036
|
+
wss.close()
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
server.listen(port, () => {
|
|
1040
|
+
console.log(`Kanban board running at http://localhost:${port}`)
|
|
1041
|
+
console.log(`API available at http://localhost:${port}/api`)
|
|
1042
|
+
console.log(`Features directory: ${absoluteFeaturesDir}`)
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
return server
|
|
1046
|
+
}
|