fix(notion): delete verb, one trash bit, row cells, and stale docs (#747)
DELETE /v1/blocks/{id} is the only delete verb the public API has and the
only one the MCP tool surface exposes, but the fake had no route for it
and `ntn api` had no DELETE method, so nothing could remove anything.
`archived` is upstream's deprecated alias for `in_trash` and always
returns the same value; the fake stored two columns, so a PATCH of
`archived` left the row in database queries while `ntn pages trash`
(in_trash) worked. Now one stored bit, both spellings on the wire.
A database row's cells now ride in its page.json under `properties`.
Also: trashing a child page moved only the page row, leaving the
child_page block in the parent listing; `datasources query` took its
TSV columns from the schema where upstream takes them from the returned
rows; and two MCP-parity cases pointed at pre-data-source paths, so they
compared two identical errors and asserted nothing.
Docs: both ntn.mdx files documented the pre-#740 grammar (--page flags,
ntn blocks/comments/search). notion.mdx, the TS setup doc and both
examples predated the data-source split; the examples were broken.
This commit is contained in:
@@ -14,7 +14,7 @@ description: Set up a Notion internal integration for the Notion resource.
|
||||
1. Under **Content Capabilities**, enable:
|
||||
- **Read content** (required)
|
||||
- **Update content** (for write commands)
|
||||
- **Insert content** (for `ntn comments create`)
|
||||
- **Insert content** (for creating pages and comments)
|
||||
1. Click **Save changes**
|
||||
1. Copy the **Internal Integration Secret** (starts with `ntn_`)
|
||||
|
||||
|
||||
+94
-33
@@ -5,7 +5,7 @@ icon: terminal
|
||||
---
|
||||
|
||||
Notion API client following the official Notion CLI grammar. Install it on the workspace and the whole tree is
|
||||
discoverable with `ntn --help`.
|
||||
discoverable with `ntn --help` or `man ntn`.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -25,46 +25,107 @@ install rides the `clis:` section; see the [CLI overview](/python/cli/index).
|
||||
|
||||
## Verbs
|
||||
|
||||
The grammar follows the official
|
||||
[Notion CLI](https://developers.notion.com/cli) (`ntn pages
|
||||
get/create/edit/trash`, `ntn datasources query`); `blocks`, `comments`
|
||||
and `search` are mirage extensions spelled with the REST API's nouns.
|
||||
JSON bodies are the raw Notion API resources.
|
||||
The grammar matches the official
|
||||
[Notion CLI](https://developers.notion.com/cli) verb for verb, and every
|
||||
case is gated against the real `ntn` binary in CI, so what is written
|
||||
here is what the program does.
|
||||
|
||||
**Ids are positional, not flags.** There is no `--page`, `--block` or
|
||||
`--datasource`; each verb names its own operand.
|
||||
|
||||
```
|
||||
ntn api <PATH>... Call the public Notion API (beta)
|
||||
ntn auth token Print the current authentication token
|
||||
ntn datasources query <ID_OR_URL>
|
||||
ntn datasources resolve <ID>
|
||||
ntn pages get <PAGE_ID> Retrieve a page as Markdown
|
||||
ntn pages create Create a page from Markdown content
|
||||
ntn pages edit <PAGE_ID> Edit a page's content from Markdown
|
||||
ntn pages trash <PAGE_ID> Trash a page
|
||||
ntn whoami Show the authenticated Notion user
|
||||
```
|
||||
|
||||
There is no `ntn blocks`, `ntn comments` or `ntn search`. Those are
|
||||
reached through `ntn api` with the REST API's own paths, exactly as
|
||||
upstream reaches them. Upstream's interactive and deploy verbs (`login`,
|
||||
`logout`, `update`, `workers`, `notion-as-code`, `doctor`, `files`) are
|
||||
out of scope for a virtualized CLI.
|
||||
|
||||
### Pages
|
||||
|
||||
```bash
|
||||
ntn pages get --page a1b2c3d4-...
|
||||
ntn pages create --json '{"parent":{"page_id":"a1b2c3d4"},"properties":{"title":[{"text":{"content":"Title"}}]}}'
|
||||
ntn pages edit --page a1b2c3d4-... --json '{"properties":{"title":[{"text":{"content":"Renamed"}}]}}'
|
||||
ntn pages trash --page a1b2c3d4-...
|
||||
```
|
||||
|
||||
| Verb | Flags | Writes |
|
||||
| -------- | ----------------- | ------ |
|
||||
| `get` | `--page` | no |
|
||||
| `create` | `--json` | yes |
|
||||
| `edit` | `--page --json` | yes |
|
||||
| `trash` | `--page` | yes |
|
||||
|
||||
`create` requires a `parent` key in the body; `edit` sends the body as
|
||||
a `PATCH /pages` request; `trash` sets `in_trash`.
|
||||
|
||||
### Blocks and comments
|
||||
Page bodies are **Markdown**, not property JSON. `create` takes the body
|
||||
on `--content` or from stdin, and the first heading becomes the title.
|
||||
|
||||
```bash
|
||||
ntn blocks append --block a1b2c3d4-... --json '{"children":[{"type":"paragraph","paragraph":{"rich_text":[{"text":{"content":"Hello"}}]}}]}'
|
||||
ntn comments create --json '{"parent":{"page_id":"a1b2c3d4"},"rich_text":[{"text":{"content":"Comment"}}]}'
|
||||
ntn pages get a1b2c3d4-...
|
||||
ntn pages get a1b2c3d4-... --json
|
||||
|
||||
ntn pages create --content '# Title' --parent page:a1b2c3d4-...
|
||||
echo '# Title' | ntn pages create --parent data-source:e5f6a7b8-...
|
||||
|
||||
ntn pages edit a1b2c3d4-... --content '# Replaced body'
|
||||
ntn pages trash a1b2c3d4-... --yes
|
||||
```
|
||||
|
||||
### Data sources and search
|
||||
| Verb | Operand | Options | Writes |
|
||||
| -------- | ----------- | -------------------------------------- | ------ |
|
||||
| `get` | `<PAGE_ID>` | `--json` | no |
|
||||
| `create` | none | `--content` `--parent` `--json` | yes |
|
||||
| `edit` | `<PAGE_ID>` | `--content` `--json` | yes |
|
||||
| `trash` | `<PAGE_ID>` | `--yes` | yes |
|
||||
|
||||
`--parent` takes `page:<id>`, `database:<id>` or `data-source:<id>`.
|
||||
`edit` replaces the page body wholesale. `trash` refuses without `--yes`
|
||||
unless a prompt can be answered, and sets `in_trash`.
|
||||
|
||||
To set a row's **property values** rather than its body, use `ntn api`:
|
||||
|
||||
```bash
|
||||
ntn datasources query --datasource e5f6a7b8-... --json '{"filter":{"property":"Status","select":{"equals":"Done"}}}'
|
||||
ntn search --query "Roadmap" --limit 5
|
||||
ntn api v1/pages/<row-id> -X PATCH \
|
||||
-d '{"properties":{"Stage":{"select":{"name":"Draft"}}}}'
|
||||
```
|
||||
|
||||
`datasources query` returns the database's row pages; `search` returns
|
||||
compact rows (`title`, `page_id`, `url`, `last_edited`, `parent_type`).
|
||||
Use the `<page-id>` / `<database-id>` from a mounted path segment as
|
||||
the `--page`, `--block`, or `--datasource` value.
|
||||
### Data sources
|
||||
|
||||
Since `2025-09-03` a database is a container of *data sources*, and the
|
||||
rows and the column schema live on the data source. `resolve` turns a
|
||||
database id into its data source ids; `query` accepts either in the same
|
||||
slot.
|
||||
|
||||
```bash
|
||||
ntn datasources resolve e5f6a7b8-...
|
||||
ntn datasources query d5000000-... --limit 10
|
||||
ntn datasources query d5000000-... -s 'Priority desc'
|
||||
ntn datasources query d5000000-... --filter '{"property":"Stage","select":{"equals":"Done"}}'
|
||||
ntn datasources query d5000000-... --json
|
||||
```
|
||||
|
||||
`query` prints one tab-separated line per row: the page id, then the
|
||||
property values in alphabetical order by column name. The columns are the
|
||||
ones the returned rows actually carry, so a result set that does not cover
|
||||
the whole schema prints narrower.
|
||||
|
||||
### Raw API
|
||||
|
||||
`ntn api` reaches every route that has no typed verb, including the only
|
||||
delete verb the public API has (`DELETE /v1/blocks/{id}`, which trashes a
|
||||
block, a page, or a database row).
|
||||
|
||||
```bash
|
||||
ntn api v1/users/me
|
||||
ntn api v1/search -d '{"query":"Roadmap"}'
|
||||
ntn api v1/search query=Roadmap
|
||||
ntn api v1/blocks/<page-id>/children page_size==10
|
||||
ntn api v1/blocks/<block-id> -X DELETE
|
||||
ntn api v1/comments -d '{"parent":{"page_id":"a1b2c3d4"},"rich_text":[{"text":{"content":"hi"}}]}'
|
||||
printf '{"query":"Roadmap"}' | ntn api v1/search
|
||||
```
|
||||
|
||||
The body comes from exactly one source: stdin, `--data`/`-d`, or inline
|
||||
`path=value` / `path:=json` inputs. Naming two is an error. `name==value`
|
||||
stays a query parameter whatever the method is, and `Header:Value` sets a
|
||||
header. Any body source makes the call a POST unless `-X`/`--method` says
|
||||
otherwise; `GET`, `POST`, `PATCH`, `PUT` and `DELETE` are accepted.
|
||||
|
||||
Use the `<page-id>` / `<database-id>` / `<data-source-id>` from a mounted
|
||||
path segment as the operand.
|
||||
|
||||
+115
-69
@@ -39,9 +39,11 @@ ws = Workspace({"/notion": resource}, mode=MountMode.WRITE)
|
||||
databases/
|
||||
<database-title>__<database-id>/
|
||||
database.json
|
||||
<row-page-title>__<page-id>/
|
||||
page.json
|
||||
...
|
||||
<data-source-name>__<data-source-id>/
|
||||
data_source.json
|
||||
<row-page-title>__<page-id>/
|
||||
page.json
|
||||
...
|
||||
```
|
||||
|
||||
Example:
|
||||
@@ -49,97 +51,133 @@ Example:
|
||||
```text
|
||||
/notion/
|
||||
pages/
|
||||
Project-Roadmap__a1b2c3d4/
|
||||
Project_Roadmap__a1b2c3d4/
|
||||
page.json
|
||||
Q1-Goals__e5f6g7h8/
|
||||
Q1_Goals__e5f6g7h8/
|
||||
page.json
|
||||
Q2-Goals__i9j0k1l2/
|
||||
Q2_Goals__i9j0k1l2/
|
||||
page.json
|
||||
Meeting-Notes__m3n4o5p6/
|
||||
Meeting_Notes__m3n4o5p6/
|
||||
page.json
|
||||
databases/
|
||||
Tasks__4a3b21915e77/
|
||||
database.json
|
||||
Write-proposal__62212c5affe6/
|
||||
page.json
|
||||
Build-dashboards__f988c5a145ef/
|
||||
page.json
|
||||
Roadmap__2c4d6a7f5bf3/
|
||||
database.json
|
||||
Tasks__d5000000-2222-3333-4444-555566667777/
|
||||
data_source.json
|
||||
Write_proposal__62212c5affe6/
|
||||
page.json
|
||||
Build_dashboards__f988c5a145ef/
|
||||
page.json
|
||||
```
|
||||
|
||||
The `pages/` hierarchy mirrors Notion's standalone page tree. The
|
||||
`databases/` hierarchy lists databases shared with the integration and
|
||||
then the row pages returned by querying each database. Each database
|
||||
directory contains `database.json` with the database metadata and its
|
||||
typed property schema (the columns), but not the rows themselves; the
|
||||
rows are the row-page directories alongside it, so `ls` the database
|
||||
directory to enumerate them. Each page directory contains a `page.json`
|
||||
file with the page metadata and content. Child pages appear as nested
|
||||
directories.
|
||||
The `pages/` hierarchy mirrors Notion's standalone page tree. Each page
|
||||
directory contains a `page.json` with the page metadata and content, and
|
||||
child pages appear as nested directories.
|
||||
|
||||
`page.json` carries the page metadata (`page_id`, `title`, `url`,
|
||||
timestamps, `parent_type`/`parent_id`, `created_by`/`last_edited_by`),
|
||||
a `markdown` field with the page body rendered as Markdown, and the raw
|
||||
`blocks`. Blocks with children embed them recursively under a
|
||||
`children` key, and the Markdown renders nested blocks with
|
||||
indentation.
|
||||
The `databases/` hierarchy is one level deeper than the page tree,
|
||||
because the `2025-09-03` API generation split a database into a
|
||||
container plus one or more **data sources**. The column schema and the
|
||||
rows both live on the data source, so a row page sits at depth 4 under
|
||||
`databases/`, not 3. The name stutters for a single-source database
|
||||
because Notion names the auto-created data source after its database;
|
||||
that disappears the moment a database holds two.
|
||||
|
||||
`database.json` carries the database metadata (`database_id`, `title`,
|
||||
`url`, timestamps, `parent`, `archived`, `is_inline`) and `properties`,
|
||||
the database's typed column schema. It does not embed the rows; read a
|
||||
row's `page.json` for its content, since a database row is itself a page
|
||||
with `parent_type` of `database_id`.
|
||||
`ls` a data source directory to enumerate its row pages.
|
||||
|
||||
### database.json
|
||||
|
||||
The database's identity plus its typed column schema (Notion's own
|
||||
property objects), with no rows inline. `ls` the database directory to
|
||||
enumerate row pages:
|
||||
The container's identity. It carries `database_id`, `title`, `url`,
|
||||
timestamps, `parent`, `archived`, `is_inline`, and the `data_sources`
|
||||
stubs that name the directories beneath it. It **does not carry
|
||||
`properties`**: at this API version the column schema lives on the data
|
||||
source, and `GET /v1/databases/{id}` no longer answers with one.
|
||||
|
||||
```json
|
||||
{
|
||||
"database_id": "2c4d6a7f-5bf3-8036-bed2-d1c95826f76b",
|
||||
"title": "Item",
|
||||
"url": "https://www.notion.so/2c4d6a7f5bf38036bed2d1c95826f76b",
|
||||
"created_time": "2025-12-09T23:36:00.000Z",
|
||||
"last_edited_time": "2026-04-15T12:30:00.000Z",
|
||||
"database_id": "eeee1111-2222-3333-4444-555566667777",
|
||||
"title": "Tasks",
|
||||
"url": "https://www.notion.so/eeee1111222233334444555566667777",
|
||||
"created_time": "2026-01-01T00:00:00.000Z",
|
||||
"last_edited_time": "2026-01-02T00:00:00.000Z",
|
||||
"parent": { "type": "workspace", "workspace": true },
|
||||
"archived": false,
|
||||
"is_inline": false,
|
||||
"data_sources": [
|
||||
{ "id": "d5000000-2222-3333-4444-555566667777", "name": "Tasks" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### data_source.json
|
||||
|
||||
The typed column schema (Notion's own property objects), with no rows
|
||||
inline:
|
||||
|
||||
```json
|
||||
{
|
||||
"data_source_id": "d5000000-2222-3333-4444-555566667777",
|
||||
"database_id": "eeee1111-2222-3333-4444-555566667777",
|
||||
"title": "Tasks",
|
||||
"created_time": "2026-01-01T00:00:00.000Z",
|
||||
"last_edited_time": "2026-01-02T00:00:00.000Z",
|
||||
"database_parent": { "type": "workspace", "workspace": true },
|
||||
"archived": false,
|
||||
"properties": {
|
||||
"Name": { "id": "title", "type": "title", "title": {} },
|
||||
"Number": { "id": "e%5BNQ", "type": "number", "number": { "format": "number" } },
|
||||
"Date": { "id": "%3BgQq", "type": "date", "date": {} }
|
||||
"Priority": { "id": "pri", "type": "number", "number": { "format": "number" } },
|
||||
"Due": { "id": "du", "type": "date", "date": {} }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To create a row, `ntn pages create` with a `database_id` parent and a
|
||||
`properties` object whose keys match this schema.
|
||||
A data source id is **not** its database id. Turn one into the other
|
||||
with `ntn datasources resolve <database-id>`.
|
||||
|
||||
### page.json
|
||||
|
||||
A page (including a database row). Notion blocks render to `markdown`
|
||||
and stay available raw under `blocks`:
|
||||
A page, including a database row. Notion blocks render to `markdown` and
|
||||
stay available raw under `blocks`; a row's cell values are under
|
||||
`properties`, as Notion's own property objects, answering to the schema
|
||||
in the `data_source.json` one level up:
|
||||
|
||||
```json
|
||||
{
|
||||
"page_id": "2c4d6a7f-5bf3-8022-826b-ed4700254ed2",
|
||||
"title": "item_0",
|
||||
"url": "https://www.notion.so/item_0-2c4d6a7f5bf38022826bed4700254ed2",
|
||||
"created_time": "2025-12-09T23:36:00.000Z",
|
||||
"last_edited_time": "2025-12-09T23:44:00.000Z",
|
||||
"parent_type": "database_id",
|
||||
"parent_id": "2c4d6a7f-5bf3-8036-bed2-d1c95826f76b",
|
||||
"page_id": "ffff1111-2222-3333-4444-555566667777",
|
||||
"title": "Write spec",
|
||||
"url": "https://www.notion.so/ffff1111222233334444555566667777",
|
||||
"created_time": "2026-01-01T00:00:00.000Z",
|
||||
"last_edited_time": "2026-01-02T00:00:00.000Z",
|
||||
"parent_type": "data_source_id",
|
||||
"parent_id": "d5000000-2222-3333-4444-555566667777",
|
||||
"archived": false,
|
||||
"created_by": "faa6ebe0-0686-466d-96b3-2480ddda715b",
|
||||
"last_edited_by": "faa6ebe0-0686-466d-96b3-2480ddda715b",
|
||||
"properties": {
|
||||
"Name": {
|
||||
"id": "title",
|
||||
"type": "title",
|
||||
"title": [{ "type": "text", "plain_text": "Write spec" }]
|
||||
},
|
||||
"Priority": { "id": "pri", "type": "number", "number": 2 },
|
||||
"Done": { "id": "dn", "type": "checkbox", "checkbox": true }
|
||||
},
|
||||
"markdown": "",
|
||||
"blocks": []
|
||||
}
|
||||
```
|
||||
|
||||
So a row's cells are readable with the ordinary tools:
|
||||
|
||||
```bash
|
||||
jq '.properties.Priority.number' "$ROW/page.json"
|
||||
jq -r '.properties | to_entries[] | "\(.key)\t\(.value.type)"' "$ROW/page.json"
|
||||
```
|
||||
|
||||
Blocks with children embed them recursively under a `children` key, and
|
||||
the Markdown renders nested blocks with indentation. A standalone page's
|
||||
`properties` holds only its title, which is what the API returns for
|
||||
one.
|
||||
|
||||
## Cache
|
||||
|
||||
Uses `IndexCacheStore` for page metadata. No separate content
|
||||
@@ -170,13 +208,24 @@ async def main():
|
||||
r = await ws.execute("ls /notion/pages/")
|
||||
print(await r.stdout_str())
|
||||
|
||||
# List shared databases and rows in a database
|
||||
# List shared databases, then a database's data sources, then its rows
|
||||
r = await ws.execute("ls /notion/databases/")
|
||||
print(await r.stdout_str())
|
||||
r = await ws.execute("tree -L 3 /notion/databases/")
|
||||
print(await r.stdout_str())
|
||||
|
||||
# Read a page
|
||||
r = await ws.execute(
|
||||
'cat "/notion/pages/Project-Roadmap__a1b2c3d4/page.json"'
|
||||
'cat "/notion/pages/Project_Roadmap__a1b2c3d4/page.json"'
|
||||
)
|
||||
print(await r.stdout_str())
|
||||
|
||||
# Read one row's cells
|
||||
r = await ws.execute(
|
||||
'jq ".properties.Priority.number" '
|
||||
'"/notion/databases/Tasks__4a3b21915e77'
|
||||
'/Tasks__d5000000-2222-3333-4444-555566667777'
|
||||
'/Write_proposal__62212c5affe6/page.json"'
|
||||
)
|
||||
print(await r.stdout_str())
|
||||
|
||||
@@ -189,22 +238,18 @@ async def main():
|
||||
print(await r.stdout_str())
|
||||
|
||||
# Search pages with the Notion search API
|
||||
r = await ws.execute('ntn search --query "Roadmap" --limit 5')
|
||||
r = await ws.execute('ntn api v1/search -d \'{"query":"Roadmap"}\'')
|
||||
print(await r.stdout_str())
|
||||
|
||||
# Create a new page
|
||||
# Create a new page from Markdown
|
||||
r = await ws.execute(
|
||||
'ntn pages create --json \'{"parent":{"page_id":"a1b2c3d4"},'
|
||||
'"properties":{"title":[{"text":{"content":"New Page"}}]}}\''
|
||||
"ntn pages create --content '# New Page' --parent page:a1b2c3d4"
|
||||
)
|
||||
print(await r.stdout_str())
|
||||
|
||||
# Append content to an existing page
|
||||
# Replace an existing page's body
|
||||
r = await ws.execute(
|
||||
'ntn blocks append --block a1b2c3d4'
|
||||
' --json \'{"children":[{"type":"paragraph","paragraph":'
|
||||
'{"rich_text":[{"text":{"content":"Appended paragraph"}}]}}]}\''
|
||||
)
|
||||
"ntn pages edit a1b2c3d4 --content '# Replaced body'")
|
||||
print(await r.stdout_str())
|
||||
|
||||
|
||||
@@ -228,7 +273,8 @@ Standard commands available on the mounted Notion tree:
|
||||
| `find` | Recursive search with `-name`, `-maxdepth` |
|
||||
| `tree` | Directory tree view |
|
||||
|
||||
Acting on Notion (creating and editing pages, appending blocks,
|
||||
comments, search) goes through the [ntn CLI](/python/cli/ntn) when
|
||||
installed. Use the `<page-id>`/`<database-id>` from a path segment as
|
||||
the `--page`, `--block`, or `--datasource` value.
|
||||
Acting on Notion (creating, editing and trashing pages, querying data
|
||||
sources, and every route that has no typed verb) goes through the
|
||||
[ntn CLI](/python/cli/ntn) when installed. Ids are positional: use the
|
||||
`<page-id>` / `<database-id>` / `<data-source-id>` from a path segment as
|
||||
the operand.
|
||||
|
||||
+94
-33
@@ -5,7 +5,7 @@ icon: terminal
|
||||
---
|
||||
|
||||
Notion API client following the official Notion CLI grammar. Install it on the workspace and the whole tree is
|
||||
discoverable with `ntn --help`.
|
||||
discoverable with `ntn --help` or `man ntn`.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -23,46 +23,107 @@ install rides the `clis:` section; see the [CLI overview](/typescript/cli/index)
|
||||
|
||||
## Verbs
|
||||
|
||||
The grammar follows the official
|
||||
[Notion CLI](https://developers.notion.com/cli) (`ntn pages
|
||||
get/create/edit/trash`, `ntn datasources query`); `blocks`, `comments`
|
||||
and `search` are mirage extensions spelled with the REST API's nouns.
|
||||
JSON bodies are the raw Notion API resources.
|
||||
The grammar matches the official
|
||||
[Notion CLI](https://developers.notion.com/cli) verb for verb, and every
|
||||
case is gated against the real `ntn` binary in CI, so what is written
|
||||
here is what the program does.
|
||||
|
||||
**Ids are positional, not flags.** There is no `--page`, `--block` or
|
||||
`--datasource`; each verb names its own operand.
|
||||
|
||||
```
|
||||
ntn api <PATH>... Call the public Notion API (beta)
|
||||
ntn auth token Print the current authentication token
|
||||
ntn datasources query <ID_OR_URL>
|
||||
ntn datasources resolve <ID>
|
||||
ntn pages get <PAGE_ID> Retrieve a page as Markdown
|
||||
ntn pages create Create a page from Markdown content
|
||||
ntn pages edit <PAGE_ID> Edit a page's content from Markdown
|
||||
ntn pages trash <PAGE_ID> Trash a page
|
||||
ntn whoami Show the authenticated Notion user
|
||||
```
|
||||
|
||||
There is no `ntn blocks`, `ntn comments` or `ntn search`. Those are
|
||||
reached through `ntn api` with the REST API's own paths, exactly as
|
||||
upstream reaches them. Upstream's interactive and deploy verbs (`login`,
|
||||
`logout`, `update`, `workers`, `notion-as-code`, `doctor`, `files`) are
|
||||
out of scope for a virtualized CLI.
|
||||
|
||||
### Pages
|
||||
|
||||
```bash
|
||||
ntn pages get --page a1b2c3d4-...
|
||||
ntn pages create --json '{"parent":{"page_id":"a1b2c3d4"},"properties":{"title":[{"text":{"content":"Title"}}]}}'
|
||||
ntn pages edit --page a1b2c3d4-... --json '{"properties":{"title":[{"text":{"content":"Renamed"}}]}}'
|
||||
ntn pages trash --page a1b2c3d4-...
|
||||
```
|
||||
|
||||
| Verb | Flags | Writes |
|
||||
| -------- | ----------------- | ------ |
|
||||
| `get` | `--page` | no |
|
||||
| `create` | `--json` | yes |
|
||||
| `edit` | `--page --json` | yes |
|
||||
| `trash` | `--page` | yes |
|
||||
|
||||
`create` requires a `parent` key in the body; `edit` sends the body as
|
||||
a `PATCH /pages` request; `trash` sets `in_trash`.
|
||||
|
||||
### Blocks and comments
|
||||
Page bodies are **Markdown**, not property JSON. `create` takes the body
|
||||
on `--content` or from stdin, and the first heading becomes the title.
|
||||
|
||||
```bash
|
||||
ntn blocks append --block a1b2c3d4-... --json '{"children":[{"type":"paragraph","paragraph":{"rich_text":[{"text":{"content":"Hello"}}]}}]}'
|
||||
ntn comments create --json '{"parent":{"page_id":"a1b2c3d4"},"rich_text":[{"text":{"content":"Comment"}}]}'
|
||||
ntn pages get a1b2c3d4-...
|
||||
ntn pages get a1b2c3d4-... --json
|
||||
|
||||
ntn pages create --content '# Title' --parent page:a1b2c3d4-...
|
||||
echo '# Title' | ntn pages create --parent data-source:e5f6a7b8-...
|
||||
|
||||
ntn pages edit a1b2c3d4-... --content '# Replaced body'
|
||||
ntn pages trash a1b2c3d4-... --yes
|
||||
```
|
||||
|
||||
### Data sources and search
|
||||
| Verb | Operand | Options | Writes |
|
||||
| -------- | ----------- | -------------------------------------- | ------ |
|
||||
| `get` | `<PAGE_ID>` | `--json` | no |
|
||||
| `create` | none | `--content` `--parent` `--json` | yes |
|
||||
| `edit` | `<PAGE_ID>` | `--content` `--json` | yes |
|
||||
| `trash` | `<PAGE_ID>` | `--yes` | yes |
|
||||
|
||||
`--parent` takes `page:<id>`, `database:<id>` or `data-source:<id>`.
|
||||
`edit` replaces the page body wholesale. `trash` refuses without `--yes`
|
||||
unless a prompt can be answered, and sets `in_trash`.
|
||||
|
||||
To set a row's **property values** rather than its body, use `ntn api`:
|
||||
|
||||
```bash
|
||||
ntn datasources query --datasource e5f6a7b8-... --json '{"filter":{"property":"Status","select":{"equals":"Done"}}}'
|
||||
ntn search --query "Roadmap" --limit 5
|
||||
ntn api v1/pages/<row-id> -X PATCH \
|
||||
-d '{"properties":{"Stage":{"select":{"name":"Draft"}}}}'
|
||||
```
|
||||
|
||||
`datasources query` returns the database's row pages; `search` returns
|
||||
compact rows (`title`, `page_id`, `url`, `last_edited`, `parent_type`).
|
||||
Use the `<page-id>` / `<database-id>` from a mounted path segment as
|
||||
the `--page`, `--block`, or `--datasource` value.
|
||||
### Data sources
|
||||
|
||||
Since `2025-09-03` a database is a container of *data sources*, and the
|
||||
rows and the column schema live on the data source. `resolve` turns a
|
||||
database id into its data source ids; `query` accepts either in the same
|
||||
slot.
|
||||
|
||||
```bash
|
||||
ntn datasources resolve e5f6a7b8-...
|
||||
ntn datasources query d5000000-... --limit 10
|
||||
ntn datasources query d5000000-... -s 'Priority desc'
|
||||
ntn datasources query d5000000-... --filter '{"property":"Stage","select":{"equals":"Done"}}'
|
||||
ntn datasources query d5000000-... --json
|
||||
```
|
||||
|
||||
`query` prints one tab-separated line per row: the page id, then the
|
||||
property values in alphabetical order by column name. The columns are the
|
||||
ones the returned rows actually carry, so a result set that does not cover
|
||||
the whole schema prints narrower.
|
||||
|
||||
### Raw API
|
||||
|
||||
`ntn api` reaches every route that has no typed verb, including the only
|
||||
delete verb the public API has (`DELETE /v1/blocks/{id}`, which trashes a
|
||||
block, a page, or a database row).
|
||||
|
||||
```bash
|
||||
ntn api v1/users/me
|
||||
ntn api v1/search -d '{"query":"Roadmap"}'
|
||||
ntn api v1/search query=Roadmap
|
||||
ntn api v1/blocks/<page-id>/children page_size==10
|
||||
ntn api v1/blocks/<block-id> -X DELETE
|
||||
ntn api v1/comments -d '{"parent":{"page_id":"a1b2c3d4"},"rich_text":[{"text":{"content":"hi"}}]}'
|
||||
printf '{"query":"Roadmap"}' | ntn api v1/search
|
||||
```
|
||||
|
||||
The body comes from exactly one source: stdin, `--data`/`-d`, or inline
|
||||
`path=value` / `path:=json` inputs. Naming two is an error. `name==value`
|
||||
stays a query parameter whatever the method is, and `Header:Value` sets a
|
||||
header. Any body source makes the call a POST unless `-X`/`--method` says
|
||||
otherwise; `GET`, `POST`, `PATCH`, `PUT` and `DELETE` are accepted.
|
||||
|
||||
Use the `<page-id>` / `<database-id>` / `<data-source-id>` from a mounted
|
||||
path segment as the operand.
|
||||
|
||||
@@ -54,9 +54,9 @@ await ws.execute('ls /notion/')
|
||||
|
||||
## Acting on Notion
|
||||
|
||||
Acting on Notion (creating and editing pages, appending blocks,
|
||||
comments, search) goes through the [ntn CLI](/typescript/cli/ntn)
|
||||
when installed.
|
||||
Acting on Notion (creating, editing and trashing pages, querying data
|
||||
sources, and every route that has no typed verb) goes through the
|
||||
[ntn CLI](/typescript/cli/ntn) when installed.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -71,33 +71,40 @@ when installed.
|
||||
databases/
|
||||
<database-title>__<database-id>/
|
||||
database.json
|
||||
<row-page-title>__<page-id>/
|
||||
page.json
|
||||
...
|
||||
<data-source-name>__<data-source-id>/
|
||||
data_source.json
|
||||
<row-page-title>__<page-id>/
|
||||
page.json
|
||||
...
|
||||
```
|
||||
|
||||
`database.json` is the database's identity plus its typed column schema,
|
||||
with no rows inline; `ls` the database directory to enumerate row pages.
|
||||
Since the `2025-09-03` API generation a database is a container of **data
|
||||
sources**, and both the column schema and the rows live on the data
|
||||
source, so a row page sits one level deeper than the page tree.
|
||||
`database.json` is the container's identity plus the `data_sources` stubs
|
||||
that name the directories beneath it, and carries **no** `properties`.
|
||||
The shape is identical to the Python connector:
|
||||
|
||||
```json
|
||||
{
|
||||
"database_id": "2c4d6a7f-5bf3-8036-bed2-d1c95826f76b",
|
||||
"title": "Item",
|
||||
"url": "https://www.notion.so/2c4d6a7f5bf38036bed2d1c95826f76b",
|
||||
"created_time": "2025-12-09T23:36:00.000Z",
|
||||
"last_edited_time": "2026-04-15T12:30:00.000Z",
|
||||
"database_id": "eeee1111-2222-3333-4444-555566667777",
|
||||
"title": "Tasks",
|
||||
"url": "https://www.notion.so/eeee1111222233334444555566667777",
|
||||
"created_time": "2026-01-01T00:00:00.000Z",
|
||||
"last_edited_time": "2026-01-02T00:00:00.000Z",
|
||||
"parent": { "type": "workspace", "workspace": true },
|
||||
"archived": false,
|
||||
"is_inline": false,
|
||||
"properties": {
|
||||
"Name": { "id": "title", "type": "title", "title": {} },
|
||||
"Number": { "id": "e%5BNQ", "type": "number", "number": { "format": "number" } },
|
||||
"Date": { "id": "%3BgQq", "type": "date", "date": {} }
|
||||
}
|
||||
"data_sources": [
|
||||
{ "id": "d5000000-2222-3333-4444-555566667777", "name": "Tasks" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`data_source.json` holds the typed column schema under `properties`; `ls`
|
||||
the data source directory to enumerate row pages.
|
||||
|
||||
A database row is itself a page: its `page.json` reports `parent_type` of
|
||||
`database_id`. For full `page.json` field details and supported edits see
|
||||
`data_source_id`, and its cell values ride in the same file under
|
||||
`properties`. For full `page.json` field details and supported edits see
|
||||
the [Python Notion docs](/python/resource/notion).
|
||||
|
||||
@@ -48,6 +48,15 @@ async def first_entry(ws: Workspace, path: str) -> str:
|
||||
return os.path.basename(out.splitlines()[0].rstrip("/"))
|
||||
|
||||
|
||||
async def pick_child(ws: Workspace, path: str, skip: str) -> str:
|
||||
result = await ws.execute(f"ls {path}/")
|
||||
for line in (await result.stdout_str()).strip().splitlines():
|
||||
name = os.path.basename(line.rstrip("/"))
|
||||
if name != skip:
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
async def explore_pages(ws: Workspace) -> None:
|
||||
print("\n########## PAGES ##########\n")
|
||||
await run(ws, "ls /notion/pages/")
|
||||
@@ -103,40 +112,51 @@ async def explore_databases(ws: Workspace) -> None:
|
||||
await run(ws, f"cat {base}/database.json", limit=1500)
|
||||
await run(ws, f'jq ".database_id" {base}/database.json')
|
||||
await run(ws, f'jq ".title" {base}/database.json')
|
||||
await run(ws, f'jq ".properties | keys" {base}/database.json')
|
||||
# The container carries data source stubs, not a column schema: since
|
||||
# 2025-09-03 `properties` lives on the data source one level down.
|
||||
await run(ws, f'jq ".data_sources" {base}/database.json')
|
||||
await run(ws, f"wc -l {base}/database.json")
|
||||
await run(ws, f"head -n 8 {base}/database.json")
|
||||
await run(ws, f"tail -n 5 {base}/database.json")
|
||||
await run(ws, f"basename {base}/database.json")
|
||||
await run(ws, f"dirname {base}/database.json")
|
||||
await run(ws, f"tree -L 1 {base}/")
|
||||
await run(ws, f"tree -L 2 {base}/")
|
||||
await run(ws, f'find {base}/ -name "database.json"')
|
||||
await run(ws, f"echo {base}/*")
|
||||
|
||||
row = ""
|
||||
result = await ws.execute(f"ls {base}/")
|
||||
for line in (await result.stdout_str()).strip().splitlines():
|
||||
name = os.path.basename(line.rstrip("/"))
|
||||
if name != "database.json":
|
||||
row = name
|
||||
break
|
||||
if not row:
|
||||
print("Database has no row pages\n")
|
||||
source = await pick_child(ws, base, "database.json")
|
||||
if not source:
|
||||
print("Database has no data sources\n")
|
||||
return
|
||||
row_base = f"{base}/{row}"
|
||||
source_base = f"{base}/{source}"
|
||||
print(f"--- data source: {source} ---\n")
|
||||
await run(ws, f"ls {source_base}/")
|
||||
await run(ws, f"cat {source_base}/data_source.json", limit=1500)
|
||||
await run(ws, f'jq ".properties | keys" {source_base}/data_source.json')
|
||||
|
||||
row = await pick_child(ws, source_base, "data_source.json")
|
||||
if not row:
|
||||
print("Data source has no row pages\n")
|
||||
return
|
||||
row_base = f"{source_base}/{row}"
|
||||
print(f"--- row page: {row} ---\n")
|
||||
await run(ws, f"ls {row_base}/")
|
||||
await run(ws, f"stat {row_base}/page.json")
|
||||
await run(ws, f"cat {row_base}/page.json", limit=1200)
|
||||
await run(ws, f'jq ".parent_type" {row_base}/page.json')
|
||||
await run(ws, f'jq ".parent_id" {row_base}/page.json')
|
||||
# A row's cells ride in the file, as Notion's own property objects,
|
||||
# answering to the schema in the data_source.json above.
|
||||
await run(ws, f'jq ".properties | keys" {row_base}/page.json')
|
||||
|
||||
|
||||
async def explore_cross_cutting(ws: Workspace) -> None:
|
||||
print("\n########## CROSS-CUTTING ##########\n")
|
||||
await run(ws, "ls /notion/")
|
||||
await run(ws, "tree -L 2 /notion/")
|
||||
await run(ws, "ntn search --query a", limit=800)
|
||||
# There is no `ntn search`: /search is reached through `ntn api`,
|
||||
# exactly as the official CLI reaches it.
|
||||
await run(ws, 'ntn api v1/search -d \'{"query":"a"}\'', limit=800)
|
||||
await run(ws, 'grep -rl "page_id" /notion/pages/', limit=800)
|
||||
await run(ws, 'rg -c "title" /notion/databases/', limit=800)
|
||||
|
||||
|
||||
@@ -45,6 +45,15 @@ async function firstEntry(ws: Workspace, path: string): Promise<string> {
|
||||
return basename(out.split('\n')[0]!.replace(/\/$/, ''))
|
||||
}
|
||||
|
||||
async function pickChild(ws: Workspace, path: string, skip: string): Promise<string> {
|
||||
const listing = (await ws.execute(`ls "${path}/"`)).stdoutText.trim().split('\n')
|
||||
for (const line of listing) {
|
||||
const name = basename(line.replace(/\/$/, ''))
|
||||
if (name !== skip && name !== '') return name
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function explorePages(ws: Workspace): Promise<void> {
|
||||
console.log('\n########## PAGES ##########\n')
|
||||
await run(ws, 'ls /notion/pages/')
|
||||
@@ -101,43 +110,53 @@ async function exploreDatabases(ws: Workspace): Promise<void> {
|
||||
await run(ws, `cat "${base}/database.json"`)
|
||||
await run(ws, `jq ".database_id" "${base}/database.json"`)
|
||||
await run(ws, `jq ".title" "${base}/database.json"`)
|
||||
await run(ws, `jq ".properties | keys" "${base}/database.json"`)
|
||||
// The container carries data source stubs, not a column schema: since
|
||||
// 2025-09-03 `properties` lives on the data source one level down.
|
||||
await run(ws, `jq ".data_sources" "${base}/database.json"`)
|
||||
await run(ws, `wc -l "${base}/database.json"`)
|
||||
await run(ws, `head -n 8 "${base}/database.json"`)
|
||||
await run(ws, `tail -n 5 "${base}/database.json"`)
|
||||
await run(ws, `basename "${base}/database.json"`)
|
||||
await run(ws, `dirname "${base}/database.json"`)
|
||||
await run(ws, `tree -L 1 "${base}/"`)
|
||||
await run(ws, `tree -L 2 "${base}/"`)
|
||||
await run(ws, `find "${base}/" -name "database.json"`)
|
||||
await run(ws, `echo "${base}/"*`)
|
||||
|
||||
const listing = (await ws.execute(`ls "${base}/"`)).stdoutText.trim().split('\n')
|
||||
let row = ''
|
||||
for (const line of listing) {
|
||||
const name = basename(line.replace(/\/$/, ''))
|
||||
if (name !== 'database.json' && name !== '') {
|
||||
row = name
|
||||
break
|
||||
}
|
||||
}
|
||||
if (row === '') {
|
||||
console.log('Database has no row pages\n')
|
||||
const source = await pickChild(ws, base, 'database.json')
|
||||
if (source === '') {
|
||||
console.log('Database has no data sources\n')
|
||||
return
|
||||
}
|
||||
const rowBase = `${base}/${row}`
|
||||
const sourceBase = `${base}/${source}`
|
||||
console.log(`--- data source: ${source} ---\n`)
|
||||
await run(ws, `ls "${sourceBase}/"`)
|
||||
await run(ws, `cat "${sourceBase}/data_source.json"`)
|
||||
await run(ws, `jq ".properties | keys" "${sourceBase}/data_source.json"`)
|
||||
|
||||
const row = await pickChild(ws, sourceBase, 'data_source.json')
|
||||
if (row === '') {
|
||||
console.log('Data source has no row pages\n')
|
||||
return
|
||||
}
|
||||
const rowBase = `${sourceBase}/${row}`
|
||||
console.log(`--- row page: ${row} ---\n`)
|
||||
await run(ws, `ls "${rowBase}/"`)
|
||||
await run(ws, `stat "${rowBase}/page.json"`)
|
||||
await run(ws, `cat "${rowBase}/page.json"`, 1200)
|
||||
await run(ws, `jq ".parent_type" "${rowBase}/page.json"`)
|
||||
await run(ws, `jq ".parent_id" "${rowBase}/page.json"`)
|
||||
// A row's cells ride in the file, as Notion's own property objects,
|
||||
// answering to the schema in the data_source.json above.
|
||||
await run(ws, `jq ".properties | keys" "${rowBase}/page.json"`)
|
||||
}
|
||||
|
||||
async function exploreCrossCutting(ws: Workspace): Promise<void> {
|
||||
console.log('\n########## CROSS-CUTTING ##########\n')
|
||||
await run(ws, 'ls /notion/')
|
||||
await run(ws, 'tree -L 2 /notion/')
|
||||
await run(ws, 'ntn search --query a', 800)
|
||||
// There is no `ntn search`: /search is reached through `ntn api`,
|
||||
// exactly as the official CLI reaches it.
|
||||
await run(ws, `ntn api v1/search -d '{"query":"a"}'`, 800)
|
||||
await run(ws, 'grep -rl "page_id" /notion/pages/', 800)
|
||||
await run(ws, 'rg -c "title" /notion/databases/', 800)
|
||||
}
|
||||
|
||||
@@ -675,6 +675,58 @@
|
||||
"stdout": "new\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ntn_archived_aliases_in_trash",
|
||||
"seq": 566052,
|
||||
"targets": [
|
||||
"cli-ntn"
|
||||
],
|
||||
"command": "ntn api v1/pages/ffff2222-3333-4444-5555-666677778888 -X PATCH -d '{\"archived\":true}' | jq -r '.archived, .in_trash'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "true\ntrue\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ntn_archived_row_leaves_query",
|
||||
"seq": 566053,
|
||||
"targets": [
|
||||
"cli-ntn"
|
||||
],
|
||||
"command": "ntn datasources query d5000000-2222-3333-4444-555566667777 | cut -f5",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "Write spec\nRow page\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ntn_delete_block_trashes_a_row",
|
||||
"seq": 566054,
|
||||
"targets": [
|
||||
"cli-ntn"
|
||||
],
|
||||
"command": "ntn api v1/blocks/ffff1111-2222-3333-4444-555566667777 -X DELETE | jq -r '.object, .in_trash'",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "block\ntrue\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ntn_deleted_row_leaves_query",
|
||||
"seq": 566055,
|
||||
"targets": [
|
||||
"cli-ntn"
|
||||
],
|
||||
"command": "ntn datasources query d5000000-2222-3333-4444-555566667777",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "a0000000-0000-4000-8000-000000000002\tRow page\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -81,6 +81,12 @@ model Pin {
|
||||
// a NotionBlock row of type child_page carrying the *same* id. Creating a page
|
||||
// under a page_id parent must write both, or readdir and block-children
|
||||
// disagree about the tree.
|
||||
//
|
||||
// Trash is ONE bit, stored as `inTrash`. Upstream's `archived` is a deprecated
|
||||
// alias for it ("This is an alias for in_trash and always returns the same
|
||||
// value"), so it is a spelling on the wire, not a column: two columns let the
|
||||
// fake answer archived=true with in_trash=false, which real Notion cannot do,
|
||||
// and made every filter have to remember to name both.
|
||||
model NotionPage {
|
||||
id String
|
||||
workspaceId String
|
||||
@@ -90,7 +96,6 @@ model NotionPage {
|
||||
propertiesJson String
|
||||
iconJson String?
|
||||
coverJson String?
|
||||
archived Boolean @default(false)
|
||||
inTrash Boolean @default(false)
|
||||
createdTime String
|
||||
lastEditedTime String
|
||||
@@ -113,7 +118,6 @@ model NotionDatabase {
|
||||
descriptionJson String?
|
||||
propertiesJson String
|
||||
isInline Boolean @default(false)
|
||||
archived Boolean @default(false)
|
||||
inTrash Boolean @default(false)
|
||||
createdTime String
|
||||
lastEditedTime String
|
||||
@@ -134,7 +138,6 @@ model NotionBlock {
|
||||
type String
|
||||
payloadJson String
|
||||
hasChildren Boolean @default(false)
|
||||
archived Boolean @default(false)
|
||||
inTrash Boolean @default(false)
|
||||
createdTime String
|
||||
lastEditedTime String
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"command": "cat /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/page.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"page_id\": \"aaaa1111-2222-3333-4444-555566667777\",\n \"title\": \"Project Roadmap\",\n \"url\": \"https://notion.example/aaaa1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"workspace\",\n \"parent_id\": \"\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"markdown\": \"# Roadmap\\n\\nShip the **beta** soon\\n\\n- phase one\\n\\n - phase one detail\\n\\n```python\\nprint(1)\\n```\\n\",\n \"blocks\": [\n {\n \"object\": \"block\",\n \"id\": \"b-a1\",\n \"type\": \"heading_1\",\n \"has_children\": false,\n \"heading_1\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Roadmap\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Roadmap\"\n }\n }\n ]\n }\n },\n {\n \"object\": \"block\",\n \"id\": \"b-a2\",\n \"type\": \"paragraph\",\n \"has_children\": false,\n \"paragraph\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Ship the \",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Ship the \"\n }\n },\n {\n \"type\": \"text\",\n \"plain_text\": \"beta\",\n \"annotations\": {\n \"bold\": true\n },\n \"text\": {\n \"content\": \"beta\"\n }\n },\n {\n \"type\": \"text\",\n \"plain_text\": \" soon\",\n \"annotations\": {},\n \"text\": {\n \"content\": \" soon\"\n }\n }\n ]\n }\n },\n {\n \"object\": \"block\",\n \"id\": \"dddd2222-3333-4444-5555-666677778888\",\n \"type\": \"bulleted_list_item\",\n \"has_children\": true,\n \"bulleted_list_item\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"phase one\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"phase one\"\n }\n }\n ]\n },\n \"children\": [\n {\n \"object\": \"block\",\n \"id\": \"b-d1\",\n \"type\": \"bulleted_list_item\",\n \"has_children\": false,\n \"bulleted_list_item\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"phase one detail\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"phase one detail\"\n }\n }\n ]\n }\n }\n ]\n },\n {\n \"object\": \"block\",\n \"id\": \"b-a4\",\n \"type\": \"code\",\n \"has_children\": false,\n \"code\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"print(1)\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"print(1)\"\n }\n }\n ],\n \"language\": \"python\"\n }\n }\n ]\n}",
|
||||
"stdout": "{\n \"page_id\": \"aaaa1111-2222-3333-4444-555566667777\",\n \"title\": \"Project Roadmap\",\n \"url\": \"https://notion.example/aaaa1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"workspace\",\n \"parent_id\": \"\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"properties\": {\n \"title\": {\n \"id\": \"title\",\n \"type\": \"title\",\n \"title\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Project Roadmap\",\n \"text\": {\n \"content\": \"Project Roadmap\"\n }\n }\n ]\n }\n },\n \"markdown\": \"# Roadmap\\n\\nShip the **beta** soon\\n\\n- phase one\\n\\n - phase one detail\\n\\n```python\\nprint(1)\\n```\\n\",\n \"blocks\": [\n {\n \"object\": \"block\",\n \"id\": \"b-a1\",\n \"type\": \"heading_1\",\n \"has_children\": false,\n \"heading_1\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Roadmap\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Roadmap\"\n }\n }\n ]\n }\n },\n {\n \"object\": \"block\",\n \"id\": \"b-a2\",\n \"type\": \"paragraph\",\n \"has_children\": false,\n \"paragraph\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Ship the \",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Ship the \"\n }\n },\n {\n \"type\": \"text\",\n \"plain_text\": \"beta\",\n \"annotations\": {\n \"bold\": true\n },\n \"text\": {\n \"content\": \"beta\"\n }\n },\n {\n \"type\": \"text\",\n \"plain_text\": \" soon\",\n \"annotations\": {},\n \"text\": {\n \"content\": \" soon\"\n }\n }\n ]\n }\n },\n {\n \"object\": \"block\",\n \"id\": \"dddd2222-3333-4444-5555-666677778888\",\n \"type\": \"bulleted_list_item\",\n \"has_children\": true,\n \"bulleted_list_item\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"phase one\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"phase one\"\n }\n }\n ]\n },\n \"children\": [\n {\n \"object\": \"block\",\n \"id\": \"b-d1\",\n \"type\": \"bulleted_list_item\",\n \"has_children\": false,\n \"bulleted_list_item\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"phase one detail\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"phase one detail\"\n }\n }\n ]\n }\n }\n ]\n },\n {\n \"object\": \"block\",\n \"id\": \"b-a4\",\n \"type\": \"code\",\n \"has_children\": false,\n \"code\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"print(1)\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"print(1)\"\n }\n }\n ],\n \"language\": \"python\"\n }\n }\n ]\n}",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
@@ -87,7 +87,7 @@
|
||||
"command": "cat /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/Q1_Goals__cccc1111-2222-3333-4444-555566667777/page.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"page_id\": \"cccc1111-2222-3333-4444-555566667777\",\n \"title\": \"Q1 Goals\",\n \"url\": \"https://notion.example/cccc1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"page_id\",\n \"parent_id\": \"aaaa1111-2222-3333-4444-555566667777\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"markdown\": \"Q1 contents\\n\",\n \"blocks\": [\n {\n \"object\": \"block\",\n \"id\": \"b-c1\",\n \"type\": \"paragraph\",\n \"has_children\": false,\n \"paragraph\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Q1 contents\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Q1 contents\"\n }\n }\n ]\n }\n }\n ]\n}",
|
||||
"stdout": "{\n \"page_id\": \"cccc1111-2222-3333-4444-555566667777\",\n \"title\": \"Q1 Goals\",\n \"url\": \"https://notion.example/cccc1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"page_id\",\n \"parent_id\": \"aaaa1111-2222-3333-4444-555566667777\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"properties\": {\n \"title\": {\n \"id\": \"title\",\n \"type\": \"title\",\n \"title\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Q1 Goals\",\n \"text\": {\n \"content\": \"Q1 Goals\"\n }\n }\n ]\n }\n },\n \"markdown\": \"Q1 contents\\n\",\n \"blocks\": [\n {\n \"object\": \"block\",\n \"id\": \"b-c1\",\n \"type\": \"paragraph\",\n \"has_children\": false,\n \"paragraph\": {\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Q1 contents\",\n \"annotations\": {},\n \"text\": {\n \"content\": \"Q1 contents\"\n }\n }\n ]\n }\n }\n ]\n}",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
@@ -139,7 +139,7 @@
|
||||
"command": "wc -l /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/page.json /notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888/page.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "125 /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/page.json\n 51 /notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888/page.json\n176 total\n",
|
||||
"stdout": "140 /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/page.json\n 66 /notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888/page.json\n206 total\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
@@ -152,7 +152,7 @@
|
||||
"command": "stat /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/page.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "name=page.json size=2979 modified=None type=json\n",
|
||||
"stdout": "name=page.json size=3254 modified=None type=json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
@@ -217,7 +217,7 @@
|
||||
"command": "grep -n alpha /notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888/page.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "12: \"markdown\": \"alpha beta gamma\\n\\n- [x] done item\\n\",\n23: \"plain_text\": \"alpha beta gamma\",\n26: \"content\": \"alpha beta gamma\"\n",
|
||||
"stdout": "27: \"markdown\": \"alpha beta gamma\\n\\n- [x] done item\\n\",\n38: \"plain_text\": \"alpha beta gamma\",\n41: \"content\": \"alpha beta gamma\"\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
@@ -347,7 +347,7 @@
|
||||
"command": "cat /notion/databases/Tasks__eeee1111-2222-3333-4444-555566667777/Tasks__d5000000-2222-3333-4444-555566667777/Write_spec__ffff1111-2222-3333-4444-555566667777/page.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"page_id\": \"ffff1111-2222-3333-4444-555566667777\",\n \"title\": \"Write spec\",\n \"url\": \"https://notion.example/ffff1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"data_source_id\",\n \"parent_id\": \"d5000000-2222-3333-4444-555566667777\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"markdown\": \"\",\n \"blocks\": []\n}",
|
||||
"stdout": "{\n \"page_id\": \"ffff1111-2222-3333-4444-555566667777\",\n \"title\": \"Write spec\",\n \"url\": \"https://notion.example/ffff1111222233334444555566667777\",\n \"created_time\": \"2026-01-01T00:00:00.000Z\",\n \"last_edited_time\": \"2026-01-02T00:00:00.000Z\",\n \"parent_type\": \"data_source_id\",\n \"parent_id\": \"d5000000-2222-3333-4444-555566667777\",\n \"archived\": false,\n \"created_by\": \"user-1\",\n \"last_edited_by\": \"user-2\",\n \"properties\": {\n \"Name\": {\n \"id\": \"title\",\n \"type\": \"title\",\n \"title\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"Write spec\",\n \"text\": {\n \"content\": \"Write spec\"\n }\n }\n ]\n },\n \"Priority\": {\n \"id\": \"pri\",\n \"type\": \"number\",\n \"number\": 2\n },\n \"Done\": {\n \"id\": \"dn\",\n \"type\": \"checkbox\",\n \"checkbox\": true\n },\n \"Stage\": {\n \"id\": \"st\",\n \"type\": \"select\",\n \"select\": {\n \"id\": \"s2\",\n \"name\": \"Review\",\n \"color\": \"blue\"\n }\n },\n \"Tags\": {\n \"id\": \"tg\",\n \"type\": \"multi_select\",\n \"multi_select\": [\n {\n \"id\": \"t1\",\n \"name\": \"infra\",\n \"color\": \"green\"\n },\n {\n \"id\": \"t2\",\n \"name\": \"docs\",\n \"color\": \"yellow\"\n }\n ]\n },\n \"Due\": {\n \"id\": \"du\",\n \"type\": \"date\",\n \"date\": {\n \"start\": \"2026-02-01\",\n \"end\": null,\n \"time_zone\": null\n }\n },\n \"Link\": {\n \"id\": \"lk\",\n \"type\": \"url\",\n \"url\": \"https://example.com/spec\"\n },\n \"Notes\": {\n \"id\": \"nt\",\n \"type\": \"rich_text\",\n \"rich_text\": [\n {\n \"type\": \"text\",\n \"plain_text\": \"needs review\",\n \"text\": {\n \"content\": \"needs review\"\n }\n }\n ]\n }\n },\n \"markdown\": \"\",\n \"blocks\": []\n}",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
@@ -360,7 +360,7 @@
|
||||
"command": "du /notion/pages/",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1211\t/notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888\n826\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/Q1_Goals__cccc1111-2222-3333-4444-555566667777\n3805\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777\n5016\t/notion/pages\n",
|
||||
"stdout": "1466\t/notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888\n1087\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/Q1_Goals__cccc1111-2222-3333-4444-555566667777\n4341\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777\n5807\t/notion/pages\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
@@ -373,7 +373,7 @@
|
||||
"command": "du /notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "826\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/Q1_Goals__cccc1111-2222-3333-4444-555566667777\n3805\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777\n",
|
||||
"stdout": "1087\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777/Q1_Goals__cccc1111-2222-3333-4444-555566667777\n4341\t/notion/pages/Project_Roadmap__aaaa1111-2222-3333-4444-555566667777\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1493,7 +1493,7 @@
|
||||
"command": "f=/notion/pages/Notes__bbbb2222-3333-4444-5555-666677778888/page.json; echo \"cold=$(stat -c %s \"$f\")\"; echo \"read=$(cat \"$f\" | wc -c)\"; echo \"warm=$(stat -c %s \"$f\")\"",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "cold=0\nread=1211\nwarm=1211\n",
|
||||
"stdout": "cold=0\nread=1466\nwarm=1466\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"clear_cache": true
|
||||
|
||||
@@ -76,7 +76,12 @@ const DIR_A = `${MOUNT}/pages/Project_Roadmap__${PAGE_A}`
|
||||
const DIR_B = `${MOUNT}/pages/Notes__${PAGE_B}`
|
||||
const DIR_C = `${DIR_A}/Q1_Goals__${PAGE_C}`
|
||||
const DB_DIR = `${MOUNT}/databases/Tasks__${DB_TASKS}`
|
||||
const ROW_1_DIR = `${DB_DIR}/Write_spec__${ROW_1}`
|
||||
// Since 2025-09-03 the rows live under the data source, not the database, so a
|
||||
// row sits one level deeper than it used to. These paths feed the MCP/REST
|
||||
// parity battery, where a stale one costs nothing visible: both arms answer
|
||||
// the same error and the case passes while asserting nothing.
|
||||
const DS_DIR = `${DB_DIR}/Tasks__${dataSourceIdOf(DB_TASKS)}`
|
||||
const ROW_1_DIR = `${DS_DIR}/Write_spec__${ROW_1}`
|
||||
|
||||
type Json = Record<string, unknown>
|
||||
|
||||
@@ -119,6 +124,7 @@ interface FixtureDatabase {
|
||||
last_edited_by?: string
|
||||
url?: string
|
||||
archived?: boolean
|
||||
in_trash?: boolean
|
||||
}
|
||||
interface FixtureBlock {
|
||||
id: string
|
||||
@@ -156,7 +162,6 @@ interface PageRow {
|
||||
propertiesJson: string
|
||||
iconJson: string | null
|
||||
coverJson: string | null
|
||||
archived: boolean
|
||||
inTrash: boolean
|
||||
createdTime: string
|
||||
lastEditedTime: string
|
||||
@@ -173,7 +178,6 @@ interface DatabaseRow {
|
||||
descriptionJson: string | null
|
||||
propertiesJson: string
|
||||
isInline: boolean
|
||||
archived: boolean
|
||||
inTrash: boolean
|
||||
createdTime: string
|
||||
lastEditedTime: string
|
||||
@@ -188,7 +192,6 @@ interface BlockRow {
|
||||
type: string
|
||||
payloadJson: string
|
||||
hasChildren: boolean
|
||||
archived: boolean
|
||||
inTrash: boolean
|
||||
createdTime: string
|
||||
lastEditedTime: string
|
||||
@@ -462,7 +465,7 @@ async function seed(db: PrismaClient, fx: Fixture, workspaceId: string): Promise
|
||||
descriptionJson: d.description !== undefined ? JSON.stringify(d.description) : null,
|
||||
propertiesJson: JSON.stringify(d.properties),
|
||||
isInline: d.is_inline ?? false,
|
||||
archived: d.archived ?? false,
|
||||
inTrash: d.in_trash ?? d.archived ?? false,
|
||||
createdTime: d.created_time ?? fx.defaults.created_time,
|
||||
lastEditedTime: d.last_edited_time ?? fx.defaults.last_edited_time,
|
||||
createdBy: d.created_by ?? fx.defaults.created_by,
|
||||
@@ -485,8 +488,7 @@ async function seed(db: PrismaClient, fx: Fixture, workspaceId: string): Promise
|
||||
propertiesJson: JSON.stringify(properties),
|
||||
iconJson: p.icon !== undefined ? JSON.stringify(p.icon) : null,
|
||||
coverJson: p.cover !== undefined ? JSON.stringify(p.cover) : null,
|
||||
archived: p.archived ?? false,
|
||||
inTrash: p.in_trash ?? false,
|
||||
inTrash: p.in_trash ?? p.archived ?? false,
|
||||
createdTime: p.created_time ?? fx.defaults.created_time,
|
||||
lastEditedTime: p.last_edited_time ?? fx.defaults.last_edited_time,
|
||||
createdBy: p.created_by ?? fx.defaults.created_by,
|
||||
@@ -535,6 +537,9 @@ async function seed(db: PrismaClient, fx: Fixture, workspaceId: string): Promise
|
||||
}
|
||||
}
|
||||
|
||||
// `archived` is upstream's deprecated alias for `in_trash` and, in its own
|
||||
// words, "always returns the same value". So both names are read off the one
|
||||
// stored bit here rather than from two columns that can disagree.
|
||||
function pageJson(row: PageRow): Json {
|
||||
const out: Json = {
|
||||
object: 'page',
|
||||
@@ -544,7 +549,7 @@ function pageJson(row: PageRow): Json {
|
||||
created_by: { object: 'user', id: row.createdBy },
|
||||
last_edited_by: { object: 'user', id: row.lastEditedBy },
|
||||
parent: pageParentJson(row.parentType, row.parentId),
|
||||
archived: row.archived,
|
||||
archived: row.inTrash,
|
||||
in_trash: row.inTrash,
|
||||
url: row.url,
|
||||
properties: JSON.parse(row.propertiesJson) as Json,
|
||||
@@ -592,7 +597,7 @@ function dataSourceJson(row: DatabaseRow): Json {
|
||||
last_edited_time: row.lastEditedTime,
|
||||
parent: { type: 'database_id', database_id: row.id },
|
||||
database_parent: parentJson(row.parentType, row.parentId),
|
||||
archived: row.archived,
|
||||
archived: row.inTrash,
|
||||
in_trash: row.inTrash,
|
||||
title: JSON.parse(row.titleJson) as unknown[],
|
||||
description: [],
|
||||
@@ -612,7 +617,7 @@ function databaseJson(row: DatabaseRow): Json {
|
||||
created_time: row.createdTime,
|
||||
last_edited_time: row.lastEditedTime,
|
||||
parent: parentJson(row.parentType, row.parentId),
|
||||
archived: row.archived,
|
||||
archived: row.inTrash,
|
||||
in_trash: row.inTrash,
|
||||
is_inline: row.isInline,
|
||||
url: row.url,
|
||||
@@ -820,7 +825,7 @@ async function childrenOf(
|
||||
parentId: string,
|
||||
): Promise<BlockRow[]> {
|
||||
return (await db.notionBlock.findMany({
|
||||
where: { workspaceId, parentId, archived: false },
|
||||
where: { workspaceId, parentId, inTrash: false },
|
||||
orderBy: { position: 'asc' },
|
||||
})) as BlockRow[]
|
||||
}
|
||||
@@ -834,14 +839,14 @@ async function searchResults(db: PrismaClient, workspaceId: string, args: Json):
|
||||
// battery's client and the official CLI can share one server.
|
||||
if (filter.value === 'database' || filter.value === 'data_source') {
|
||||
const rows = (await db.notionDatabase.findMany({
|
||||
where: { workspaceId, archived: false, inTrash: false },
|
||||
where: { workspaceId, inTrash: false },
|
||||
orderBy: [{ position: 'asc' }, { id: 'asc' }],
|
||||
})) as DatabaseRow[]
|
||||
const kept = rows.filter((r) => matches(r.titleText))
|
||||
return filter.value === 'data_source' ? kept.map(dataSourceJson) : kept.map(databaseJson)
|
||||
}
|
||||
const rows = (await db.notionPage.findMany({
|
||||
where: { workspaceId, archived: false, inTrash: false },
|
||||
where: { workspaceId, inTrash: false },
|
||||
orderBy: [{ position: 'asc' }, { id: 'asc' }],
|
||||
})) as PageRow[]
|
||||
return rows.filter((r) => matches(r.titleText)).map(pageJson)
|
||||
@@ -1157,6 +1162,49 @@ async function createComment(
|
||||
return { status: 200, json: commentJson(row) }
|
||||
}
|
||||
|
||||
// A child page is one object in two tables (see the schema's NotionPage note),
|
||||
// so trashing it has to move both rows: the NotionPage row is what /search and
|
||||
// a database query read, the NotionBlock row is what the parent's children
|
||||
// listing reads, and setting only one leaves the page gone from half the
|
||||
// surfaces and present in the other half.
|
||||
async function setTrashed(
|
||||
db: PrismaClient,
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
trashed: boolean,
|
||||
): Promise<void> {
|
||||
const where = { workspaceId_id: { workspaceId, id } }
|
||||
if ((await db.notionPage.findFirst({ where: { workspaceId, id } })) !== null) {
|
||||
await db.notionPage.update({ where, data: { inTrash: trashed } })
|
||||
}
|
||||
if ((await db.notionBlock.findFirst({ where: { workspaceId, id } })) !== null) {
|
||||
await db.notionBlock.update({ where, data: { inTrash: trashed } })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /v1/blocks/{id} is the only delete verb the public API has, and the
|
||||
// only one the MCP tool surface exposes (API-delete-a-block), so without it an
|
||||
// MCP client cannot remove anything. Upstream: "Sets a Block object, including
|
||||
// page blocks, to in_trash: true", which covers database rows, so this resolves
|
||||
// a block id first and falls back to a page of the same id.
|
||||
async function deleteBlock(db: PrismaClient, workspaceId: string, id: string): Promise<Reply> {
|
||||
const block = (await db.notionBlock.findFirst({
|
||||
where: { workspaceId, id },
|
||||
})) as BlockRow | null
|
||||
const page = (await db.notionPage.findFirst({
|
||||
where: { workspaceId, id },
|
||||
})) as PageRow | null
|
||||
if (block === null && page === null) return notFound('block', id)
|
||||
await setTrashed(db, workspaceId, id, true)
|
||||
// A page that owns no block row (a top-level page, or a database row) still
|
||||
// answers as a block, which is what "including page blocks" means.
|
||||
const body =
|
||||
block === null
|
||||
? { object: 'block', id, type: 'child_page', has_children: false, child_page: { title: (page as PageRow).titleText } }
|
||||
: blockJson(block)
|
||||
return { status: 200, json: { ...body, archived: true, in_trash: true } }
|
||||
}
|
||||
|
||||
async function updatePage(
|
||||
db: PrismaClient,
|
||||
workspaceId: string,
|
||||
@@ -1166,8 +1214,10 @@ async function updatePage(
|
||||
const row = (await db.notionPage.findFirst({ where: { workspaceId, id } })) as PageRow | null
|
||||
if (row === null) return notFound('page', id)
|
||||
const data: Record<string, unknown> = {}
|
||||
if (typeof body.archived === 'boolean') data.archived = body.archived
|
||||
if (typeof body.in_trash === 'boolean') data.inTrash = body.in_trash
|
||||
// Two spellings of one bit, so `ntn pages trash` (in_trash) and an API or
|
||||
// MCP client (archived) reach the same state rather than half of it.
|
||||
const trash = typeof body.in_trash === 'boolean' ? body.in_trash : body.archived
|
||||
if (typeof trash === 'boolean') await setTrashed(db, workspaceId, id, trash)
|
||||
if (body.properties !== undefined) {
|
||||
const owner =
|
||||
row.parentType === 'database_id' && row.parentId !== null
|
||||
@@ -1361,6 +1411,10 @@ async function handle(
|
||||
return appendChildren(db, ws, fx, parts[2] ?? '', body)
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && parts.length === 3 && parts[1] === 'blocks') {
|
||||
return deleteBlock(db, ws, parts[2] ?? '')
|
||||
}
|
||||
|
||||
if (method === 'POST' && parts.length === 2 && parts[1] === 'comments') {
|
||||
return createComment(db, ws, fx, body)
|
||||
}
|
||||
@@ -1515,6 +1569,14 @@ async function toolPayload(db: PrismaClient, name: string, args: Json): Promise<
|
||||
const size = intOr(args.page_size, MAX_PAGE_SIZE)
|
||||
return pageOf(rows.map(blockJson), cursorOf(args.start_cursor), size)
|
||||
}
|
||||
// The one delete verb the tool surface has. It mutates, so it takes the same
|
||||
// per-workspace queue every REST mutation takes rather than a second rule.
|
||||
if (name === 'API-delete-a-block') {
|
||||
const id = String(args.block_id)
|
||||
const reply = await serialize(ws, () => deleteBlock(db, ws, id))
|
||||
if (reply.status !== 200) throw new Error(`mock notion: unknown block ${id}`)
|
||||
return reply.json
|
||||
}
|
||||
throw new Error(`mock notion: unsupported tool ${name}`)
|
||||
}
|
||||
|
||||
@@ -1581,8 +1643,11 @@ export const CASES: ReadonlyArray<readonly [string, string]> = [
|
||||
['ls_databases', `ls ${MOUNT}/databases/`],
|
||||
['ls_database_dir', `ls ${DB_DIR}/`],
|
||||
['cat_database_json', `cat ${DB_DIR}/database.json`],
|
||||
['jq_db_props', `jq ".properties | keys" ${DB_DIR}/database.json`],
|
||||
['ls_data_source_dir', `ls ${DS_DIR}/`],
|
||||
['cat_data_source_json', `cat ${DS_DIR}/data_source.json`],
|
||||
['jq_data_source_props', `jq ".properties | keys" ${DS_DIR}/data_source.json`],
|
||||
['cat_row', `cat ${ROW_1_DIR}/page.json`],
|
||||
['jq_row_cells', `jq ".properties.Priority.number" ${ROW_1_DIR}/page.json`],
|
||||
['du_pages', `du ${MOUNT}/pages/`],
|
||||
['du_page_a', `du ${DIR_A}/`],
|
||||
]
|
||||
|
||||
@@ -22,8 +22,8 @@ from mirage.commands.cli.builtin.ntn.util import (compact_json, first_text,
|
||||
from mirage.commands.cli.types import CLIInvocation
|
||||
from mirage.commands.errors import UsageError
|
||||
from mirage.commands.spec.types import FlagView
|
||||
from mirage.core.notion._client import (notion_get, notion_patch, notion_post,
|
||||
notion_put)
|
||||
from mirage.core.notion._client import (notion_delete, notion_get,
|
||||
notion_patch, notion_post, notion_put)
|
||||
from mirage.core.notion.config import NotionConfig
|
||||
from mirage.io.stream import yield_bytes
|
||||
from mirage.io.types import ByteSource, IOResult, materialize
|
||||
@@ -58,11 +58,16 @@ REFUSAL_EXIT = 5
|
||||
# at all".
|
||||
NotionCall = Callable[..., Awaitable[dict[str, Any]]]
|
||||
|
||||
# DELETE is here because `DELETE /v1/blocks/{id}` is the only delete verb the
|
||||
# public API has, so without it the one way to remove anything is unreachable
|
||||
# from this CLI. It takes no body, which is why it is reached through `-X`
|
||||
# rather than by a body source inferring it.
|
||||
METHODS: dict[str, NotionCall] = {
|
||||
"GET": notion_get,
|
||||
"POST": notion_post,
|
||||
"PATCH": notion_patch,
|
||||
"PUT": notion_put,
|
||||
"DELETE": notion_delete,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -144,12 +144,19 @@ async def query(
|
||||
if fl.as_bool("json"):
|
||||
return yield_bytes(pretty_json(result)), IOResult()
|
||||
|
||||
# Columns are the schema's property names in alphabetical order,
|
||||
# which is what the upstream CLI prints whatever order the API
|
||||
# happens to report the schema in.
|
||||
columns = sorted((data_source.get("properties") or {}).keys())
|
||||
# Columns are the property names the returned rows actually carry, in
|
||||
# alphabetical order, not the data source's whole schema. Upstream
|
||||
# derives them from the page objects it got back, so a result set that
|
||||
# does not cover the schema prints narrower: a row created from Markdown
|
||||
# alone holds only its title column, and on its own it prints as
|
||||
# `<id>\t<title>` rather than as one title among seven blanks.
|
||||
rows = result.get("results") or []
|
||||
columns = sorted(
|
||||
{name
|
||||
for row in rows
|
||||
for name in (row.get("properties") or {})})
|
||||
lines: list[str] = []
|
||||
for row in result.get("results") or []:
|
||||
for row in rows:
|
||||
props = row.get("properties") or {}
|
||||
cells = [property_cell(props.get(name)) for name in columns]
|
||||
lines.append("\t".join([str(row.get("id", "")), *cells]) + "\n")
|
||||
|
||||
@@ -166,6 +166,32 @@ async def notion_put(
|
||||
return data
|
||||
|
||||
|
||||
# DELETE carries no body at all, which is why it does not take one: the only
|
||||
# route the public API exposes it on is /v1/blocks/{id}, whose whole payload is
|
||||
# the id in the path.
|
||||
async def notion_delete(
|
||||
config: NotionConfig,
|
||||
path: str,
|
||||
body: JsonValue = None,
|
||||
extra_headers: Mapping[str, str] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
url = f"{config.base_url}{path}"
|
||||
headers = notion_headers(config, extra_headers)
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.delete(url, headers=headers, params=params) as resp:
|
||||
data = await resp.json()
|
||||
if resp.status >= 400:
|
||||
message = data.get(
|
||||
"message") or f"Notion API error: HTTP {resp.status}"
|
||||
raise NotionAPIError(
|
||||
message,
|
||||
status=resp.status,
|
||||
code=data.get("code"),
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def paginate_list(
|
||||
config: NotionConfig,
|
||||
path: str,
|
||||
|
||||
@@ -30,6 +30,14 @@ def normalize_page(page: dict[str, Any],
|
||||
b for b in blocks
|
||||
if b.get("type") not in ("child_page", "child_database")
|
||||
]
|
||||
properties = page.get("properties", {})
|
||||
if not isinstance(properties, dict):
|
||||
properties = {}
|
||||
# A database row's cells are its `properties`, and they are the reason
|
||||
# the row exists, so they belong in the file rather than only in a
|
||||
# `datasources query`. Kept as Notion's own property objects for the
|
||||
# same reason `blocks` is: the schema they answer to is rendered one
|
||||
# level up, in data_source.json's `properties`.
|
||||
return {
|
||||
"page_id": page.get("id", ""),
|
||||
"title": extract_title(page),
|
||||
@@ -41,6 +49,7 @@ def normalize_page(page: dict[str, Any],
|
||||
"archived": page.get("archived", False),
|
||||
"created_by": page.get("created_by", {}).get("id", ""),
|
||||
"last_edited_by": page.get("last_edited_by", {}).get("id", ""),
|
||||
"properties": properties,
|
||||
"markdown": blocks_to_markdown(content_blocks),
|
||||
"blocks": content_blocks,
|
||||
}
|
||||
|
||||
@@ -59,7 +59,11 @@ def extract_data_source_title(data_source: dict[str, Any]) -> str:
|
||||
|
||||
def extract_title(page: dict[str, Any]) -> str:
|
||||
props = page.get("properties", {})
|
||||
if not isinstance(props, dict):
|
||||
return ""
|
||||
for prop in props.values():
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
if prop.get("type") == "title":
|
||||
title_items = prop.get("title", [])
|
||||
return "".join(item.get("plain_text", "") for item in title_items)
|
||||
|
||||
@@ -297,3 +297,34 @@ async def test_body_infers_post_and_output_is_compact(monkeypatch):
|
||||
# Compact and key-sorted, the upstream serializer for `ntn api`,
|
||||
# with the trailing newline the real binary emits.
|
||||
assert (await materialize(out)) == b'{"a":2,"b":1}\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_is_a_reachable_method(monkeypatch):
|
||||
# `DELETE /v1/blocks/{id}` is the only delete verb the public API has,
|
||||
# and it is the one the MCP tool surface exposes as
|
||||
# API-delete-a-block, so a table without it leaves an agent no way to
|
||||
# remove anything. It reached the user as `unsupported method: DELETE`
|
||||
# (exit 2) where the real binary trashes the block.
|
||||
seen: dict[str, Any] = {}
|
||||
|
||||
async def fake_delete(
|
||||
config: NotionConfig,
|
||||
path: str,
|
||||
body: JsonValue = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
seen["path"] = path
|
||||
seen["body"] = body
|
||||
return {"object": "block", "in_trash": True}
|
||||
|
||||
monkeypatch.setitem(METHODS, "DELETE", fake_delete)
|
||||
out, io = await api(
|
||||
CLIInvocation(CONFIG,
|
||||
texts=("v1/blocks/abc-123", ),
|
||||
flags={"method": "delete"}))
|
||||
assert io.exit_code == 0
|
||||
assert seen["path"] == "/blocks/abc-123"
|
||||
# No body source on the line, so nothing is invented for one.
|
||||
assert seen["body"] is None
|
||||
assert (await materialize(out)) == b'{"in_trash":true,"object":"block"}\n'
|
||||
|
||||
@@ -278,6 +278,50 @@ async def test_datasources_query_sorts_columns_by_name(monkeypatch):
|
||||
assert await _text(out) == "R1\tWrite spec\t2\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_datasources_query_takes_columns_from_the_rows(monkeypatch):
|
||||
# Upstream derives the columns from the page objects it got back, not
|
||||
# from the data source's schema, so a result set that does not cover
|
||||
# the schema prints narrower. A row created from Markdown alone holds
|
||||
# only its title column, and on its own it prints as `<id>\t<title>`
|
||||
# rather than as one title among blanks.
|
||||
async def fake_source(config, source_id):
|
||||
return {
|
||||
"id": source_id,
|
||||
"properties": {
|
||||
"Priority": {
|
||||
"type": "number"
|
||||
},
|
||||
"Name": {
|
||||
"type": "title"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async def fake_query(config, source_id, body):
|
||||
return {
|
||||
"results": [{
|
||||
"id": "R2",
|
||||
"properties": {
|
||||
"Name": {
|
||||
"type": "title",
|
||||
"title": [{
|
||||
"plain_text": "Row page"
|
||||
}],
|
||||
},
|
||||
},
|
||||
}],
|
||||
"has_more":
|
||||
False,
|
||||
}
|
||||
|
||||
monkeypatch.setitem(query.__globals__, "get_data_source", fake_source)
|
||||
monkeypatch.setitem(query.__globals__, "query_data_source_page",
|
||||
fake_query)
|
||||
out, _io = await query(CLIInvocation(CONFIG, texts=("S1", ), flags={}))
|
||||
assert await _text(out) == "R2\tRow page\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_datasources_query_reports_the_next_cursor(monkeypatch):
|
||||
|
||||
|
||||
@@ -116,6 +116,47 @@ class TestNormalizePage:
|
||||
assert "Hello" in result["markdown"]
|
||||
assert len(result["blocks"]) == 1
|
||||
|
||||
def test_a_row_carries_its_cells(self):
|
||||
# A database row's cells are its `properties`, and they are the
|
||||
# reason the row exists. Without them `cat` on a row answered with
|
||||
# metadata and an empty markdown, and the row's actual data was
|
||||
# reachable only through `ntn datasources query`.
|
||||
page = {
|
||||
"id": "row-1",
|
||||
"parent": {
|
||||
"type": "data_source_id",
|
||||
"data_source_id": "ds-1",
|
||||
},
|
||||
"properties": {
|
||||
"Name": {
|
||||
"id": "title",
|
||||
"type": "title",
|
||||
"title": [{
|
||||
"plain_text": "Write spec"
|
||||
}],
|
||||
},
|
||||
"Priority": {
|
||||
"id": "pri",
|
||||
"type": "number",
|
||||
"number": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
result = normalize_page(page, [])
|
||||
assert result["properties"]["Priority"]["number"] == 2
|
||||
assert result["parent_id"] == "ds-1"
|
||||
# Kept as Notion's own property objects, not flattened, so the
|
||||
# ids and types the schema is written in survive the render.
|
||||
assert result["properties"]["Name"]["id"] == "title"
|
||||
|
||||
def test_a_page_without_properties_renders_an_empty_map(self):
|
||||
result = normalize_page({"id": "p1"}, [])
|
||||
assert result["properties"] == {}
|
||||
|
||||
def test_a_non_object_properties_is_not_forwarded(self):
|
||||
result = normalize_page({"id": "p1", "properties": []}, [])
|
||||
assert result["properties"] == {}
|
||||
|
||||
def test_to_json_bytes(self):
|
||||
data = to_json_bytes({"key": "value"})
|
||||
assert isinstance(data, bytes)
|
||||
|
||||
@@ -43,7 +43,11 @@ const INLINE_SOURCE = 'inline body inputs'
|
||||
const BAD_BODY_EXIT = 1
|
||||
const REFUSAL_EXIT = 5
|
||||
|
||||
const METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT'])
|
||||
// DELETE is here because `DELETE /v1/blocks/{id}` is the only delete verb the
|
||||
// public API has, so without it the one way to remove anything is unreachable
|
||||
// from this CLI. It takes no body, which is why it is reached through `-X`
|
||||
// rather than by a body source inferring it.
|
||||
const METHODS = new Set(['GET', 'POST', 'PATCH', 'PUT', 'DELETE'])
|
||||
|
||||
// One inline input the CLI could not interpret, carrying the clause upstream
|
||||
// puts after its fixed lead.
|
||||
|
||||
@@ -129,10 +129,18 @@ export async function query(inv: CLIInvocation): Promise<CommandFnResult> {
|
||||
const result = await queryDataSourcePage(transport, strOf(dataSource, 'id'), body)
|
||||
if (fl.asBool('json')) return [prettyJson(result), new IOResult()]
|
||||
|
||||
// Columns are the schema's property names in alphabetical order, which is
|
||||
// what the upstream CLI prints whatever order the API reports the schema in.
|
||||
const columns = Object.keys(asObject(dataSource.properties)).sort()
|
||||
// Columns are the property names the returned rows actually carry, in
|
||||
// alphabetical order, not the data source's whole schema. Upstream derives
|
||||
// them from the page objects it got back, so a result set that does not
|
||||
// cover the schema prints narrower: a row created from Markdown alone holds
|
||||
// only its title column, and on its own it prints as `<id>\t<title>` rather
|
||||
// than as one title among seven blanks.
|
||||
const rows = Array.isArray(result.results) ? result.results : []
|
||||
const named = new Set<string>()
|
||||
for (const row of rows) {
|
||||
for (const name of Object.keys(asObject(asObject(row).properties))) named.add(name)
|
||||
}
|
||||
const columns = [...named].sort()
|
||||
let out = ''
|
||||
for (const row of rows) {
|
||||
const record = asObject(row)
|
||||
|
||||
@@ -224,6 +224,37 @@ describe('ntn verbs', () => {
|
||||
RESPONSE = { id: 'P1' }
|
||||
})
|
||||
|
||||
// Upstream derives the columns from the page objects it got back, not from
|
||||
// the data source's schema, so a result set that does not cover the schema
|
||||
// prints narrower. A row created from Markdown alone holds only its title
|
||||
// column, and on its own it prints as `<id>\t<title>` rather than as one
|
||||
// title among blanks.
|
||||
it('datasources query takes its columns from the returned rows', async () => {
|
||||
CALLS.length = 0
|
||||
RESPONSE = {
|
||||
id: 'S1',
|
||||
properties: { Priority: { type: 'number' }, Name: { type: 'title' } },
|
||||
results: [
|
||||
{ id: 'R2', properties: { Name: { type: 'title', title: [{ plain_text: 'Row page' }] } } },
|
||||
],
|
||||
has_more: false,
|
||||
}
|
||||
const [out] = unwrap(await query(makeInv({}, ['S1'])))
|
||||
expect(DEC.decode(out as Uint8Array)).toBe('R2\tRow page\n')
|
||||
RESPONSE = { id: 'P1' }
|
||||
})
|
||||
|
||||
// `DELETE /v1/blocks/{id}` is the only delete verb the public API has, and
|
||||
// it is the one the MCP tool surface exposes as API-delete-a-block, so a
|
||||
// method table without it leaves an agent no way to remove anything.
|
||||
it('api can issue the one delete verb the API has', async () => {
|
||||
REQUESTS.length = 0
|
||||
const [out] = unwrap(await api(makeInv({ method: 'delete' }, ['v1/blocks/B1'])))
|
||||
// No body source on the line, so nothing is invented for one.
|
||||
expect(REQUESTS[0]).toEqual({ method: 'DELETE', path: '/blocks/B1' })
|
||||
expect(DEC.decode(out as Uint8Array)).toBe('{"id":"P1"}\n')
|
||||
})
|
||||
|
||||
it('api infers the method and strips the version prefix from the path', async () => {
|
||||
REQUESTS.length = 0
|
||||
await api(makeInv({}, ['v1/users/me']))
|
||||
|
||||
@@ -153,7 +153,7 @@ export interface HttpNotionTransportOptions {
|
||||
}
|
||||
|
||||
export interface RestCall {
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT'
|
||||
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
path: string
|
||||
query?: Record<string, unknown>
|
||||
/**
|
||||
@@ -252,7 +252,11 @@ export class HttpNotionTransport implements NotionTransport {
|
||||
...(call.headers ?? {}),
|
||||
},
|
||||
}
|
||||
if (call.method !== 'GET') init.body = JSON.stringify(call.body ?? {})
|
||||
// GET and DELETE carry no body: the only route DELETE exists on is
|
||||
// /v1/blocks/{id}, whose whole payload is the id in the path.
|
||||
if (call.method !== 'GET' && call.method !== 'DELETE') {
|
||||
init.body = JSON.stringify(call.body ?? {})
|
||||
}
|
||||
const res = await this.fetch(url, init)
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
if (res.status >= 400) {
|
||||
|
||||
@@ -130,6 +130,9 @@ describe('normalizePage', () => {
|
||||
archived: false,
|
||||
created_by: 'user-1',
|
||||
last_edited_by: 'user-2',
|
||||
properties: {
|
||||
Name: { id: 'title', type: 'title', title: [{ plain_text: 'Hello' }] },
|
||||
},
|
||||
markdown: 'Hi\n\n# Title\n',
|
||||
blocks,
|
||||
})
|
||||
@@ -150,11 +153,32 @@ describe('normalizePage', () => {
|
||||
archived: false,
|
||||
created_by: '',
|
||||
last_edited_by: '',
|
||||
properties: {},
|
||||
markdown: '',
|
||||
blocks: [],
|
||||
})
|
||||
})
|
||||
|
||||
// A database row's cells are its `properties`, and they are the reason the
|
||||
// row exists. Without them `cat` on a row answered with metadata and an
|
||||
// empty markdown, and the row's actual data was reachable only through
|
||||
// `ntn datasources query`.
|
||||
it('carries the cells of a database row', () => {
|
||||
const page = {
|
||||
id: 'row-1',
|
||||
parent: { type: 'data_source_id', data_source_id: 'ds-1' },
|
||||
properties: {
|
||||
Name: { id: 'title', type: 'title', title: [{ plain_text: 'Write spec' }] },
|
||||
Priority: { id: 'pri', type: 'number', number: 2 },
|
||||
},
|
||||
}
|
||||
const out = normalizePage(page, [])
|
||||
expect(out.parent_id).toBe('ds-1')
|
||||
// Kept as Notion's own property objects, not flattened, so the ids and
|
||||
// types the schema is written in survive the render.
|
||||
expect(out.properties).toEqual(page.properties)
|
||||
})
|
||||
|
||||
it('drops child_page and child_database blocks from the body', () => {
|
||||
const page = { id: '2c4e9c3a-1234-5678-90ab-cdef01234567' }
|
||||
const keep = { object: 'block', id: 'b1', type: 'paragraph', paragraph: { rich_text: [] } }
|
||||
|
||||
@@ -133,6 +133,7 @@ export interface NormalizedPage {
|
||||
archived: boolean
|
||||
created_by: string
|
||||
last_edited_by: string
|
||||
properties: Json
|
||||
markdown: string
|
||||
blocks: Json[]
|
||||
}
|
||||
@@ -160,6 +161,11 @@ export interface NormalizedDataSource {
|
||||
properties: Json
|
||||
}
|
||||
|
||||
// A database row's cells are its `properties`, and they are the reason the row
|
||||
// exists, so they belong in the file rather than only in a `datasources query`.
|
||||
// Kept as Notion's own property objects for the same reason `blocks` is: the
|
||||
// schema they answer to is rendered one level up, in data_source.json's
|
||||
// `properties`.
|
||||
export function normalizePage(page: Json, blocks: readonly Json[]): NormalizedPage {
|
||||
const parent = asObject(page.parent)
|
||||
const parentType = strOf(parent, 'type')
|
||||
@@ -180,6 +186,7 @@ export function normalizePage(page: Json, blocks: readonly Json[]): NormalizedPa
|
||||
archived: boolOf(page, 'archived'),
|
||||
created_by: strOf(asObject(page.created_by), 'id'),
|
||||
last_edited_by: strOf(asObject(page.last_edited_by), 'id'),
|
||||
properties: asObject(page.properties),
|
||||
markdown: blocksToMarkdown(contentBlocks),
|
||||
blocks: contentBlocks,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user