Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f03d597
feat(page): upload standalone local images via api
0xble Apr 1, 2026
e1bd99d
fix(page): make local image upload work
0xble Apr 1, 2026
cf14138
fix(page): escape multipart filenames
0xble Apr 1, 2026
582132a
fix(page): handle cleanup errors and CRLF images
0xble Apr 1, 2026
f1db53c
feat(page): add --skip-local-images flag to upload and sync
0xble Apr 1, 2026
d96b12d
fix(page): wire official api overrides through page commands
0xble Apr 3, 2026
ce4479d
fix(page): strip local images without requiring source files
0xble Apr 18, 2026
ce000bf
fix(page): guard sync rollback against truncated snapshots
0xble Apr 18, 2026
35fdc88
fix(page): scan local images with code-block and paren awareness
0xble Apr 18, 2026
5ff612a
fix(page): restore empty snapshots during local-image rollback
0xble Apr 18, 2026
729b00e
fix(page): handle nested brackets and protocol-relative image URLs
0xble Apr 18, 2026
315f531
fix(page): fail local-image substitution when page ID is missing
0xble Apr 18, 2026
e8ec25b
fix(page): skip escaped image markers and titles in destinations
0xble Apr 18, 2026
d31965d
fix(page): check local-image parent before uploading
0xble Apr 18, 2026
c4f3824
refactor(page): stream uploads, parallelize, share constants
0xble Apr 18, 2026
bf87d08
fix(page): gate sync on truncated snapshot and strengthen scanner
0xble Apr 18, 2026
0838c60
fix(page): strip indented placeholders in --skip-local-images mode
0xble Apr 18, 2026
9bd9b15
fix(page): force placeholders to column zero so substitution lands in…
0xble Apr 18, 2026
391db41
fix(page): resolve file:// hosts and ~/ paths portably
0xble Apr 18, 2026
1ef067c
fix(page): gate sync when snapshot has unknown block IDs
0xble Apr 18, 2026
0fd1e95
fix(page): resolve parent IDs before uploading local images
0xble Apr 18, 2026
b7a5954
fix(page): report orphan page URL when create ID is unparsable
0xble Apr 18, 2026
71d2800
fix(images): restrict Windows drive detection to avoid URI false posi…
0xble Apr 18, 2026
3a9eb0a
fix(api): reject >20MB single_part uploads with a clear error
0xble Apr 18, 2026
ae15b56
fix(images): honor \> escapes inside angle-bracket image destinations
0xble Apr 18, 2026
38b6876
fix(images): decode escaped spaces and skip titles in destinations
0xble Apr 18, 2026
e1b03ab
fix(page): serialize local image uploads to respect Notion rate limits
0xble Apr 18, 2026
0fba6bc
fix(images): skip blockquoted lines so quoted fences don't upload
0xble Apr 18, 2026
ce2ca0b
fix(page): preflight MCP before local image uploads
lox Apr 27, 2026
688d6ff
fix(images): honor blockquote indent limit
0xble Apr 30, 2026
d866ccf
fix(images): skip raw html blocks
0xble Apr 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,16 @@ notion-cli page upload ./document.md --title "Custom Title" # Explicit title
notion-cli page upload ./document.md --parent "Engineering" # Parent by name or ID
notion-cli page upload ./document.md --parent-db <db-id> # Upload as database entry
notion-cli page upload ./document.md --icon "📄" # Set emoji icon
notion-cli page upload ./document.md # Uploads standalone local images when configured
notion-cli page upload ./document.md --skip-local-images # Strips standalone local image lines instead

# Sync a markdown file (create or update)
notion-cli page sync ./document.md # Creates page, writes notion-id to frontmatter
notion-cli page sync ./document.md # Updates page using notion-id from frontmatter
notion-cli page sync ./document.md --parent "Engineering" # Set parent on first sync
notion-cli page sync ./document.md --parent-db <db-id> # Sync as database entry
notion-cli page sync ./document.md # Uploads standalone local images when configured
notion-cli page sync ./document.md --skip-local-images # Strips standalone local image lines instead

# Edit an existing page
notion-cli page edit <page> --replace "New content" # Replace all content
Expand All @@ -105,6 +109,8 @@ The `<page>` argument accepts a URL, ID, or page name.

`page view` shows open page-level comments and inline block discussions by default. Inline discussions are rendered in context, with the anchor text wrapped in `[[...]]` and the discussion shown immediately below it. Use `--no-comments` to suppress comments, `--raw` to inspect the original Notion markup, and `--json` to return the page plus a `Comments` array.

`page upload` and `page sync` support native local image upload for standalone markdown image lines like `![Alt](./diagram.png)`. When local images are present, `notion-cli` uploads those files through the official Notion API and keeps them in document order. This requires an official API token configured through `auth api setup` or `NOTION_API_TOKEN`. Pass `--skip-local-images` to silently remove standalone local image lines instead of uploading them. Inline or mixed-content local image syntax is rejected instead of being guessed.

### Search

```bash
Expand Down Expand Up @@ -170,6 +176,9 @@ The CLI uses Notion's remote MCP server with OAuth authentication. On first run,
| Variable | Description |
|----------|-------------|
| `NOTION_ACCESS_TOKEN` | Access token for CI/headless usage (skips OAuth) |
| `NOTION_API_TOKEN` | Official Notion API token used for upload fallback and verification |
| `NOTION_API_BASE_URL` | Override the official Notion API base URL |
| `NOTION_API_NOTION_VERSION` | Override the official Notion API version |

## How It Works

Expand Down
11 changes: 0 additions & 11 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,17 +198,6 @@ type AuthAPIStatusCmd struct {
JSON bool `help:"Output as JSON" short:"j"`
}

func officialAPIOverrides(ctx *Context) config.APIOverrides {
if ctx == nil {
return config.APIOverrides{}
}
return config.APIOverrides{
BaseURL: ctx.APIBaseURL,
NotionVersion: ctx.APINotionVersion,
Token: ctx.APIToken,
}
}

func (c *AuthAPIStatusCmd) Run(ctx *Context) error {
ctx.JSON = c.JSON

Expand Down
231 changes: 187 additions & 44 deletions cmd/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"

"github.com/lox/notion-cli/internal/api"
"github.com/lox/notion-cli/internal/cli"
"github.com/lox/notion-cli/internal/mcp"
"github.com/lox/notion-cli/internal/output"
Expand All @@ -25,6 +26,7 @@ type PageCmd struct {
var loadPageViewCommentsFn = loadPageViewComments
var printViewedPageFn = output.PrintViewedPage
var printWarningFn = output.PrintWarning
var requirePageClientFn = cli.RequireClient

type PageListCmd struct {
Query string `help:"Filter pages by name" short:"q"`
Expand Down Expand Up @@ -249,37 +251,40 @@ func runPageCreate(ctx *Context, title, parent, content string) error {
}

type PageUploadCmd struct {
File string `arg:"" help:"Markdown file to upload" type:"existingfile"`
Title string `help:"Page title (default: filename or first heading)" short:"t"`
Parent string `help:"Parent page URL, name, or ID" short:"p"`
ParentDB string `help:"Parent database URL, name, or ID" name:"parent-db" short:"d"`
Icon string `help:"Emoji icon for the page" short:"i"`
JSON bool `help:"Output as JSON" short:"j"`
File string `arg:"" help:"Markdown file to upload" type:"existingfile"`
Title string `help:"Page title (default: filename or first heading)" short:"t"`
Parent string `help:"Parent page URL, name, or ID" short:"p"`
ParentDB string `help:"Parent database URL, name, or ID" name:"parent-db" short:"d"`
Icon string `help:"Emoji icon for the page" short:"i"`
SkipLocalImages bool `help:"Strip local image references instead of uploading them" name:"skip-local-images"`
JSON bool `help:"Output as JSON" short:"j"`
}

func (c *PageUploadCmd) Run(ctx *Context) error {
ctx.JSON = c.JSON
return runPageUpload(ctx, c.File, c.Title, c.Parent, c.ParentDB, c.Icon)
return runPageUpload(ctx, c.File, c.Title, c.Parent, c.ParentDB, c.Icon, c.SkipLocalImages)
}

func runPageUpload(ctx *Context, file, title, parent, parentDB, icon string) error {
func runPageUpload(ctx *Context, file, title, parent, parentDB, icon string, skipLocalImages bool) error {
content, err := os.ReadFile(file)
if err != nil {
output.PrintError(err)
return err
}

markdown := string(content)

if title == "" {
title = extractTitleFromMarkdown(markdown)
}
if title == "" {
title = strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
}

if icon == "" {
icon, title = extractEmojiFromTitle(title)
bgCtx := context.Background()
if skipLocalImages {
markdown, err = stripLocalImages(markdown)
if err != nil {
output.PrintError(err)
return err
}
} else {
if err := checkLocalImageParent(markdown, parent, parentDB); err != nil {
output.PrintError(err)
return err
}
}

client, err := cli.RequireClient()
Expand All @@ -288,13 +293,9 @@ func runPageUpload(ctx *Context, file, title, parent, parentDB, icon string) err
}
defer func() { _ = client.Close() }()

bgCtx := context.Background()

req := mcp.CreatePageRequest{
Title: title,
Content: markdown,
}

// Resolve parent IDs before any upload side effects so an invalid
// --parent/--parent-db doesn't leave orphaned file uploads behind.
req := mcp.CreatePageRequest{}
if parentDB != "" {
dbID, err := cli.ResolveDatabaseID(bgCtx, client, parentDB)
if err != nil {
Expand All @@ -316,11 +317,39 @@ func runPageUpload(ctx *Context, file, title, parent, parentDB, icon string) err
req.ParentPageID = parentID
}

var localUploads []uploadedLocalImage
if !skipLocalImages {
markdown, localUploads, err = prepareLocalImageUploads(ctx, bgCtx, file, markdown)
if err != nil {
output.PrintError(err)
return err
}
}

if title == "" {
title = extractTitleFromMarkdown(markdown)
}
if title == "" {
title = strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))
}

if icon == "" {
icon, title = extractEmojiFromTitle(title)
}

req.Title = title
req.Content = markdown

resp, err := client.CreatePage(bgCtx, req)
if err != nil {
output.PrintError(err)
return err
}
pageID := pageIDFromCreateResponse(resp)
if err := substituteOrCleanup(ctx, bgCtx, pageID, resp.URL, localUploads); err != nil {
output.PrintError(err)
return err
}

displayTitle := title
if icon != "" {
Expand All @@ -329,7 +358,7 @@ func runPageUpload(ctx *Context, file, title, parent, parentDB, icon string) err

if ctx.JSON {
outPage := output.Page{
ID: resp.ID,
ID: pageID,
URL: resp.URL,
Title: displayTitle,
Icon: icon,
Expand Down Expand Up @@ -513,20 +542,21 @@ func parsePageEditProperties(props []string) (map[string]any, error) {
}

type PageSyncCmd struct {
File string `arg:"" help:"Markdown file to sync" type:"existingfile"`
Title string `help:"Page title (default: filename or first heading)" short:"t"`
Parent string `help:"Parent page URL, name, or ID" short:"p"`
ParentDB string `help:"Parent database URL, name, or ID" name:"parent-db" short:"d"`
Icon string `help:"Emoji icon for the page" short:"i"`
JSON bool `help:"Output as JSON" short:"j"`
File string `arg:"" help:"Markdown file to sync" type:"existingfile"`
Title string `help:"Page title (default: filename or first heading)" short:"t"`
Parent string `help:"Parent page URL, name, or ID" short:"p"`
ParentDB string `help:"Parent database URL, name, or ID" name:"parent-db" short:"d"`
Icon string `help:"Emoji icon for the page" short:"i"`
SkipLocalImages bool `help:"Strip local image references instead of uploading them" name:"skip-local-images"`
JSON bool `help:"Output as JSON" short:"j"`
}

func (c *PageSyncCmd) Run(ctx *Context) error {
ctx.JSON = c.JSON
return runPageSync(ctx, c.File, c.Title, c.Parent, c.ParentDB, c.Icon)
return runPageSync(ctx, c.File, c.Title, c.Parent, c.ParentDB, c.Icon, c.SkipLocalImages)
}

func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error {
func runPageSync(ctx *Context, file, title, parent, parentDB, icon string, skipLocalImages bool) error {
raw, err := os.ReadFile(file)
if err != nil {
output.PrintError(err)
Expand All @@ -535,6 +565,104 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error

content := string(raw)
fm, body := cli.ParseFrontmatter(content)
bgCtx := context.Background()
var localUploads []uploadedLocalImage
var snapshot *api.PageMarkdown
var resolvedParentPageID, resolvedParentDatabaseID string
var client *mcp.Client
defer func() {
if client != nil {
_ = client.Close()
}
}()
if skipLocalImages {
body, err = stripLocalImages(body)
if err != nil {
output.PrintError(err)
return err
}
} else {
// Dry-run scan so we know whether uploads will happen before anything
// goes over the wire. This lets us validate the parent flags and,
// for in-place sync, fetch the rollback snapshot (and gate on
// truncation) before we touch the official API.
_, placements, scanErr := cli.FindStandaloneLocalImageLines(body)
if scanErr != nil {
output.PrintError(scanErr)
return scanErr
}
hasLocalImages := len(placements) > 0

if fm.NotionID == "" {
if err := checkLocalImageParent(body, parent, parentDB); err != nil {
output.PrintError(err)
return err
}
}

if hasLocalImages && fm.NotionID != "" {
client, err = requirePageClientFn()
if err != nil {
return err
}

apiClient, err := cli.RequireOfficialAPIClient(officialAPIOverrides(ctx))
if err != nil {
output.PrintError(err)
return err
}
snapshot, err = apiClient.GetPageMarkdown(bgCtx, fm.NotionID)
if err != nil {
output.PrintError(err)
return err
}
if snapshot.Truncated {
finalErr := fmt.Errorf("cannot sync local images safely: page %s markdown snapshot is truncated, so rollback on a failed substitution would leave placeholders in the page. Retry without local images or reduce the page before syncing", fm.NotionID)
output.PrintError(finalErr)
return finalErr
}
if len(snapshot.UnknownBlockIDs) > 0 {
finalErr := fmt.Errorf("cannot sync local images safely: page %s contains %d block(s) that cannot be represented in markdown, so rollback on a failed substitution would drop them. Retry without local images or remove the unsupported blocks before syncing", fm.NotionID, len(snapshot.UnknownBlockIDs))
output.PrintError(finalErr)
return finalErr
}
}

// For the create path, resolve parent IDs before any uploads so an
// invalid --parent/--parent-db doesn't leave orphaned file uploads.
if hasLocalImages && fm.NotionID == "" {
client, err = requirePageClientFn()
if err != nil {
return err
}
if parentDB != "" {
dbID, err := cli.ResolveDatabaseID(bgCtx, client, parentDB)
if err != nil {
output.PrintError(err)
return err
}
dbID, err = client.ResolveDataSourceID(bgCtx, dbID)
if err != nil {
output.PrintError(err)
return err
}
resolvedParentDatabaseID = dbID
} else if parent != "" {
parentID, err := cli.ResolvePageID(bgCtx, client, parent)
if err != nil {
output.PrintError(err)
return err
}
resolvedParentPageID = parentID
}
}

body, localUploads, err = prepareLocalImageUploads(ctx, bgCtx, file, body)
if err != nil {
Comment thread
0xble marked this conversation as resolved.
output.PrintError(err)
return err
}
}

if title == "" {
title = extractTitleFromMarkdown(body)
Expand All @@ -546,13 +674,12 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error
icon, title = extractEmojiFromTitle(title)
}

client, err := cli.RequireClient()
if err != nil {
return err
if client == nil {
client, err = requirePageClientFn()
if err != nil {
return err
}
}
defer func() { _ = client.Close() }()

bgCtx := context.Background()

if fm.NotionID != "" {
req := mcp.UpdatePageRequest{
Expand All @@ -564,6 +691,15 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error
output.PrintError(err)
return err
}
if err := substituteUploadedLocalImages(ctx, bgCtx, fm.NotionID, localUploads); err != nil {
finalErr := fmt.Errorf("insert uploaded local images: %w", err)
rollbackErr := rollbackSyncedPage(bgCtx, client, fm.NotionID, snapshot)
if rollbackErr != nil {
finalErr = fmt.Errorf("%w (rollback failed: %v)", finalErr, rollbackErr)
}
output.PrintError(finalErr)
return finalErr
}

displayTitle := title
if icon != "" {
Expand All @@ -588,7 +724,13 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error
Content: body,
}

if parentDB != "" {
// Reuse parent IDs pre-resolved above when local images were involved;
// otherwise resolve here for the no-upload create path.
if resolvedParentDatabaseID != "" {
req.ParentDatabaseID = resolvedParentDatabaseID
} else if resolvedParentPageID != "" {
req.ParentPageID = resolvedParentPageID
} else if parentDB != "" {
dbID, err := cli.ResolveDatabaseID(bgCtx, client, parentDB)
if err != nil {
output.PrintError(err)
Expand All @@ -615,9 +757,10 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error
return err
}

pageID := resp.ID
if pageID == "" && resp.URL != "" {
pageID, _ = cli.ExtractNotionUUID(resp.URL)
pageID := pageIDFromCreateResponse(resp)
if err := substituteOrCleanup(ctx, bgCtx, pageID, resp.URL, localUploads); err != nil {
output.PrintError(err)
return err
}
if pageID == "" {
output.PrintWarning("Page created but could not retrieve ID for frontmatter")
Expand Down
Loading
Loading