@doist/todoist-sdk 15.1.0 → 15.2.0

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.
@@ -0,0 +1,231 @@
1
+ import { FILTER_QUERY_DATE_TOKENS, FILTER_QUERY_LANGUAGES, FILTER_QUERY_OPERATOR_TOKENS, FILTER_QUERY_PATTERNS, FILTER_QUERY_TOKENS, } from './filter-query-definitions.js';
2
+ const DEFAULT_LANGUAGE = 'en';
3
+ const DATE_TOKENS = new Set(FILTER_QUERY_DATE_TOKENS);
4
+ const OPERATOR_TOKENS = new Set(FILTER_QUERY_OPERATOR_TOKENS);
5
+ /** Operators are the same symbols in every language, and they end a bare date. */
6
+ const OPERATOR_PATTERN = /[()!&|]/;
7
+ /**
8
+ * Deepest run of parentheses this reads before giving up.
9
+ *
10
+ * Parentheses are the only construct left that recurses, and Todoist caps a
11
+ * saved query long before this. The limit is what keeps a hostile string from
12
+ * reaching the call stack.
13
+ */
14
+ const MAX_NESTING_DEPTH = 100;
15
+ /** Codes the parser answers to beyond the language names themselves. */
16
+ const LANGUAGE_ALIASES = new Map([
17
+ ['zh-cn', 'zh_cn'],
18
+ ['zh-hans', 'zh_cn'],
19
+ ['zh_hans', 'zh_cn'],
20
+ ['zh-tw', 'zh_tw'],
21
+ ['zh-hant', 'zh_tw'],
22
+ ['zh_hant', 'zh_tw'],
23
+ ]);
24
+ const MATCHERS_BY_LANGUAGE = new Map();
25
+ function lookUpLanguage(code) {
26
+ var _a;
27
+ return ((_a = LANGUAGE_ALIASES.get(code)) !== null && _a !== void 0 ? _a : FILTER_QUERY_LANGUAGES.find((language) => language === code));
28
+ }
29
+ function resolveLanguage(lang) {
30
+ var _a, _b;
31
+ if (!lang)
32
+ return DEFAULT_LANGUAGE;
33
+ const code = lang.toLowerCase();
34
+ return (_b = (_a = lookUpLanguage(code)) !== null && _a !== void 0 ? _a : lookUpLanguage(code.split(/[-_]/)[0])) !== null && _b !== void 0 ? _b : DEFAULT_LANGUAGE;
35
+ }
36
+ function createMatchers(language) {
37
+ const patterns = FILTER_QUERY_PATTERNS[language];
38
+ const matchers = [];
39
+ for (const token of FILTER_QUERY_TOKENS) {
40
+ for (const pattern of patterns[token]) {
41
+ matchers.push({
42
+ token,
43
+ length: pattern.length,
44
+ regex: new RegExp(`\\s*${pattern}\\s*`, 'iuy'),
45
+ });
46
+ }
47
+ }
48
+ // Longest pattern first, as the engine does. Otherwise `all` claims the
49
+ // front of Dutch's `alles bekijken` and the rest reads as a date.
50
+ return matchers.sort((a, b) => b.length - a.length);
51
+ }
52
+ function matchersFor(language) {
53
+ const cached = MATCHERS_BY_LANGUAGE.get(language);
54
+ if (cached)
55
+ return cached;
56
+ const matchers = createMatchers(language);
57
+ MATCHERS_BY_LANGUAGE.set(language, matchers);
58
+ return matchers;
59
+ }
60
+ function matchAt(query, cursor, matchers) {
61
+ for (const matcher of matchers) {
62
+ matcher.regex.lastIndex = cursor;
63
+ if (matcher.regex.test(query) && matcher.regex.lastIndex > cursor) {
64
+ return { token: matcher.token, end: matcher.regex.lastIndex };
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * Splits a query on unescaped commas, the way Todoist does before parsing.
71
+ * Each part is drawn as its own list.
72
+ */
73
+ function splitSubqueries(query) {
74
+ const subqueries = [];
75
+ let current = '';
76
+ let escaped = false;
77
+ for (let index = 0; index < query.length; index += 1) {
78
+ const character = query[index];
79
+ if (character === ',' && !escaped) {
80
+ subqueries.push(current);
81
+ current = '';
82
+ continue;
83
+ }
84
+ current += character;
85
+ escaped = !escaped && character === '\\';
86
+ }
87
+ subqueries.push(current);
88
+ return subqueries.map((subquery) => subquery.trim()).filter((subquery) => subquery.length > 0);
89
+ }
90
+ /**
91
+ * Reads a subquery into tokens.
92
+ *
93
+ * Anything the vocabulary doesn't claim is a bare date: the engine tries every
94
+ * keyword first and hands what is left to its date parser, which is what reads
95
+ * `today`, `hoy` and `2026-12-24`. A bare date runs to the next operator.
96
+ */
97
+ function tokenize(subquery, matchers) {
98
+ const tokens = [];
99
+ let cursor = 0;
100
+ while (cursor < subquery.length) {
101
+ const match = matchAt(subquery, cursor, matchers);
102
+ if (match) {
103
+ tokens.push(match.token);
104
+ cursor = match.end;
105
+ continue;
106
+ }
107
+ const offset = subquery.slice(cursor + 1).search(OPERATOR_PATTERN);
108
+ tokens.push('BARE_DATE');
109
+ cursor = offset === -1 ? subquery.length : cursor + 1 + offset;
110
+ }
111
+ return tokens;
112
+ }
113
+ function peek(reader) {
114
+ return reader.index < reader.tokens.length ? reader.tokens[reader.index] : undefined;
115
+ }
116
+ function next(reader) {
117
+ const token = peek(reader);
118
+ reader.index += 1;
119
+ return token;
120
+ }
121
+ /**
122
+ * `<expression 4> ::= <expression 3> [ or <expression 4> ]`
123
+ *
124
+ * The engine nests this to the right and folds with a minimum, so reading the
125
+ * chain in a loop lands on the same grouping without a frame per operator.
126
+ */
127
+ function readOr(reader) {
128
+ let grouping = readAnd(reader);
129
+ while (grouping !== null && peek(reader) === 'OR') {
130
+ next(reader);
131
+ const right = readAnd(reader);
132
+ if (right === null)
133
+ return null;
134
+ // `|` keeps the lower grouping, so one flat side flattens the pair.
135
+ grouping = grouping === 'DAY' && right === 'DAY' ? 'DAY' : 'FLAT';
136
+ }
137
+ return grouping;
138
+ }
139
+ /** `<expression 3> ::= <expression 2> [ and <expression 3> ]` */
140
+ function readAnd(reader) {
141
+ let grouping = readNot(reader);
142
+ while (grouping !== null && peek(reader) === 'AND') {
143
+ next(reader);
144
+ const right = readNot(reader);
145
+ if (right === null)
146
+ return null;
147
+ // `&` keeps the higher grouping, so one dated side leads the pair.
148
+ grouping = grouping === 'DAY' || right === 'DAY' ? 'DAY' : 'FLAT';
149
+ }
150
+ return grouping;
151
+ }
152
+ /** `<expression 2> ::= { [ not ] <expression 2> } | <expression 1>` */
153
+ function readNot(reader) {
154
+ let negated = false;
155
+ while (peek(reader) === 'NOT') {
156
+ next(reader);
157
+ negated = true;
158
+ }
159
+ const grouping = readOperand(reader);
160
+ if (grouping === null)
161
+ return null;
162
+ // A negation never groups by day, however the expression under it reads.
163
+ return negated ? 'FLAT' : grouping;
164
+ }
165
+ /** `<expression 1> ::= { "(" <expression> ")" } | <query>` */
166
+ function readOperand(reader) {
167
+ const token = next(reader);
168
+ if (token === undefined)
169
+ return null;
170
+ if (token === 'LEFT') {
171
+ if (reader.depth >= MAX_NESTING_DEPTH)
172
+ return null;
173
+ reader.depth += 1;
174
+ const grouping = readOr(reader);
175
+ reader.depth -= 1;
176
+ if (grouping === null || next(reader) !== 'RIGHT')
177
+ return null;
178
+ return grouping;
179
+ }
180
+ if (token === 'BARE_DATE')
181
+ return 'DAY';
182
+ if (OPERATOR_TOKENS.has(token))
183
+ return null;
184
+ return DATE_TOKENS.has(token) ? 'DAY' : 'FLAT';
185
+ }
186
+ function groupingOf(subquery, matchers) {
187
+ const reader = { tokens: tokenize(subquery, matchers), index: 0, depth: 0 };
188
+ const grouping = readOr(reader);
189
+ // Tokens left over mean the query doesn't parse, which the engine rejects.
190
+ return grouping !== null && reader.index === reader.tokens.length ? grouping : null;
191
+ }
192
+ /**
193
+ * Returns whether a filter query leads with dates rather than with priority.
194
+ *
195
+ * Todoist gives every filter one of two default hierarchies, and a query that
196
+ * mentions dates gets the date-first one. Pass the answer to {@link sortTasks}
197
+ * as its `defaultOrder`:
198
+ *
199
+ * ```ts
200
+ * sortTasks(tasks, {
201
+ * sortedBy: savedOptions?.sortedBy,
202
+ * sortOrder: savedOptions?.sortOrder,
203
+ * defaultOrder: isDateDrivenQuery(query, { lang }) ? 'DATE_FIRST' : 'PRIORITY_FIRST',
204
+ * })
205
+ * ```
206
+ *
207
+ * The query is read in `lang`, the account language `getUser` returns, the way
208
+ * the API reads it. English keywords parse under every language, so `lang` only
209
+ * matters for a query written in the account's own: `sin fecha` is a keyword
210
+ * under `es` and an unreadable phrase under `en`.
211
+ *
212
+ * Commas divide a filter into subqueries and Todoist draws each one as its own
213
+ * list, with its own hierarchy. Classify them one at a time for exact results;
214
+ * a whole query answers true only when every subquery is date-driven.
215
+ *
216
+ * Two kinds of unreadable query part company here. One that breaks the grammar,
217
+ * such as `(today`, returns false. One that only breaks the vocabulary, such as
218
+ * `sin fecha` under `en`, returns true, because a word this package doesn't
219
+ * recognize is treated as a bare date. Pass the account's own `lang` and the
220
+ * question doesn't arise; get it wrong and a keyword can read as a date.
221
+ *
222
+ * The function performs no I/O.
223
+ *
224
+ * @see sortTasks
225
+ */
226
+ export function isDateDrivenQuery(query, options = {}) {
227
+ const matchers = matchersFor(resolveLanguage(options.lang));
228
+ const subqueries = splitSubqueries(query);
229
+ return (subqueries.length > 0 &&
230
+ subqueries.every((subquery) => groupingOf(subquery, matchers) === 'DAY'));
231
+ }