digital-tasks 2.0.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.
@@ -0,0 +1,622 @@
1
+ /**
2
+ * Markdown Task List Parser and Serializer
3
+ *
4
+ * Bidirectional conversion between markdown task lists and Task objects.
5
+ *
6
+ * ## Syntax
7
+ *
8
+ * - `- [ ]` = Unordered/parallel tasks (can run simultaneously)
9
+ * - `1. [ ]` = Ordered/sequential tasks (must run in order)
10
+ * - `- [x]` or `1. [x]` = Completed task
11
+ * - `- [-]` = In progress task
12
+ * - `- [~]` = Blocked task
13
+ * - `- [!]` = Failed task
14
+ * - Indentation (2 spaces) = Subtasks
15
+ * - `# Heading` = Project name
16
+ * - `## Heading` = Task group/section
17
+ *
18
+ * ## Example
19
+ *
20
+ * ```markdown
21
+ * # Launch Feature
22
+ *
23
+ * ## Planning (parallel)
24
+ * - [ ] Design mockups
25
+ * - [ ] Write technical spec
26
+ * - [x] Create project board
27
+ *
28
+ * ## Implementation (sequential)
29
+ * 1. [ ] Implement backend API
30
+ * 2. [-] Implement frontend UI
31
+ * - [ ] Create components
32
+ * - [ ] Add state management
33
+ * 3. [ ] Write tests
34
+ *
35
+ * ## Deployment
36
+ * 1. [ ] Deploy to staging
37
+ * 2. [ ] QA testing
38
+ * 3. [ ] Deploy to production
39
+ * ```
40
+ *
41
+ * @packageDocumentation
42
+ */
43
+
44
+ import type { TaskStatus, TaskPriority } from './types.js'
45
+ import type {
46
+ Project,
47
+ TaskNode,
48
+ TaskDefinition,
49
+ ParallelGroup,
50
+ SequentialGroup,
51
+ ExecutionMode,
52
+ } from './project.js'
53
+ import { task, parallel, sequential, createProject } from './project.js'
54
+
55
+ // ============================================================================
56
+ // Status Markers
57
+ // ============================================================================
58
+
59
+ /**
60
+ * Markdown checkbox markers and their task status
61
+ */
62
+ const STATUS_MARKERS: Record<string, TaskStatus> = {
63
+ ' ': 'pending',
64
+ 'x': 'completed',
65
+ 'X': 'completed',
66
+ '-': 'in_progress',
67
+ '~': 'blocked',
68
+ '!': 'failed',
69
+ '/': 'cancelled',
70
+ '?': 'review',
71
+ }
72
+
73
+ /**
74
+ * Reverse mapping: task status to marker
75
+ */
76
+ const STATUS_TO_MARKER: Record<TaskStatus, string> = {
77
+ pending: ' ',
78
+ queued: ' ',
79
+ assigned: '-',
80
+ in_progress: '-',
81
+ blocked: '~',
82
+ review: '?',
83
+ completed: 'x',
84
+ failed: '!',
85
+ cancelled: '/',
86
+ }
87
+
88
+ /**
89
+ * Priority markers (can be added after checkbox)
90
+ */
91
+ const PRIORITY_MARKERS: Record<string, TaskPriority> = {
92
+ '!!': 'critical',
93
+ '!': 'urgent',
94
+ '^': 'high',
95
+ '': 'normal',
96
+ 'v': 'low',
97
+ }
98
+
99
+ // ============================================================================
100
+ // Parser Types
101
+ // ============================================================================
102
+
103
+ interface ParsedLine {
104
+ indent: number
105
+ isTask: boolean
106
+ isOrdered: boolean
107
+ orderNumber?: number
108
+ status?: TaskStatus
109
+ priority?: TaskPriority
110
+ title: string
111
+ isHeading: boolean
112
+ headingLevel?: number
113
+ raw: string
114
+ }
115
+
116
+ interface ParseContext {
117
+ lines: ParsedLine[]
118
+ index: number
119
+ projectName?: string
120
+ sections: Array<{
121
+ name: string
122
+ mode: ExecutionMode
123
+ tasks: TaskNode[]
124
+ }>
125
+ }
126
+
127
+ // ============================================================================
128
+ // Parser
129
+ // ============================================================================
130
+
131
+ /**
132
+ * Parse a single line of markdown
133
+ */
134
+ function parseLine(line: string): ParsedLine {
135
+ const raw = line
136
+
137
+ // Count leading spaces for indent (2 spaces = 1 level)
138
+ const leadingSpaces = line.match(/^(\s*)/)?.[1].length || 0
139
+ const indent = Math.floor(leadingSpaces / 2)
140
+ const trimmed = line.slice(leadingSpaces)
141
+
142
+ // Check for heading
143
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
144
+ if (headingMatch) {
145
+ return {
146
+ indent,
147
+ isTask: false,
148
+ isOrdered: false,
149
+ title: headingMatch[2].trim(),
150
+ isHeading: true,
151
+ headingLevel: headingMatch[1].length,
152
+ raw,
153
+ }
154
+ }
155
+
156
+ // Check for unordered task: - [ ] or - [x] etc.
157
+ const unorderedMatch = trimmed.match(/^[-*]\s+\[([^\]]*)\]\s*(.*)$/)
158
+ if (unorderedMatch) {
159
+ const marker = unorderedMatch[1]
160
+ let title = unorderedMatch[2].trim()
161
+ let priority: TaskPriority = 'normal'
162
+
163
+ // Check for priority marker at start of title
164
+ if (title.startsWith('!!')) {
165
+ priority = 'critical'
166
+ title = title.slice(2).trim()
167
+ } else if (title.startsWith('!')) {
168
+ priority = 'urgent'
169
+ title = title.slice(1).trim()
170
+ } else if (title.startsWith('^')) {
171
+ priority = 'high'
172
+ title = title.slice(1).trim()
173
+ } else if (title.startsWith('v')) {
174
+ priority = 'low'
175
+ title = title.slice(1).trim()
176
+ }
177
+
178
+ return {
179
+ indent,
180
+ isTask: true,
181
+ isOrdered: false,
182
+ status: STATUS_MARKERS[marker] || 'pending',
183
+ priority,
184
+ title,
185
+ isHeading: false,
186
+ raw,
187
+ }
188
+ }
189
+
190
+ // Check for ordered task: 1. [ ] or 1. [x] etc.
191
+ const orderedMatch = trimmed.match(/^(\d+)\.\s+\[([^\]]*)\]\s*(.*)$/)
192
+ if (orderedMatch) {
193
+ const marker = orderedMatch[2]
194
+ let title = orderedMatch[3].trim()
195
+ let priority: TaskPriority = 'normal'
196
+
197
+ // Check for priority marker
198
+ if (title.startsWith('!!')) {
199
+ priority = 'critical'
200
+ title = title.slice(2).trim()
201
+ } else if (title.startsWith('!')) {
202
+ priority = 'urgent'
203
+ title = title.slice(1).trim()
204
+ } else if (title.startsWith('^')) {
205
+ priority = 'high'
206
+ title = title.slice(1).trim()
207
+ } else if (title.startsWith('v')) {
208
+ priority = 'low'
209
+ title = title.slice(1).trim()
210
+ }
211
+
212
+ return {
213
+ indent,
214
+ isTask: true,
215
+ isOrdered: true,
216
+ orderNumber: parseInt(orderedMatch[1], 10),
217
+ status: STATUS_MARKERS[marker] || 'pending',
218
+ priority,
219
+ title,
220
+ isHeading: false,
221
+ raw,
222
+ }
223
+ }
224
+
225
+ // Plain text line
226
+ return {
227
+ indent,
228
+ isTask: false,
229
+ isOrdered: false,
230
+ title: trimmed,
231
+ isHeading: false,
232
+ raw,
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Parse tasks at a specific indent level
238
+ */
239
+ function parseTasksAtIndent(
240
+ lines: ParsedLine[],
241
+ startIndex: number,
242
+ baseIndent: number
243
+ ): { tasks: TaskNode[]; nextIndex: number; mode: ExecutionMode } {
244
+ const tasks: TaskNode[] = []
245
+ let index = startIndex
246
+ let mode: ExecutionMode = 'parallel' // Default based on first task type
247
+ let modeSet = false
248
+
249
+ while (index < lines.length) {
250
+ const line = lines[index]
251
+
252
+ // Stop if we've gone back to a lower indent level
253
+ if (line.indent < baseIndent && (line.isTask || line.isHeading)) {
254
+ break
255
+ }
256
+
257
+ // Skip lines at lower indent (they belong to parent)
258
+ if (line.indent < baseIndent) {
259
+ index++
260
+ continue
261
+ }
262
+
263
+ // Process tasks at our indent level
264
+ if (line.indent === baseIndent && line.isTask) {
265
+ // Set mode based on first task
266
+ if (!modeSet) {
267
+ mode = line.isOrdered ? 'sequential' : 'parallel'
268
+ modeSet = true
269
+ }
270
+
271
+ // Parse subtasks
272
+ const { tasks: subtasks, nextIndex } = parseTasksAtIndent(
273
+ lines,
274
+ index + 1,
275
+ baseIndent + 1
276
+ )
277
+
278
+ const taskDef = task(line.title, {
279
+ priority: line.priority,
280
+ subtasks: subtasks.length > 0 ? subtasks : undefined,
281
+ metadata: {
282
+ _originalStatus: line.status,
283
+ _lineNumber: index,
284
+ },
285
+ })
286
+
287
+ tasks.push(taskDef)
288
+ index = nextIndex
289
+ } else {
290
+ index++
291
+ }
292
+ }
293
+
294
+ return { tasks, nextIndex: index, mode }
295
+ }
296
+
297
+ /**
298
+ * Parse a markdown string into a Project
299
+ *
300
+ * @example
301
+ * ```ts
302
+ * const markdown = `
303
+ * # My Project
304
+ *
305
+ * - [ ] Task 1
306
+ * - [ ] Task 2
307
+ *
308
+ * ## Sequential Work
309
+ * 1. [ ] Step 1
310
+ * 2. [ ] Step 2
311
+ * `
312
+ *
313
+ * const project = parseMarkdown(markdown)
314
+ * ```
315
+ */
316
+ export function parseMarkdown(markdown: string): Project {
317
+ // Normalize line endings (handle Windows \r\n and old Mac \r)
318
+ const normalizedMarkdown = markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
319
+ const rawLines = normalizedMarkdown.split('\n')
320
+ const lines = rawLines.map(parseLine)
321
+
322
+ let projectName = 'Untitled Project'
323
+ let projectDescription: string | undefined
324
+ const allTasks: TaskNode[] = []
325
+
326
+ // Find project name from first h1
327
+ const h1Index = lines.findIndex(l => l.isHeading && l.headingLevel === 1)
328
+ if (h1Index !== -1) {
329
+ projectName = lines[h1Index].title
330
+ }
331
+
332
+ // Process sections and tasks
333
+ let currentSection: { name: string; mode: ExecutionMode; tasks: TaskNode[] } | null = null
334
+ let index = 0
335
+
336
+ while (index < lines.length) {
337
+ const line = lines[index]
338
+
339
+ // New section (h2)
340
+ if (line.isHeading && line.headingLevel === 2) {
341
+ // Save previous section
342
+ if (currentSection && currentSection.tasks.length > 0) {
343
+ if (currentSection.mode === 'sequential') {
344
+ allTasks.push(sequential(...currentSection.tasks))
345
+ } else {
346
+ allTasks.push(parallel(...currentSection.tasks))
347
+ }
348
+ }
349
+
350
+ // Detect mode from section name (e.g., "## Implementation (sequential)")
351
+ let sectionName = line.title
352
+ let sectionMode: ExecutionMode = 'parallel'
353
+
354
+ const modeMatch = sectionName.match(/\((parallel|sequential)\)\s*$/i)
355
+ if (modeMatch) {
356
+ sectionMode = modeMatch[1].toLowerCase() as ExecutionMode
357
+ sectionName = sectionName.replace(/\s*\((parallel|sequential)\)\s*$/i, '')
358
+ }
359
+
360
+ currentSection = { name: sectionName, mode: sectionMode, tasks: [] }
361
+ index++
362
+ continue
363
+ }
364
+
365
+ // Task at root level or in section
366
+ if (line.isTask && line.indent === 0) {
367
+ const { tasks, nextIndex, mode } = parseTasksAtIndent(lines, index, 0)
368
+
369
+ if (currentSection) {
370
+ currentSection.tasks.push(...tasks)
371
+ // Update section mode based on first task if not explicitly set
372
+ if (currentSection.tasks.length === tasks.length) {
373
+ currentSection.mode = mode
374
+ }
375
+ } else {
376
+ // No section, add to root with appropriate grouping
377
+ if (mode === 'sequential') {
378
+ allTasks.push(sequential(...tasks))
379
+ } else {
380
+ allTasks.push(parallel(...tasks))
381
+ }
382
+ }
383
+ index = nextIndex
384
+ continue
385
+ }
386
+
387
+ index++
388
+ }
389
+
390
+ // Add final section
391
+ if (currentSection && currentSection.tasks.length > 0) {
392
+ if (currentSection.mode === 'sequential') {
393
+ allTasks.push(sequential(...currentSection.tasks))
394
+ } else {
395
+ allTasks.push(parallel(...currentSection.tasks))
396
+ }
397
+ }
398
+
399
+ return createProject({
400
+ name: projectName,
401
+ description: projectDescription,
402
+ tasks: allTasks,
403
+ })
404
+ }
405
+
406
+ // ============================================================================
407
+ // Serializer
408
+ // ============================================================================
409
+
410
+ /**
411
+ * Options for markdown serialization
412
+ */
413
+ export interface SerializeOptions {
414
+ /** Include status markers (default: true) */
415
+ includeStatus?: boolean
416
+ /** Include priority markers (default: false) */
417
+ includePriority?: boolean
418
+ /** Indent size in spaces (default: 2) */
419
+ indentSize?: number
420
+ /** Include section headings (default: true) */
421
+ includeSections?: boolean
422
+ }
423
+
424
+ /**
425
+ * Serialize a task node to markdown lines
426
+ */
427
+ function serializeTaskNode(
428
+ node: TaskNode,
429
+ indent: number,
430
+ options: SerializeOptions,
431
+ isSequential: boolean
432
+ ): string[] {
433
+ const lines: string[] = []
434
+ const indentStr = ' '.repeat(indent * (options.indentSize || 2))
435
+
436
+ if (node.__type === 'task') {
437
+ const taskDef = node as TaskDefinition
438
+ const status = (taskDef.metadata?._originalStatus as TaskStatus) || 'pending'
439
+ const marker = options.includeStatus !== false ? STATUS_TO_MARKER[status] : ' '
440
+
441
+ let prefix: string
442
+ if (isSequential) {
443
+ // Use numbered list for sequential
444
+ const num = (taskDef.metadata?._sequenceNumber as number) || 1
445
+ prefix = `${num}. [${marker}]`
446
+ } else {
447
+ // Use bullet for parallel
448
+ prefix = `- [${marker}]`
449
+ }
450
+
451
+ let title = taskDef.title
452
+ if (options.includePriority && taskDef.priority && taskDef.priority !== 'normal') {
453
+ const priorityMarker = taskDef.priority === 'critical' ? '!!'
454
+ : taskDef.priority === 'urgent' ? '!'
455
+ : taskDef.priority === 'high' ? '^'
456
+ : taskDef.priority === 'low' ? 'v'
457
+ : ''
458
+ title = `${priorityMarker}${title}`
459
+ }
460
+
461
+ lines.push(`${indentStr}${prefix} ${title}`)
462
+
463
+ // Serialize subtasks
464
+ if (taskDef.subtasks && taskDef.subtasks.length > 0) {
465
+ for (const subtask of taskDef.subtasks) {
466
+ lines.push(...serializeTaskNode(subtask, indent + 1, options, false))
467
+ }
468
+ }
469
+ } else if (node.__type === 'parallel') {
470
+ const group = node as ParallelGroup
471
+ let seqNum = 1
472
+ for (const child of group.tasks) {
473
+ if (child.__type === 'task') {
474
+ (child as TaskDefinition).metadata = {
475
+ ...(child as TaskDefinition).metadata,
476
+ _sequenceNumber: seqNum++,
477
+ }
478
+ }
479
+ lines.push(...serializeTaskNode(child, indent, options, false))
480
+ }
481
+ } else if (node.__type === 'sequential') {
482
+ const group = node as SequentialGroup
483
+ let seqNum = 1
484
+ for (const child of group.tasks) {
485
+ if (child.__type === 'task') {
486
+ (child as TaskDefinition).metadata = {
487
+ ...(child as TaskDefinition).metadata,
488
+ _sequenceNumber: seqNum++,
489
+ }
490
+ }
491
+ lines.push(...serializeTaskNode(child, indent, options, true))
492
+ }
493
+ }
494
+
495
+ return lines
496
+ }
497
+
498
+ /**
499
+ * Serialize a Project to markdown
500
+ *
501
+ * @example
502
+ * ```ts
503
+ * const project = createProject({
504
+ * name: 'My Project',
505
+ * tasks: [
506
+ * parallel(
507
+ * task('Task 1'),
508
+ * task('Task 2'),
509
+ * ),
510
+ * sequential(
511
+ * task('Step 1'),
512
+ * task('Step 2'),
513
+ * ),
514
+ * ],
515
+ * })
516
+ *
517
+ * const markdown = toMarkdown(project)
518
+ * // # My Project
519
+ * //
520
+ * // - [ ] Task 1
521
+ * // - [ ] Task 2
522
+ * //
523
+ * // 1. [ ] Step 1
524
+ * // 2. [ ] Step 2
525
+ * ```
526
+ */
527
+ export function toMarkdown(project: Project, options: SerializeOptions = {}): string {
528
+ const lines: string[] = []
529
+
530
+ // Project title
531
+ lines.push(`# ${project.name}`)
532
+ lines.push('')
533
+
534
+ if (project.description) {
535
+ lines.push(project.description)
536
+ lines.push('')
537
+ }
538
+
539
+ // Tasks
540
+ for (const node of project.tasks) {
541
+ const taskLines = serializeTaskNode(node, 0, options, false)
542
+ lines.push(...taskLines)
543
+
544
+ // Add blank line between top-level groups
545
+ if (taskLines.length > 0) {
546
+ lines.push('')
547
+ }
548
+ }
549
+
550
+ return lines.join('\n').trim() + '\n'
551
+ }
552
+
553
+ // ============================================================================
554
+ // Conversion Utilities
555
+ // ============================================================================
556
+
557
+ /**
558
+ * Update task statuses in a project from markdown
559
+ * (Useful for syncing when markdown is edited externally)
560
+ */
561
+ export function syncStatusFromMarkdown(
562
+ project: Project,
563
+ markdown: string
564
+ ): Project {
565
+ const parsed = parseMarkdown(markdown)
566
+
567
+ // Build a map of task titles to statuses from parsed markdown
568
+ const statusMap = new Map<string, TaskStatus>()
569
+
570
+ function collectStatuses(node: TaskNode) {
571
+ if (node.__type === 'task') {
572
+ const taskDef = node as TaskDefinition
573
+ const status = taskDef.metadata?._originalStatus as TaskStatus
574
+ if (status) {
575
+ statusMap.set(taskDef.title, status)
576
+ }
577
+ if (taskDef.subtasks) {
578
+ taskDef.subtasks.forEach(collectStatuses)
579
+ }
580
+ } else if (node.__type === 'parallel' || node.__type === 'sequential') {
581
+ const group = node as ParallelGroup | SequentialGroup
582
+ group.tasks.forEach(collectStatuses)
583
+ }
584
+ }
585
+
586
+ parsed.tasks.forEach(collectStatuses)
587
+
588
+ // Update statuses in original project
589
+ function updateStatuses(node: TaskNode): TaskNode {
590
+ if (node.__type === 'task') {
591
+ const taskDef = node as TaskDefinition
592
+ const newStatus = statusMap.get(taskDef.title)
593
+ return {
594
+ ...taskDef,
595
+ metadata: {
596
+ ...taskDef.metadata,
597
+ _originalStatus: newStatus || taskDef.metadata?._originalStatus,
598
+ },
599
+ subtasks: taskDef.subtasks?.map(updateStatuses),
600
+ }
601
+ } else if (node.__type === 'parallel') {
602
+ const group = node as ParallelGroup
603
+ return {
604
+ ...group,
605
+ tasks: group.tasks.map(updateStatuses),
606
+ }
607
+ } else if (node.__type === 'sequential') {
608
+ const group = node as SequentialGroup
609
+ return {
610
+ ...group,
611
+ tasks: group.tasks.map(updateStatuses),
612
+ }
613
+ }
614
+ return node
615
+ }
616
+
617
+ return {
618
+ ...project,
619
+ tasks: project.tasks.map(updateStatuses),
620
+ updatedAt: new Date(),
621
+ }
622
+ }