idea-manager 0.2.0 → 0.3.1

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.
Files changed (61) hide show
  1. package/README.md +33 -41
  2. package/next.config.ts +0 -1
  3. package/package.json +2 -2
  4. package/{src/app/icon.svg → public/favicon.svg} +2 -2
  5. package/src/app/api/filesystem/route.ts +49 -0
  6. package/src/app/api/projects/[id]/cleanup/route.ts +32 -0
  7. package/src/app/api/projects/[id]/items/[itemId]/refine/route.ts +36 -0
  8. package/src/app/api/projects/[id]/items/[itemId]/route.ts +23 -1
  9. package/src/app/api/projects/[id]/items/route.ts +51 -1
  10. package/src/app/api/projects/[id]/scan/route.ts +73 -0
  11. package/src/app/api/projects/[id]/scan/stream/route.ts +112 -0
  12. package/src/app/api/projects/[id]/structure/route.ts +34 -3
  13. package/src/app/api/projects/[id]/structure/stream/route.ts +157 -0
  14. package/src/app/api/projects/[id]/sub-projects/[subId]/route.ts +39 -0
  15. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/chat/route.ts +60 -0
  16. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/prompt/route.ts +26 -0
  17. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/[taskId]/route.ts +39 -0
  18. package/src/app/api/projects/[id]/sub-projects/[subId]/tasks/route.ts +33 -0
  19. package/src/app/api/projects/[id]/sub-projects/route.ts +31 -0
  20. package/src/app/api/projects/route.ts +1 -1
  21. package/src/app/globals.css +465 -5
  22. package/src/app/layout.tsx +3 -0
  23. package/src/app/page.tsx +260 -88
  24. package/src/app/projects/[id]/page.tsx +366 -183
  25. package/src/cli.ts +10 -10
  26. package/src/components/DirectoryPicker.tsx +137 -0
  27. package/src/components/ScanPanel.tsx +743 -0
  28. package/src/components/brainstorm/Editor.tsx +20 -4
  29. package/src/components/brainstorm/MemoPin.tsx +91 -5
  30. package/src/components/dashboard/SubProjectCard.tsx +76 -0
  31. package/src/components/dashboard/TabBar.tsx +42 -0
  32. package/src/components/task/ProjectTree.tsx +223 -0
  33. package/src/components/task/PromptEditor.tsx +107 -0
  34. package/src/components/task/StatusFlow.tsx +43 -0
  35. package/src/components/task/TaskChat.tsx +134 -0
  36. package/src/components/task/TaskDetail.tsx +205 -0
  37. package/src/components/task/TaskList.tsx +119 -0
  38. package/src/components/tree/CardView.tsx +206 -0
  39. package/src/components/tree/RefinePopover.tsx +157 -0
  40. package/src/components/tree/TreeNode.tsx +147 -38
  41. package/src/components/tree/TreeView.tsx +270 -26
  42. package/src/components/ui/ConfirmDialog.tsx +88 -0
  43. package/src/lib/ai/chat-responder.ts +4 -2
  44. package/src/lib/ai/cleanup.ts +87 -0
  45. package/src/lib/ai/client.ts +175 -58
  46. package/src/lib/ai/prompter.ts +19 -24
  47. package/src/lib/ai/refiner.ts +128 -0
  48. package/src/lib/ai/structurer.ts +340 -11
  49. package/src/lib/db/queries/context.ts +76 -0
  50. package/src/lib/db/queries/items.ts +133 -12
  51. package/src/lib/db/queries/projects.ts +12 -8
  52. package/src/lib/db/queries/sub-projects.ts +122 -0
  53. package/src/lib/db/queries/task-conversations.ts +27 -0
  54. package/src/lib/db/queries/task-prompts.ts +32 -0
  55. package/src/lib/db/queries/tasks.ts +133 -0
  56. package/src/lib/db/schema.ts +75 -0
  57. package/src/lib/mcp/server.ts +38 -39
  58. package/src/lib/mcp/tools.ts +47 -45
  59. package/src/lib/scanner.ts +573 -0
  60. package/src/lib/task-store.ts +97 -0
  61. package/src/types/index.ts +65 -0
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
 
3
+ import { useState, useEffect } from 'react';
3
4
  import TreeNode from './TreeNode';
5
+ import CardView from './CardView';
4
6
 
5
7
  interface IItemTree {
6
8
  id: string;
@@ -10,6 +12,7 @@ interface IItemTree {
10
12
  priority: string;
11
13
  status: string;
12
14
  is_locked: boolean;
15
+ is_pinned: boolean;
13
16
  children: IItemTree[];
14
17
  }
15
18
 
@@ -18,43 +21,284 @@ interface TreeViewProps {
18
21
  loading: boolean;
19
22
  projectId: string;
20
23
  onItemUpdate: (itemId: string, data: Record<string, unknown>) => void;
24
+ onItemDelete: (itemId: string) => void;
25
+ onBulkDelete: (itemIds: string[] | 'all') => void;
26
+ onBulkStatus: (status: string) => void;
27
+ onTreeRefresh: (tree: IItemTree[]) => void;
28
+ onCleanup?: () => void;
29
+ cleaning?: boolean;
21
30
  }
22
31
 
23
- export default function TreeView({ items, loading, projectId, onItemUpdate }: TreeViewProps) {
32
+ type ViewMode = 'tree' | 'card';
33
+
34
+ function collectIds(item: IItemTree): string[] {
35
+ return [item.id, ...item.children.flatMap(collectIds)];
36
+ }
37
+
38
+ function filterDone(items: IItemTree[]): IItemTree[] {
39
+ return items
40
+ .filter(item => item.status !== 'done')
41
+ .map(item => ({
42
+ ...item,
43
+ children: filterDone(item.children),
44
+ }));
45
+ }
46
+
47
+ export default function TreeView({ items, loading, projectId, onItemUpdate, onItemDelete, onBulkDelete, onBulkStatus, onTreeRefresh, onCleanup, cleaning }: TreeViewProps) {
48
+ const [selectMode, setSelectMode] = useState(false);
49
+ const [selected, setSelected] = useState<Set<string>>(new Set());
50
+ const [hideDone, setHideDone] = useState(() => {
51
+ if (typeof window !== 'undefined') {
52
+ return localStorage.getItem('im-hide-done') !== 'false';
53
+ }
54
+ return true;
55
+ });
56
+
57
+ useEffect(() => {
58
+ localStorage.setItem('im-hide-done', String(hideDone));
59
+ }, [hideDone]);
60
+ const [viewMode, setViewMode] = useState<ViewMode>('card');
61
+ const [collapseAll, setCollapseAll] = useState(false);
62
+ const [collapseKey, setCollapseKey] = useState(0);
63
+
64
+ const toggleSelect = (id: string) => {
65
+ setSelected(prev => {
66
+ const next = new Set(prev);
67
+ if (next.has(id)) {
68
+ next.delete(id);
69
+ } else {
70
+ next.add(id);
71
+ }
72
+ return next;
73
+ });
74
+ };
75
+
76
+ const handleDeleteSelected = () => {
77
+ if (selected.size === 0) return;
78
+ const ids = Array.from(selected);
79
+ onBulkDelete(ids);
80
+ setSelected(new Set());
81
+ setSelectMode(false);
82
+ };
83
+
84
+ const handleDeleteAll = () => {
85
+ onBulkDelete('all');
86
+ setSelected(new Set());
87
+ setSelectMode(false);
88
+ };
89
+
90
+ const handleSelectAll = () => {
91
+ const allIds = items.flatMap(collectIds);
92
+ setSelected(new Set(allIds));
93
+ };
94
+
95
+ const totalCount = items.reduce((sum, item) => sum + 1 + countChildren(item), 0);
96
+ const doneCount = countByStatus(items, 'done');
97
+ const displayItems = hideDone ? filterDone(items) : items;
98
+
24
99
  return (
25
100
  <div className="flex flex-col h-full">
26
101
  <div className="flex items-center justify-between px-4 py-2 border-b border-border">
27
- <h2 className="text-sm font-medium text-muted-foreground">구조화 뷰</h2>
28
- {loading && (
29
- <span className="text-xs text-accent animate-pulse">
30
- AI 분석 중...
31
- </span>
32
- )}
102
+ <div className="flex items-center gap-2">
103
+ <h2 className="text-sm font-medium text-muted-foreground">구조화 뷰</h2>
104
+ {totalCount > 0 && (
105
+ <span className="text-xs text-muted-foreground/60">{totalCount}</span>
106
+ )}
107
+ {doneCount > 0 && (
108
+ <button
109
+ onClick={() => setHideDone(!hideDone)}
110
+ className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
111
+ hideDone
112
+ ? 'bg-accent/15 text-accent'
113
+ : 'text-muted-foreground/50 hover:text-muted-foreground'
114
+ }`}
115
+ title={hideDone ? '완료 항목 표시' : '완료 항목 숨기기'}
116
+ >
117
+ {hideDone ? `+${doneCount} 숨김` : `${doneCount} 완료`}
118
+ </button>
119
+ )}
120
+ {loading && (
121
+ <span className="text-xs text-accent animate-pulse">AI 분석 중...</span>
122
+ )}
123
+ {cleaning && !loading && (
124
+ <span className="text-xs text-muted-foreground animate-pulse">정리 중...</span>
125
+ )}
126
+ </div>
127
+ <div className="flex items-center gap-2">
128
+ {/* View mode toggle */}
129
+ <div className="view-toggle">
130
+ <button
131
+ onClick={() => setViewMode('card')}
132
+ className={`view-toggle-btn ${viewMode === 'card' ? 'view-toggle-btn-active' : ''}`}
133
+ >
134
+ 카드
135
+ </button>
136
+ <button
137
+ onClick={() => setViewMode('tree')}
138
+ className={`view-toggle-btn ${viewMode === 'tree' ? 'view-toggle-btn-active' : ''}`}
139
+ >
140
+ 트리
141
+ </button>
142
+ </div>
143
+
144
+ {items.length > 0 && viewMode === 'tree' && (
145
+ <div className="flex items-center gap-1">
146
+ {selectMode ? (
147
+ <>
148
+ <button
149
+ onClick={handleSelectAll}
150
+ className="text-xs px-2 py-1 text-muted-foreground hover:text-foreground rounded transition-colors"
151
+ >
152
+ 전체선택
153
+ </button>
154
+ <button
155
+ onClick={handleDeleteSelected}
156
+ disabled={selected.size === 0}
157
+ className="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors disabled:opacity-30"
158
+ >
159
+ 선택삭제 ({selected.size})
160
+ </button>
161
+ <button
162
+ onClick={() => { setSelectMode(false); setSelected(new Set()); }}
163
+ className="text-xs px-2 py-1 text-muted-foreground hover:text-foreground rounded transition-colors"
164
+ >
165
+ 취소
166
+ </button>
167
+ </>
168
+ ) : (
169
+ <>
170
+ {onCleanup && (
171
+ <button
172
+ onClick={onCleanup}
173
+ disabled={cleaning || loading}
174
+ className="text-xs px-2 py-1 text-accent hover:bg-accent/10 rounded transition-colors disabled:opacity-30"
175
+ >
176
+ 정리
177
+ </button>
178
+ )}
179
+ <button
180
+ onClick={() => { setCollapseAll(!collapseAll); setCollapseKey(k => k + 1); }}
181
+ className="text-xs px-2 py-1 text-muted-foreground hover:text-foreground rounded transition-colors"
182
+ title={collapseAll ? '전체 펼치기' : '전체 접기'}
183
+ >
184
+ {collapseAll ? '펼치기' : '접기'}
185
+ </button>
186
+ <button
187
+ onClick={() => onBulkStatus('done')}
188
+ className="text-xs px-2 py-1 text-success hover:bg-success/10 rounded transition-colors"
189
+ >
190
+ 전체완료
191
+ </button>
192
+ <button
193
+ onClick={() => setSelectMode(true)}
194
+ className="text-xs px-2 py-1 text-muted-foreground hover:text-foreground rounded transition-colors"
195
+ >
196
+ 선택
197
+ </button>
198
+ <button
199
+ onClick={handleDeleteAll}
200
+ className="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors"
201
+ >
202
+ 전체삭제
203
+ </button>
204
+ </>
205
+ )}
206
+ </div>
207
+ )}
208
+
209
+ {items.length > 0 && viewMode === 'card' && (
210
+ <div className="flex items-center gap-1">
211
+ {onCleanup && (
212
+ <button
213
+ onClick={onCleanup}
214
+ disabled={cleaning || loading}
215
+ className="text-xs px-2 py-1 text-accent hover:bg-accent/10 rounded transition-colors disabled:opacity-30"
216
+ >
217
+ 정리
218
+ </button>
219
+ )}
220
+ <button
221
+ onClick={() => onBulkStatus('done')}
222
+ className="text-xs px-2 py-1 text-success hover:bg-success/10 rounded transition-colors"
223
+ >
224
+ 전체완료
225
+ </button>
226
+ <button
227
+ onClick={handleDeleteAll}
228
+ className="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors"
229
+ >
230
+ 전체삭제
231
+ </button>
232
+ </div>
233
+ )}
234
+ </div>
33
235
  </div>
34
236
 
35
- <div className="flex-1 overflow-auto p-2">
36
- {items.length === 0 && !loading ? (
37
- <div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
38
- <div className="text-4xl mb-3">&#x1F5C2;</div>
39
- <p className="mb-2">아직 구조화된 항목이 없습니다</p>
40
- <p className="text-xs text-center">
41
- 왼쪽 패널에서 아이디어를 입력해보세요.
42
- <br />
43
- 입력을 멈추면 3초 후 AI가 자동으로 구조화합니다.
44
- </p>
237
+ <div className="flex-1 overflow-auto">
238
+ {displayItems.length === 0 && !loading ? (
239
+ <div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm p-4">
240
+ {items.length > 0 && hideDone ? (
241
+ <>
242
+ <div className="text-4xl mb-3">&#x2705;</div>
243
+ <p className="mb-2">모든 항목이 완료되었습니다</p>
244
+ <button
245
+ onClick={() => setHideDone(false)}
246
+ className="text-xs text-accent hover:underline"
247
+ >
248
+ 완료 항목 보기
249
+ </button>
250
+ </>
251
+ ) : (
252
+ <>
253
+ <div className="text-4xl mb-3">&#x1F5C2;</div>
254
+ <p className="mb-2">아직 구조화된 항목이 없습니다</p>
255
+ <p className="text-xs text-center">
256
+ 왼쪽 패널에서 아이디어를 입력해보세요.
257
+ <br />
258
+ 입력을 멈추면 3초 후 AI가 자동으로 구조화합니다.
259
+ </p>
260
+ </>
261
+ )}
45
262
  </div>
263
+ ) : viewMode === 'card' ? (
264
+ <CardView
265
+ items={displayItems}
266
+ onItemUpdate={onItemUpdate}
267
+ onItemDelete={onItemDelete}
268
+ />
46
269
  ) : (
47
- items.map((item) => (
48
- <TreeNode
49
- key={item.id}
50
- item={item}
51
- depth={0}
52
- projectId={projectId}
53
- onItemUpdate={onItemUpdate}
54
- />
55
- ))
270
+ <div className="p-2">
271
+ {displayItems.map((item) => (
272
+ <TreeNode
273
+ key={`${item.id}-${collapseKey}`}
274
+ item={item}
275
+ depth={0}
276
+ projectId={projectId}
277
+ onItemUpdate={onItemUpdate}
278
+ onItemDelete={onItemDelete}
279
+ onTreeRefresh={onTreeRefresh}
280
+ selectMode={selectMode}
281
+ selected={selected}
282
+ onToggleSelect={toggleSelect}
283
+ defaultExpanded={!collapseAll}
284
+ />
285
+ ))}
286
+ </div>
56
287
  )}
57
288
  </div>
58
289
  </div>
59
290
  );
60
291
  }
292
+
293
+ function countChildren(item: IItemTree): number {
294
+ return item.children.reduce((sum, child) => sum + 1 + countChildren(child), 0);
295
+ }
296
+
297
+ function countByStatus(items: IItemTree[], status: string): number {
298
+ let count = 0;
299
+ for (const item of items) {
300
+ if (item.status === status) count++;
301
+ count += countByStatus(item.children, status);
302
+ }
303
+ return count;
304
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useCallback } from 'react';
4
+
5
+ interface ConfirmDialogProps {
6
+ open: boolean;
7
+ title: string;
8
+ description?: string;
9
+ confirmLabel?: string;
10
+ cancelLabel?: string;
11
+ variant?: 'danger' | 'default';
12
+ onConfirm: () => void;
13
+ onCancel: () => void;
14
+ }
15
+
16
+ export default function ConfirmDialog({
17
+ open,
18
+ title,
19
+ description,
20
+ confirmLabel = 'Confirm',
21
+ cancelLabel = 'Cancel',
22
+ variant = 'default',
23
+ onConfirm,
24
+ onCancel,
25
+ }: ConfirmDialogProps) {
26
+ const confirmRef = useRef<HTMLButtonElement>(null);
27
+ const overlayRef = useRef<HTMLDivElement>(null);
28
+
29
+ useEffect(() => {
30
+ if (open) {
31
+ confirmRef.current?.focus();
32
+ }
33
+ }, [open]);
34
+
35
+ const handleKeyDown = useCallback((e: KeyboardEvent) => {
36
+ if (!open) return;
37
+ if (e.key === 'Escape') onCancel();
38
+ if (e.key === 'Enter') onConfirm();
39
+ }, [open, onCancel, onConfirm]);
40
+
41
+ useEffect(() => {
42
+ window.addEventListener('keydown', handleKeyDown);
43
+ return () => window.removeEventListener('keydown', handleKeyDown);
44
+ }, [handleKeyDown]);
45
+
46
+ if (!open) return null;
47
+
48
+ return (
49
+ <div
50
+ ref={overlayRef}
51
+ onClick={(e) => { if (e.target === overlayRef.current) onCancel(); }}
52
+ className="fixed inset-0 z-50 flex items-center justify-center"
53
+ style={{ background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(2px)' }}
54
+ >
55
+ <div
56
+ className="bg-card border border-border rounded-xl shadow-2xl shadow-black/40
57
+ w-full max-w-sm mx-4 animate-dialog-in"
58
+ >
59
+ <div className="p-5">
60
+ <h3 className="text-sm font-semibold text-foreground">{title}</h3>
61
+ {description && (
62
+ <p className="text-xs text-muted-foreground mt-1.5 leading-relaxed">{description}</p>
63
+ )}
64
+ </div>
65
+ <div className="flex justify-end gap-2 px-5 pb-4">
66
+ <button
67
+ onClick={onCancel}
68
+ className="px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground
69
+ bg-muted hover:bg-card-hover border border-border rounded-md transition-colors"
70
+ >
71
+ {cancelLabel}
72
+ </button>
73
+ <button
74
+ ref={confirmRef}
75
+ onClick={onConfirm}
76
+ className={`px-3 py-1.5 text-xs text-white rounded-md transition-colors ${
77
+ variant === 'danger'
78
+ ? 'bg-destructive hover:bg-destructive/80'
79
+ : 'bg-primary hover:bg-primary-hover'
80
+ }`}
81
+ >
82
+ {confirmLabel}
83
+ </button>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ );
88
+ }
@@ -2,6 +2,7 @@ import { runStructureWithQuestions, type IStructuredItem } from './client';
2
2
  import { replaceItems } from '../db/queries/items';
3
3
  import { getRecentConversations, addMessage } from '../db/queries/conversations';
4
4
  import { getBrainstorm } from '../db/queries/brainstorms';
5
+ import { getProjectContextSummary } from '../db/queries/context';
5
6
  import { resolveMemos, createMemosFromQuestions } from '../db/queries/memos';
6
7
  import type { IItemTree, IMemo, IConversation } from '@/types';
7
8
 
@@ -29,8 +30,9 @@ export async function handleChatResponse(
29
30
  content: h.content,
30
31
  }));
31
32
 
32
- // AI call with updated conversation context
33
- const result = await runStructureWithQuestions(brainstorm.content, historyForAi);
33
+ // AI call with updated conversation context + project docs
34
+ const projectContext = getProjectContextSummary(projectId) || undefined;
35
+ const result = await runStructureWithQuestions(brainstorm.content, historyForAi, projectContext);
34
36
 
35
37
  // Replace items in DB
36
38
  const dbItems = mapToDbFormat(result.items as IStructuredItem[]);
@@ -0,0 +1,87 @@
1
+ import { runClaude, extractJson, type IStructuredItem } from './client';
2
+ import { replaceItems, getItemTree } from '../db/queries/items';
3
+ import type { IItemTree } from '@/types';
4
+
5
+ function serializeItems(items: IItemTree[], depth = 0): string {
6
+ const lines: string[] = [];
7
+ for (const item of items) {
8
+ const indent = ' '.repeat(depth);
9
+ const status = item.status || 'pending';
10
+ lines.push(`${indent}- [${item.item_type}/${item.priority}/${status}] ${item.title}: ${item.description || ''}`);
11
+ if (item.children && item.children.length > 0) {
12
+ lines.push(serializeItems(item.children, depth + 1));
13
+ }
14
+ }
15
+ return lines.join('\n');
16
+ }
17
+
18
+ function countItems(items: IItemTree[]): number {
19
+ let count = 0;
20
+ for (const item of items) {
21
+ count++;
22
+ if (item.children) count += countItems(item.children);
23
+ }
24
+ return count;
25
+ }
26
+
27
+ function mapToDbFormat(items: IStructuredItem[]): Parameters<typeof replaceItems>[2] {
28
+ return items.map((item) => ({
29
+ parent_id: null,
30
+ title: item.title,
31
+ description: item.description,
32
+ item_type: item.item_type,
33
+ priority: item.priority,
34
+ status: item.status,
35
+ children: item.children ? mapToDbFormat(item.children) : undefined,
36
+ }));
37
+ }
38
+
39
+ export async function cleanupItems(
40
+ projectId: string,
41
+ brainstormId: string,
42
+ items: IItemTree[],
43
+ brainstormContent: string,
44
+ ): Promise<{ items: IItemTree[]; changed: boolean }> {
45
+ const serialized = serializeItems(items);
46
+ const beforeCount = countItems(items);
47
+
48
+ const prompt = `You are a JSON-only deduplication machine. You NEVER respond with text, explanations, or conversation.
49
+ You ALWAYS output ONLY a raw JSON array, nothing else.
50
+
51
+ Your job: clean up the structured item tree below by removing duplicates and merging similar items.
52
+
53
+ Schema per item:
54
+ { "title": string, "description": string, "item_type": "feature"|"task"|"bug"|"idea"|"note", "priority": "high"|"medium"|"low", "status": "pending"|"in_progress"|"done", "children": [same schema] }
55
+
56
+ Rules:
57
+ - Output MUST start with [ and end with ]
58
+ - No markdown fences, no explanation, no text before or after the JSON
59
+ - MERGE items that describe the same concept (combine their descriptions, keep the more specific title)
60
+ - REMOVE exact or near-exact duplicates (keep the one with more detail)
61
+ - PRESERVE the status of items — if one copy is "done" and another is "pending", keep "done"
62
+ - PRESERVE the hierarchy — keep parent-child relationships logical
63
+ - Keep titles concise (under 50 chars)
64
+ - Do NOT add new items that weren't in the original
65
+ - Do NOT remove items just because they seem unimportant — only remove TRUE duplicates
66
+ - If the brainstorming context is provided, use it to understand which items are actually the same concept
67
+
68
+ ${brainstormContent ? `사용자의 브레인스토밍 메모:\n${brainstormContent}\n\n` : ''}현재 구조화된 항목 (중복 제거 및 병합하세요):
69
+ ${serialized}`;
70
+
71
+ const resultText = await runClaude(prompt);
72
+ const json = extractJson(resultText, 'array');
73
+ const cleaned = JSON.parse(json) as IStructuredItem[];
74
+
75
+ const afterCount = cleaned.reduce((sum, item) => sum + 1 + countStructuredChildren(item), 0);
76
+ const changed = afterCount !== beforeCount;
77
+
78
+ const dbItems = mapToDbFormat(cleaned);
79
+ const tree = replaceItems(projectId, brainstormId, dbItems);
80
+
81
+ return { items: tree, changed };
82
+ }
83
+
84
+ function countStructuredChildren(item: IStructuredItem): number {
85
+ if (!item.children) return 0;
86
+ return item.children.reduce((sum, child) => sum + 1 + countStructuredChildren(child), 0);
87
+ }