todoosy 0.3.3 → 0.3.5

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,719 @@
1
+ "use strict";
2
+ /**
3
+ * Todoosy Parser
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseTokensInParenGroup = parseTokensInParenGroup;
7
+ exports.extractParenGroups = extractParenGroups;
8
+ exports.parse = parse;
9
+ const HEADING_REGEX = /^(#{1,6})\s+(.*)$/;
10
+ const LIST_ITEM_REGEX = /^(\s*)([-*]|\d+\.)\s+(.*)$/;
11
+ const DUE_ISO_REGEX = /^due\s+(\d{4})-(\d{2})-(\d{2})$/i;
12
+ const DUE_US_REGEX = /^due\s+(\d{1,2})\/(\d{1,2})\/(\d{4})$/i;
13
+ const DUE_US_SHORT_REGEX = /^due\s+(\d{1,2})\/(\d{1,2})\/(\d{2})$/i;
14
+ const PRIORITY_REGEX = /^p(\d+)$/i;
15
+ const ESTIMATE_REGEX = /^(\d+)([mhd])$/i;
16
+ const HASHTAG_REGEX = /^#([a-zA-Z][a-zA-Z0-9_-]*)$/;
17
+ const MONTH_NAMES = {
18
+ january: 1, jan: 1,
19
+ february: 2, feb: 2,
20
+ march: 3, mar: 3,
21
+ april: 4, apr: 4,
22
+ may: 5,
23
+ june: 6, jun: 6,
24
+ july: 7, jul: 7,
25
+ august: 8, aug: 8,
26
+ september: 9, sep: 9,
27
+ october: 10, oct: 10,
28
+ november: 11, nov: 11,
29
+ december: 12, dec: 12,
30
+ };
31
+ // Built-in progress states (normalized to lowercase)
32
+ const PROGRESS_STATES = new Set(['done', 'deleted', 'in progress', 'blocked']);
33
+ function inferYear(month, day) {
34
+ const now = new Date();
35
+ const currentYear = now.getFullYear();
36
+ const currentMonth = now.getMonth() + 1;
37
+ const currentDay = now.getDate();
38
+ // Calculate months difference
39
+ let monthsDiff = (currentMonth - month) + (currentDay > day ? 0 : 0);
40
+ if (currentMonth > month || (currentMonth === month && currentDay > day)) {
41
+ monthsDiff = (currentMonth - month) + (currentDay > day ? 0 : -1);
42
+ // More precise: is the date more than 3 months in the past?
43
+ const candidateDate = new Date(currentYear, month - 1, day);
44
+ const threeMonthsAgo = new Date(now);
45
+ threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
46
+ if (candidateDate < threeMonthsAgo) {
47
+ return currentYear + 1;
48
+ }
49
+ }
50
+ return currentYear;
51
+ }
52
+ function parseDate(dateStr) {
53
+ // ISO format: YYYY-MM-DD or YYYY-M-D
54
+ const isoMatch = dateStr.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
55
+ if (isoMatch) {
56
+ const year = isoMatch[1];
57
+ const month = isoMatch[2].padStart(2, '0');
58
+ const day = isoMatch[3].padStart(2, '0');
59
+ return { date: `${year}-${month}-${day}`, valid: true, raw: dateStr };
60
+ }
61
+ // Short ISO format: YY-MM-DD or YY-M-D
62
+ const isoShortMatch = dateStr.match(/^(\d{2})-(\d{1,2})-(\d{1,2})$/);
63
+ if (isoShortMatch) {
64
+ const year = `20${isoShortMatch[1]}`;
65
+ const month = isoShortMatch[2].padStart(2, '0');
66
+ const day = isoShortMatch[3].padStart(2, '0');
67
+ return { date: `${year}-${month}-${day}`, valid: true, raw: dateStr };
68
+ }
69
+ // Year-first with slashes: YYYY/MM/DD
70
+ const ymdSlashMatch = dateStr.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/);
71
+ if (ymdSlashMatch) {
72
+ const year = ymdSlashMatch[1];
73
+ const month = ymdSlashMatch[2].padStart(2, '0');
74
+ const day = ymdSlashMatch[3].padStart(2, '0');
75
+ return { date: `${year}-${month}-${day}`, valid: true, raw: dateStr };
76
+ }
77
+ // Slash format: X/X/X - need smart heuristics to determine format
78
+ // Could be YY/MM/DD, MM/DD/YY, DD/MM/YY, MM/DD/YYYY, DD/MM/YYYY
79
+ const slashMatch = dateStr.match(/^(\d{1,4})\/(\d{1,2})\/(\d{1,4})$/);
80
+ if (slashMatch) {
81
+ const first = parseInt(slashMatch[1], 10);
82
+ const second = parseInt(slashMatch[2], 10);
83
+ const third = parseInt(slashMatch[3], 10);
84
+ const firstLen = slashMatch[1].length;
85
+ const thirdLen = slashMatch[3].length;
86
+ let year, month, day;
87
+ // 4-digit year at start: YYYY/MM/DD
88
+ if (firstLen === 4) {
89
+ year = first;
90
+ month = second;
91
+ day = third;
92
+ }
93
+ // 4-digit year at end: XX/XX/YYYY
94
+ else if (thirdLen === 4) {
95
+ year = third;
96
+ // Heuristic: if first > 12, must be DD/MM/YYYY, otherwise MM/DD/YYYY
97
+ if (first > 12) {
98
+ day = first;
99
+ month = second;
100
+ }
101
+ else {
102
+ month = first;
103
+ day = second;
104
+ }
105
+ }
106
+ // 2-digit year - need to determine position
107
+ else if (firstLen === 2 && thirdLen === 2) {
108
+ // Smart heuristics:
109
+ // - If first > 31, must be YY/MM/DD (year at start)
110
+ // - If first > 12, must be DD/MM/YY (day at start)
111
+ // - Otherwise treat as MM/DD/YY (US convention)
112
+ if (first > 31) {
113
+ // YY/MM/DD
114
+ year = 2000 + first;
115
+ month = second;
116
+ day = third;
117
+ }
118
+ else if (first > 12) {
119
+ // DD/MM/YY
120
+ day = first;
121
+ month = second;
122
+ year = 2000 + third;
123
+ }
124
+ else {
125
+ // MM/DD/YY (US convention)
126
+ month = first;
127
+ day = second;
128
+ year = 2000 + third;
129
+ }
130
+ }
131
+ // Single digit somewhere
132
+ else {
133
+ // Default to MM/DD/YY if year at end seems 2-digit, else YYYY/MM/DD
134
+ if (thirdLen <= 2) {
135
+ // XX/XX/YY
136
+ if (first > 12) {
137
+ day = first;
138
+ month = second;
139
+ }
140
+ else {
141
+ month = first;
142
+ day = second;
143
+ }
144
+ year = 2000 + third;
145
+ }
146
+ else {
147
+ // Shouldn't happen, but handle it
148
+ year = first;
149
+ month = second;
150
+ day = third;
151
+ }
152
+ }
153
+ return {
154
+ date: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
155
+ valid: true,
156
+ raw: dateStr
157
+ };
158
+ }
159
+ return { date: null, valid: false, raw: dateStr };
160
+ }
161
+ function parseTextDate(parts) {
162
+ if (parts.length < 2) {
163
+ return { date: null, valid: false, partsConsumed: 0 };
164
+ }
165
+ // Try "Month Day [Year]" format first
166
+ const monthStr = parts[0].toLowerCase();
167
+ let month = MONTH_NAMES[monthStr];
168
+ if (month !== undefined) {
169
+ const dayStr = parts[1];
170
+ const dayMatch = dayStr.match(/^(\d{1,2})$/);
171
+ if (dayMatch) {
172
+ const day = parseInt(dayMatch[1], 10);
173
+ if (day >= 1 && day <= 31) {
174
+ // Check for year (4-digit or 2-digit)
175
+ if (parts.length >= 3) {
176
+ const yearStr = parts[2];
177
+ const yearMatch = yearStr.match(/^(\d{4})$/);
178
+ if (yearMatch) {
179
+ const year = parseInt(yearMatch[1], 10);
180
+ const monthPadded = String(month).padStart(2, '0');
181
+ const dayPadded = String(day).padStart(2, '0');
182
+ return { date: `${year}-${monthPadded}-${dayPadded}`, valid: true, partsConsumed: 3 };
183
+ }
184
+ // Try 2-digit year
185
+ const yearShortMatch = yearStr.match(/^(\d{2})$/);
186
+ if (yearShortMatch) {
187
+ const year = 2000 + parseInt(yearShortMatch[1], 10);
188
+ const monthPadded = String(month).padStart(2, '0');
189
+ const dayPadded = String(day).padStart(2, '0');
190
+ return { date: `${year}-${monthPadded}-${dayPadded}`, valid: true, partsConsumed: 3 };
191
+ }
192
+ }
193
+ // No year provided, infer it
194
+ const year = inferYear(month, day);
195
+ const monthPadded = String(month).padStart(2, '0');
196
+ const dayPadded = String(day).padStart(2, '0');
197
+ return { date: `${year}-${monthPadded}-${dayPadded}`, valid: true, partsConsumed: 2 };
198
+ }
199
+ }
200
+ }
201
+ // Try "Day Month [Year]" format
202
+ const dayFirstMatch = parts[0].match(/^(\d{1,2})$/);
203
+ if (dayFirstMatch) {
204
+ const day = parseInt(dayFirstMatch[1], 10);
205
+ if (day >= 1 && day <= 31) {
206
+ const monthStr2 = parts[1].toLowerCase();
207
+ month = MONTH_NAMES[monthStr2];
208
+ if (month !== undefined) {
209
+ // Check for year (4-digit or 2-digit)
210
+ if (parts.length >= 3) {
211
+ const yearStr = parts[2];
212
+ const yearMatch = yearStr.match(/^(\d{4})$/);
213
+ if (yearMatch) {
214
+ const year = parseInt(yearMatch[1], 10);
215
+ const monthPadded = String(month).padStart(2, '0');
216
+ const dayPadded = String(day).padStart(2, '0');
217
+ return { date: `${year}-${monthPadded}-${dayPadded}`, valid: true, partsConsumed: 3 };
218
+ }
219
+ // Try 2-digit year
220
+ const yearShortMatch = yearStr.match(/^(\d{2})$/);
221
+ if (yearShortMatch) {
222
+ const year = 2000 + parseInt(yearShortMatch[1], 10);
223
+ const monthPadded = String(month).padStart(2, '0');
224
+ const dayPadded = String(day).padStart(2, '0');
225
+ return { date: `${year}-${monthPadded}-${dayPadded}`, valid: true, partsConsumed: 3 };
226
+ }
227
+ }
228
+ // No year provided, infer it
229
+ const year = inferYear(month, day);
230
+ const monthPadded = String(month).padStart(2, '0');
231
+ const dayPadded = String(day).padStart(2, '0');
232
+ return { date: `${year}-${monthPadded}-${dayPadded}`, valid: true, partsConsumed: 2 };
233
+ }
234
+ }
235
+ }
236
+ return { date: null, valid: false, partsConsumed: 0 };
237
+ }
238
+ function parseTokensInParenGroup(content, groupStart) {
239
+ const tokens = [];
240
+ // Split by comma and/or whitespace
241
+ const parts = content.split(/[,\s]+/).filter(p => p.length > 0);
242
+ let currentPos = 0;
243
+ const skipIndices = new Set();
244
+ for (let i = 0; i < parts.length; i++) {
245
+ if (skipIndices.has(i))
246
+ continue;
247
+ const part = parts[i];
248
+ const partStart = content.indexOf(part, currentPos);
249
+ const absoluteStart = groupStart + 1 + partStart; // +1 for opening paren
250
+ const absoluteEnd = absoluteStart + part.length;
251
+ currentPos = partStart + part.length;
252
+ // Check for due date
253
+ if (part.toLowerCase() === 'due') {
254
+ // Look for the next part(s) as the date
255
+ const remainingParts = parts.slice(i + 1);
256
+ if (remainingParts.length > 0) {
257
+ // Check for soft date prefix (~)
258
+ let isSoft = false;
259
+ let datePartsToCheck = remainingParts;
260
+ if (remainingParts[0].startsWith('~')) {
261
+ isSoft = true;
262
+ // Remove the tilde for parsing
263
+ datePartsToCheck = [remainingParts[0].slice(1), ...remainingParts.slice(1)];
264
+ }
265
+ // First try standard date formats (single part)
266
+ const dateResult = parseDate(datePartsToCheck[0]);
267
+ if (dateResult.valid) {
268
+ const nextPartStart = content.indexOf(remainingParts[0], currentPos);
269
+ const nextAbsoluteEnd = groupStart + 1 + nextPartStart + remainingParts[0].length;
270
+ tokens.push({
271
+ type: 'due',
272
+ value: dateResult.date,
273
+ raw: `due ${remainingParts[0]}`,
274
+ start: absoluteStart,
275
+ end: nextAbsoluteEnd,
276
+ soft: isSoft || undefined,
277
+ });
278
+ skipIndices.add(i + 1);
279
+ continue;
280
+ }
281
+ // Try text date formats (multiple parts: Month Day [Year])
282
+ const textDateResult = parseTextDate(datePartsToCheck);
283
+ if (textDateResult.valid) {
284
+ // Calculate the end position
285
+ let rawParts = [`due`];
286
+ let endPos = currentPos;
287
+ for (let j = 0; j < textDateResult.partsConsumed; j++) {
288
+ rawParts.push(remainingParts[j]);
289
+ skipIndices.add(i + 1 + j);
290
+ endPos = content.indexOf(remainingParts[j], endPos) + remainingParts[j].length;
291
+ }
292
+ const finalAbsoluteEnd = groupStart + 1 + endPos;
293
+ tokens.push({
294
+ type: 'due',
295
+ value: textDateResult.date,
296
+ raw: rawParts.join(' '),
297
+ start: absoluteStart,
298
+ end: finalAbsoluteEnd,
299
+ soft: isSoft || undefined,
300
+ });
301
+ continue;
302
+ }
303
+ }
304
+ continue;
305
+ }
306
+ // Check for priority
307
+ const priorityMatch = part.match(PRIORITY_REGEX);
308
+ if (priorityMatch) {
309
+ tokens.push({
310
+ type: 'priority',
311
+ value: parseInt(priorityMatch[1], 10),
312
+ raw: part,
313
+ start: absoluteStart,
314
+ end: absoluteEnd,
315
+ });
316
+ continue;
317
+ }
318
+ // Check for estimate
319
+ const estimateMatch = part.match(ESTIMATE_REGEX);
320
+ if (estimateMatch) {
321
+ const num = parseInt(estimateMatch[1], 10);
322
+ const unit = estimateMatch[2].toLowerCase();
323
+ let minutes;
324
+ switch (unit) {
325
+ case 'm':
326
+ minutes = num;
327
+ break;
328
+ case 'h':
329
+ minutes = num * 60;
330
+ break;
331
+ case 'd':
332
+ minutes = num * 480;
333
+ break;
334
+ default: minutes = num;
335
+ }
336
+ tokens.push({
337
+ type: 'estimate',
338
+ value: minutes,
339
+ raw: part,
340
+ start: absoluteStart,
341
+ end: absoluteEnd,
342
+ });
343
+ continue;
344
+ }
345
+ // Check for progress states
346
+ const partLower = part.toLowerCase();
347
+ // Check for single-word progress states: done, deleted, blocked
348
+ if (PROGRESS_STATES.has(partLower)) {
349
+ tokens.push({
350
+ type: 'progress',
351
+ value: partLower,
352
+ raw: part,
353
+ start: absoluteStart,
354
+ end: absoluteEnd,
355
+ });
356
+ continue;
357
+ }
358
+ // Check for multi-word progress state: "in progress"
359
+ if (partLower === 'in') {
360
+ const remainingParts = parts.slice(i + 1);
361
+ if (remainingParts.length > 0 && remainingParts[0].toLowerCase() === 'progress') {
362
+ const nextPartStart = content.indexOf(remainingParts[0], currentPos);
363
+ const nextAbsoluteEnd = groupStart + 1 + nextPartStart + remainingParts[0].length;
364
+ tokens.push({
365
+ type: 'progress',
366
+ value: 'in progress',
367
+ raw: `${part} ${remainingParts[0]}`,
368
+ start: absoluteStart,
369
+ end: nextAbsoluteEnd,
370
+ });
371
+ skipIndices.add(i + 1);
372
+ continue;
373
+ }
374
+ }
375
+ // Check for hashtags
376
+ const hashtagMatch = part.match(HASHTAG_REGEX);
377
+ if (hashtagMatch) {
378
+ tokens.push({
379
+ type: 'hashtag',
380
+ value: hashtagMatch[1].toLowerCase(),
381
+ raw: part,
382
+ start: absoluteStart,
383
+ end: absoluteEnd,
384
+ });
385
+ continue;
386
+ }
387
+ // Check for standalone dates (without "due" prefix)
388
+ // Check for soft date prefix (~)
389
+ let isSoftStandalone = false;
390
+ let partToCheck = part;
391
+ if (part.startsWith('~')) {
392
+ isSoftStandalone = true;
393
+ partToCheck = part.slice(1);
394
+ }
395
+ // First try standard date formats (single part)
396
+ const standaloneDateResult = parseDate(partToCheck);
397
+ if (standaloneDateResult.valid) {
398
+ tokens.push({
399
+ type: 'due',
400
+ value: standaloneDateResult.date,
401
+ raw: part,
402
+ start: absoluteStart,
403
+ end: absoluteEnd,
404
+ soft: isSoftStandalone || undefined,
405
+ });
406
+ continue;
407
+ }
408
+ // Try text date formats starting with this part (Month Day [Year] or Day Month [Year])
409
+ const remainingPartsForDate = parts.slice(i);
410
+ // For text dates, check if first part starts with ~
411
+ let datePartsForTextParsing = remainingPartsForDate;
412
+ if (isSoftStandalone) {
413
+ datePartsForTextParsing = [partToCheck, ...remainingPartsForDate.slice(1)];
414
+ }
415
+ const standaloneTextDateResult = parseTextDate(datePartsForTextParsing);
416
+ if (standaloneTextDateResult.valid) {
417
+ // Calculate the end position
418
+ let rawParts = [];
419
+ let endPos = partStart;
420
+ for (let j = 0; j < standaloneTextDateResult.partsConsumed; j++) {
421
+ rawParts.push(remainingPartsForDate[j]);
422
+ if (j > 0)
423
+ skipIndices.add(i + j);
424
+ endPos = content.indexOf(remainingPartsForDate[j], endPos) + remainingPartsForDate[j].length;
425
+ }
426
+ const finalAbsoluteEnd = groupStart + 1 + endPos;
427
+ tokens.push({
428
+ type: 'due',
429
+ value: standaloneTextDateResult.date,
430
+ raw: rawParts.join(' '),
431
+ start: absoluteStart,
432
+ end: finalAbsoluteEnd,
433
+ soft: isSoftStandalone || undefined,
434
+ });
435
+ continue;
436
+ }
437
+ }
438
+ return {
439
+ start: groupStart,
440
+ end: groupStart + content.length + 2, // +2 for parens
441
+ content,
442
+ tokens,
443
+ hasRecognizedTokens: tokens.length > 0,
444
+ };
445
+ }
446
+ function extractParenGroups(line, lineStart) {
447
+ const groups = [];
448
+ let i = 0;
449
+ while (i < line.length) {
450
+ if (line[i] === '(') {
451
+ const start = i; // Position within the line (content)
452
+ let depth = 1;
453
+ i++;
454
+ while (i < line.length && depth > 0) {
455
+ if (line[i] === '(')
456
+ depth++;
457
+ else if (line[i] === ')')
458
+ depth--;
459
+ i++;
460
+ }
461
+ if (depth === 0) {
462
+ const content = line.slice(start + 1, i - 1);
463
+ // Pass the relative position within content string
464
+ const group = parseTokensInParenGroup(content, start);
465
+ group.start = start; // Store relative position
466
+ group.end = i; // Store relative position
467
+ groups.push(group);
468
+ }
469
+ }
470
+ else {
471
+ i++;
472
+ }
473
+ }
474
+ return groups;
475
+ }
476
+ function buildTitleText(rawText, groups) {
477
+ // Remove groups that have recognized tokens
478
+ // Process in reverse order to maintain correct positions
479
+ const sortedGroups = [...groups]
480
+ .filter(g => g.hasRecognizedTokens)
481
+ .sort((a, b) => b.start - a.start);
482
+ let result = rawText;
483
+ for (const group of sortedGroups) {
484
+ const before = result.slice(0, group.start);
485
+ const after = result.slice(group.end);
486
+ result = before + after;
487
+ }
488
+ // Clean up extra whitespace
489
+ return result.replace(/\s+/g, ' ').trim();
490
+ }
491
+ function buildMetadata(groups) {
492
+ const metadata = {
493
+ due: null,
494
+ due_soft: null,
495
+ priority: null,
496
+ estimate_minutes: null,
497
+ progress: null,
498
+ hashtags: [],
499
+ effective_hashtags: [],
500
+ };
501
+ // Collect all tokens from all groups
502
+ const allTokens = groups.flatMap(g => g.tokens);
503
+ // Collect unique hashtags (sorted alphabetically)
504
+ const hashtagSet = new Set();
505
+ // Last occurrence wins for non-hashtag tokens
506
+ for (const token of allTokens) {
507
+ switch (token.type) {
508
+ case 'due':
509
+ metadata.due = token.value;
510
+ metadata.due_soft = token.soft ?? null;
511
+ break;
512
+ case 'priority':
513
+ metadata.priority = token.value;
514
+ break;
515
+ case 'estimate':
516
+ metadata.estimate_minutes = token.value;
517
+ break;
518
+ case 'progress':
519
+ metadata.progress = token.value;
520
+ break;
521
+ case 'hashtag':
522
+ hashtagSet.add(token.value);
523
+ break;
524
+ }
525
+ }
526
+ // Store sorted unique hashtags
527
+ metadata.hashtags = [...hashtagSet].sort();
528
+ return metadata;
529
+ }
530
+ function parse(text) {
531
+ const lines = text.split('\n');
532
+ const items = [];
533
+ const warnings = [];
534
+ let nextId = 0;
535
+ let offset = 0;
536
+ // Stack to track current context: [itemId, indentLevel]
537
+ const listStack = [];
538
+ let currentHeadingId = null;
539
+ const rootIds = [];
540
+ // Map id -> children for building the tree
541
+ const childrenMap = new Map();
542
+ // First pass: identify all items and their basic info
543
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
544
+ const line = lines[lineNum];
545
+ const lineStart = offset;
546
+ const lineEnd = offset + line.length;
547
+ // Check for heading
548
+ const headingMatch = line.match(HEADING_REGEX);
549
+ if (headingMatch) {
550
+ const level = headingMatch[1].length;
551
+ const content = headingMatch[2];
552
+ // Close any open list context
553
+ listStack.length = 0;
554
+ const groups = extractParenGroups(content, lineStart + headingMatch[1].length + 1);
555
+ const titleText = buildTitleText(content, groups);
556
+ const metadata = buildMetadata(groups);
557
+ const id = String(nextId++);
558
+ const item = {
559
+ id,
560
+ type: 'heading',
561
+ level,
562
+ raw_line: line,
563
+ title_text: titleText,
564
+ metadata,
565
+ comments: [],
566
+ children: [],
567
+ item_span: [lineStart, lineEnd],
568
+ subtree_span: [lineStart, lineEnd],
569
+ line: lineNum + 1,
570
+ column: 1,
571
+ };
572
+ items.push(item);
573
+ childrenMap.set(id, []);
574
+ rootIds.push(id);
575
+ currentHeadingId = id;
576
+ offset = lineEnd + 1;
577
+ continue;
578
+ }
579
+ // Check for list item
580
+ const listMatch = line.match(LIST_ITEM_REGEX);
581
+ if (listMatch) {
582
+ const indent = listMatch[1].length;
583
+ const marker = listMatch[2];
584
+ const content = listMatch[3];
585
+ const contentStart = lineStart + indent + marker.length + 1;
586
+ const groups = extractParenGroups(content, contentStart);
587
+ const titleText = buildTitleText(content, groups);
588
+ const metadata = buildMetadata(groups);
589
+ // Determine marker type and sequence number
590
+ const isNumbered = /^\d+\.$/.test(marker);
591
+ const markerType = isNumbered ? 'numbered' : 'bullet';
592
+ const sequenceNumber = isNumbered ? parseInt(marker.slice(0, -1), 10) : undefined;
593
+ const id = String(nextId++);
594
+ const item = {
595
+ id,
596
+ type: 'list',
597
+ marker_type: markerType,
598
+ sequence_number: sequenceNumber,
599
+ raw_line: line,
600
+ title_text: titleText,
601
+ metadata,
602
+ comments: [],
603
+ children: [],
604
+ item_span: [lineStart, lineEnd],
605
+ subtree_span: [lineStart, lineEnd],
606
+ line: lineNum + 1,
607
+ column: 1,
608
+ };
609
+ items.push(item);
610
+ childrenMap.set(id, []);
611
+ // Determine parent
612
+ // Pop items from stack that are at same or greater indent
613
+ while (listStack.length > 0 && listStack[listStack.length - 1].indent >= indent) {
614
+ listStack.pop();
615
+ }
616
+ if (listStack.length > 0) {
617
+ // Parent is the top of the stack
618
+ const parentId = listStack[listStack.length - 1].id;
619
+ childrenMap.get(parentId).push(id);
620
+ }
621
+ else if (currentHeadingId !== null) {
622
+ // Parent is current heading
623
+ childrenMap.get(currentHeadingId).push(id);
624
+ }
625
+ else {
626
+ // No parent, it's a root
627
+ rootIds.push(id);
628
+ }
629
+ listStack.push({ id, indent });
630
+ offset = lineEnd + 1;
631
+ continue;
632
+ }
633
+ // Not a heading or list item - could be a comment or blank line
634
+ offset = lineEnd + 1;
635
+ }
636
+ // Second pass: collect comments
637
+ offset = 0;
638
+ let currentItemIndex = -1;
639
+ let hasStartedComments = false;
640
+ let blankAfterCommentStart = false;
641
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
642
+ const line = lines[lineNum];
643
+ const lineStart = offset;
644
+ const lineEnd = offset + line.length;
645
+ // Check if this line starts a new item
646
+ const headingMatch = line.match(HEADING_REGEX);
647
+ const listMatch = line.match(LIST_ITEM_REGEX);
648
+ if (headingMatch || listMatch) {
649
+ currentItemIndex = items.findIndex(item => item.item_span[0] === lineStart);
650
+ hasStartedComments = false;
651
+ blankAfterCommentStart = false;
652
+ offset = lineEnd + 1;
653
+ continue;
654
+ }
655
+ // Check for blank line
656
+ if (line.trim() === '') {
657
+ if (hasStartedComments) {
658
+ // Blank line after comments started = stop collecting
659
+ blankAfterCommentStart = true;
660
+ }
661
+ // Blank line before comments started (e.g., after heading) = ignore
662
+ offset = lineEnd + 1;
663
+ continue;
664
+ }
665
+ // Non-blank, non-item line - potential comment
666
+ if (currentItemIndex >= 0 && !blankAfterCommentStart) {
667
+ const currentItem = items[currentItemIndex];
668
+ currentItem.comments.push(line.trim());
669
+ currentItem.item_span[1] = lineEnd;
670
+ hasStartedComments = true;
671
+ }
672
+ offset = lineEnd + 1;
673
+ }
674
+ // Build children arrays and compute subtree spans
675
+ for (const item of items) {
676
+ item.children = childrenMap.get(item.id) || [];
677
+ }
678
+ // Compute subtree spans (post-order traversal)
679
+ function computeSubtreeSpan(id) {
680
+ const item = items.find(i => i.id === id);
681
+ let end = item.item_span[1];
682
+ for (const childId of item.children) {
683
+ const childSpan = computeSubtreeSpan(childId);
684
+ end = Math.max(end, childSpan[1]);
685
+ }
686
+ item.subtree_span = [item.item_span[0], end];
687
+ return item.subtree_span;
688
+ }
689
+ for (const rootId of rootIds) {
690
+ computeSubtreeSpan(rootId);
691
+ }
692
+ // Update root_ids to only include top-level items
693
+ const actualRootIds = items
694
+ .filter(item => {
695
+ // Check if this item is a child of any other item
696
+ return !items.some(other => other.children.includes(item.id));
697
+ })
698
+ .map(item => item.id);
699
+ // Compute effective_hashtags through inheritance (pre-order traversal)
700
+ function computeEffectiveHashtags(id, parentEffectiveHashtags) {
701
+ const item = items.find(i => i.id === id);
702
+ // Merge parent's effective_hashtags with own hashtags, deduplicate and sort
703
+ const combined = new Set([...parentEffectiveHashtags, ...item.metadata.hashtags]);
704
+ item.metadata.effective_hashtags = [...combined].sort();
705
+ for (const childId of item.children) {
706
+ computeEffectiveHashtags(childId, item.metadata.effective_hashtags);
707
+ }
708
+ }
709
+ for (const rootId of actualRootIds) {
710
+ computeEffectiveHashtags(rootId, []);
711
+ }
712
+ return {
713
+ ast: {
714
+ items,
715
+ root_ids: actualRootIds,
716
+ },
717
+ warnings,
718
+ };
719
+ }