From a31d7c1aaaf70f1aa84affe70ce6925c570e7707 Mon Sep 17 00:00:00 2001 From: 4ier Date: Thu, 30 Apr 2026 14:12:07 +0800 Subject: [PATCH] feat(page): add 'page markdown' and 'page set-markdown' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #37. Wraps the two 2025 Notion endpoints that do server-side markdown I/O on a full page: GET /v1/pages/:id/markdown → notion page markdown PATCH /v1/pages/:id/markdown → notion page set-markdown ### page markdown Prints a page's content as markdown. Strictly better than `block list --md` for page-level dumps: the server handles toggles, columns, synced blocks, and databases-as-pages uniformly and always matches what the Notion UI shows. notion page markdown notion page markdown > page.md notion page markdown --out page.md notion page markdown --format json # full response incl. truncated flag Surfaces Notion's 'truncated' flag and 'unknown_block_ids' as stderr notes in default output so silent partial renders don't go unnoticed. ### page set-markdown Updates a page from markdown in one API call. Supports all four mutation modes Notion's PATCH endpoint accepts: --replace (default) replace_content: overwrite whole page --append insert_content at end --after insert_content after ellipsis anchor ("start...end") --range replace_content_range bounded by ellipsis anchor --file (use '-' for stdin) --text inline markdown --allow-deleting-content pass allow flag for destructive modes Because Notion handles the full markdown → blocks conversion server-side, this is the cleanest way to write long documents — the 100-children batching (#21) doesn't apply to set-markdown. ### Implementation Two pure helpers isolate the fiddly bits for unit testing without network: - buildSetMarkdownBody: produces the correct envelope for each mode; rejects ambiguous flag combinations up front. - readMarkdownSource: picks the source (--file / --text / stdin); same mutual-exclusion rules as the rest of the CLI. ### Tests - Exhaustive buildSetMarkdownBody coverage across all four modes + allow_deleting_content + multi-mode rejection. - readMarkdownSource: required / conflict / file / stdin / file-missing. ### Smoke Verified end-to-end against a real workspace: replace via --file, append --text, markdown read with --out, stdin replace, and multi-mode rejection all behave as documented. --- cmd/page.go | 12 ++ cmd/page_markdown.go | 275 ++++++++++++++++++++++++++++++++++++++ cmd/page_markdown_test.go | 149 +++++++++++++++++++++ 3 files changed, 436 insertions(+) create mode 100644 cmd/page_markdown.go create mode 100644 cmd/page_markdown_test.go diff --git a/cmd/page.go b/cmd/page.go index aba7e6d..e5275ca 100644 --- a/cmd/page.go +++ b/cmd/page.go @@ -966,9 +966,21 @@ func init() { pageCmd.AddCommand(pageLinkCmd) pageCmd.AddCommand(pageUnlinkCmd) pageCmd.AddCommand(pageEditCmd) + pageCmd.AddCommand(pageMarkdownCmd) + pageCmd.AddCommand(pageSetMarkdownCmd) pagePropertyCmd.Flags().String("name", "", "Look up the property by its display name instead of id") pagePropertyCmd.Flags().Int("page-size", 100, "Items per underlying API call (1-100)") + + pageMarkdownCmd.Flags().String("out", "", "Write markdown to file instead of stdout") + + pageSetMarkdownCmd.Flags().String("file", "", "Read markdown from file (use '-' for stdin)") + pageSetMarkdownCmd.Flags().String("text", "", "Inline markdown string") + pageSetMarkdownCmd.Flags().Bool("replace", false, "Replace the entire page content (default if no mode flag set)") + pageSetMarkdownCmd.Flags().Bool("append", false, "Append to the end of the page") + pageSetMarkdownCmd.Flags().String("after", "", "Insert after ellipsis anchor (format: 'start...end')") + pageSetMarkdownCmd.Flags().String("range", "", "Replace range bounded by ellipsis anchor") + pageSetMarkdownCmd.Flags().Bool("allow-deleting-content", false, "Allow operation to delete child pages/databases") } // openBrowser opens a URL in the default browser. diff --git a/cmd/page_markdown.go b/cmd/page_markdown.go new file mode 100644 index 0000000..46a3626 --- /dev/null +++ b/cmd/page_markdown.go @@ -0,0 +1,275 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/4ier/notion-cli/internal/client" + "github.com/4ier/notion-cli/internal/render" + "github.com/4ier/notion-cli/internal/util" + "github.com/spf13/cobra" +) + +// pageMarkdownCmd wraps GET /v1/pages/:id/markdown — Notion's server-side +// markdown rendering of a page. Strictly better than 'block list --md' for +// most page targets because the server handles toggles, columns, synced +// blocks, callouts, and databases-as-pages uniformly. +// +// For non-page targets (a single block) 'block list --md' is still the right +// tool — this command only accepts a page id / url. +var pageMarkdownCmd = &cobra.Command{ + Use: "markdown ", + Short: "Render a page as markdown (server-side, complete)", + Long: `Render a full Notion page as markdown, returning the server-rendered +text. This is the preferred way to dump a page to markdown: the server +handles nested layouts (toggles, columns, synced blocks) correctly and +is always consistent with how the Notion UI presents the page. + +Compared to 'block list --md': + - Works on the entire page tree including nested databases. + - Cannot address a single sub-block (use 'block list' for that). + - Reports whether the content was 'truncated' and which blocks (if any) + the server could not render, via the 'unknown_block_ids' field in + --format json. + +Examples: + notion page markdown + notion page markdown > page.md + notion page markdown --format json # full response incl. truncated flag + notion page markdown --out page.md # write directly to file`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + token, err := getToken() + if err != nil { + return err + } + + pageID := util.ResolveID(args[0]) + outPath, _ := cmd.Flags().GetString("out") + + c := client.New(token) + c.SetDebug(debugMode) + + data, err := c.Get(fmt.Sprintf("/v1/pages/%s/markdown", pageID)) + if err != nil { + return fmt.Errorf("get page markdown: %w", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + if outputFormat == "json" { + return render.JSON(result) + } + + markdown, _ := result["markdown"].(string) + truncated, _ := result["truncated"].(bool) + if unknowns, ok := result["unknown_block_ids"].([]interface{}); ok && len(unknowns) > 0 { + fmt.Fprintf(os.Stderr, "note: %d block(s) could not be rendered as markdown (see --format json for ids)\n", len(unknowns)) + } + if truncated { + fmt.Fprintln(os.Stderr, "note: response was truncated server-side — very long pages may be incomplete") + } + + if outPath != "" { + if err := os.WriteFile(outPath, []byte(markdown), 0o644); err != nil { + return fmt.Errorf("write %s: %w", outPath, err) + } + fmt.Fprintf(os.Stderr, "✓ wrote %d bytes to %s\n", len(markdown), outPath) + return nil + } + fmt.Print(markdown) + if !strings.HasSuffix(markdown, "\n") { + fmt.Println() + } + return nil + }, +} + +// pageSetMarkdownCmd wraps PATCH /v1/pages/:id/markdown with the four +// mutation modes the Notion API supports: +// +// --replace replace_content: overwrite whole page (default) +// --append insert_content: append at the end of the page +// --after insert_content: insert after a text anchor +// --range replace_content_range: replace a range +// +// Ellipsis anchors look like "start text...end text" per the Notion API +// convention. +var pageSetMarkdownCmd = &cobra.Command{ + Use: "set-markdown ", + Short: "Replace / append / edit page content using markdown", + Long: `Update a page's content by sending markdown to Notion's server-side +renderer. One call replaces or inserts as many blocks as the markdown +parses to — no need to batch, and no 100-children limit applies. + +Modes (pick one, default is --replace): + + --replace Overwrite the entire page content. + --append Insert content at the end of the page. + --after Insert content after an ellipsis anchor + (format: "start text...end text"). + --range Replace a range bounded by an ellipsis anchor. + +Source (pick one): + + --file Read markdown from a file. Use '-' for stdin. + --text Inline markdown string. + +Examples: + notion page set-markdown --file new.md + cat new.md | notion page set-markdown --file - + notion page set-markdown --append --text "\n\n> Update: done." + notion page set-markdown --after "Status...done" --text "More detail below." + notion page set-markdown --replace --file new.md --allow-deleting-content`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + token, err := getToken() + if err != nil { + return err + } + + pageID := util.ResolveID(args[0]) + filePath, _ := cmd.Flags().GetString("file") + text, _ := cmd.Flags().GetString("text") + replace, _ := cmd.Flags().GetBool("replace") + appendMode, _ := cmd.Flags().GetBool("append") + after, _ := cmd.Flags().GetString("after") + rangeAnchor, _ := cmd.Flags().GetString("range") + allowDelete, _ := cmd.Flags().GetBool("allow-deleting-content") + + content, err := readMarkdownSource(filePath, text) + if err != nil { + return err + } + + body, err := buildSetMarkdownBody(content, replace, appendMode, after, rangeAnchor, allowDelete) + if err != nil { + return err + } + + c := client.New(token) + c.SetDebug(debugMode) + + data, err := c.Patch(fmt.Sprintf("/v1/pages/%s/markdown", pageID), body) + if err != nil { + return fmt.Errorf("set page markdown: %w", err) + } + + if outputFormat == "json" { + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("parse response: %w", err) + } + return render.JSON(result) + } + + mode := "replaced" + switch { + case appendMode: + mode = "appended to" + case after != "": + mode = "inserted after anchor in" + case rangeAnchor != "": + mode = "replaced range in" + } + fmt.Printf("✓ %s page\n", mode) + return nil + }, +} + +// readMarkdownSource picks the markdown source from --file / --text / stdin. +// Exactly one must be set; --file "-" also reads from stdin. +func readMarkdownSource(filePath, text string) (string, error) { + if filePath == "" && text == "" { + return "", fmt.Errorf("one of --file or --text is required") + } + if filePath != "" && text != "" { + return "", fmt.Errorf("--file and --text are mutually exclusive") + } + if text != "" { + return text, nil + } + if filePath == "-" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("read stdin: %w", err) + } + return string(data), nil + } + data, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("read %s: %w", filePath, err) + } + return string(data), nil +} + +// buildSetMarkdownBody assembles the PATCH body for the four mutation modes +// Notion's page-markdown endpoint accepts. At most one mode flag must be +// truthy; if none are set we default to replace_content. +func buildSetMarkdownBody(content string, replace, appendMode bool, after, rangeAnchor string, allowDelete bool) (map[string]interface{}, error) { + // Count how many mode flags are set so we can reject ambiguous combinations. + modes := 0 + if replace { + modes++ + } + if appendMode { + modes++ + } + if after != "" { + modes++ + } + if rangeAnchor != "" { + modes++ + } + if modes > 1 { + return nil, fmt.Errorf("pick at most one of --replace, --append, --after, --range") + } + + switch { + case appendMode: + return map[string]interface{}{ + "type": "insert_content", + "insert_content": map[string]interface{}{ + "content": content, + }, + }, nil + case after != "": + return map[string]interface{}{ + "type": "insert_content", + "insert_content": map[string]interface{}{ + "content": content, + "after": after, + }, + }, nil + case rangeAnchor != "": + rr := map[string]interface{}{ + "content": content, + "content_range": rangeAnchor, + } + if allowDelete { + rr["allow_deleting_content"] = true + } + return map[string]interface{}{ + "type": "replace_content_range", + "replace_content_range": rr, + }, nil + default: + // Default is replace_content. + rc := map[string]interface{}{ + "new_str": content, + } + if allowDelete { + rc["allow_deleting_content"] = true + } + return map[string]interface{}{ + "type": "replace_content", + "replace_content": rc, + }, nil + } +} diff --git a/cmd/page_markdown_test.go b/cmd/page_markdown_test.go new file mode 100644 index 0000000..ee9718b --- /dev/null +++ b/cmd/page_markdown_test.go @@ -0,0 +1,149 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildSetMarkdownBody_DefaultReplace(t *testing.T) { + body, err := buildSetMarkdownBody("hello", false, false, "", "", false) + if err != nil { + t.Fatal(err) + } + if body["type"] != "replace_content" { + t.Errorf("type = %v, want replace_content (default)", body["type"]) + } + rc := body["replace_content"].(map[string]interface{}) + if rc["new_str"] != "hello" { + t.Errorf("new_str = %v", rc["new_str"]) + } + if _, has := rc["allow_deleting_content"]; has { + t.Error("allow_deleting_content should be omitted when not set") + } +} + +func TestBuildSetMarkdownBody_ReplaceWithAllowDelete(t *testing.T) { + body, err := buildSetMarkdownBody("x", true, false, "", "", true) + if err != nil { + t.Fatal(err) + } + rc := body["replace_content"].(map[string]interface{}) + if rc["allow_deleting_content"] != true { + t.Error("allow_deleting_content should be true") + } +} + +func TestBuildSetMarkdownBody_Append(t *testing.T) { + body, err := buildSetMarkdownBody("x", false, true, "", "", false) + if err != nil { + t.Fatal(err) + } + if body["type"] != "insert_content" { + t.Errorf("type = %v", body["type"]) + } + ins := body["insert_content"].(map[string]interface{}) + if ins["content"] != "x" { + t.Errorf("content = %v", ins["content"]) + } + if _, has := ins["after"]; has { + t.Error("after should be omitted for plain append") + } +} + +func TestBuildSetMarkdownBody_After(t *testing.T) { + body, err := buildSetMarkdownBody("x", false, false, "Start...End", "", false) + if err != nil { + t.Fatal(err) + } + if body["type"] != "insert_content" { + t.Errorf("type = %v", body["type"]) + } + ins := body["insert_content"].(map[string]interface{}) + if ins["after"] != "Start...End" { + t.Errorf("after = %v", ins["after"]) + } +} + +func TestBuildSetMarkdownBody_Range(t *testing.T) { + body, err := buildSetMarkdownBody("x", false, false, "", "old...gone", true) + if err != nil { + t.Fatal(err) + } + if body["type"] != "replace_content_range" { + t.Errorf("type = %v", body["type"]) + } + rr := body["replace_content_range"].(map[string]interface{}) + if rr["content_range"] != "old...gone" { + t.Error("content_range missing") + } + if rr["allow_deleting_content"] != true { + t.Error("allow_deleting_content should be true") + } +} + +func TestBuildSetMarkdownBody_MultipleModesRejected(t *testing.T) { + _, err := buildSetMarkdownBody("x", true, true, "", "", false) + if err == nil || !strings.Contains(err.Error(), "at most one") { + t.Errorf("should reject multiple modes, got %v", err) + } +} + +func TestReadMarkdownSource_MissingBoth(t *testing.T) { + _, err := readMarkdownSource("", "") + if err == nil || !strings.Contains(err.Error(), "required") { + t.Errorf("expected required error, got %v", err) + } +} + +func TestReadMarkdownSource_BothSetConflict(t *testing.T) { + _, err := readMarkdownSource("path", "text") + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected conflict error, got %v", err) + } +} + +func TestReadMarkdownSource_Text(t *testing.T) { + got, err := readMarkdownSource("", "inline md") + if err != nil || got != "inline md" { + t.Errorf("got %q err %v", got, err) + } +} + +func TestReadMarkdownSource_File(t *testing.T) { + tmp := t.TempDir() + p := filepath.Join(tmp, "src.md") + if err := os.WriteFile(p, []byte("from-file"), 0o600); err != nil { + t.Fatal(err) + } + got, err := readMarkdownSource(p, "") + if err != nil || got != "from-file" { + t.Errorf("got %q err %v", got, err) + } +} + +func TestReadMarkdownSource_FileNotFound(t *testing.T) { + _, err := readMarkdownSource("/nonexistent/file.md", "") + if err == nil || !strings.Contains(err.Error(), "read") { + t.Errorf("expected read error, got %v", err) + } +} + +func TestReadMarkdownSource_Stdin(t *testing.T) { + // Redirect stdin to a pipe + r, w, _ := os.Pipe() + w.Write([]byte("from-stdin")) + w.Close() + old := os.Stdin + os.Stdin = r + defer func() { os.Stdin = old }() + + got, err := readMarkdownSource("-", "") + if err != nil { + t.Fatal(err) + } + if got != "from-stdin" { + t.Errorf("got %q", got) + } +}