@jordancoin/notioncli 1.2.1 → 1.3.1
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/.github/workflows/publish.yml +74 -0
- package/README.md +118 -420
- package/TECHNICAL.md +185 -0
- package/bin/notion.js +18 -1611
- package/commands/blocks.js +142 -0
- package/commands/comments.js +59 -0
- package/commands/config.js +328 -0
- package/commands/crud.js +196 -0
- package/commands/database.js +241 -0
- package/commands/import-export.js +162 -0
- package/commands/pages.js +203 -0
- package/commands/query.js +73 -0
- package/commands/search.js +45 -0
- package/commands/upload.js +84 -0
- package/commands/users.js +73 -0
- package/lib/config.js +68 -0
- package/lib/context.js +359 -0
- package/lib/filters.js +257 -0
- package/lib/format.js +258 -0
- package/lib/helpers.js +13 -334
- package/lib/markdown.js +488 -0
- package/lib/paginate.js +74 -0
- package/lib/retry.js +76 -0
- package/package.json +1 -1
- package/skill/SKILL.md +51 -10
- package/skill/marketplace.json +2 -2
- package/test/unit.test.js +662 -0
- package/test/debug-parent.js +0 -32
- package/test/live-relations-test.js +0 -309
package/lib/context.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
const { Client } = require('@notionhq/client');
|
|
2
|
+
const helpers = require('./helpers');
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
richTextToPlain,
|
|
6
|
+
propValue,
|
|
7
|
+
buildPropValue,
|
|
8
|
+
printTable,
|
|
9
|
+
pagesToRows,
|
|
10
|
+
formatCsv,
|
|
11
|
+
formatYaml,
|
|
12
|
+
outputFormatted,
|
|
13
|
+
buildFilterFromSchema,
|
|
14
|
+
buildCompoundFilter,
|
|
15
|
+
markdownToBlocks,
|
|
16
|
+
blocksToMarkdown,
|
|
17
|
+
parseCsv,
|
|
18
|
+
kebabToProperty,
|
|
19
|
+
extractDynamicProps,
|
|
20
|
+
UUID_REGEX,
|
|
21
|
+
paginate,
|
|
22
|
+
withRetry,
|
|
23
|
+
getNotionApiErrorDetails,
|
|
24
|
+
} = helpers;
|
|
25
|
+
|
|
26
|
+
const { CONFIG_DIR, CONFIG_PATH } = helpers.getConfigPaths();
|
|
27
|
+
|
|
28
|
+
// ─── Lazy Notion client ────────────────────────────────────────────────────────
|
|
29
|
+
let _notion = null;
|
|
30
|
+
let _notionWithRetry = null;
|
|
31
|
+
|
|
32
|
+
function wrapNotionClient(notion) {
|
|
33
|
+
const wrap = target => new Proxy(target, {
|
|
34
|
+
get(obj, prop) {
|
|
35
|
+
const value = obj[prop];
|
|
36
|
+
if (typeof value === 'function') {
|
|
37
|
+
return (...args) => withRetry(() => value.apply(obj, args));
|
|
38
|
+
}
|
|
39
|
+
if (value && typeof value === 'object') {
|
|
40
|
+
return wrap(value);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
return wrap(notion);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createNotionClient(apiKey) {
|
|
49
|
+
const notion = new Client({ auth: apiKey });
|
|
50
|
+
return wrapNotionClient(notion);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createContext(program) {
|
|
54
|
+
function loadConfig() {
|
|
55
|
+
return helpers.loadConfig(CONFIG_PATH);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function saveConfig(config) {
|
|
59
|
+
helpers.saveConfig(config, CONFIG_DIR, CONFIG_PATH);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get the active workspace name from --workspace flag or config.
|
|
64
|
+
*/
|
|
65
|
+
function getWorkspaceName() {
|
|
66
|
+
return program.opts().workspace || undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get the active workspace config { apiKey, aliases, name }.
|
|
71
|
+
*/
|
|
72
|
+
function getWorkspaceConfig() {
|
|
73
|
+
const config = loadConfig();
|
|
74
|
+
const ws = helpers.resolveWorkspace(config, getWorkspaceName());
|
|
75
|
+
if (ws.error) {
|
|
76
|
+
console.error(`Error: ${ws.error}`);
|
|
77
|
+
if (ws.available && ws.available.length > 0) {
|
|
78
|
+
console.error(`Available workspaces: ${ws.available.join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
return ws;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve API key: env var → workspace config → error with setup instructions
|
|
87
|
+
*/
|
|
88
|
+
function getApiKey() {
|
|
89
|
+
if (process.env.NOTION_API_KEY) return process.env.NOTION_API_KEY;
|
|
90
|
+
const ws = getWorkspaceConfig();
|
|
91
|
+
if (ws.apiKey) return ws.apiKey;
|
|
92
|
+
console.error('Error: No Notion API key found.');
|
|
93
|
+
console.error('');
|
|
94
|
+
console.error('Set it up with one of:');
|
|
95
|
+
console.error(' 1. notion init --key ntn_your_api_key');
|
|
96
|
+
console.error(' 2. export NOTION_API_KEY=ntn_your_api_key');
|
|
97
|
+
console.error('');
|
|
98
|
+
console.error('Get a key at: https://www.notion.so/profile/integrations');
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a user-given alias or UUID to { database_id, data_source_id }.
|
|
104
|
+
* If given a raw UUID, we use it for both IDs (the SDK figures it out).
|
|
105
|
+
*/
|
|
106
|
+
function resolveDb(aliasOrId) {
|
|
107
|
+
const ws = getWorkspaceConfig();
|
|
108
|
+
if (ws.aliases && ws.aliases[aliasOrId]) {
|
|
109
|
+
return ws.aliases[aliasOrId];
|
|
110
|
+
}
|
|
111
|
+
if (UUID_REGEX.test(aliasOrId)) {
|
|
112
|
+
return { database_id: aliasOrId, data_source_id: aliasOrId };
|
|
113
|
+
}
|
|
114
|
+
const aliasNames = ws.aliases ? Object.keys(ws.aliases) : [];
|
|
115
|
+
console.error(`Unknown database alias: "${aliasOrId}"`);
|
|
116
|
+
if (aliasNames.length > 0) {
|
|
117
|
+
console.error(`Available aliases: ${aliasNames.join(', ')}`);
|
|
118
|
+
} else {
|
|
119
|
+
console.error('No aliases configured. Add one with: notion alias add <name> <database-id>');
|
|
120
|
+
}
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve alias + filter → page ID, or pass through a raw UUID.
|
|
126
|
+
* Used by update, delete, get, blocks, comments, comment, append.
|
|
127
|
+
*
|
|
128
|
+
* Returns { pageId, dbIds } where dbIds is non-null when resolved via alias.
|
|
129
|
+
*/
|
|
130
|
+
async function resolvePageId(aliasOrId, filterInput) {
|
|
131
|
+
// Normalize filter: accept string or array, extract first non-empty
|
|
132
|
+
const filterStr = Array.isArray(filterInput)
|
|
133
|
+
? (filterInput.length > 0 ? filterInput : null)
|
|
134
|
+
: filterInput;
|
|
135
|
+
const ws = getWorkspaceConfig();
|
|
136
|
+
if (ws.aliases && ws.aliases[aliasOrId]) {
|
|
137
|
+
if (!filterStr || (Array.isArray(filterStr) && filterStr.length === 0)) {
|
|
138
|
+
console.error('When using an alias, --filter is required to identify a specific page.');
|
|
139
|
+
console.error(`Example: notion update ${aliasOrId} --filter "Name=My Page" --prop "Status=Done"`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
const dbIds = ws.aliases[aliasOrId];
|
|
143
|
+
const notion = getNotion();
|
|
144
|
+
const filter = await buildFilter(dbIds, filterStr);
|
|
145
|
+
const res = await notion.dataSources.query({
|
|
146
|
+
data_source_id: dbIds.data_source_id,
|
|
147
|
+
filter,
|
|
148
|
+
page_size: 5,
|
|
149
|
+
});
|
|
150
|
+
if (res.results.length === 0) {
|
|
151
|
+
console.error('No matching page found.');
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
if (res.results.length > 1) {
|
|
155
|
+
console.error(`Multiple pages match (${res.results.length}). Use a more specific filter or pass a page ID directly.`);
|
|
156
|
+
const rows = pagesToRows(res.results);
|
|
157
|
+
const cols = Object.keys(rows[0]).slice(0, 4);
|
|
158
|
+
printTable(rows, cols);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
return { pageId: res.results[0].id, dbIds };
|
|
162
|
+
}
|
|
163
|
+
// Check if it looks like a UUID — if not, it's probably a typo'd alias
|
|
164
|
+
if (!UUID_REGEX.test(aliasOrId)) {
|
|
165
|
+
const aliasNames = ws.aliases ? Object.keys(ws.aliases) : [];
|
|
166
|
+
console.error(`Unknown alias: "${aliasOrId}"`);
|
|
167
|
+
if (aliasNames.length > 0) {
|
|
168
|
+
console.error(`Available aliases: ${aliasNames.join(', ')}`);
|
|
169
|
+
} else {
|
|
170
|
+
console.error('No aliases configured. Run: notion init --key <your-api-key>');
|
|
171
|
+
}
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
// Treat as raw page ID
|
|
175
|
+
return { pageId: aliasOrId, dbIds: null };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function getNotion() {
|
|
179
|
+
if (!_notion) {
|
|
180
|
+
_notion = new Client({ auth: getApiKey() });
|
|
181
|
+
_notionWithRetry = wrapNotionClient(_notion);
|
|
182
|
+
}
|
|
183
|
+
return _notionWithRetry;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Check if --json flag is set anywhere in the command chain */
|
|
187
|
+
function getGlobalJson(cmd) {
|
|
188
|
+
let c = cmd;
|
|
189
|
+
while (c) {
|
|
190
|
+
if (c.opts().json) return true;
|
|
191
|
+
c = c.parent;
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Wrap a command action with standard error handling.
|
|
198
|
+
* Reduces try/catch boilerplate across all commands.
|
|
199
|
+
*/
|
|
200
|
+
async function runCommand(name, fn) {
|
|
201
|
+
try {
|
|
202
|
+
await fn();
|
|
203
|
+
} catch (err) {
|
|
204
|
+
const details = getNotionApiErrorDetails(err);
|
|
205
|
+
if (details) {
|
|
206
|
+
console.error(`${name} failed: Notion API error`);
|
|
207
|
+
if (details.status !== undefined) console.error(`Status: ${details.status}`);
|
|
208
|
+
if (details.code) console.error(`Code: ${details.code}`);
|
|
209
|
+
if (details.message) console.error(`Message: ${details.message}`);
|
|
210
|
+
if (details.body) {
|
|
211
|
+
const bodyText = typeof details.body === 'string'
|
|
212
|
+
? details.body
|
|
213
|
+
: JSON.stringify(details.body, null, 2);
|
|
214
|
+
console.error(`Body: ${bodyText}`);
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
console.error(`${name} failed:`, err.message);
|
|
218
|
+
}
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* If --json flag is set, output raw JSON and return true. Otherwise return false.
|
|
225
|
+
* Use: if (jsonOutput(cmd, result)) return;
|
|
226
|
+
*/
|
|
227
|
+
function jsonOutput(cmd, result) {
|
|
228
|
+
if (getGlobalJson(cmd)) {
|
|
229
|
+
console.log(JSON.stringify(result, null, 2));
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Fetch data source schema — returns map of lowercase_name → { type, name }
|
|
237
|
+
* Uses dataSources.retrieve() which accepts the data_source_id.
|
|
238
|
+
*/
|
|
239
|
+
async function getDbSchema(dbIds) {
|
|
240
|
+
const notion = getNotion();
|
|
241
|
+
const dsId = dbIds.data_source_id;
|
|
242
|
+
const ds = await notion.dataSources.retrieve({ data_source_id: dsId });
|
|
243
|
+
const schema = {};
|
|
244
|
+
for (const [name, prop] of Object.entries(ds.properties)) {
|
|
245
|
+
schema[name.toLowerCase()] = { type: prop.type, name };
|
|
246
|
+
}
|
|
247
|
+
return schema;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Build properties object from --prop key=value pairs using schema */
|
|
251
|
+
async function buildProperties(dbIds, props) {
|
|
252
|
+
const schema = await getDbSchema(dbIds);
|
|
253
|
+
const properties = {};
|
|
254
|
+
|
|
255
|
+
for (const kv of props) {
|
|
256
|
+
const eqIdx = kv.indexOf('=');
|
|
257
|
+
if (eqIdx === -1) {
|
|
258
|
+
console.error(`Invalid property format: ${kv} (expected key=value)`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
const key = kv.slice(0, eqIdx);
|
|
262
|
+
const value = kv.slice(eqIdx + 1);
|
|
263
|
+
|
|
264
|
+
const schemaEntry = schema[key.toLowerCase()];
|
|
265
|
+
if (!schemaEntry) {
|
|
266
|
+
console.error(`Property "${key}" not found in database schema.`);
|
|
267
|
+
console.error(`Available: ${Object.values(schema).map(s => s.name).join(', ')}`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const built = buildPropValue(schemaEntry.type, value);
|
|
272
|
+
if (built && built.error) {
|
|
273
|
+
console.error(`Invalid value for "${schemaEntry.name}" (${schemaEntry.type}): ${built.error}`);
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
properties[schemaEntry.name] = built;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return properties;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Parse filter string(s) into a Notion filter object. Accepts string or array. */
|
|
283
|
+
async function buildFilter(dbIds, filterInput) {
|
|
284
|
+
const schema = await getDbSchema(dbIds);
|
|
285
|
+
const filters = Array.isArray(filterInput) ? filterInput : [filterInput];
|
|
286
|
+
const result = buildCompoundFilter(schema, filters);
|
|
287
|
+
if (result.error) {
|
|
288
|
+
console.error(result.error);
|
|
289
|
+
if (result.available) {
|
|
290
|
+
console.error(`Available: ${result.available.join(', ')}`);
|
|
291
|
+
}
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
return result.filter;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
CONFIG_DIR,
|
|
299
|
+
CONFIG_PATH,
|
|
300
|
+
loadConfig,
|
|
301
|
+
saveConfig,
|
|
302
|
+
getWorkspaceName,
|
|
303
|
+
getWorkspaceConfig,
|
|
304
|
+
getApiKey,
|
|
305
|
+
resolveDb,
|
|
306
|
+
resolvePageId,
|
|
307
|
+
getNotion,
|
|
308
|
+
createNotionClient,
|
|
309
|
+
wrapNotionClient,
|
|
310
|
+
runCommand,
|
|
311
|
+
jsonOutput,
|
|
312
|
+
getGlobalJson,
|
|
313
|
+
getDbSchema,
|
|
314
|
+
buildProperties,
|
|
315
|
+
buildFilter,
|
|
316
|
+
richTextToPlain,
|
|
317
|
+
propValue,
|
|
318
|
+
buildPropValue,
|
|
319
|
+
printTable,
|
|
320
|
+
pagesToRows,
|
|
321
|
+
formatCsv,
|
|
322
|
+
formatYaml,
|
|
323
|
+
outputFormatted,
|
|
324
|
+
buildFilterFromSchema,
|
|
325
|
+
buildCompoundFilter,
|
|
326
|
+
markdownToBlocks,
|
|
327
|
+
blocksToMarkdown,
|
|
328
|
+
parseCsv,
|
|
329
|
+
kebabToProperty,
|
|
330
|
+
extractDynamicProps,
|
|
331
|
+
UUID_REGEX,
|
|
332
|
+
paginate,
|
|
333
|
+
withRetry,
|
|
334
|
+
getNotionApiErrorDetails,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = {
|
|
339
|
+
createContext,
|
|
340
|
+
richTextToPlain,
|
|
341
|
+
propValue,
|
|
342
|
+
buildPropValue,
|
|
343
|
+
printTable,
|
|
344
|
+
pagesToRows,
|
|
345
|
+
formatCsv,
|
|
346
|
+
formatYaml,
|
|
347
|
+
outputFormatted,
|
|
348
|
+
buildFilterFromSchema,
|
|
349
|
+
buildCompoundFilter,
|
|
350
|
+
markdownToBlocks,
|
|
351
|
+
blocksToMarkdown,
|
|
352
|
+
parseCsv,
|
|
353
|
+
kebabToProperty,
|
|
354
|
+
extractDynamicProps,
|
|
355
|
+
UUID_REGEX,
|
|
356
|
+
paginate,
|
|
357
|
+
withRetry,
|
|
358
|
+
getNotionApiErrorDetails,
|
|
359
|
+
};
|
package/lib/filters.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// lib/filters.js — Filter parsing and building for Notion API queries
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a filter string into { key, operator, value }.
|
|
5
|
+
* Supports: >=, <=, !=, >, <, = (default)
|
|
6
|
+
* Examples: "Status=Active", "Day>5", "Date>=2026-01-01", "Name!=Draft"
|
|
7
|
+
*/
|
|
8
|
+
function parseFilterOperator(filterStr) {
|
|
9
|
+
const operatorChars = new Set(['>', '<', '=', '!']);
|
|
10
|
+
let inQuote = null;
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < filterStr.length; i++) {
|
|
13
|
+
const ch = filterStr[i];
|
|
14
|
+
if (ch === '"' || ch === "'") {
|
|
15
|
+
if (inQuote === ch) {
|
|
16
|
+
inQuote = null;
|
|
17
|
+
} else if (!inQuote) {
|
|
18
|
+
inQuote = ch;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (inQuote) continue;
|
|
23
|
+
|
|
24
|
+
let op = null;
|
|
25
|
+
const two = filterStr.slice(i, i + 2);
|
|
26
|
+
if (two === '>=' || two === '<=' || two === '!=') {
|
|
27
|
+
op = two;
|
|
28
|
+
} else if (ch === '>' || ch === '<' || ch === '=') {
|
|
29
|
+
op = ch;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!op) continue;
|
|
33
|
+
|
|
34
|
+
const key = filterStr.slice(0, i);
|
|
35
|
+
if (!key) continue;
|
|
36
|
+
|
|
37
|
+
let keyHasOperator = false;
|
|
38
|
+
for (const keyChar of key) {
|
|
39
|
+
if (operatorChars.has(keyChar)) {
|
|
40
|
+
keyHasOperator = true;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (keyHasOperator) continue;
|
|
45
|
+
|
|
46
|
+
let value = filterStr.slice(i + op.length);
|
|
47
|
+
if (value.length >= 2) {
|
|
48
|
+
const first = value[0];
|
|
49
|
+
const last = value[value.length - 1];
|
|
50
|
+
if ((first === '"' || first === "'") && last === first) {
|
|
51
|
+
value = value.slice(1, -1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { key, operator: op, value };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { error: `Invalid filter format: ${filterStr} (expected key=value, key>value, etc.)` };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve relative date keywords to ISO date strings.
|
|
63
|
+
*/
|
|
64
|
+
function resolveRelativeDate(value) {
|
|
65
|
+
const lower = value.toLowerCase();
|
|
66
|
+
const now = new Date();
|
|
67
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
68
|
+
|
|
69
|
+
switch (lower) {
|
|
70
|
+
case 'today':
|
|
71
|
+
return today.toISOString().split('T')[0];
|
|
72
|
+
case 'yesterday': {
|
|
73
|
+
const d = new Date(today); d.setDate(d.getDate() - 1);
|
|
74
|
+
return d.toISOString().split('T')[0];
|
|
75
|
+
}
|
|
76
|
+
case 'tomorrow': {
|
|
77
|
+
const d = new Date(today); d.setDate(d.getDate() + 1);
|
|
78
|
+
return d.toISOString().split('T')[0];
|
|
79
|
+
}
|
|
80
|
+
case 'last_week': case 'last-week': {
|
|
81
|
+
const d = new Date(today); d.setDate(d.getDate() - 7);
|
|
82
|
+
return d.toISOString().split('T')[0];
|
|
83
|
+
}
|
|
84
|
+
case 'next_week': case 'next-week': {
|
|
85
|
+
const d = new Date(today); d.setDate(d.getDate() + 7);
|
|
86
|
+
return d.toISOString().split('T')[0];
|
|
87
|
+
}
|
|
88
|
+
default:
|
|
89
|
+
return value; // Return as-is if not a keyword
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Map { operator, type } to Notion filter condition.
|
|
95
|
+
*/
|
|
96
|
+
function operatorToCondition(type, operator, value) {
|
|
97
|
+
// Resolve relative dates
|
|
98
|
+
if (type === 'date') {
|
|
99
|
+
value = resolveRelativeDate(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Number coercion
|
|
103
|
+
if (type === 'number') {
|
|
104
|
+
const num = Number(value);
|
|
105
|
+
if (Number.isNaN(num)) {
|
|
106
|
+
return { error: `Invalid number value "${value}" for numeric filter.` };
|
|
107
|
+
}
|
|
108
|
+
value = num;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Checkbox coercion
|
|
112
|
+
if (type === 'checkbox') {
|
|
113
|
+
value = value === 'true' || value === '1' || value === 'yes';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Map operators to Notion API condition names
|
|
117
|
+
const conditionMap = {
|
|
118
|
+
'=': getDefaultCondition(type, value),
|
|
119
|
+
'!=': getNotEqualCondition(type, value),
|
|
120
|
+
'>': { [getFilterType(type)]: { after: type === 'date' ? value : undefined, greater_than: type !== 'date' ? value : undefined } },
|
|
121
|
+
'<': { [getFilterType(type)]: { before: type === 'date' ? value : undefined, less_than: type !== 'date' ? value : undefined } },
|
|
122
|
+
'>=': { [getFilterType(type)]: { on_or_after: type === 'date' ? value : undefined, greater_than_or_equal_to: type !== 'date' ? value : undefined } },
|
|
123
|
+
'<=': { [getFilterType(type)]: { on_or_before: type === 'date' ? value : undefined, less_than_or_equal_to: type !== 'date' ? value : undefined } },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const condition = conditionMap[operator];
|
|
127
|
+
if (!condition) return null;
|
|
128
|
+
|
|
129
|
+
// Clean undefined values
|
|
130
|
+
const filterType = getFilterType(type);
|
|
131
|
+
if (condition[filterType]) {
|
|
132
|
+
const inner = condition[filterType];
|
|
133
|
+
for (const k of Object.keys(inner)) {
|
|
134
|
+
if (inner[k] === undefined) delete inner[k];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return condition;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Get the Notion filter type key for a schema type */
|
|
142
|
+
function getFilterType(type) {
|
|
143
|
+
// Most types use themselves as the filter key
|
|
144
|
+
return type;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Default equals/contains condition for = operator */
|
|
148
|
+
function getDefaultCondition(type, value) {
|
|
149
|
+
switch (type) {
|
|
150
|
+
case 'title':
|
|
151
|
+
case 'rich_text':
|
|
152
|
+
return { [type]: { contains: value } };
|
|
153
|
+
case 'select':
|
|
154
|
+
return { select: { equals: value } };
|
|
155
|
+
case 'multi_select':
|
|
156
|
+
return { multi_select: { contains: value } };
|
|
157
|
+
case 'number':
|
|
158
|
+
return { number: { equals: value } };
|
|
159
|
+
case 'checkbox':
|
|
160
|
+
return { checkbox: { equals: value } };
|
|
161
|
+
case 'date':
|
|
162
|
+
return { date: { equals: value } };
|
|
163
|
+
case 'status':
|
|
164
|
+
return { status: { equals: value } };
|
|
165
|
+
default:
|
|
166
|
+
return { [type]: { equals: value } };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Not-equal condition for != operator */
|
|
171
|
+
function getNotEqualCondition(type, value) {
|
|
172
|
+
switch (type) {
|
|
173
|
+
case 'title':
|
|
174
|
+
case 'rich_text':
|
|
175
|
+
return { [type]: { does_not_contain: value } };
|
|
176
|
+
case 'select':
|
|
177
|
+
return { select: { does_not_equal: value } };
|
|
178
|
+
case 'multi_select':
|
|
179
|
+
return { multi_select: { does_not_contain: value } };
|
|
180
|
+
case 'number':
|
|
181
|
+
return { number: { does_not_equal: value } };
|
|
182
|
+
case 'checkbox':
|
|
183
|
+
return { checkbox: { does_not_equal: value } };
|
|
184
|
+
case 'date':
|
|
185
|
+
// Notion doesn't have date does_not_equal; skip
|
|
186
|
+
return { date: { does_not_equal: value } };
|
|
187
|
+
case 'status':
|
|
188
|
+
return { status: { does_not_equal: value } };
|
|
189
|
+
default:
|
|
190
|
+
return { [type]: { does_not_equal: value } };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build a Notion filter object from a schema and a single filter string.
|
|
196
|
+
* Supports operators: =, !=, >, <, >=, <=
|
|
197
|
+
* Supports relative dates: today, yesterday, tomorrow, last_week, next_week
|
|
198
|
+
*/
|
|
199
|
+
function buildFilterFromSchema(schema, filterStr) {
|
|
200
|
+
const parsed = parseFilterOperator(filterStr);
|
|
201
|
+
if (parsed.error) return parsed;
|
|
202
|
+
|
|
203
|
+
const { key, operator, value } = parsed;
|
|
204
|
+
const schemaEntry = schema[key.toLowerCase()];
|
|
205
|
+
if (!schemaEntry) {
|
|
206
|
+
return {
|
|
207
|
+
error: `Filter property "${key}" not found in database schema.`,
|
|
208
|
+
available: Object.values(schema).map(s => s.name),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const propName = schemaEntry.name;
|
|
213
|
+
const type = schemaEntry.type;
|
|
214
|
+
|
|
215
|
+
const condition = operatorToCondition(type, operator, value);
|
|
216
|
+
if (condition && condition.error) {
|
|
217
|
+
return condition;
|
|
218
|
+
}
|
|
219
|
+
if (!condition) {
|
|
220
|
+
return { error: `Operator "${operator}" not supported for type "${type}"` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { filter: { property: propName, ...condition } };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Build a compound AND filter from multiple filter strings.
|
|
228
|
+
* Each filter is parsed independently, then combined with AND.
|
|
229
|
+
*/
|
|
230
|
+
function buildCompoundFilter(schema, filterStrs) {
|
|
231
|
+
if (!Array.isArray(filterStrs) || filterStrs.length === 0) {
|
|
232
|
+
return { error: 'No filters provided' };
|
|
233
|
+
}
|
|
234
|
+
if (filterStrs.length === 1) {
|
|
235
|
+
return buildFilterFromSchema(schema, filterStrs[0]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const filters = [];
|
|
239
|
+
for (const f of filterStrs) {
|
|
240
|
+
const result = buildFilterFromSchema(schema, f);
|
|
241
|
+
if (result.error) return result;
|
|
242
|
+
filters.push(result.filter);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { filter: { and: filters } };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
parseFilterOperator,
|
|
250
|
+
resolveRelativeDate,
|
|
251
|
+
operatorToCondition,
|
|
252
|
+
getDefaultCondition,
|
|
253
|
+
getNotEqualCondition,
|
|
254
|
+
getFilterType,
|
|
255
|
+
buildFilterFromSchema,
|
|
256
|
+
buildCompoundFilter,
|
|
257
|
+
};
|