todoosy 0.3.4 → 0.3.7

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