@jordancoin/notioncli 1.0.0 → 1.1.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.
- package/README.md +123 -61
- package/bin/notion.js +200 -3
- package/lib/helpers.js +19 -4
- package/package.json +1 -1
- package/skill/SKILL.md +23 -2
- package/skill/marketplace.json +16 -0
- package/test/debug-parent.js +32 -0
- package/test/live-relations-test.js +309 -0
- package/test/unit.test.js +45 -6
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
[](https://nodejs.org/)
|
|
8
8
|
|
|
9
|
-
A powerful CLI for the Notion API —
|
|
9
|
+
A powerful CLI for the Notion API — aliases, zero UUIDs, relations & rollups, and full block-level CRUD. Built for humans and AI agents.
|
|
10
10
|
|
|
11
11
|
**No more copy-pasting UUIDs.** Set up aliases once, then just type `notion query tasks` or `notion add projects --prop "Name=Ship it"`.
|
|
12
12
|
|
|
@@ -34,6 +34,39 @@ Zero UUIDs. One command. You're ready to go.
|
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
37
|
+
## What's New (v1.1)
|
|
38
|
+
|
|
39
|
+
notioncli now understands Notion as a **graph**, not just a table.
|
|
40
|
+
|
|
41
|
+
- 🔗 **Relations** — `get` resolves linked page titles automatically. New `notion relations` command for exploring connected pages.
|
|
42
|
+
- 📊 **Rollups** — Numbers, dates, and arrays are parsed into readable values. No more raw JSON blobs.
|
|
43
|
+
- 🧱 **Blocks CRUD** — Edit and delete blocks directly with `block-edit` and `block-delete`. Use `--ids` flag on `blocks` for precise targeting.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Notion as a Graph
|
|
48
|
+
|
|
49
|
+
Notion databases don't live in isolation — they're connected by relations and rollups. notioncli treats your workspace as a **graph of linked pages**:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
$ notion get tasks --filter "Name=Ship v1.1"
|
|
53
|
+
Properties:
|
|
54
|
+
Name: Ship v1.1
|
|
55
|
+
Status: Active
|
|
56
|
+
Project: Launch CLI ← relation resolved to title
|
|
57
|
+
Task Count: 3 ← rollup parsed to number
|
|
58
|
+
|
|
59
|
+
$ notion relations tasks --filter "Name=Ship v1.1"
|
|
60
|
+
Project: 1 linked page
|
|
61
|
+
id │ title │ url
|
|
62
|
+
──────────┼────────────┼──────────────────────
|
|
63
|
+
302903e2… │ Launch CLI │ https://notion.so/...
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Relations resolve to human-readable titles. Rollups return real values. Blocks can be queried, edited, and deleted directly. This makes it possible to explore, automate, and reason about complex workspaces entirely from the CLI.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
37
70
|
## Setup
|
|
38
71
|
|
|
39
72
|
### 1. Create a Notion Integration
|
|
@@ -90,7 +123,7 @@ notion alias rename reading-list reads
|
|
|
90
123
|
|
|
91
124
|
### `notion query` — Query a Database
|
|
92
125
|
|
|
93
|
-
The command you'll use most. Filter, sort, and browse your data
|
|
126
|
+
The command you'll use most. Filter, sort, and browse your data. Rollups are automatically parsed into numbers, dates, or arrays instead of raw JSON.
|
|
94
127
|
|
|
95
128
|
```
|
|
96
129
|
$ notion query projects
|
|
@@ -107,12 +140,6 @@ With filters and sorting:
|
|
|
107
140
|
|
|
108
141
|
```
|
|
109
142
|
$ notion query projects --filter Status=Active --sort Date:desc --limit 5
|
|
110
|
-
Date │ Name │ Status │ Priority
|
|
111
|
-
───────────┼───────────────┼────────┼─────────
|
|
112
|
-
2026-02-09 │ Launch CLI │ Active │ High
|
|
113
|
-
2026-02-08 │ Write Docs │ Active │ Medium
|
|
114
|
-
|
|
115
|
-
2 results
|
|
116
143
|
```
|
|
117
144
|
|
|
118
145
|
### `notion add` — Add a Page
|
|
@@ -143,52 +170,70 @@ $ notion update workouts --filter "Name=LEGS #5" --prop "Notes=Great session"
|
|
|
143
170
|
|
|
144
171
|
### `notion delete` — Delete (Archive) a Page
|
|
145
172
|
|
|
146
|
-
By page ID:
|
|
147
|
-
```
|
|
148
|
-
$ notion delete a1b2c3d4-5678-90ab-cdef-1234567890ab
|
|
149
|
-
🗑️ Archived page: a1b2c3d4-...
|
|
150
|
-
(Restore it from the trash in Notion if needed)
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
By alias + filter:
|
|
154
173
|
```
|
|
155
174
|
$ notion delete workouts --filter "Date=2026-02-09"
|
|
156
175
|
🗑️ Archived page: a1b2c3d4-...
|
|
176
|
+
(Restore it from the trash in Notion if needed)
|
|
157
177
|
```
|
|
158
178
|
|
|
159
179
|
### `notion get` — View Page Details
|
|
160
180
|
|
|
161
|
-
|
|
162
|
-
```
|
|
163
|
-
$ notion get a1b2c3d4-5678-90ab-cdef-1234567890ab
|
|
164
|
-
```
|
|
181
|
+
Relations are **automatically resolved** to linked page titles:
|
|
165
182
|
|
|
166
|
-
By alias + filter:
|
|
167
183
|
```
|
|
168
|
-
$ notion get
|
|
184
|
+
$ notion get tasks --filter "Name=Implement relations"
|
|
169
185
|
Page: a1b2c3d4-5678-90ab-cdef-1234567890ab
|
|
170
|
-
URL: https://www.notion.so
|
|
186
|
+
URL: https://www.notion.so/...
|
|
171
187
|
Created: 2026-02-10T14:30:00.000Z
|
|
172
188
|
Updated: 2026-02-10T14:30:00.000Z
|
|
173
189
|
|
|
174
190
|
Properties:
|
|
175
|
-
Name:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
191
|
+
Name: Implement relations
|
|
192
|
+
Project: Build CLI ← resolved title, not a UUID
|
|
193
|
+
Done: ✓
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### `notion relations` — Explore Connections
|
|
197
|
+
|
|
198
|
+
See what a page is linked to, with resolved titles and URLs:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
$ notion relations tasks --filter "Name=Implement relations"
|
|
202
|
+
Project: 1 linked page
|
|
203
|
+
id │ title │ url
|
|
204
|
+
──────────┼───────────┼──────────────────────
|
|
205
|
+
302903e2… │ Build CLI │ https://notion.so/...
|
|
179
206
|
```
|
|
180
207
|
|
|
181
|
-
### `notion blocks` — View Page Content
|
|
208
|
+
### `notion blocks` — View & Edit Page Content
|
|
209
|
+
|
|
210
|
+
View blocks with optional IDs for targeting:
|
|
182
211
|
|
|
183
|
-
By page ID or alias + filter:
|
|
184
212
|
```
|
|
185
|
-
$ notion blocks
|
|
186
|
-
# Project Overview
|
|
187
|
-
This is the main project page.
|
|
188
|
-
• First task
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
213
|
+
$ notion blocks tasks --filter "Name=Ship v1.1" --ids
|
|
214
|
+
[a1b2c3d4] # Project Overview
|
|
215
|
+
[e5f67890] This is the main project page.
|
|
216
|
+
[abcd1234] • First task
|
|
217
|
+
[efgh5678] ☑ Completed item
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
In v1.1+, blocks are fully editable and deletable — not just readable:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
$ notion block-edit a1b2c3d4-5678-90ab-cdef-1234567890ab "Updated heading text"
|
|
224
|
+
✅ Updated heading_1 block: a1b2c3d4…
|
|
225
|
+
|
|
226
|
+
$ notion block-delete a1b2c3d4-5678-90ab-cdef-1234567890ab
|
|
227
|
+
🗑️ Deleted block: a1b2c3d4…
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Use `notion blocks --ids` to list block IDs for precise edits.
|
|
231
|
+
|
|
232
|
+
### `notion append` — Add Content to a Page
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
$ notion append tasks "Status update: phase 1 complete" --filter "Name=Ship feature"
|
|
236
|
+
✅ Appended text block to page a1b2c3d4-...
|
|
192
237
|
```
|
|
193
238
|
|
|
194
239
|
### `notion dbs` — List All Databases
|
|
@@ -207,12 +252,19 @@ f9e8d7c6-b5a4-3210-fedc-ba0987654321 │ Reading List │ https://...
|
|
|
207
252
|
|
|
208
253
|
```
|
|
209
254
|
$ notion search "meeting"
|
|
210
|
-
|
|
211
|
-
─────────────────────────────────────┼─────────────┼────────────────┼──────────────
|
|
212
|
-
a1b2c3d4-e5f6-7890-abcd-ef1234567890 │ page │ Meeting Notes │ https://...
|
|
213
|
-
f9e8d7c6-b5a4-3210-fedc-ba0987654321 │ data_source │ Meetings DB │ https://...
|
|
255
|
+
```
|
|
214
256
|
|
|
215
|
-
|
|
257
|
+
### `notion users` / `notion user <id>` — Workspace Users
|
|
258
|
+
|
|
259
|
+
```
|
|
260
|
+
$ notion users
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### `notion comments` / `notion comment` — Page Comments
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
$ notion comments tasks --filter "Name=Ship feature"
|
|
267
|
+
$ notion comment tasks "Shipped! 🚀" --filter "Name=Ship feature"
|
|
216
268
|
```
|
|
217
269
|
|
|
218
270
|
### `notion alias` — Manage Aliases
|
|
@@ -220,17 +272,10 @@ f9e8d7c6-b5a4-3210-fedc-ba0987654321 │ data_source │ Meetings DB │ http
|
|
|
220
272
|
Aliases are created automatically by `notion init`, but you can manage them:
|
|
221
273
|
|
|
222
274
|
```bash
|
|
223
|
-
# See
|
|
224
|
-
notion alias
|
|
225
|
-
|
|
226
|
-
#
|
|
227
|
-
notion alias rename project-tracker projects
|
|
228
|
-
|
|
229
|
-
# Add one manually (auto-discovers the right IDs)
|
|
230
|
-
notion alias add tasks a1b2c3d4-e5f6-7890-abcd-ef1234567890
|
|
231
|
-
|
|
232
|
-
# Remove one
|
|
233
|
-
notion alias remove tasks
|
|
275
|
+
notion alias list # See all aliases
|
|
276
|
+
notion alias rename project-tracker projects # Rename one
|
|
277
|
+
notion alias add tasks <database-id> # Add manually
|
|
278
|
+
notion alias remove tasks # Remove one
|
|
234
279
|
```
|
|
235
280
|
|
|
236
281
|
### `--json` — Raw JSON Output
|
|
@@ -239,12 +284,20 @@ Add `--json` to any command for the raw Notion API response:
|
|
|
239
284
|
|
|
240
285
|
```bash
|
|
241
286
|
notion --json query projects --limit 1
|
|
242
|
-
notion --json
|
|
243
|
-
notion --json get a1b2c3d4-...
|
|
287
|
+
notion --json get tasks --filter "Name=Ship it"
|
|
244
288
|
```
|
|
245
289
|
|
|
246
290
|
Great for piping into `jq` or other tools.
|
|
247
291
|
|
|
292
|
+
### `--output` — Output Formats
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
notion query tasks --output csv # CSV
|
|
296
|
+
notion query tasks --output yaml # YAML
|
|
297
|
+
notion query tasks --output json # JSON
|
|
298
|
+
notion query tasks --output table # Default
|
|
299
|
+
```
|
|
300
|
+
|
|
248
301
|
---
|
|
249
302
|
|
|
250
303
|
## Use It With AI Agents
|
|
@@ -263,17 +316,20 @@ notion query tasks --filter Status=Todo --sort Priority:desc
|
|
|
263
316
|
notion add tasks --prop "Name=Fix bug #42" --prop "Status=In Progress"
|
|
264
317
|
notion update tasks --filter "Name=Fix bug #42" --prop "Status=Done"
|
|
265
318
|
|
|
266
|
-
#
|
|
267
|
-
notion
|
|
268
|
-
|
|
319
|
+
# Explore relations between databases
|
|
320
|
+
notion relations tasks --filter "Name=Fix bug #42"
|
|
321
|
+
|
|
322
|
+
# View and edit page content
|
|
323
|
+
notion blocks tasks --filter "Name=Fix bug #42" --ids
|
|
324
|
+
notion block-edit <block-id> "Updated content"
|
|
269
325
|
notion append tasks "Deployed to production" --filter "Name=Fix bug #42"
|
|
270
326
|
|
|
327
|
+
# Comment and collaborate
|
|
328
|
+
notion comment tasks "Shipped! 🚀" --filter "Name=Fix bug #42"
|
|
329
|
+
|
|
271
330
|
# Delete by alias + filter
|
|
272
331
|
notion delete tasks --filter "Name=Fix bug #42"
|
|
273
332
|
|
|
274
|
-
# Or use raw page IDs if you already have them
|
|
275
|
-
notion update <page-id> --prop "Status=Done"
|
|
276
|
-
|
|
277
333
|
# Get raw JSON for parsing
|
|
278
334
|
notion --json query projects --limit 10
|
|
279
335
|
```
|
|
@@ -282,7 +338,7 @@ No API key management, no curl commands, no JSON formatting — just simple shel
|
|
|
282
338
|
|
|
283
339
|
### Zero-UUID Workflow
|
|
284
340
|
|
|
285
|
-
Every page-targeted command (`update`, `delete`, `get`, `blocks`, `comments`, `comment`, `append`)
|
|
341
|
+
Every page-targeted command (`update`, `delete`, `get`, `blocks`, `relations`, `comments`, `comment`, `append`, `block-edit`, `block-delete`) accepts a **database alias + `--filter`** as an alternative to a raw page ID:
|
|
286
342
|
|
|
287
343
|
```bash
|
|
288
344
|
# Instead of: notion update a1b2c3d4-5678-90ab-cdef-1234567890ab --prop "Status=Done"
|
|
@@ -324,6 +380,12 @@ The latest Notion API introduced a dual-ID system for databases. Each database n
|
|
|
324
380
|
|
|
325
381
|
You don't need to think about this. It just works.
|
|
326
382
|
|
|
383
|
+
### Reliability
|
|
384
|
+
|
|
385
|
+
- 139 unit tests
|
|
386
|
+
- Tested against live Notion workspaces
|
|
387
|
+
- Designed to fail loudly and safely when filters match zero or multiple pages
|
|
388
|
+
|
|
327
389
|
### Built on the Official SDK
|
|
328
390
|
|
|
329
391
|
notioncli uses [`@notionhq/client`](https://github.com/makenotion/notion-sdk-js) v5.x, Notion's official JavaScript SDK.
|
package/bin/notion.js
CHANGED
|
@@ -203,7 +203,7 @@ async function buildFilter(dbIds, filterStr) {
|
|
|
203
203
|
program
|
|
204
204
|
.name('notion')
|
|
205
205
|
.description('A powerful CLI for the Notion API — query databases, manage pages, and automate your workspace from the terminal.')
|
|
206
|
-
.version('1.
|
|
206
|
+
.version('1.1.0')
|
|
207
207
|
.option('--json', 'Output raw JSON instead of formatted tables');
|
|
208
208
|
|
|
209
209
|
// ─── init ──────────────────────────────────────────────────────────────────────
|
|
@@ -619,7 +619,43 @@ program
|
|
|
619
619
|
console.log('');
|
|
620
620
|
console.log('Properties:');
|
|
621
621
|
for (const [name, prop] of Object.entries(page.properties)) {
|
|
622
|
-
|
|
622
|
+
if (prop.type === 'relation') {
|
|
623
|
+
const rels = prop.relation || [];
|
|
624
|
+
if (rels.length === 0) {
|
|
625
|
+
console.log(` ${name}: (none)`);
|
|
626
|
+
} else {
|
|
627
|
+
// Resolve relation titles
|
|
628
|
+
const titles = [];
|
|
629
|
+
for (const rel of rels) {
|
|
630
|
+
try {
|
|
631
|
+
const linked = await notion.pages.retrieve({ page_id: rel.id });
|
|
632
|
+
let t = '';
|
|
633
|
+
for (const [, p] of Object.entries(linked.properties)) {
|
|
634
|
+
if (p.type === 'title') { t = propValue(p); break; }
|
|
635
|
+
}
|
|
636
|
+
titles.push(t || rel.id.slice(0, 8) + '…');
|
|
637
|
+
} catch {
|
|
638
|
+
titles.push(rel.id.slice(0, 8) + '…');
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
console.log(` ${name}: ${titles.join(', ')}`);
|
|
642
|
+
}
|
|
643
|
+
} else if (prop.type === 'rollup') {
|
|
644
|
+
const r = prop.rollup;
|
|
645
|
+
if (!r) {
|
|
646
|
+
console.log(` ${name}: (empty)`);
|
|
647
|
+
} else if (r.type === 'number') {
|
|
648
|
+
console.log(` ${name}: ${r.number != null ? r.number : '(empty)'}`);
|
|
649
|
+
} else if (r.type === 'date') {
|
|
650
|
+
console.log(` ${name}: ${r.date ? r.date.start : '(empty)'}`);
|
|
651
|
+
} else if (r.type === 'array' && r.array) {
|
|
652
|
+
console.log(` ${name}: ${r.array.map(item => propValue(item)).join(', ')}`);
|
|
653
|
+
} else {
|
|
654
|
+
console.log(` ${name}: ${JSON.stringify(r)}`);
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
console.log(` ${name}: ${propValue(prop)}`);
|
|
658
|
+
}
|
|
623
659
|
}
|
|
624
660
|
} catch (err) {
|
|
625
661
|
console.error('Get failed:', err.message);
|
|
@@ -632,6 +668,7 @@ program
|
|
|
632
668
|
.command('blocks <page-or-alias>')
|
|
633
669
|
.description('Get page content as rendered blocks by ID or alias + filter')
|
|
634
670
|
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
671
|
+
.option('--ids', 'Show block IDs alongside content (for editing/deleting)')
|
|
635
672
|
.action(async (target, opts, cmd) => {
|
|
636
673
|
try {
|
|
637
674
|
const notion = getNotion();
|
|
@@ -663,7 +700,8 @@ program
|
|
|
663
700
|
: type === 'code' ? '```\n'
|
|
664
701
|
: '';
|
|
665
702
|
const suffix = type === 'code' ? '\n```' : '';
|
|
666
|
-
|
|
703
|
+
const idTag = opts.ids ? `[${block.id.slice(0, 8)}] ` : '';
|
|
704
|
+
console.log(`${idTag}${prefix}${text}${suffix}`);
|
|
667
705
|
}
|
|
668
706
|
} catch (err) {
|
|
669
707
|
console.error('Blocks failed:', err.message);
|
|
@@ -671,6 +709,165 @@ program
|
|
|
671
709
|
}
|
|
672
710
|
});
|
|
673
711
|
|
|
712
|
+
// ─── block-edit ────────────────────────────────────────────────────────────────
|
|
713
|
+
program
|
|
714
|
+
.command('block-edit <block-id> <text>')
|
|
715
|
+
.description('Update a block\'s text content')
|
|
716
|
+
.action(async (blockId, text, opts, cmd) => {
|
|
717
|
+
try {
|
|
718
|
+
const notion = getNotion();
|
|
719
|
+
// First retrieve the block to know its type
|
|
720
|
+
const block = await notion.blocks.retrieve({ block_id: blockId });
|
|
721
|
+
const type = block.type;
|
|
722
|
+
|
|
723
|
+
// Build the update payload based on block type
|
|
724
|
+
const supportedTextTypes = [
|
|
725
|
+
'paragraph', 'heading_1', 'heading_2', 'heading_3',
|
|
726
|
+
'bulleted_list_item', 'numbered_list_item', 'quote', 'callout', 'toggle',
|
|
727
|
+
];
|
|
728
|
+
|
|
729
|
+
if (type === 'to_do') {
|
|
730
|
+
const res = await notion.blocks.update({
|
|
731
|
+
block_id: blockId,
|
|
732
|
+
to_do: {
|
|
733
|
+
rich_text: [{ text: { content: text } }],
|
|
734
|
+
checked: block.to_do?.checked || false,
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
if (getGlobalJson(cmd)) { console.log(JSON.stringify(res, null, 2)); return; }
|
|
738
|
+
console.log(`✅ Updated ${type} block: ${blockId.slice(0, 8)}…`);
|
|
739
|
+
} else if (supportedTextTypes.includes(type)) {
|
|
740
|
+
const res = await notion.blocks.update({
|
|
741
|
+
block_id: blockId,
|
|
742
|
+
[type]: {
|
|
743
|
+
rich_text: [{ text: { content: text } }],
|
|
744
|
+
},
|
|
745
|
+
});
|
|
746
|
+
if (getGlobalJson(cmd)) { console.log(JSON.stringify(res, null, 2)); return; }
|
|
747
|
+
console.log(`✅ Updated ${type} block: ${blockId.slice(0, 8)}…`);
|
|
748
|
+
} else if (type === 'code') {
|
|
749
|
+
const res = await notion.blocks.update({
|
|
750
|
+
block_id: blockId,
|
|
751
|
+
code: {
|
|
752
|
+
rich_text: [{ text: { content: text } }],
|
|
753
|
+
language: block.code?.language || 'plain text',
|
|
754
|
+
},
|
|
755
|
+
});
|
|
756
|
+
if (getGlobalJson(cmd)) { console.log(JSON.stringify(res, null, 2)); return; }
|
|
757
|
+
console.log(`✅ Updated code block: ${blockId.slice(0, 8)}…`);
|
|
758
|
+
} else {
|
|
759
|
+
console.error(`Block type "${type}" doesn't support text editing.`);
|
|
760
|
+
console.error('Supported types: paragraph, headings, lists, to_do, quote, callout, toggle, code');
|
|
761
|
+
process.exit(1);
|
|
762
|
+
}
|
|
763
|
+
} catch (err) {
|
|
764
|
+
console.error('Block edit failed:', err.message);
|
|
765
|
+
process.exit(1);
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// ─── block-delete ──────────────────────────────────────────────────────────────
|
|
770
|
+
program
|
|
771
|
+
.command('block-delete <block-id>')
|
|
772
|
+
.description('Delete a block from a page')
|
|
773
|
+
.action(async (blockId, opts, cmd) => {
|
|
774
|
+
try {
|
|
775
|
+
const notion = getNotion();
|
|
776
|
+
const res = await notion.blocks.delete({ block_id: blockId });
|
|
777
|
+
if (getGlobalJson(cmd)) {
|
|
778
|
+
console.log(JSON.stringify(res, null, 2));
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
console.log(`🗑️ Deleted block: ${blockId.slice(0, 8)}…`);
|
|
782
|
+
} catch (err) {
|
|
783
|
+
console.error('Block delete failed:', err.message);
|
|
784
|
+
process.exit(1);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// ─── relations ─────────────────────────────────────────────────────────────────
|
|
789
|
+
program
|
|
790
|
+
.command('relations <page-or-alias>')
|
|
791
|
+
.description('Show all relation and rollup properties with resolved titles')
|
|
792
|
+
.option('--filter <key=value>', 'Filter to find the page (required when using an alias)')
|
|
793
|
+
.action(async (target, opts, cmd) => {
|
|
794
|
+
try {
|
|
795
|
+
const notion = getNotion();
|
|
796
|
+
const { pageId } = await resolvePageId(target, opts.filter);
|
|
797
|
+
const page = await notion.pages.retrieve({ page_id: pageId });
|
|
798
|
+
|
|
799
|
+
if (getGlobalJson(cmd)) {
|
|
800
|
+
console.log(JSON.stringify(page, null, 2));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let found = false;
|
|
805
|
+
|
|
806
|
+
for (const [name, prop] of Object.entries(page.properties)) {
|
|
807
|
+
if (prop.type === 'relation') {
|
|
808
|
+
const rels = prop.relation || [];
|
|
809
|
+
if (rels.length === 0) {
|
|
810
|
+
console.log(`\n${name}: (no linked pages)`);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
found = true;
|
|
814
|
+
console.log(`\n${name}: ${rels.length} linked page${rels.length !== 1 ? 's' : ''}`);
|
|
815
|
+
|
|
816
|
+
// Resolve each related page title
|
|
817
|
+
const rows = [];
|
|
818
|
+
for (const rel of rels) {
|
|
819
|
+
try {
|
|
820
|
+
const linked = await notion.pages.retrieve({ page_id: rel.id });
|
|
821
|
+
let title = '';
|
|
822
|
+
for (const [, p] of Object.entries(linked.properties)) {
|
|
823
|
+
if (p.type === 'title') {
|
|
824
|
+
title = propValue(p);
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
rows.push({
|
|
829
|
+
id: rel.id.slice(0, 8) + '…',
|
|
830
|
+
title: title || '(untitled)',
|
|
831
|
+
url: linked.url || '',
|
|
832
|
+
});
|
|
833
|
+
} catch {
|
|
834
|
+
rows.push({ id: rel.id.slice(0, 8) + '…', title: '(access denied)', url: '' });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
printTable(rows, ['id', 'title', 'url']);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (prop.type === 'rollup') {
|
|
841
|
+
found = true;
|
|
842
|
+
const r = prop.rollup;
|
|
843
|
+
console.log(`\n${name} (rollup):`);
|
|
844
|
+
if (!r) {
|
|
845
|
+
console.log(' (empty)');
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
if (r.type === 'number') {
|
|
849
|
+
console.log(` ${r.function || 'value'}: ${r.number}`);
|
|
850
|
+
} else if (r.type === 'date') {
|
|
851
|
+
console.log(` ${r.date ? r.date.start : '(empty)'}`);
|
|
852
|
+
} else if (r.type === 'array' && r.array) {
|
|
853
|
+
for (const item of r.array) {
|
|
854
|
+
console.log(` • ${propValue(item)}`);
|
|
855
|
+
}
|
|
856
|
+
} else {
|
|
857
|
+
console.log(` ${JSON.stringify(r)}`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (!found) {
|
|
863
|
+
console.log('This page has no relation or rollup properties.');
|
|
864
|
+
}
|
|
865
|
+
} catch (err) {
|
|
866
|
+
console.error('Relations failed:', err.message);
|
|
867
|
+
process.exit(1);
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
674
871
|
// ─── dbs ───────────────────────────────────────────────────────────────────────
|
|
675
872
|
program
|
|
676
873
|
.command('dbs')
|
package/lib/helpers.js
CHANGED
|
@@ -74,10 +74,25 @@ function propValue(prop) {
|
|
|
74
74
|
return f.string || f.number?.toString() || f.boolean?.toString() || f.date?.start || '';
|
|
75
75
|
}
|
|
76
76
|
return '';
|
|
77
|
-
case 'relation':
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
77
|
+
case 'relation': {
|
|
78
|
+
const rels = prop.relation || [];
|
|
79
|
+
if (rels.length === 0) return '';
|
|
80
|
+
if (rels.length === 1) return `→ ${rels[0].id.slice(0, 8)}…`;
|
|
81
|
+
return `→ ${rels.length} linked`;
|
|
82
|
+
}
|
|
83
|
+
case 'rollup': {
|
|
84
|
+
const r = prop.rollup;
|
|
85
|
+
if (!r) return '';
|
|
86
|
+
switch (r.type) {
|
|
87
|
+
case 'number': return r.number != null ? String(r.number) : '';
|
|
88
|
+
case 'date': return r.date ? (r.date.end ? `${r.date.start} → ${r.date.end}` : r.date.start) : '';
|
|
89
|
+
case 'array': {
|
|
90
|
+
if (!r.array || r.array.length === 0) return '';
|
|
91
|
+
return r.array.map(item => propValue(item)).join(', ');
|
|
92
|
+
}
|
|
93
|
+
default: return JSON.stringify(r);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
81
96
|
case 'people':
|
|
82
97
|
return (prop.people || []).map(p => p.name || p.id).join(', ');
|
|
83
98
|
case 'files':
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: notion
|
|
3
|
-
description: Notion API for creating and managing pages, databases, and
|
|
3
|
+
description: Notion API for creating and managing pages, databases, blocks, relations, and rollups via the notioncli CLI tool.
|
|
4
4
|
homepage: https://github.com/JordanCoin/notioncli
|
|
5
5
|
metadata:
|
|
6
6
|
openclaw:
|
|
@@ -8,7 +8,7 @@ metadata:
|
|
|
8
8
|
requires:
|
|
9
9
|
env: ["NOTION_API_KEY"]
|
|
10
10
|
primaryEnv: NOTION_API_KEY
|
|
11
|
-
install: "npm install -g notioncli"
|
|
11
|
+
install: "npm install -g @jordancoin/notioncli"
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
# notioncli — Notion API Skill
|
|
@@ -120,6 +120,27 @@ notion delete tasks --filter "Name=Old task" # By alias + filter
|
|
|
120
120
|
notion delete workouts --filter "Date=2026-02-09" # By alias + filter
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
+
### Relations & Rollups
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
notion relations tasks --filter "Name=Ship feature" # See linked pages with titles
|
|
127
|
+
notion relations projects --filter "Name=Launch CLI" # Explore connections
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Relations are automatically resolved to page titles in `get` output. Rollups are parsed into numbers, dates, or arrays instead of raw JSON.
|
|
131
|
+
|
|
132
|
+
### Blocks CRUD
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
notion blocks tasks --filter "Name=Ship feature" # View page content
|
|
136
|
+
notion blocks tasks --filter "Name=Ship feature" --ids # View with block IDs
|
|
137
|
+
notion append tasks "New paragraph" --filter "Name=Ship feature" # Append block
|
|
138
|
+
notion block-edit <block-id> "Updated text" # Edit a block
|
|
139
|
+
notion block-delete <block-id> # Delete a block
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Use `--ids` to get block IDs, then target specific blocks with `block-edit` or `block-delete`.
|
|
143
|
+
|
|
123
144
|
### Appending Content
|
|
124
145
|
|
|
125
146
|
```bash
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "notioncli",
|
|
3
|
+
"name": "notioncli — Notion CLI",
|
|
4
|
+
"description": "Query databases, manage pages, explore relations, and edit blocks in your Notion workspace from the terminal.",
|
|
5
|
+
"longDescription": "A powerful CLI for the Notion API built for both humans and AI agents. Auto-discovers databases and creates aliases so you never type UUIDs. Supports querying, filtering, sorting, creating, updating, deleting pages, comments, and appending content. Works with Notion's 2025 dual-ID API. Zero-UUID workflow via alias + filter on every command.",
|
|
6
|
+
"category": "Productivity",
|
|
7
|
+
"price": 0,
|
|
8
|
+
"icon": "📝",
|
|
9
|
+
"author": "JordanCoin",
|
|
10
|
+
"authorUrl": "https://github.com/JordanCoin",
|
|
11
|
+
"repository": "https://github.com/JordanCoin/notioncli",
|
|
12
|
+
"version": "1.1.0",
|
|
13
|
+
"tags": ["notion", "cli", "api", "database", "productivity", "workspace", "automation", "ai-agent"],
|
|
14
|
+
"requirements": ["Node.js >= 18", "Notion API key (free)"],
|
|
15
|
+
"optional": []
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { Client } = require('@notionhq/client');
|
|
2
|
+
const h = require('../lib/helpers');
|
|
3
|
+
const notion = new Client({ auth: h.loadConfig(h.getConfigPaths().CONFIG_PATH).apiKey });
|
|
4
|
+
const parentId = '302903e2-cff4-8059-b808-e3c953fd7d26';
|
|
5
|
+
|
|
6
|
+
(async () => {
|
|
7
|
+
const db = await notion.databases.create({
|
|
8
|
+
parent: { type: 'page_id', page_id: parentId },
|
|
9
|
+
title: [{ text: { content: 'Quick Test DB' } }],
|
|
10
|
+
properties: { Name: { title: {} }, Priority: { number: {} } }
|
|
11
|
+
});
|
|
12
|
+
console.log('DB created:', db.id.slice(0,8));
|
|
13
|
+
console.log('DB keys with id:', Object.keys(db).filter(k => k.includes('id')));
|
|
14
|
+
console.log('database_id:', db.database_id);
|
|
15
|
+
|
|
16
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
17
|
+
|
|
18
|
+
for (const pt of ['data_source_id', 'database_id']) {
|
|
19
|
+
const pid = pt === 'database_id' && db.database_id ? db.database_id : db.id;
|
|
20
|
+
try {
|
|
21
|
+
const page = await notion.pages.create({
|
|
22
|
+
parent: { type: pt, [pt]: pid },
|
|
23
|
+
properties: { Name: { title: [{ text: { content: 'test ' + pt } }] } }
|
|
24
|
+
});
|
|
25
|
+
console.log(pt, 'WORKS:', page.id.slice(0,8));
|
|
26
|
+
await notion.pages.update({ page_id: page.id, archived: true });
|
|
27
|
+
} catch(e) { console.log(pt, 'FAILED:', e.code, e.message.slice(0,100)); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await notion.blocks.delete({ block_id: db.id });
|
|
31
|
+
console.log('cleaned');
|
|
32
|
+
})();
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Live integration test: Relations, Rollups & Blocks CRUD
|
|
3
|
+
// Creates temp databases, links them, tests CLI output, cleans up.
|
|
4
|
+
|
|
5
|
+
const { Client } = require('@notionhq/client');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const CLI = path.join(__dirname, '..', 'bin', 'notion.js');
|
|
10
|
+
const run = (cmd) => execSync(`node ${CLI} ${cmd}`, { encoding: 'utf-8' }).trim();
|
|
11
|
+
const runJson = (cmd) => JSON.parse(execSync(`node ${CLI} --json ${cmd}`, { encoding: 'utf-8' }));
|
|
12
|
+
|
|
13
|
+
const notion = new Client({ auth: process.env.NOTION_API_KEY || require('../lib/helpers').loadConfig(
|
|
14
|
+
require('../lib/helpers').getConfigPaths().CONFIG_PATH
|
|
15
|
+
).apiKey });
|
|
16
|
+
|
|
17
|
+
let parentPageId = null;
|
|
18
|
+
let projectsDbId = null;
|
|
19
|
+
let projectsDsId = null;
|
|
20
|
+
let tasksDbId = null;
|
|
21
|
+
let tasksDsId = null;
|
|
22
|
+
let testPageIds = [];
|
|
23
|
+
let createdDbIds = [];
|
|
24
|
+
|
|
25
|
+
async function setup() {
|
|
26
|
+
console.log('\n🔧 Setting up test databases...\n');
|
|
27
|
+
|
|
28
|
+
// Create a dedicated test page as parent (needs to be under a page the integration can access)
|
|
29
|
+
// First, find an existing page the integration has access to
|
|
30
|
+
const search = await notion.search({ filter: { value: 'page', property: 'object' }, page_size: 20 });
|
|
31
|
+
|
|
32
|
+
// Look for a top-level page (parent is workspace), or any page that's not inside a database
|
|
33
|
+
let rootPageId = null;
|
|
34
|
+
for (const p of search.results) {
|
|
35
|
+
if (p.parent?.type === 'workspace' || p.parent?.type === 'page_id') {
|
|
36
|
+
rootPageId = p.id;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!rootPageId) {
|
|
42
|
+
// No standalone page found — create the DBs using the workspace parent directly
|
|
43
|
+
// by creating a page in the integration's space
|
|
44
|
+
console.log('No standalone page found. Will use first available page.');
|
|
45
|
+
rootPageId = search.results[0]?.id;
|
|
46
|
+
if (!rootPageId) throw new Error('No pages accessible by integration');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Create a test container page
|
|
50
|
+
const containerPage = await notion.pages.create({
|
|
51
|
+
parent: { type: 'page_id', page_id: rootPageId },
|
|
52
|
+
properties: {
|
|
53
|
+
title: { title: [{ text: { content: 'CLI Test Container (auto-delete)' } }] },
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
parentPageId = containerPage.id;
|
|
57
|
+
testPageIds.push(parentPageId);
|
|
58
|
+
console.log(`📄 Created test container page: ${parentPageId.slice(0, 8)}…`);
|
|
59
|
+
|
|
60
|
+
// 1. Create "CLI Test Projects" database
|
|
61
|
+
console.log('\nCreating Projects DB...');
|
|
62
|
+
const projectsDb = await notion.databases.create({
|
|
63
|
+
parent: { type: 'page_id', page_id: parentPageId },
|
|
64
|
+
title: [{ text: { content: 'CLI Test Projects' } }],
|
|
65
|
+
properties: {
|
|
66
|
+
'Name': { title: {} },
|
|
67
|
+
'Status': { select: { options: [
|
|
68
|
+
{ name: 'Active', color: 'green' },
|
|
69
|
+
{ name: 'Done', color: 'gray' },
|
|
70
|
+
]}},
|
|
71
|
+
'Priority': { number: {} },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
// databases.create() returns: .id = data_source_id, .database_id = database_id for page creation
|
|
75
|
+
projectsDsId = projectsDb.id;
|
|
76
|
+
projectsDbId = projectsDb.database_id || projectsDb.id;
|
|
77
|
+
createdDbIds.push(projectsDb.id);
|
|
78
|
+
console.log(`✅ Projects DB (db: ${projectsDbId.slice(0, 8)}…, ds: ${projectsDsId.slice(0, 8)}…)`);
|
|
79
|
+
|
|
80
|
+
// 2. Create "CLI Test Tasks" database with relation to Projects
|
|
81
|
+
console.log('Creating Tasks DB with relation...');
|
|
82
|
+
const tasksDb = await notion.databases.create({
|
|
83
|
+
parent: { type: 'page_id', page_id: parentPageId },
|
|
84
|
+
title: [{ text: { content: 'CLI Test Tasks' } }],
|
|
85
|
+
properties: {
|
|
86
|
+
'Name': { title: {} },
|
|
87
|
+
'Done': { checkbox: {} },
|
|
88
|
+
'Project': { relation: { database_id: projectsDbId } },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
tasksDsId = tasksDb.id;
|
|
92
|
+
tasksDbId = tasksDb.database_id || tasksDb.id;
|
|
93
|
+
createdDbIds.push(tasksDb.id);
|
|
94
|
+
console.log(`✅ Tasks DB (db: ${tasksDbId.slice(0, 8)}…, ds: ${tasksDsId.slice(0, 8)}…)`);
|
|
95
|
+
|
|
96
|
+
// 3. Add project pages
|
|
97
|
+
console.log('\nAdding test data...');
|
|
98
|
+
const proj1 = await notion.pages.create({
|
|
99
|
+
parent: { type: 'database_id', database_id: projectsDbId },
|
|
100
|
+
properties: {
|
|
101
|
+
'Name': { title: [{ text: { content: 'Build CLI' } }] },
|
|
102
|
+
'Status': { select: { name: 'Active' } },
|
|
103
|
+
'Priority': { number: 1 },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
testPageIds.push(proj1.id);
|
|
107
|
+
console.log(` 📌 Project: "Build CLI"`);
|
|
108
|
+
|
|
109
|
+
const proj2 = await notion.pages.create({
|
|
110
|
+
parent: { type: 'database_id', database_id: projectsDbId },
|
|
111
|
+
properties: {
|
|
112
|
+
'Name': { title: [{ text: { content: 'Write Docs' } }] },
|
|
113
|
+
'Status': { select: { name: 'Done' } },
|
|
114
|
+
'Priority': { number: 2 },
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
testPageIds.push(proj2.id);
|
|
118
|
+
console.log(` 📌 Project: "Write Docs"`);
|
|
119
|
+
|
|
120
|
+
// 4. Add task pages linked to projects
|
|
121
|
+
const task1 = await notion.pages.create({
|
|
122
|
+
parent: { type: 'database_id', database_id: tasksDbId },
|
|
123
|
+
properties: {
|
|
124
|
+
'Name': { title: [{ text: { content: 'Implement relations' } }] },
|
|
125
|
+
'Done': { checkbox: true },
|
|
126
|
+
'Project': { relation: [{ id: proj1.id }] },
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
testPageIds.push(task1.id);
|
|
130
|
+
console.log(` 📋 Task: "Implement relations" → Build CLI`);
|
|
131
|
+
|
|
132
|
+
const task2 = await notion.pages.create({
|
|
133
|
+
parent: { type: 'database_id', database_id: tasksDbId },
|
|
134
|
+
properties: {
|
|
135
|
+
'Name': { title: [{ text: { content: 'Add tests' } }] },
|
|
136
|
+
'Done': { checkbox: false },
|
|
137
|
+
'Project': { relation: [{ id: proj1.id }] },
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
testPageIds.push(task2.id);
|
|
141
|
+
console.log(` 📋 Task: "Add tests" → Build CLI`);
|
|
142
|
+
|
|
143
|
+
const task3 = await notion.pages.create({
|
|
144
|
+
parent: { type: 'database_id', database_id: tasksDbId },
|
|
145
|
+
properties: {
|
|
146
|
+
'Name': { title: [{ text: { content: 'Write README' } }] },
|
|
147
|
+
'Done': { checkbox: true },
|
|
148
|
+
'Project': { relation: [{ id: proj2.id }] },
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
testPageIds.push(task3.id);
|
|
152
|
+
console.log(` 📋 Task: "Write README" → Write Docs`);
|
|
153
|
+
|
|
154
|
+
// 5. Register CLI aliases
|
|
155
|
+
run(`alias add test-projects ${projectsDsId}`);
|
|
156
|
+
run(`alias add test-tasks ${tasksDsId}`);
|
|
157
|
+
console.log(`\n✅ Aliases registered: test-projects, test-tasks\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function runTests() {
|
|
161
|
+
let passed = 0;
|
|
162
|
+
let failed = 0;
|
|
163
|
+
|
|
164
|
+
function check(name, condition, detail) {
|
|
165
|
+
if (condition) {
|
|
166
|
+
console.log(` ✅ ${name}`);
|
|
167
|
+
passed++;
|
|
168
|
+
} else {
|
|
169
|
+
console.log(` ❌ ${name}${detail ? ' — ' + detail : ''}`);
|
|
170
|
+
failed++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log('🧪 Running live tests...\n');
|
|
175
|
+
|
|
176
|
+
// --- Test 1: Query tasks — relation column formatting ---
|
|
177
|
+
console.log('--- 1. query (relation display) ---');
|
|
178
|
+
try {
|
|
179
|
+
const out = run('query test-tasks');
|
|
180
|
+
check('query shows tasks', out.includes('Implement relations'));
|
|
181
|
+
check('relation shows → format', out.includes('→'));
|
|
182
|
+
console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
|
|
183
|
+
} catch (e) { check('query test-tasks', false, e.message); }
|
|
184
|
+
|
|
185
|
+
// --- Test 2: Get task — resolve relation to project title ---
|
|
186
|
+
console.log('--- 2. get (relation resolution) ---');
|
|
187
|
+
try {
|
|
188
|
+
const out = run('get test-tasks --filter "Name=Implement relations"');
|
|
189
|
+
check('get resolves relation to title', out.includes('Build CLI'));
|
|
190
|
+
check('get shows URL', out.includes('notion.so'));
|
|
191
|
+
console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
|
|
192
|
+
} catch (e) { check('get with relation', false, e.message); }
|
|
193
|
+
|
|
194
|
+
// --- Test 3: Relations command — graph explorer ---
|
|
195
|
+
console.log('--- 3. relations (graph explorer) ---');
|
|
196
|
+
try {
|
|
197
|
+
const out = run('relations test-tasks --filter "Name=Implement relations"');
|
|
198
|
+
check('relations shows linked pages', out.includes('linked') || out.includes('Build CLI'));
|
|
199
|
+
console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
|
|
200
|
+
} catch (e) { check('relations command', false, e.message); }
|
|
201
|
+
|
|
202
|
+
// --- Test 4: Reverse relation (project → tasks) ---
|
|
203
|
+
console.log('--- 4. reverse relation ---');
|
|
204
|
+
try {
|
|
205
|
+
const out = run('relations test-projects --filter "Name=Build CLI"');
|
|
206
|
+
const hasLinks = out.includes('Implement') || out.includes('Add tests') || out.includes('linked');
|
|
207
|
+
check('project shows reverse relations', hasLinks);
|
|
208
|
+
console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
|
|
209
|
+
} catch (e) { check('reverse relation', false, e.message); }
|
|
210
|
+
|
|
211
|
+
// --- Test 5: Blocks --ids ---
|
|
212
|
+
console.log('--- 5. blocks --ids ---');
|
|
213
|
+
try {
|
|
214
|
+
run('append test-tasks "Test block for live test" --filter "Name=Implement relations"');
|
|
215
|
+
const out = run('blocks test-tasks --filter "Name=Implement relations" --ids');
|
|
216
|
+
check('blocks --ids shows ID prefix', out.includes('['));
|
|
217
|
+
check('blocks shows appended text', out.includes('Test block'));
|
|
218
|
+
console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
|
|
219
|
+
} catch (e) { check('blocks --ids', false, e.message); }
|
|
220
|
+
|
|
221
|
+
// --- Test 6: Block edit ---
|
|
222
|
+
console.log('--- 6. block-edit ---');
|
|
223
|
+
try {
|
|
224
|
+
const json = runJson('blocks test-tasks --filter "Name=Implement relations"');
|
|
225
|
+
const blockId = json.results[json.results.length - 1].id;
|
|
226
|
+
const out = run(`block-edit ${blockId} "EDITED by CLI test"`);
|
|
227
|
+
check('block-edit succeeds', out.includes('✅'));
|
|
228
|
+
const verify = run('blocks test-tasks --filter "Name=Implement relations"');
|
|
229
|
+
check('edited content visible', verify.includes('EDITED'));
|
|
230
|
+
console.log(`\n${verify.split('\n').map(l => ' ' + l).join('\n')}\n`);
|
|
231
|
+
} catch (e) { check('block-edit', false, e.message); }
|
|
232
|
+
|
|
233
|
+
// --- Test 7: Block delete ---
|
|
234
|
+
console.log('--- 7. block-delete ---');
|
|
235
|
+
try {
|
|
236
|
+
const json = runJson('blocks test-tasks --filter "Name=Implement relations"');
|
|
237
|
+
const blockId = json.results[json.results.length - 1].id;
|
|
238
|
+
const out = run(`block-delete ${blockId}`);
|
|
239
|
+
check('block-delete succeeds', out.includes('🗑'));
|
|
240
|
+
const verify = run('blocks test-tasks --filter "Name=Implement relations"');
|
|
241
|
+
check('deleted content gone', !verify.includes('EDITED'));
|
|
242
|
+
} catch (e) { check('block-delete', false, e.message); }
|
|
243
|
+
|
|
244
|
+
// --- Test 8: JSON output preserves relation data ---
|
|
245
|
+
console.log('--- 8. json output ---');
|
|
246
|
+
try {
|
|
247
|
+
const json = runJson('get test-tasks --filter "Name=Implement relations"');
|
|
248
|
+
check('json has properties', !!json.properties);
|
|
249
|
+
check('json has relation property', !!json.properties.Project);
|
|
250
|
+
check('relation type correct', json.properties.Project.type === 'relation');
|
|
251
|
+
check('relation has linked IDs', json.properties.Project.relation.length > 0);
|
|
252
|
+
} catch (e) { check('json output', false, e.message); }
|
|
253
|
+
|
|
254
|
+
// --- Test 9: Query projects with rollup-like display ---
|
|
255
|
+
console.log('--- 9. query projects ---');
|
|
256
|
+
try {
|
|
257
|
+
const out = run('query test-projects');
|
|
258
|
+
check('query shows projects', out.includes('Build CLI'));
|
|
259
|
+
check('query shows both projects', out.includes('Write Docs'));
|
|
260
|
+
console.log(`\n${out.split('\n').map(l => ' ' + l).join('\n')}\n`);
|
|
261
|
+
} catch (e) { check('query projects', false, e.message); }
|
|
262
|
+
|
|
263
|
+
console.log(`\n${'═'.repeat(50)}`);
|
|
264
|
+
console.log(`Results: ${passed} passed, ${failed} failed out of ${passed + failed}`);
|
|
265
|
+
console.log('═'.repeat(50));
|
|
266
|
+
|
|
267
|
+
return failed;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function cleanup() {
|
|
271
|
+
console.log('\n🧹 Cleaning up...');
|
|
272
|
+
|
|
273
|
+
// Archive pages first (before their parent DBs get deleted)
|
|
274
|
+
for (const id of testPageIds.filter(id => !createdDbIds.includes(id))) {
|
|
275
|
+
try { await notion.pages.update({ page_id: id, archived: true }); } catch {}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Then delete DBs and container page
|
|
279
|
+
for (const id of createdDbIds) {
|
|
280
|
+
try { await notion.blocks.delete({ block_id: id }); } catch {}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Delete container page last
|
|
284
|
+
if (parentPageId) {
|
|
285
|
+
try { await notion.blocks.delete({ block_id: parentPageId }); } catch {}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try { run('alias remove test-projects'); } catch {}
|
|
289
|
+
try { run('alias remove test-tasks'); } catch {}
|
|
290
|
+
|
|
291
|
+
console.log('✅ Cleaned up\n');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function main() {
|
|
295
|
+
try {
|
|
296
|
+
await setup();
|
|
297
|
+
const failures = await runTests();
|
|
298
|
+
await cleanup();
|
|
299
|
+
process.exit(failures > 0 ? 1 : 0);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
console.error('\n💥 Test crashed:', err.message);
|
|
302
|
+
if (err.body) console.error('API body:', JSON.stringify(err.body).slice(0, 500));
|
|
303
|
+
if (err.stack) console.error(err.stack);
|
|
304
|
+
await cleanup().catch(() => {});
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
main();
|
package/test/unit.test.js
CHANGED
|
@@ -151,15 +151,54 @@ describe('propValue', () => {
|
|
|
151
151
|
assert.equal(propValue({ type: 'formula', formula: null }), '');
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
it('handles relation type', () => {
|
|
155
|
-
const prop = { type: 'relation', relation: [{ id: 'abc' }, { id: 'def' }] };
|
|
156
|
-
assert.equal(propValue(prop), 'abc, def');
|
|
154
|
+
it('handles relation type — empty', () => {
|
|
157
155
|
assert.equal(propValue({ type: 'relation', relation: [] }), '');
|
|
158
156
|
});
|
|
159
157
|
|
|
160
|
-
it('handles
|
|
161
|
-
const
|
|
162
|
-
assert.equal(propValue(
|
|
158
|
+
it('handles relation type — single', () => {
|
|
159
|
+
const prop = { type: 'relation', relation: [{ id: 'abc12345-6789-0000-0000-000000000000' }] };
|
|
160
|
+
assert.equal(propValue(prop), '→ abc12345…');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('handles relation type — multiple', () => {
|
|
164
|
+
const prop = { type: 'relation', relation: [{ id: 'aaa' }, { id: 'bbb' }, { id: 'ccc' }] };
|
|
165
|
+
assert.equal(propValue(prop), '→ 3 linked');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('handles rollup type — number', () => {
|
|
169
|
+
assert.equal(propValue({ type: 'rollup', rollup: { type: 'number', number: 100 } }), '100');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('handles rollup type — null number', () => {
|
|
173
|
+
assert.equal(propValue({ type: 'rollup', rollup: { type: 'number', number: null } }), '');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('handles rollup type — date', () => {
|
|
177
|
+
assert.equal(propValue({ type: 'rollup', rollup: { type: 'date', date: { start: '2026-02-09' } } }), '2026-02-09');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('handles rollup type — date range', () => {
|
|
181
|
+
assert.equal(propValue({ type: 'rollup', rollup: { type: 'date', date: { start: '2026-02-01', end: '2026-02-28' } } }), '2026-02-01 → 2026-02-28');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('handles rollup type — array', () => {
|
|
185
|
+
const rollup = {
|
|
186
|
+
type: 'array',
|
|
187
|
+
array: [
|
|
188
|
+
{ type: 'number', number: 1 },
|
|
189
|
+
{ type: 'number', number: 2 },
|
|
190
|
+
{ type: 'number', number: 3 },
|
|
191
|
+
],
|
|
192
|
+
};
|
|
193
|
+
assert.equal(propValue({ type: 'rollup', rollup }), '1, 2, 3');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('handles rollup type — empty array', () => {
|
|
197
|
+
assert.equal(propValue({ type: 'rollup', rollup: { type: 'array', array: [] } }), '');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('handles rollup type — null', () => {
|
|
201
|
+
assert.equal(propValue({ type: 'rollup', rollup: null }), '');
|
|
163
202
|
});
|
|
164
203
|
|
|
165
204
|
it('handles people type', () => {
|