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/cjs/parser.js
CHANGED
|
@@ -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
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
39
|
+
return todayY;
|
|
51
40
|
}
|
|
52
|
-
function
|
|
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
|
|
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
|
|
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
|
|
64
|
+
return `${year}-${month}-${day}`;
|
|
76
65
|
}
|
|
77
|
-
// Slash 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
|
|
128
|
+
return null;
|
|
160
129
|
}
|
|
161
|
-
function parseTextDate(parts) {
|
|
162
|
-
if (parts.length
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
//
|
|
202
|
-
const dayFirstMatch = parts[
|
|
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
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
181
|
+
return null;
|
|
237
182
|
}
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
//
|
|
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:
|
|
314
|
-
end:
|
|
352
|
+
start: absStart(i),
|
|
353
|
+
end: absEnd(i),
|
|
315
354
|
});
|
|
316
355
|
continue;
|
|
317
356
|
}
|
|
318
|
-
//
|
|
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:
|
|
341
|
-
end:
|
|
379
|
+
start: absStart(i),
|
|
380
|
+
end: absEnd(i),
|
|
342
381
|
});
|
|
343
382
|
continue;
|
|
344
383
|
}
|
|
345
|
-
//
|
|
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:
|
|
354
|
-
end:
|
|
391
|
+
start: absStart(i),
|
|
392
|
+
end: absEnd(i),
|
|
355
393
|
});
|
|
356
394
|
continue;
|
|
357
395
|
}
|
|
358
|
-
//
|
|
359
|
-
if
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
//
|
|
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:
|
|
383
|
-
end:
|
|
416
|
+
start: absStart(i),
|
|
417
|
+
end: absEnd(i),
|
|
384
418
|
});
|
|
385
419
|
continue;
|
|
386
420
|
}
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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:
|
|
430
|
-
raw
|
|
431
|
-
start:
|
|
432
|
-
end:
|
|
433
|
-
soft:
|
|
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,
|
|
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,
|
|
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;
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
group.
|
|
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
|
-
|
|
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
|
}
|