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