kanban-lite 1.0.9 → 1.0.13
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/dist/cli.js +1015 -407
- package/dist/extension.js +897 -347
- package/dist/mcp-server.js +550 -189
- package/dist/sdk/index.cjs +1223 -0
- package/dist/sdk/index.mjs +1168 -0
- package/dist/sdk/sdk/KanbanSDK.d.ts +39 -21
- package/dist/sdk/sdk/index.d.ts +4 -3
- package/dist/sdk/sdk/migration.d.ts +6 -0
- package/dist/sdk/sdk/types.d.ts +3 -5
- package/dist/sdk/shared/config.d.ts +23 -10
- package/dist/sdk/shared/types.d.ts +18 -4
- package/dist/standalone-webview/index.js +27 -27
- package/dist/standalone-webview/index.js.map +1 -1
- package/dist/standalone-webview/style.css +1 -1
- package/dist/standalone.js +831 -328
- package/dist/webview/index.js +45 -45
- package/dist/webview/index.js.map +1 -1
- package/dist/webview/style.css +1 -1
- package/package.json +4 -3
- package/src/cli/index.ts +217 -95
- package/src/extension/KanbanPanel.ts +49 -22
- package/src/mcp-server/index.ts +138 -62
- package/src/sdk/KanbanSDK.ts +283 -77
- package/src/sdk/__tests__/KanbanSDK.test.ts +5 -5
- package/src/sdk/__tests__/migration.test.ts +269 -0
- package/src/sdk/__tests__/multi-board.test.ts +449 -0
- package/src/sdk/index.ts +4 -3
- package/src/sdk/migration.ts +52 -0
- package/src/sdk/types.ts +3 -6
- package/src/shared/config.ts +144 -22
- package/src/shared/types.ts +14 -5
- package/src/standalone/__tests__/server.integration.test.ts +38 -37
- package/src/standalone/server.ts +239 -21
- package/src/webview/App.tsx +17 -7
- package/src/webview/components/Toolbar.tsx +99 -3
- package/src/webview/store/index.ts +11 -3
- package/.kanban/backlog/1-test1.md +0 -30
- package/.kanban.json +0 -42
package/src/sdk/KanbanSDK.ts
CHANGED
|
@@ -1,41 +1,214 @@
|
|
|
1
1
|
import * as fs from 'fs/promises'
|
|
2
2
|
import * as path from 'path'
|
|
3
3
|
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'
|
|
4
|
-
import type { Comment, Feature,
|
|
4
|
+
import type { Comment, Feature, KanbanColumn, BoardInfo } from '../shared/types'
|
|
5
5
|
import { getTitleFromContent, generateFeatureFilename, extractNumericId } from '../shared/types'
|
|
6
|
-
import { readConfig, writeConfig, configToSettings, settingsToConfig, allocateCardId, syncCardIdCounter } from '../shared/config'
|
|
6
|
+
import { readConfig, writeConfig, configToSettings, settingsToConfig, allocateCardId, syncCardIdCounter, getBoardConfig } from '../shared/config'
|
|
7
|
+
import type { BoardConfig } from '../shared/config'
|
|
7
8
|
import type { CardDisplaySettings } from '../shared/types'
|
|
9
|
+
import type { Priority } from '../shared/types'
|
|
8
10
|
import { parseFeatureFile, serializeFeature } from './parser'
|
|
9
11
|
import { ensureDirectories, ensureStatusSubfolders, getFeatureFilePath, getStatusFromPath, moveFeatureFile, renameFeatureFile } from './fileUtils'
|
|
10
12
|
import type { CreateCardInput } from './types'
|
|
13
|
+
import { migrateFileSystemToMultiBoard } from './migration'
|
|
11
14
|
|
|
12
15
|
export class KanbanSDK {
|
|
16
|
+
private _migrated = false
|
|
17
|
+
|
|
13
18
|
constructor(public readonly featuresDir: string) {}
|
|
14
19
|
|
|
15
20
|
get workspaceRoot(): string {
|
|
16
21
|
return path.dirname(this.featuresDir)
|
|
17
22
|
}
|
|
18
23
|
|
|
24
|
+
// --- Board resolution helpers ---
|
|
25
|
+
|
|
26
|
+
private _resolveBoardId(boardId?: string): string {
|
|
27
|
+
const config = readConfig(this.workspaceRoot)
|
|
28
|
+
return boardId || config.defaultBoard
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private _boardDir(boardId?: string): string {
|
|
32
|
+
const resolvedId = this._resolveBoardId(boardId)
|
|
33
|
+
return path.join(this.featuresDir, 'boards', resolvedId)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private _isCompletedStatus(status: string, boardId?: string): boolean {
|
|
37
|
+
const config = readConfig(this.workspaceRoot)
|
|
38
|
+
const resolvedId = boardId || config.defaultBoard
|
|
39
|
+
const board = config.boards[resolvedId]
|
|
40
|
+
if (!board || board.columns.length === 0) return status === 'done'
|
|
41
|
+
return board.columns[board.columns.length - 1].id === status
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private async _ensureMigrated(): Promise<void> {
|
|
45
|
+
if (this._migrated) return
|
|
46
|
+
await migrateFileSystemToMultiBoard(this.featuresDir)
|
|
47
|
+
this._migrated = true
|
|
48
|
+
}
|
|
49
|
+
|
|
19
50
|
async init(): Promise<void> {
|
|
20
|
-
await
|
|
51
|
+
await this._ensureMigrated()
|
|
52
|
+
const boardDir = this._boardDir()
|
|
53
|
+
await ensureDirectories(boardDir)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Board management ---
|
|
57
|
+
|
|
58
|
+
listBoards(): BoardInfo[] {
|
|
59
|
+
const config = readConfig(this.workspaceRoot)
|
|
60
|
+
return Object.entries(config.boards).map(([id, board]) => ({
|
|
61
|
+
id,
|
|
62
|
+
name: board.name,
|
|
63
|
+
description: board.description
|
|
64
|
+
}))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
createBoard(id: string, name: string, options?: {
|
|
68
|
+
description?: string
|
|
69
|
+
columns?: KanbanColumn[]
|
|
70
|
+
defaultStatus?: string
|
|
71
|
+
defaultPriority?: Priority
|
|
72
|
+
}): BoardInfo {
|
|
73
|
+
const config = readConfig(this.workspaceRoot)
|
|
74
|
+
if (config.boards[id]) {
|
|
75
|
+
throw new Error(`Board already exists: ${id}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const columns = options?.columns || [...config.boards[config.defaultBoard]?.columns || [
|
|
79
|
+
{ id: 'backlog', name: 'Backlog', color: '#6b7280' },
|
|
80
|
+
{ id: 'todo', name: 'To Do', color: '#3b82f6' },
|
|
81
|
+
{ id: 'in-progress', name: 'In Progress', color: '#f59e0b' },
|
|
82
|
+
{ id: 'review', name: 'Review', color: '#8b5cf6' },
|
|
83
|
+
{ id: 'done', name: 'Done', color: '#22c55e' }
|
|
84
|
+
]]
|
|
85
|
+
|
|
86
|
+
config.boards[id] = {
|
|
87
|
+
name,
|
|
88
|
+
description: options?.description,
|
|
89
|
+
columns,
|
|
90
|
+
nextCardId: 1,
|
|
91
|
+
defaultStatus: options?.defaultStatus || columns[0]?.id || 'backlog',
|
|
92
|
+
defaultPriority: options?.defaultPriority || config.defaultPriority
|
|
93
|
+
}
|
|
94
|
+
writeConfig(this.workspaceRoot, config)
|
|
95
|
+
|
|
96
|
+
return { id, name, description: options?.description }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async deleteBoard(boardId: string): Promise<void> {
|
|
100
|
+
const config = readConfig(this.workspaceRoot)
|
|
101
|
+
if (!config.boards[boardId]) {
|
|
102
|
+
throw new Error(`Board not found: ${boardId}`)
|
|
103
|
+
}
|
|
104
|
+
if (config.defaultBoard === boardId) {
|
|
105
|
+
throw new Error(`Cannot delete the default board: ${boardId}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check if board has cards
|
|
109
|
+
const cards = await this.listCards(undefined, boardId)
|
|
110
|
+
if (cards.length > 0) {
|
|
111
|
+
throw new Error(`Cannot delete board "${boardId}": ${cards.length} card(s) still exist`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Remove board directory
|
|
115
|
+
const boardDir = this._boardDir(boardId)
|
|
116
|
+
try {
|
|
117
|
+
await fs.rm(boardDir, { recursive: true })
|
|
118
|
+
} catch {
|
|
119
|
+
// Directory might not exist
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
delete config.boards[boardId]
|
|
123
|
+
writeConfig(this.workspaceRoot, config)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
getBoard(boardId: string): BoardConfig {
|
|
127
|
+
return getBoardConfig(this.workspaceRoot, boardId)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
updateBoard(boardId: string, updates: Partial<Omit<BoardConfig, 'nextCardId'>>): BoardConfig {
|
|
131
|
+
const config = readConfig(this.workspaceRoot)
|
|
132
|
+
const board = config.boards[boardId]
|
|
133
|
+
if (!board) {
|
|
134
|
+
throw new Error(`Board not found: ${boardId}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (updates.name !== undefined) board.name = updates.name
|
|
138
|
+
if (updates.description !== undefined) board.description = updates.description
|
|
139
|
+
if (updates.columns !== undefined) board.columns = updates.columns
|
|
140
|
+
if (updates.defaultStatus !== undefined) board.defaultStatus = updates.defaultStatus
|
|
141
|
+
if (updates.defaultPriority !== undefined) board.defaultPriority = updates.defaultPriority
|
|
142
|
+
|
|
143
|
+
writeConfig(this.workspaceRoot, config)
|
|
144
|
+
return board
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async transferCard(cardId: string, fromBoardId: string, toBoardId: string, targetStatus?: string): Promise<Feature> {
|
|
148
|
+
const toBoardDir = this._boardDir(toBoardId)
|
|
149
|
+
|
|
150
|
+
const config = readConfig(this.workspaceRoot)
|
|
151
|
+
if (!config.boards[fromBoardId]) throw new Error(`Board not found: ${fromBoardId}`)
|
|
152
|
+
if (!config.boards[toBoardId]) throw new Error(`Board not found: ${toBoardId}`)
|
|
153
|
+
|
|
154
|
+
// Find card in source board
|
|
155
|
+
const card = await this.getCard(cardId, fromBoardId)
|
|
156
|
+
if (!card) throw new Error(`Card not found: ${cardId} in board ${fromBoardId}`)
|
|
157
|
+
|
|
158
|
+
// Determine target status
|
|
159
|
+
const toBoard = config.boards[toBoardId]
|
|
160
|
+
const newStatus = targetStatus || toBoard.defaultStatus || toBoard.columns[0]?.id || 'backlog'
|
|
161
|
+
|
|
162
|
+
// Ensure target directory exists
|
|
163
|
+
const targetDir = path.join(toBoardDir, newStatus)
|
|
164
|
+
await fs.mkdir(targetDir, { recursive: true })
|
|
165
|
+
|
|
166
|
+
// Move file
|
|
167
|
+
const oldPath = card.filePath
|
|
168
|
+
const filename = path.basename(oldPath)
|
|
169
|
+
const newPath = path.join(targetDir, filename)
|
|
170
|
+
await fs.rename(oldPath, newPath)
|
|
171
|
+
|
|
172
|
+
// Update card metadata
|
|
173
|
+
card.status = newStatus
|
|
174
|
+
card.boardId = toBoardId
|
|
175
|
+
card.filePath = newPath
|
|
176
|
+
card.modified = new Date().toISOString()
|
|
177
|
+
card.completedAt = this._isCompletedStatus(newStatus, toBoardId) ? new Date().toISOString() : null
|
|
178
|
+
|
|
179
|
+
// Recompute order for target column
|
|
180
|
+
const targetCards = await this.listCards(undefined, toBoardId)
|
|
181
|
+
const cardsInStatus = targetCards
|
|
182
|
+
.filter(c => c.status === newStatus && c.id !== cardId)
|
|
183
|
+
.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
184
|
+
const lastOrder = cardsInStatus.length > 0 ? cardsInStatus[cardsInStatus.length - 1].order : null
|
|
185
|
+
card.order = generateKeyBetween(lastOrder, null)
|
|
186
|
+
|
|
187
|
+
await fs.writeFile(card.filePath, serializeFeature(card), 'utf-8')
|
|
188
|
+
|
|
189
|
+
return card
|
|
21
190
|
}
|
|
22
191
|
|
|
23
192
|
// --- Card CRUD ---
|
|
24
193
|
|
|
25
|
-
async listCards(columns?: string[]): Promise<Feature[]> {
|
|
26
|
-
await
|
|
194
|
+
async listCards(columns?: string[], boardId?: string): Promise<Feature[]> {
|
|
195
|
+
await this._ensureMigrated()
|
|
196
|
+
const boardDir = this._boardDir(boardId)
|
|
197
|
+
const resolvedBoardId = this._resolveBoardId(boardId)
|
|
198
|
+
|
|
199
|
+
await ensureDirectories(boardDir)
|
|
27
200
|
if (columns) {
|
|
28
|
-
await ensureStatusSubfolders(
|
|
201
|
+
await ensureStatusSubfolders(boardDir, columns)
|
|
29
202
|
}
|
|
30
203
|
|
|
31
204
|
// Phase 1: Migrate flat root .md files into their status subfolder
|
|
32
205
|
try {
|
|
33
|
-
const rootFiles = await this._readMdFiles(
|
|
206
|
+
const rootFiles = await this._readMdFiles(boardDir)
|
|
34
207
|
for (const filePath of rootFiles) {
|
|
35
208
|
try {
|
|
36
209
|
const card = await this._loadCard(filePath)
|
|
37
210
|
if (card) {
|
|
38
|
-
await moveFeatureFile(filePath,
|
|
211
|
+
await moveFeatureFile(filePath, boardDir, card.status, card.attachments)
|
|
39
212
|
}
|
|
40
213
|
} catch {
|
|
41
214
|
// Skip files that fail to migrate
|
|
@@ -47,15 +220,23 @@ export class KanbanSDK {
|
|
|
47
220
|
|
|
48
221
|
// Phase 2: Load .md files from ALL subdirectories
|
|
49
222
|
const cards: Feature[] = []
|
|
50
|
-
|
|
223
|
+
let entries: import('fs').Dirent[]
|
|
224
|
+
try {
|
|
225
|
+
entries = await fs.readdir(boardDir, { withFileTypes: true }) as import('fs').Dirent[]
|
|
226
|
+
} catch {
|
|
227
|
+
return []
|
|
228
|
+
}
|
|
51
229
|
for (const entry of entries) {
|
|
52
230
|
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
53
|
-
const subdir = path.join(
|
|
231
|
+
const subdir = path.join(boardDir, entry.name)
|
|
54
232
|
try {
|
|
55
233
|
const mdFiles = await this._readMdFiles(subdir)
|
|
56
234
|
for (const filePath of mdFiles) {
|
|
57
235
|
const card = await this._loadCard(filePath)
|
|
58
|
-
if (card)
|
|
236
|
+
if (card) {
|
|
237
|
+
card.boardId = resolvedBoardId
|
|
238
|
+
cards.push(card)
|
|
239
|
+
}
|
|
59
240
|
}
|
|
60
241
|
} catch {
|
|
61
242
|
// Skip unreadable directories
|
|
@@ -64,10 +245,10 @@ export class KanbanSDK {
|
|
|
64
245
|
|
|
65
246
|
// Phase 3: Reconcile status ↔ folder mismatches
|
|
66
247
|
for (const card of cards) {
|
|
67
|
-
const pathStatus = getStatusFromPath(card.filePath,
|
|
248
|
+
const pathStatus = getStatusFromPath(card.filePath, boardDir)
|
|
68
249
|
if (pathStatus !== null && pathStatus !== card.status) {
|
|
69
250
|
try {
|
|
70
|
-
card.filePath = await moveFeatureFile(card.filePath,
|
|
251
|
+
card.filePath = await moveFeatureFile(card.filePath, boardDir, card.status, card.attachments)
|
|
71
252
|
} catch {
|
|
72
253
|
// Will retry on next load
|
|
73
254
|
}
|
|
@@ -98,30 +279,35 @@ export class KanbanSDK {
|
|
|
98
279
|
.map(c => parseInt(c.id, 10))
|
|
99
280
|
.filter(n => !Number.isNaN(n))
|
|
100
281
|
if (numericIds.length > 0) {
|
|
101
|
-
syncCardIdCounter(
|
|
282
|
+
syncCardIdCounter(this.workspaceRoot, resolvedBoardId, numericIds)
|
|
102
283
|
}
|
|
103
284
|
|
|
104
285
|
return cards.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
105
286
|
}
|
|
106
287
|
|
|
107
|
-
async getCard(cardId: string): Promise<Feature | null> {
|
|
108
|
-
const cards = await this.listCards()
|
|
288
|
+
async getCard(cardId: string, boardId?: string): Promise<Feature | null> {
|
|
289
|
+
const cards = await this.listCards(undefined, boardId)
|
|
109
290
|
return cards.find(c => c.id === cardId) || null
|
|
110
291
|
}
|
|
111
292
|
|
|
112
293
|
async createCard(data: CreateCardInput): Promise<Feature> {
|
|
113
|
-
await
|
|
294
|
+
await this._ensureMigrated()
|
|
295
|
+
const resolvedBoardId = this._resolveBoardId(data.boardId)
|
|
296
|
+
const boardDir = this._boardDir(resolvedBoardId)
|
|
297
|
+
await ensureDirectories(boardDir)
|
|
298
|
+
|
|
299
|
+
const config = readConfig(this.workspaceRoot)
|
|
300
|
+
const board = config.boards[resolvedBoardId]
|
|
114
301
|
|
|
115
|
-
const status = data.status || 'backlog'
|
|
116
|
-
const priority = data.priority || 'medium'
|
|
302
|
+
const status = data.status || board?.defaultStatus || config.defaultStatus || 'backlog'
|
|
303
|
+
const priority = data.priority || board?.defaultPriority || config.defaultPriority || 'medium'
|
|
117
304
|
const title = getTitleFromContent(data.content)
|
|
118
|
-
const
|
|
119
|
-
const numericId = allocateCardId(workspaceRoot)
|
|
305
|
+
const numericId = allocateCardId(this.workspaceRoot, resolvedBoardId)
|
|
120
306
|
const filename = generateFeatureFilename(numericId, title)
|
|
121
307
|
const now = new Date().toISOString()
|
|
122
308
|
|
|
123
309
|
// Compute order: place at end of target column
|
|
124
|
-
const cards = await this.listCards()
|
|
310
|
+
const cards = await this.listCards(undefined, resolvedBoardId)
|
|
125
311
|
const cardsInStatus = cards
|
|
126
312
|
.filter(c => c.status === status)
|
|
127
313
|
.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
|
|
@@ -131,19 +317,20 @@ export class KanbanSDK {
|
|
|
131
317
|
|
|
132
318
|
const card: Feature = {
|
|
133
319
|
id: String(numericId),
|
|
320
|
+
boardId: resolvedBoardId,
|
|
134
321
|
status,
|
|
135
322
|
priority,
|
|
136
323
|
assignee: data.assignee ?? null,
|
|
137
324
|
dueDate: data.dueDate ?? null,
|
|
138
325
|
created: now,
|
|
139
326
|
modified: now,
|
|
140
|
-
completedAt: status
|
|
327
|
+
completedAt: this._isCompletedStatus(status, resolvedBoardId) ? now : null,
|
|
141
328
|
labels: data.labels || [],
|
|
142
329
|
attachments: data.attachments || [],
|
|
143
330
|
comments: [],
|
|
144
331
|
order: generateKeyBetween(lastOrder, null),
|
|
145
332
|
content: data.content,
|
|
146
|
-
filePath: getFeatureFilePath(
|
|
333
|
+
filePath: getFeatureFilePath(boardDir, status, filename)
|
|
147
334
|
}
|
|
148
335
|
|
|
149
336
|
await fs.mkdir(path.dirname(card.filePath), { recursive: true })
|
|
@@ -152,20 +339,22 @@ export class KanbanSDK {
|
|
|
152
339
|
return card
|
|
153
340
|
}
|
|
154
341
|
|
|
155
|
-
async updateCard(cardId: string, updates: Partial<Feature
|
|
156
|
-
const card = await this.getCard(cardId)
|
|
342
|
+
async updateCard(cardId: string, updates: Partial<Feature>, boardId?: string): Promise<Feature> {
|
|
343
|
+
const card = await this.getCard(cardId, boardId)
|
|
157
344
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
158
345
|
|
|
346
|
+
const resolvedBoardId = card.boardId || this._resolveBoardId(boardId)
|
|
347
|
+
const boardDir = this._boardDir(resolvedBoardId)
|
|
159
348
|
const oldStatus = card.status
|
|
160
349
|
const oldTitle = getTitleFromContent(card.content)
|
|
161
350
|
|
|
162
|
-
// Merge updates (exclude filePath/id from being overwritten)
|
|
163
|
-
const { filePath: _fp, id: _id, ...safeUpdates } = updates
|
|
351
|
+
// Merge updates (exclude filePath/id/boardId from being overwritten)
|
|
352
|
+
const { filePath: _fp, id: _id, boardId: _bid, ...safeUpdates } = updates
|
|
164
353
|
Object.assign(card, safeUpdates)
|
|
165
354
|
card.modified = new Date().toISOString()
|
|
166
355
|
|
|
167
356
|
if (oldStatus !== card.status) {
|
|
168
|
-
card.completedAt = card.status
|
|
357
|
+
card.completedAt = this._isCompletedStatus(card.status, resolvedBoardId) ? new Date().toISOString() : null
|
|
169
358
|
}
|
|
170
359
|
|
|
171
360
|
// Write updated content
|
|
@@ -181,24 +370,26 @@ export class KanbanSDK {
|
|
|
181
370
|
|
|
182
371
|
// Move file if status changed
|
|
183
372
|
if (oldStatus !== card.status) {
|
|
184
|
-
const newPath = await moveFeatureFile(card.filePath,
|
|
373
|
+
const newPath = await moveFeatureFile(card.filePath, boardDir, card.status, card.attachments)
|
|
185
374
|
card.filePath = newPath
|
|
186
375
|
}
|
|
187
376
|
|
|
188
377
|
return card
|
|
189
378
|
}
|
|
190
379
|
|
|
191
|
-
async moveCard(cardId: string, newStatus:
|
|
192
|
-
const cards = await this.listCards()
|
|
380
|
+
async moveCard(cardId: string, newStatus: string, position?: number, boardId?: string): Promise<Feature> {
|
|
381
|
+
const cards = await this.listCards(undefined, boardId)
|
|
193
382
|
const card = cards.find(c => c.id === cardId)
|
|
194
383
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
195
384
|
|
|
385
|
+
const resolvedBoardId = card.boardId || this._resolveBoardId(boardId)
|
|
386
|
+
const boardDir = this._boardDir(resolvedBoardId)
|
|
196
387
|
const oldStatus = card.status
|
|
197
388
|
card.status = newStatus
|
|
198
389
|
card.modified = new Date().toISOString()
|
|
199
390
|
|
|
200
391
|
if (oldStatus !== newStatus) {
|
|
201
|
-
card.completedAt = newStatus
|
|
392
|
+
card.completedAt = this._isCompletedStatus(newStatus, resolvedBoardId) ? new Date().toISOString() : null
|
|
202
393
|
}
|
|
203
394
|
|
|
204
395
|
// Compute new fractional order
|
|
@@ -218,26 +409,26 @@ export class KanbanSDK {
|
|
|
218
409
|
|
|
219
410
|
// Move file if status changed
|
|
220
411
|
if (oldStatus !== newStatus) {
|
|
221
|
-
const newPath = await moveFeatureFile(card.filePath,
|
|
412
|
+
const newPath = await moveFeatureFile(card.filePath, boardDir, newStatus, card.attachments)
|
|
222
413
|
card.filePath = newPath
|
|
223
414
|
}
|
|
224
415
|
|
|
225
416
|
return card
|
|
226
417
|
}
|
|
227
418
|
|
|
228
|
-
async deleteCard(cardId: string): Promise<void> {
|
|
229
|
-
const card = await this.getCard(cardId)
|
|
419
|
+
async deleteCard(cardId: string, boardId?: string): Promise<void> {
|
|
420
|
+
const card = await this.getCard(cardId, boardId)
|
|
230
421
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
231
422
|
await fs.unlink(card.filePath)
|
|
232
423
|
}
|
|
233
424
|
|
|
234
|
-
async getCardsByStatus(status:
|
|
235
|
-
const cards = await this.listCards()
|
|
425
|
+
async getCardsByStatus(status: string, boardId?: string): Promise<Feature[]> {
|
|
426
|
+
const cards = await this.listCards(undefined, boardId)
|
|
236
427
|
return cards.filter(c => c.status === status)
|
|
237
428
|
}
|
|
238
429
|
|
|
239
|
-
async getUniqueAssignees(): Promise<string[]> {
|
|
240
|
-
const cards = await this.listCards()
|
|
430
|
+
async getUniqueAssignees(boardId?: string): Promise<string[]> {
|
|
431
|
+
const cards = await this.listCards(undefined, boardId)
|
|
241
432
|
const assignees = new Set<string>()
|
|
242
433
|
for (const c of cards) {
|
|
243
434
|
if (c.assignee) assignees.add(c.assignee)
|
|
@@ -245,8 +436,8 @@ export class KanbanSDK {
|
|
|
245
436
|
return [...assignees].sort()
|
|
246
437
|
}
|
|
247
438
|
|
|
248
|
-
async getUniqueLabels(): Promise<string[]> {
|
|
249
|
-
const cards = await this.listCards()
|
|
439
|
+
async getUniqueLabels(boardId?: string): Promise<string[]> {
|
|
440
|
+
const cards = await this.listCards(undefined, boardId)
|
|
250
441
|
const labels = new Set<string>()
|
|
251
442
|
for (const c of cards) {
|
|
252
443
|
for (const l of c.labels) labels.add(l)
|
|
@@ -256,8 +447,8 @@ export class KanbanSDK {
|
|
|
256
447
|
|
|
257
448
|
// --- Attachment management ---
|
|
258
449
|
|
|
259
|
-
async addAttachment(cardId: string, sourcePath: string): Promise<Feature> {
|
|
260
|
-
const card = await this.getCard(cardId)
|
|
450
|
+
async addAttachment(cardId: string, sourcePath: string, boardId?: string): Promise<Feature> {
|
|
451
|
+
const card = await this.getCard(cardId, boardId)
|
|
261
452
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
262
453
|
|
|
263
454
|
const fileName = path.basename(sourcePath)
|
|
@@ -281,8 +472,8 @@ export class KanbanSDK {
|
|
|
281
472
|
return card
|
|
282
473
|
}
|
|
283
474
|
|
|
284
|
-
async removeAttachment(cardId: string, attachment: string): Promise<Feature> {
|
|
285
|
-
const card = await this.getCard(cardId)
|
|
475
|
+
async removeAttachment(cardId: string, attachment: string, boardId?: string): Promise<Feature> {
|
|
476
|
+
const card = await this.getCard(cardId, boardId)
|
|
286
477
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
287
478
|
|
|
288
479
|
card.attachments = card.attachments.filter(a => a !== attachment)
|
|
@@ -292,22 +483,22 @@ export class KanbanSDK {
|
|
|
292
483
|
return card
|
|
293
484
|
}
|
|
294
485
|
|
|
295
|
-
async listAttachments(cardId: string): Promise<string[]> {
|
|
296
|
-
const card = await this.getCard(cardId)
|
|
486
|
+
async listAttachments(cardId: string, boardId?: string): Promise<string[]> {
|
|
487
|
+
const card = await this.getCard(cardId, boardId)
|
|
297
488
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
298
489
|
return card.attachments
|
|
299
490
|
}
|
|
300
491
|
|
|
301
492
|
// --- Comment management ---
|
|
302
493
|
|
|
303
|
-
async listComments(cardId: string): Promise<Comment[]> {
|
|
304
|
-
const card = await this.getCard(cardId)
|
|
494
|
+
async listComments(cardId: string, boardId?: string): Promise<Comment[]> {
|
|
495
|
+
const card = await this.getCard(cardId, boardId)
|
|
305
496
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
306
497
|
return card.comments || []
|
|
307
498
|
}
|
|
308
499
|
|
|
309
|
-
async addComment(cardId: string, author: string, content: string): Promise<Feature> {
|
|
310
|
-
const card = await this.getCard(cardId)
|
|
500
|
+
async addComment(cardId: string, author: string, content: string, boardId?: string): Promise<Feature> {
|
|
501
|
+
const card = await this.getCard(cardId, boardId)
|
|
311
502
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
312
503
|
|
|
313
504
|
if (!card.comments) card.comments = []
|
|
@@ -332,8 +523,8 @@ export class KanbanSDK {
|
|
|
332
523
|
return card
|
|
333
524
|
}
|
|
334
525
|
|
|
335
|
-
async updateComment(cardId: string, commentId: string, content: string): Promise<Feature> {
|
|
336
|
-
const card = await this.getCard(cardId)
|
|
526
|
+
async updateComment(cardId: string, commentId: string, content: string, boardId?: string): Promise<Feature> {
|
|
527
|
+
const card = await this.getCard(cardId, boardId)
|
|
337
528
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
338
529
|
|
|
339
530
|
const comment = (card.comments || []).find(c => c.id === commentId)
|
|
@@ -346,8 +537,8 @@ export class KanbanSDK {
|
|
|
346
537
|
return card
|
|
347
538
|
}
|
|
348
539
|
|
|
349
|
-
async deleteComment(cardId: string, commentId: string): Promise<Feature> {
|
|
350
|
-
const card = await this.getCard(cardId)
|
|
540
|
+
async deleteComment(cardId: string, commentId: string, boardId?: string): Promise<Feature> {
|
|
541
|
+
const card = await this.getCard(cardId, boardId)
|
|
351
542
|
if (!card) throw new Error(`Card not found: ${cardId}`)
|
|
352
543
|
|
|
353
544
|
card.comments = (card.comments || []).filter(c => c.id !== commentId)
|
|
@@ -357,67 +548,82 @@ export class KanbanSDK {
|
|
|
357
548
|
return card
|
|
358
549
|
}
|
|
359
550
|
|
|
360
|
-
// --- Column management ---
|
|
551
|
+
// --- Column management (board-scoped) ---
|
|
361
552
|
|
|
362
|
-
listColumns(): KanbanColumn[] {
|
|
363
|
-
|
|
553
|
+
listColumns(boardId?: string): KanbanColumn[] {
|
|
554
|
+
const config = readConfig(this.workspaceRoot)
|
|
555
|
+
const resolvedId = boardId || config.defaultBoard
|
|
556
|
+
const board = config.boards[resolvedId]
|
|
557
|
+
return board?.columns || []
|
|
364
558
|
}
|
|
365
559
|
|
|
366
|
-
addColumn(column: KanbanColumn): KanbanColumn[] {
|
|
560
|
+
addColumn(column: KanbanColumn, boardId?: string): KanbanColumn[] {
|
|
367
561
|
const config = readConfig(this.workspaceRoot)
|
|
368
|
-
|
|
562
|
+
const resolvedId = boardId || config.defaultBoard
|
|
563
|
+
const board = config.boards[resolvedId]
|
|
564
|
+
if (!board) throw new Error(`Board not found: ${resolvedId}`)
|
|
565
|
+
if (board.columns.some(c => c.id === column.id)) {
|
|
369
566
|
throw new Error(`Column already exists: ${column.id}`)
|
|
370
567
|
}
|
|
371
|
-
|
|
568
|
+
board.columns.push(column)
|
|
372
569
|
writeConfig(this.workspaceRoot, config)
|
|
373
|
-
return
|
|
570
|
+
return board.columns
|
|
374
571
|
}
|
|
375
572
|
|
|
376
|
-
updateColumn(columnId: string, updates: Partial<Omit<KanbanColumn, 'id'
|
|
573
|
+
updateColumn(columnId: string, updates: Partial<Omit<KanbanColumn, 'id'>>, boardId?: string): KanbanColumn[] {
|
|
377
574
|
const config = readConfig(this.workspaceRoot)
|
|
378
|
-
const
|
|
575
|
+
const resolvedId = boardId || config.defaultBoard
|
|
576
|
+
const board = config.boards[resolvedId]
|
|
577
|
+
if (!board) throw new Error(`Board not found: ${resolvedId}`)
|
|
578
|
+
const col = board.columns.find(c => c.id === columnId)
|
|
379
579
|
if (!col) throw new Error(`Column not found: ${columnId}`)
|
|
380
580
|
if (updates.name !== undefined) col.name = updates.name
|
|
381
581
|
if (updates.color !== undefined) col.color = updates.color
|
|
382
582
|
writeConfig(this.workspaceRoot, config)
|
|
383
|
-
return
|
|
583
|
+
return board.columns
|
|
384
584
|
}
|
|
385
585
|
|
|
386
|
-
async removeColumn(columnId: string): Promise<KanbanColumn[]> {
|
|
586
|
+
async removeColumn(columnId: string, boardId?: string): Promise<KanbanColumn[]> {
|
|
387
587
|
const config = readConfig(this.workspaceRoot)
|
|
388
|
-
const
|
|
588
|
+
const resolvedId = boardId || config.defaultBoard
|
|
589
|
+
const board = config.boards[resolvedId]
|
|
590
|
+
if (!board) throw new Error(`Board not found: ${resolvedId}`)
|
|
591
|
+
const idx = board.columns.findIndex(c => c.id === columnId)
|
|
389
592
|
if (idx === -1) throw new Error(`Column not found: ${columnId}`)
|
|
390
593
|
|
|
391
594
|
// Check if any cards use this column
|
|
392
|
-
const cards = await this.listCards()
|
|
595
|
+
const cards = await this.listCards(undefined, resolvedId)
|
|
393
596
|
const cardsInColumn = cards.filter(c => c.status === columnId)
|
|
394
597
|
if (cardsInColumn.length > 0) {
|
|
395
598
|
throw new Error(`Cannot remove column "${columnId}": ${cardsInColumn.length} card(s) still in this column`)
|
|
396
599
|
}
|
|
397
600
|
|
|
398
|
-
|
|
601
|
+
board.columns.splice(idx, 1)
|
|
399
602
|
writeConfig(this.workspaceRoot, config)
|
|
400
|
-
return
|
|
603
|
+
return board.columns
|
|
401
604
|
}
|
|
402
605
|
|
|
403
|
-
reorderColumns(columnIds: string[]): KanbanColumn[] {
|
|
606
|
+
reorderColumns(columnIds: string[], boardId?: string): KanbanColumn[] {
|
|
404
607
|
const config = readConfig(this.workspaceRoot)
|
|
405
|
-
const
|
|
608
|
+
const resolvedId = boardId || config.defaultBoard
|
|
609
|
+
const board = config.boards[resolvedId]
|
|
610
|
+
if (!board) throw new Error(`Board not found: ${resolvedId}`)
|
|
611
|
+
const colMap = new Map(board.columns.map(c => [c.id, c]))
|
|
406
612
|
|
|
407
613
|
// Validate all IDs exist
|
|
408
614
|
for (const id of columnIds) {
|
|
409
615
|
if (!colMap.has(id)) throw new Error(`Column not found: ${id}`)
|
|
410
616
|
}
|
|
411
|
-
if (columnIds.length !==
|
|
617
|
+
if (columnIds.length !== board.columns.length) {
|
|
412
618
|
throw new Error('Must include all column IDs when reordering')
|
|
413
619
|
}
|
|
414
620
|
|
|
415
|
-
|
|
621
|
+
board.columns = columnIds.map(id => colMap.get(id) as KanbanColumn)
|
|
416
622
|
writeConfig(this.workspaceRoot, config)
|
|
417
|
-
return
|
|
623
|
+
return board.columns
|
|
418
624
|
}
|
|
419
625
|
|
|
420
|
-
// --- Settings management ---
|
|
626
|
+
// --- Settings management (global) ---
|
|
421
627
|
|
|
422
628
|
getSettings(): CardDisplaySettings {
|
|
423
629
|
return configToSettings(readConfig(this.workspaceRoot))
|
|
@@ -9,7 +9,7 @@ function createTempDir(): string {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
function writeCardFile(dir: string, filename: string, content: string, subfolder?: string): void {
|
|
12
|
-
const targetDir = subfolder ? path.join(dir, subfolder) : dir
|
|
12
|
+
const targetDir = subfolder ? path.join(dir, 'boards', 'default', subfolder) : dir
|
|
13
13
|
fs.mkdirSync(targetDir, { recursive: true })
|
|
14
14
|
fs.writeFileSync(path.join(targetDir, filename), content, 'utf-8')
|
|
15
15
|
}
|
|
@@ -217,7 +217,7 @@ describe('KanbanSDK', () => {
|
|
|
217
217
|
const updated = await sdk.updateCard('move-status', { status: 'in-progress' })
|
|
218
218
|
expect(updated.filePath).toContain('/in-progress/')
|
|
219
219
|
expect(fs.existsSync(updated.filePath)).toBe(true)
|
|
220
|
-
expect(fs.existsSync(path.join(tempDir, 'backlog', 'move-status.md'))).toBe(false)
|
|
220
|
+
expect(fs.existsSync(path.join(tempDir, 'boards', 'default', 'backlog', 'move-status.md'))).toBe(false)
|
|
221
221
|
})
|
|
222
222
|
|
|
223
223
|
it('should throw for non-existent card', async () => {
|
|
@@ -261,7 +261,7 @@ describe('KanbanSDK', () => {
|
|
|
261
261
|
describe('deleteCard', () => {
|
|
262
262
|
it('should remove the file from disk', async () => {
|
|
263
263
|
writeCardFile(tempDir, 'delete-me.md', makeCardContent({ id: 'delete-me' }), 'backlog')
|
|
264
|
-
const filePath = path.join(tempDir, 'backlog', 'delete-me.md')
|
|
264
|
+
const filePath = path.join(tempDir, 'boards', 'default', 'backlog', 'delete-me.md')
|
|
265
265
|
expect(fs.existsSync(filePath)).toBe(true)
|
|
266
266
|
|
|
267
267
|
await sdk.deleteCard('delete-me')
|
|
@@ -318,7 +318,7 @@ describe('KanbanSDK', () => {
|
|
|
318
318
|
expect(updated.attachments).toContain('test-attach.txt')
|
|
319
319
|
|
|
320
320
|
// Verify file was copied to the status subfolder
|
|
321
|
-
const destPath = path.join(tempDir, 'backlog', 'test-attach.txt')
|
|
321
|
+
const destPath = path.join(tempDir, 'boards', 'default', 'backlog', 'test-attach.txt')
|
|
322
322
|
expect(fs.existsSync(destPath)).toBe(true)
|
|
323
323
|
|
|
324
324
|
fs.unlinkSync(srcFile)
|
|
@@ -412,7 +412,7 @@ describe('KanbanSDK', () => {
|
|
|
412
412
|
// Verify persisted
|
|
413
413
|
const raw = fs.readFileSync(path.join(workspaceDir, '.kanban.json'), 'utf-8')
|
|
414
414
|
const config = JSON.parse(raw)
|
|
415
|
-
expect(config.columns.length).toBe(6)
|
|
415
|
+
expect(config.boards.default.columns.length).toBe(6)
|
|
416
416
|
})
|
|
417
417
|
|
|
418
418
|
it('should throw if column ID already exists', () => {
|