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.
- package/dist/cjs/formatter.d.ts +2 -1
- package/dist/cjs/formatter.js +11 -4
- package/dist/cjs/index.d.ts +3 -1
- package/dist/cjs/index.js +5 -1
- package/dist/cjs/linter.d.ts +2 -1
- package/dist/cjs/linter.js +140 -126
- package/dist/cjs/parser.d.ts +11 -4
- package/dist/cjs/parser.js +269 -287
- package/dist/cjs/query.d.ts +2 -1
- package/dist/cjs/query.js +12 -5
- package/dist/cjs/relative-date.d.ts +34 -0
- package/dist/cjs/relative-date.js +233 -0
- package/dist/cjs/settings.js +1 -1
- package/dist/cjs/types.d.ts +2 -0
- package/dist/formatter.d.ts +2 -1
- package/dist/formatter.d.ts.map +1 -1
- package/dist/formatter.js +11 -4
- package/dist/formatter.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/linter.d.ts +2 -1
- package/dist/linter.d.ts.map +1 -1
- package/dist/linter.js +140 -126
- package/dist/linter.js.map +1 -1
- package/dist/parser.d.ts +11 -4
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +269 -287
- package/dist/parser.js.map +1 -1
- package/dist/query.d.ts +2 -1
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +12 -5
- package/dist/query.js.map +1 -1
- package/dist/relative-date.d.ts +35 -0
- package/dist/relative-date.d.ts.map +1 -0
- package/dist/relative-date.js +229 -0
- package/dist/relative-date.js.map +1 -0
- package/dist/settings.js +1 -1
- package/dist/settings.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- 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
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
34
|
+
return todayY;
|
|
46
35
|
}
|
|
47
|
-
function
|
|
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
|
|
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
|
|
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
|
|
59
|
+
return `${year}-${month}-${day}`;
|
|
71
60
|
}
|
|
72
|
-
// Slash 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
|
|
123
|
+
return null;
|
|
155
124
|
}
|
|
156
|
-
function parseTextDate(parts) {
|
|
157
|
-
if (parts.length
|
|
158
|
-
return
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
//
|
|
197
|
-
const dayFirstMatch = parts[
|
|
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
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
176
|
+
return null;
|
|
232
177
|
}
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
//
|
|
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:
|
|
309
|
-
end:
|
|
347
|
+
start: absStart(i),
|
|
348
|
+
end: absEnd(i),
|
|
310
349
|
});
|
|
311
350
|
continue;
|
|
312
351
|
}
|
|
313
|
-
//
|
|
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:
|
|
336
|
-
end:
|
|
374
|
+
start: absStart(i),
|
|
375
|
+
end: absEnd(i),
|
|
337
376
|
});
|
|
338
377
|
continue;
|
|
339
378
|
}
|
|
340
|
-
//
|
|
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:
|
|
349
|
-
end:
|
|
386
|
+
start: absStart(i),
|
|
387
|
+
end: absEnd(i),
|
|
350
388
|
});
|
|
351
389
|
continue;
|
|
352
390
|
}
|
|
353
|
-
//
|
|
354
|
-
if
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
//
|
|
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:
|
|
378
|
-
end:
|
|
411
|
+
start: absStart(i),
|
|
412
|
+
end: absEnd(i),
|
|
379
413
|
});
|
|
380
414
|
continue;
|
|
381
415
|
}
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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:
|
|
425
|
-
raw
|
|
426
|
-
start:
|
|
427
|
-
end:
|
|
428
|
-
soft:
|
|
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,
|
|
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,
|
|
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;
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
group.
|
|
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
|
-
|
|
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
|
}
|