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.
- package/dist/cjs/formatter.d.ts +5 -0
- package/dist/cjs/formatter.js +202 -0
- package/dist/cjs/index.d.ts +13 -0
- package/dist/cjs/index.js +29 -0
- package/dist/cjs/linter.d.ts +6 -0
- package/dist/cjs/linter.js +518 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/parser.d.ts +11 -0
- package/dist/cjs/parser.js +719 -0
- package/dist/cjs/query.d.ts +22 -0
- package/dist/cjs/query.js +153 -0
- package/dist/cjs/scheme.d.ts +7 -0
- package/dist/cjs/scheme.js +14 -0
- package/dist/cjs/sequence.d.ts +53 -0
- package/dist/cjs/sequence.js +233 -0
- package/dist/cjs/settings.d.ts +65 -0
- package/dist/cjs/settings.js +262 -0
- package/dist/cjs/types.d.ts +102 -0
- package/dist/cjs/types.js +5 -0
- package/dist/settings.js +1 -1
- package/dist/settings.js.map +1 -1
- package/package.json +10 -4
|
@@ -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
|
+
}
|