@task-mcp/shared 1.0.28 → 1.0.29
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/algorithms/index.d.ts +1 -1
- package/dist/algorithms/index.d.ts.map +1 -1
- package/dist/algorithms/index.js +1 -1
- package/dist/algorithms/index.js.map +1 -1
- package/dist/algorithms/topological-sort.d.ts +21 -1
- package/dist/algorithms/topological-sort.d.ts.map +1 -1
- package/dist/algorithms/topological-sort.js +12 -1
- package/dist/algorithms/topological-sort.js.map +1 -1
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +2 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/schemas/session.d.ts +521 -0
- package/dist/schemas/session.d.ts.map +1 -0
- package/dist/schemas/session.js +79 -0
- package/dist/schemas/session.js.map +1 -0
- package/dist/schemas/task.d.ts.map +1 -1
- package/dist/schemas/task.js +20 -6
- package/dist/schemas/task.js.map +1 -1
- package/dist/schemas/view.d.ts +18 -18
- package/dist/utils/hierarchy.d.ts.map +1 -1
- package/dist/utils/hierarchy.js +6 -3
- package/dist/utils/hierarchy.js.map +1 -1
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +17 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/plan-parser.d.ts +57 -0
- package/dist/utils/plan-parser.d.ts.map +1 -0
- package/dist/utils/plan-parser.js +377 -0
- package/dist/utils/plan-parser.js.map +1 -0
- package/dist/utils/terminal-ui.d.ts +129 -0
- package/dist/utils/terminal-ui.d.ts.map +1 -1
- package/dist/utils/terminal-ui.js +191 -0
- package/dist/utils/terminal-ui.js.map +1 -1
- package/dist/utils/terminal-ui.test.js +236 -0
- package/dist/utils/terminal-ui.test.js.map +1 -1
- package/package.json +2 -2
- package/src/algorithms/index.ts +3 -0
- package/src/algorithms/topological-sort.ts +31 -1
- package/src/schemas/index.ts +11 -0
- package/src/schemas/session.ts +100 -0
- package/src/schemas/task.ts +30 -16
- package/src/utils/hierarchy.ts +8 -3
- package/src/utils/index.ts +31 -0
- package/src/utils/plan-parser.ts +478 -0
- package/src/utils/terminal-ui.test.ts +286 -0
- package/src/utils/terminal-ui.ts +315 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import type { Priority, WorkContext } from "../schemas/task.js";
|
|
2
|
+
import { extractMetadata } from "./natural-language.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parsed task from plan text
|
|
6
|
+
*/
|
|
7
|
+
export interface ParsedPlanTask {
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
level: number; // 0 = root phase, 1 = section, 2 = task, 3 = subtask
|
|
11
|
+
tempId: string;
|
|
12
|
+
parentTempId?: string;
|
|
13
|
+
dependsOn?: string[];
|
|
14
|
+
workContext?: Partial<WorkContext>;
|
|
15
|
+
priority?: Priority;
|
|
16
|
+
tags?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Options for parsing plan text
|
|
21
|
+
*/
|
|
22
|
+
export interface ParsePlanOptions {
|
|
23
|
+
/** Default priority for tasks without explicit priority (default: medium) */
|
|
24
|
+
defaultPriority?: Priority;
|
|
25
|
+
/** Parse natural language patterns like !high, #tag (default: true) */
|
|
26
|
+
parseNaturalLanguage?: boolean;
|
|
27
|
+
/** Infer dependencies from 'depends on', 'after' patterns (default: true) */
|
|
28
|
+
inferDependencies?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Result of parsing plan text
|
|
33
|
+
*/
|
|
34
|
+
export interface ParsePlanResult {
|
|
35
|
+
tasks: ParsedPlanTask[];
|
|
36
|
+
phases: string[];
|
|
37
|
+
warnings: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Maximum input length to prevent DoS
|
|
42
|
+
*/
|
|
43
|
+
const MAX_PLAN_LENGTH = 50000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Preprocess input to remove code blocks and YAML frontmatter
|
|
47
|
+
*/
|
|
48
|
+
function preprocessInput(input: string): string {
|
|
49
|
+
let result = input;
|
|
50
|
+
|
|
51
|
+
// Remove YAML frontmatter (--- at start of file)
|
|
52
|
+
// Must be at the very beginning of the document
|
|
53
|
+
const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
|
|
54
|
+
if (frontmatterMatch) {
|
|
55
|
+
result = result.slice(frontmatterMatch[0].length);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Remove fenced code blocks (``` or ~~~)
|
|
59
|
+
// Matches both backticks and tildes, with optional language identifier
|
|
60
|
+
result = result.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?\n\1$/gm, "");
|
|
61
|
+
|
|
62
|
+
// Also handle code blocks that might not have matching closing fence
|
|
63
|
+
// (replace opening fence and content until next fence or end)
|
|
64
|
+
result = result.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?=\n(`{3,}|~{3,})|$)/gm, (match) => {
|
|
65
|
+
// Only remove if it looks like a code block (has content)
|
|
66
|
+
if (match.includes("\n")) {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
return match;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse markdown/structured text into hierarchical tasks
|
|
77
|
+
*
|
|
78
|
+
* Parsing rules:
|
|
79
|
+
* - `# Header` → Top-level phase (level 0)
|
|
80
|
+
* - `## SubHeader` → Section (level 1)
|
|
81
|
+
* - `- Item` → Task (level based on indentation)
|
|
82
|
+
* - `depends on X`, `after X` → Dependency inference
|
|
83
|
+
* - `!high`, `#tag` → Natural language patterns
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* const result = parsePlan(`
|
|
88
|
+
* # Phase 1: Setup
|
|
89
|
+
* - Create database schema !high
|
|
90
|
+
* - Define user table
|
|
91
|
+
* - Define session table
|
|
92
|
+
* - Setup API endpoints (depends on database)
|
|
93
|
+
* `);
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
export function parsePlan(
|
|
97
|
+
input: string,
|
|
98
|
+
options: ParsePlanOptions = {}
|
|
99
|
+
): ParsePlanResult {
|
|
100
|
+
if (input.length > MAX_PLAN_LENGTH) {
|
|
101
|
+
return {
|
|
102
|
+
tasks: [],
|
|
103
|
+
phases: [],
|
|
104
|
+
warnings: [`Input exceeds maximum length of ${MAX_PLAN_LENGTH} characters`],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const {
|
|
109
|
+
defaultPriority = "medium",
|
|
110
|
+
parseNaturalLanguage = true,
|
|
111
|
+
inferDependencies = true,
|
|
112
|
+
} = options;
|
|
113
|
+
|
|
114
|
+
// Preprocess to remove code blocks and YAML frontmatter
|
|
115
|
+
const processedInput = preprocessInput(input);
|
|
116
|
+
const lines = processedInput.split("\n");
|
|
117
|
+
const tasks: ParsedPlanTask[] = [];
|
|
118
|
+
const phases: string[] = [];
|
|
119
|
+
const warnings: string[] = [];
|
|
120
|
+
|
|
121
|
+
let taskCounter = 0;
|
|
122
|
+
let currentPhase: { tempId: string; title: string } | null = null;
|
|
123
|
+
let currentSection: { tempId: string; level: number } | null = null;
|
|
124
|
+
let currentParent: { tempId: string; level: number; indent: number } | null = null;
|
|
125
|
+
const parentStack: Array<{ tempId: string; level: number; indent: number }> = [];
|
|
126
|
+
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
const trimmed = line.trim();
|
|
129
|
+
|
|
130
|
+
// Skip empty lines
|
|
131
|
+
if (!trimmed) continue;
|
|
132
|
+
|
|
133
|
+
// Parse H1 headers as phases (level 0)
|
|
134
|
+
const h1Match = trimmed.match(/^#\s+(.+)$/);
|
|
135
|
+
if (h1Match) {
|
|
136
|
+
const phaseTitle = h1Match[1]!.trim();
|
|
137
|
+
const tempId = `phase-${++taskCounter}`;
|
|
138
|
+
currentPhase = { tempId, title: phaseTitle };
|
|
139
|
+
phases.push(phaseTitle);
|
|
140
|
+
|
|
141
|
+
// Extract metadata if enabled
|
|
142
|
+
let title = phaseTitle;
|
|
143
|
+
let priority: Priority | undefined;
|
|
144
|
+
let tags: string[] | undefined;
|
|
145
|
+
|
|
146
|
+
if (parseNaturalLanguage) {
|
|
147
|
+
const metadata = extractMetadata(phaseTitle);
|
|
148
|
+
title = metadata.remaining || phaseTitle;
|
|
149
|
+
priority = metadata.priority;
|
|
150
|
+
tags = metadata.tags;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
tasks.push({
|
|
154
|
+
title,
|
|
155
|
+
level: 0,
|
|
156
|
+
tempId,
|
|
157
|
+
priority: priority ?? "high",
|
|
158
|
+
...(tags && tags.length > 0 && { tags }),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Reset section and parent stack
|
|
162
|
+
currentSection = null;
|
|
163
|
+
currentParent = null;
|
|
164
|
+
parentStack.length = 0;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Parse H2 headers as sections (level 1)
|
|
169
|
+
const h2Match = trimmed.match(/^##\s+(.+)$/);
|
|
170
|
+
if (h2Match) {
|
|
171
|
+
const sectionTitle = h2Match[1]!.trim();
|
|
172
|
+
const tempId = `section-${++taskCounter}`;
|
|
173
|
+
|
|
174
|
+
let title = sectionTitle;
|
|
175
|
+
let priority: Priority | undefined;
|
|
176
|
+
let tags: string[] | undefined;
|
|
177
|
+
|
|
178
|
+
if (parseNaturalLanguage) {
|
|
179
|
+
const metadata = extractMetadata(sectionTitle);
|
|
180
|
+
title = metadata.remaining || sectionTitle;
|
|
181
|
+
priority = metadata.priority;
|
|
182
|
+
tags = metadata.tags;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
tasks.push({
|
|
186
|
+
title,
|
|
187
|
+
level: 1,
|
|
188
|
+
tempId,
|
|
189
|
+
...(currentPhase && { parentTempId: currentPhase.tempId }),
|
|
190
|
+
priority: priority ?? defaultPriority,
|
|
191
|
+
...(tags && tags.length > 0 && { tags }),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
currentSection = { tempId, level: 1 };
|
|
195
|
+
currentParent = null;
|
|
196
|
+
parentStack.length = 0;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Parse list items as tasks
|
|
201
|
+
const listMatch = line.match(/^(\s*)[-*]\s+(.+)$/);
|
|
202
|
+
if (listMatch) {
|
|
203
|
+
const indent = listMatch[1]!.length;
|
|
204
|
+
const content = listMatch[2]!.trim();
|
|
205
|
+
const tempId = `task-${++taskCounter}`;
|
|
206
|
+
|
|
207
|
+
// Determine parent based on indentation
|
|
208
|
+
let parentTempId: string | undefined;
|
|
209
|
+
let level: number;
|
|
210
|
+
|
|
211
|
+
// Pop parents from stack that have same or greater indentation
|
|
212
|
+
while (parentStack.length > 0 && parentStack[parentStack.length - 1]!.indent >= indent) {
|
|
213
|
+
parentStack.pop();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (parentStack.length > 0) {
|
|
217
|
+
// Use the last item on stack as parent
|
|
218
|
+
const parent = parentStack[parentStack.length - 1]!;
|
|
219
|
+
parentTempId = parent.tempId;
|
|
220
|
+
level = Math.min(parent.level + 1, 3); // Max depth 3
|
|
221
|
+
} else if (currentSection) {
|
|
222
|
+
parentTempId = currentSection.tempId;
|
|
223
|
+
level = 2;
|
|
224
|
+
} else if (currentPhase) {
|
|
225
|
+
parentTempId = currentPhase.tempId;
|
|
226
|
+
level = 1;
|
|
227
|
+
} else {
|
|
228
|
+
level = 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Extract metadata from content
|
|
232
|
+
let title = content;
|
|
233
|
+
let description: string | undefined;
|
|
234
|
+
let priority: Priority | undefined;
|
|
235
|
+
let tags: string[] | undefined;
|
|
236
|
+
let dependsOn: string[] | undefined;
|
|
237
|
+
|
|
238
|
+
if (parseNaturalLanguage) {
|
|
239
|
+
const metadata = extractMetadata(content);
|
|
240
|
+
title = metadata.remaining || content;
|
|
241
|
+
priority = metadata.priority;
|
|
242
|
+
tags = metadata.tags;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Infer dependencies from text
|
|
246
|
+
if (inferDependencies) {
|
|
247
|
+
dependsOn = inferDependenciesFromText(title, tasks);
|
|
248
|
+
// Clean up dependency text from title
|
|
249
|
+
title = cleanDependencyText(title);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Extract description from remaining parentheses (not dependency-related)
|
|
253
|
+
const descExtracted = extractDescription(title);
|
|
254
|
+
title = descExtracted.title;
|
|
255
|
+
description = descExtracted.description;
|
|
256
|
+
|
|
257
|
+
const task: ParsedPlanTask = {
|
|
258
|
+
title,
|
|
259
|
+
level,
|
|
260
|
+
tempId,
|
|
261
|
+
...(parentTempId && { parentTempId }),
|
|
262
|
+
...(description && { description }),
|
|
263
|
+
priority: priority ?? defaultPriority,
|
|
264
|
+
...(tags && tags.length > 0 && { tags }),
|
|
265
|
+
...(dependsOn && dependsOn.length > 0 && { dependsOn }),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
tasks.push(task);
|
|
269
|
+
|
|
270
|
+
// Push to parent stack for potential children
|
|
271
|
+
parentStack.push({ tempId, level, indent });
|
|
272
|
+
currentParent = { tempId, level, indent };
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Parse numbered list items
|
|
277
|
+
const numberedMatch = line.match(/^(\s*)(\d+)[.)]\s+(.+)$/);
|
|
278
|
+
if (numberedMatch) {
|
|
279
|
+
const indent = numberedMatch[1]!.length;
|
|
280
|
+
const content = numberedMatch[3]!.trim();
|
|
281
|
+
const tempId = `task-${++taskCounter}`;
|
|
282
|
+
|
|
283
|
+
// Same logic as unordered lists
|
|
284
|
+
while (parentStack.length > 0 && parentStack[parentStack.length - 1]!.indent >= indent) {
|
|
285
|
+
parentStack.pop();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let parentTempId: string | undefined;
|
|
289
|
+
let level: number;
|
|
290
|
+
|
|
291
|
+
if (parentStack.length > 0) {
|
|
292
|
+
const parent = parentStack[parentStack.length - 1]!;
|
|
293
|
+
parentTempId = parent.tempId;
|
|
294
|
+
level = Math.min(parent.level + 1, 3);
|
|
295
|
+
} else if (currentSection) {
|
|
296
|
+
parentTempId = currentSection.tempId;
|
|
297
|
+
level = 2;
|
|
298
|
+
} else if (currentPhase) {
|
|
299
|
+
parentTempId = currentPhase.tempId;
|
|
300
|
+
level = 1;
|
|
301
|
+
} else {
|
|
302
|
+
level = 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let title = content;
|
|
306
|
+
let description: string | undefined;
|
|
307
|
+
let priority: Priority | undefined;
|
|
308
|
+
let tags: string[] | undefined;
|
|
309
|
+
let dependsOn: string[] | undefined;
|
|
310
|
+
|
|
311
|
+
if (parseNaturalLanguage) {
|
|
312
|
+
const metadata = extractMetadata(content);
|
|
313
|
+
title = metadata.remaining || content;
|
|
314
|
+
priority = metadata.priority;
|
|
315
|
+
tags = metadata.tags;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (inferDependencies) {
|
|
319
|
+
dependsOn = inferDependenciesFromText(title, tasks);
|
|
320
|
+
title = cleanDependencyText(title);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Extract description from remaining parentheses
|
|
324
|
+
const descExtracted = extractDescription(title);
|
|
325
|
+
title = descExtracted.title;
|
|
326
|
+
description = descExtracted.description;
|
|
327
|
+
|
|
328
|
+
tasks.push({
|
|
329
|
+
title,
|
|
330
|
+
level,
|
|
331
|
+
tempId,
|
|
332
|
+
...(parentTempId && { parentTempId }),
|
|
333
|
+
...(description && { description }),
|
|
334
|
+
priority: priority ?? defaultPriority,
|
|
335
|
+
...(tags && tags.length > 0 && { tags }),
|
|
336
|
+
...(dependsOn && dependsOn.length > 0 && { dependsOn }),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
parentStack.push({ tempId, level, indent });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return { tasks, phases, warnings };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Infer dependencies from text patterns like "depends on X", "after X"
|
|
349
|
+
*/
|
|
350
|
+
function inferDependenciesFromText(
|
|
351
|
+
content: string,
|
|
352
|
+
existingTasks: ParsedPlanTask[]
|
|
353
|
+
): string[] {
|
|
354
|
+
const deps: string[] = [];
|
|
355
|
+
const lower = content.toLowerCase();
|
|
356
|
+
|
|
357
|
+
// Pattern: (depends on|after|requires|following) "phrase" or phrase
|
|
358
|
+
const patterns = [
|
|
359
|
+
/\(?\s*depends?\s+on\s+(.+?)\s*\)?/gi,
|
|
360
|
+
/\(?\s*after\s+(.+?)\s*\)?/gi,
|
|
361
|
+
/\(?\s*requires?\s+(.+?)\s*\)?/gi,
|
|
362
|
+
/\(?\s*following\s+(.+?)\s*\)?/gi,
|
|
363
|
+
/\(?\s*blocked\s+by\s+(.+?)\s*\)?/gi,
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
for (const pattern of patterns) {
|
|
367
|
+
let match;
|
|
368
|
+
while ((match = pattern.exec(lower)) !== null) {
|
|
369
|
+
const depPhrase = match[1]!.trim();
|
|
370
|
+
|
|
371
|
+
// Try to find matching task by title
|
|
372
|
+
const found = existingTasks.find((t) => {
|
|
373
|
+
const taskTitle = t.title.toLowerCase();
|
|
374
|
+
return (
|
|
375
|
+
taskTitle.includes(depPhrase) ||
|
|
376
|
+
depPhrase.includes(taskTitle) ||
|
|
377
|
+
fuzzyMatch(taskTitle, depPhrase)
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
if (found && !deps.includes(found.tempId)) {
|
|
382
|
+
deps.push(found.tempId);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return deps;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Simple fuzzy matching for dependency detection
|
|
392
|
+
*/
|
|
393
|
+
function fuzzyMatch(a: string, b: string): boolean {
|
|
394
|
+
// Remove common words and compare
|
|
395
|
+
const stopWords = ["the", "a", "an", "to", "for", "of", "in", "on", "at"];
|
|
396
|
+
const normalize = (s: string) =>
|
|
397
|
+
s
|
|
398
|
+
.toLowerCase()
|
|
399
|
+
.split(/\s+/)
|
|
400
|
+
.filter((w) => !stopWords.includes(w))
|
|
401
|
+
.join(" ");
|
|
402
|
+
|
|
403
|
+
const aNorm = normalize(a);
|
|
404
|
+
const bNorm = normalize(b);
|
|
405
|
+
|
|
406
|
+
// Check if significant overlap
|
|
407
|
+
const aWords = aNorm.split(" ");
|
|
408
|
+
const bWords = bNorm.split(" ");
|
|
409
|
+
|
|
410
|
+
const matches = aWords.filter((w) => bWords.includes(w));
|
|
411
|
+
return matches.length >= Math.min(2, Math.min(aWords.length, bWords.length));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Remove dependency text from title
|
|
416
|
+
*/
|
|
417
|
+
function cleanDependencyText(title: string): string {
|
|
418
|
+
return title
|
|
419
|
+
.replace(/\(?\s*depends?\s+on\s+.+?\s*\)?/gi, "")
|
|
420
|
+
.replace(/\(?\s*after\s+.+?\s*\)?/gi, "")
|
|
421
|
+
.replace(/\(?\s*requires?\s+.+?\s*\)?/gi, "")
|
|
422
|
+
.replace(/\(?\s*following\s+.+?\s*\)?/gi, "")
|
|
423
|
+
.replace(/\(?\s*blocked\s+by\s+.+?\s*\)?/gi, "")
|
|
424
|
+
.replace(/\s+/g, " ")
|
|
425
|
+
.trim();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Extract description from title
|
|
430
|
+
*
|
|
431
|
+
* Patterns supported:
|
|
432
|
+
* - "제목: 설명" → colon separator
|
|
433
|
+
* - "제목 -- 설명" → double dash separator
|
|
434
|
+
* - "제목 (설명)" → parentheses (after dependency cleanup)
|
|
435
|
+
*
|
|
436
|
+
* @example
|
|
437
|
+
* extractDescription("API 구현: 라우팅, 인증 담당")
|
|
438
|
+
* // → { title: "API 구현", description: "라우팅, 인증 담당" }
|
|
439
|
+
*
|
|
440
|
+
* extractDescription("API 구현 (라우팅, 인증 담당)")
|
|
441
|
+
* // → { title: "API 구현", description: "라우팅, 인증 담당" }
|
|
442
|
+
*/
|
|
443
|
+
function extractDescription(title: string): { title: string; description?: string } {
|
|
444
|
+
// Pattern 1: Colon separator (but not time like "10:30")
|
|
445
|
+
// Match "제목: 설명" but not "10:30" or "http://..."
|
|
446
|
+
const colonMatch = title.match(/^(.+?):\s+([^/].{5,})$/);
|
|
447
|
+
if (colonMatch && !/^\d+:\d+/.test(colonMatch[2]!)) {
|
|
448
|
+
return {
|
|
449
|
+
title: colonMatch[1]!.trim(),
|
|
450
|
+
description: colonMatch[2]!.trim(),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Pattern 2: Double dash separator "제목 -- 설명"
|
|
455
|
+
const dashMatch = title.match(/^(.+?)\s+--\s+(.+)$/);
|
|
456
|
+
if (dashMatch) {
|
|
457
|
+
return {
|
|
458
|
+
title: dashMatch[1]!.trim(),
|
|
459
|
+
description: dashMatch[2]!.trim(),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Pattern 3: Parentheses at end (only if not dependency-like)
|
|
464
|
+
// Already cleaned by cleanDependencyText, so remaining () is description
|
|
465
|
+
const parenMatch = title.match(/^(.+?)\s*\(([^)]+)\)\s*$/);
|
|
466
|
+
if (parenMatch) {
|
|
467
|
+
const content = parenMatch[2]!.trim();
|
|
468
|
+
// Skip if looks like a short annotation (single word, abbreviation)
|
|
469
|
+
if (content.length > 3 && content.includes(" ")) {
|
|
470
|
+
return {
|
|
471
|
+
title: parenMatch[1]!.trim(),
|
|
472
|
+
description: content,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return { title };
|
|
478
|
+
}
|