Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ notion-cli page sync ./document.md # Updates page using
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

# Local image paths like ./image.png are uploaded via official API fallback during upload/sync
# when detected in markdown content. Requires NOTION_API_TOKEN.

# Edit an existing page
notion-cli page edit <url> --replace "New content" # Replace all content
notion-cli page edit <url> --find "old text" --replace-with "new text" # Find and replace
Expand Down
136 changes: 128 additions & 8 deletions cmd/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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 Down Expand Up @@ -206,6 +207,15 @@ func runPageUpload(ctx *Context, file, title, parent, parentDB, icon string) err
}

markdown := string(content)
bgCtx := context.Background()
localUploads, err := maybeUploadLocalImages(bgCtx, file, markdown, "", "")
if err != nil {
output.PrintError(err)
return err
}
if len(localUploads) > 0 {
output.PrintInfo(fmt.Sprintf("Uploaded %d local image(s) via Notion REST file uploads", len(localUploads)))
}

if title == "" {
title = extractTitleFromMarkdown(markdown)
Expand All @@ -224,8 +234,6 @@ 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,
Expand Down Expand Up @@ -257,6 +265,15 @@ func runPageUpload(ctx *Context, file, title, parent, parentDB, icon string) err
output.PrintError(err)
return err
}
pageID := pageIDFromCreateResponse(resp)
if len(localUploads) > 0 {
if pageID == "" {
output.PrintWarning("Page created but could not retrieve ID to append uploaded local images")
} else if err := appendUploadedLocalImages(bgCtx, pageID, localUploads); err != nil {
output.PrintError(err)
return err
}
}

displayTitle := title
if icon != "" {
Expand All @@ -265,7 +282,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 @@ -392,6 +409,15 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error

content := string(raw)
fm, body := cli.ParseFrontmatter(content)
bgCtx := context.Background()
localUploads, err := maybeUploadLocalImages(bgCtx, file, body, "", "")
if err != nil {
output.PrintError(err)
return err
}
if len(localUploads) > 0 {
output.PrintInfo(fmt.Sprintf("Uploaded %d local image(s) via Notion REST file uploads", len(localUploads)))
}

if title == "" {
title = extractTitleFromMarkdown(body)
Expand All @@ -409,8 +435,6 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error
}
defer func() { _ = client.Close() }()

bgCtx := context.Background()

if fm.NotionID != "" {
req := mcp.UpdatePageRequest{
PageID: fm.NotionID,
Expand All @@ -421,6 +445,12 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error
output.PrintError(err)
return err
}
if len(localUploads) > 0 {
if err := appendUploadedLocalImages(bgCtx, fm.NotionID, localUploads); err != nil {
output.PrintError(err)
return err
}
}

displayTitle := title
if icon != "" {
Expand Down Expand Up @@ -472,9 +502,14 @@ 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 len(localUploads) > 0 {
if pageID == "" {
output.PrintWarning("Page created but could not retrieve ID to append uploaded local images")
} else if err := appendUploadedLocalImages(bgCtx, pageID, localUploads); err != nil {
output.PrintError(err)
return err
}
}
if pageID == "" {
output.PrintWarning("Page created but could not retrieve ID for frontmatter")
Expand Down Expand Up @@ -511,3 +546,88 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error
}
return nil
}

type uploadedLocalImage struct {
Alt string
FileUploadID string
ResolvedPath string
}

func maybeUploadLocalImages(ctx context.Context, sourceFile, markdown, assetBaseURL, _ string) ([]uploadedLocalImage, error) {
if strings.TrimSpace(assetBaseURL) != "" {
return nil, nil
}

images, err := cli.FindLocalMarkdownImages(markdown, sourceFile)
if err != nil {
return nil, err
}
if len(images) == 0 {
return nil, nil
}

apiClient, err := cli.RequireOfficialAPIClient()
if err != nil {
return nil, err
}

uploadIDByPath := make(map[string]string, len(images))
uploads := make([]uploadedLocalImage, 0, len(images))
for _, image := range images {
uploadID, ok := uploadIDByPath[image.Resolved]
if !ok {
fileData, err := os.ReadFile(image.Resolved)
if err != nil {
return nil, fmt.Errorf("read local image %q: %w", image.Resolved, err)
}
uploadID, err = apiClient.UploadFile(ctx, filepath.Base(image.Resolved), fileData)
if err != nil {
return nil, fmt.Errorf("upload local image %q: %w", image.Resolved, err)
}
uploadIDByPath[image.Resolved] = uploadID
}

uploads = append(uploads, uploadedLocalImage{
Alt: image.Alt,
FileUploadID: uploadID,
ResolvedPath: image.Resolved,
})
}

return uploads, nil
}

func appendUploadedLocalImages(ctx context.Context, pageID string, uploads []uploadedLocalImage) error {
if strings.TrimSpace(pageID) == "" || len(uploads) == 0 {
return nil
}

apiClient, err := cli.RequireOfficialAPIClient()
if err != nil {
return err
}

blocks := make([]api.UploadedImageBlock, 0, len(uploads))
for _, upload := range uploads {
blocks = append(blocks, api.UploadedImageBlock{
FileUploadID: upload.FileUploadID,
Caption: upload.Alt,
})
}

return apiClient.AppendUploadedImageBlocks(ctx, pageID, blocks)
}

func pageIDFromCreateResponse(resp *mcp.CreatePageResponse) string {
if resp == nil {
return ""
}
if strings.TrimSpace(resp.ID) != "" {
return strings.TrimSpace(resp.ID)
}
if strings.TrimSpace(resp.URL) == "" {
return ""
}
id, _ := cli.ExtractNotionUUID(resp.URL)
return id
}
116 changes: 116 additions & 0 deletions cmd/page_local_images_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package cmd

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)

func TestMaybeUploadLocalImagesSkipsWhenAssetBaseURLSet(t *testing.T) {
t.Setenv("HOME", t.TempDir())

uploads, err := maybeUploadLocalImages(context.Background(), "/tmp/doc.md", "![A](./a.png)", "https://cdn.example.com/base", "")
if err != nil {
t.Fatalf("maybeUploadLocalImages: %v", err)
}
if len(uploads) != 0 {
t.Fatalf("expected no uploads, got %d", len(uploads))
}
}

func TestMaybeUploadLocalImagesUploadsAndDeduplicates(t *testing.T) {
tmp := t.TempDir()
docDir := filepath.Join(tmp, "docs")
if err := os.MkdirAll(filepath.Join(docDir, "assets"), 0o755); err != nil {
t.Fatalf("mkdir assets: %v", err)
}
img := filepath.Join(docDir, "assets", "diagram.png")
if err := os.WriteFile(img, []byte("PNGDATA"), 0o644); err != nil {
t.Fatalf("write image: %v", err)
}
doc := filepath.Join(docDir, "guide.md")
markdown := "![One](./assets/diagram.png)\n![Two](./assets/diagram.png)\n"

createCalls := 0
sendCalls := 0
getCalls := 0

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/v1/file_uploads":
createCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"upload_123","status":"pending"}`))
return

case r.Method == http.MethodPost && r.URL.Path == "/v1/file_uploads/upload_123/send":
sendCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"upload_123","status":"uploaded"}`))
return

case r.Method == http.MethodGet && r.URL.Path == "/v1/file_uploads/upload_123":
getCalls++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"upload_123","status":"uploaded"}`))
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer srv.Close()

t.Setenv("HOME", t.TempDir())
t.Setenv("NOTION_API_BASE_URL", srv.URL+"/v1")
t.Setenv("NOTION_API_TOKEN", "test-token")

uploads, err := maybeUploadLocalImages(context.Background(), doc, markdown, "", "")
if err != nil {
t.Fatalf("maybeUploadLocalImages: %v", err)
}
if len(uploads) != 2 {
t.Fatalf("len(uploads)=%d, want 2", len(uploads))
}
if uploads[0].FileUploadID != "upload_123" || uploads[1].FileUploadID != "upload_123" {
t.Fatalf("unexpected upload ids: %#v", uploads)
}
if createCalls != 1 || sendCalls != 1 || getCalls != 1 {
t.Fatalf("unexpected call counts create=%d send=%d get=%d", createCalls, sendCalls, getCalls)
}
}

func TestAppendUploadedLocalImages(t *testing.T) {
var gotBody map[string]any

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch || r.URL.Path != "/v1/blocks/page_123/children" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
defer func() { _ = r.Body.Close() }()
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"object":"list","results":[]}`))
}))
defer srv.Close()

t.Setenv("HOME", t.TempDir())
t.Setenv("NOTION_API_BASE_URL", srv.URL+"/v1")
t.Setenv("NOTION_API_TOKEN", "test-token")

err := appendUploadedLocalImages(context.Background(), "page_123", []uploadedLocalImage{
{Alt: "Diagram", FileUploadID: "upload_1"},
})
if err != nil {
t.Fatalf("appendUploadedLocalImages: %v", err)
}

children, ok := gotBody["children"].([]any)
if !ok || len(children) != 1 {
t.Fatalf("children payload mismatch: %#v", gotBody["children"])
}
}
Loading