From 0b85a4ae54ad0d4306504a7233492da3a23abd2a Mon Sep 17 00:00:00 2001 From: 4ier Date: Thu, 30 Apr 2026 14:08:45 +0800 Subject: [PATCH] feat(page): add 'page property' that auto-paginates relation / rollup / rich_text Closes #38. Fixes silent truncation of property values with >25 items. Problem: 'notion page view' and 'notion page props' both call GET /v1/pages/:id, which returns every property in a single response but truncates values at 25 items. A relation pointing to 40 pages silently loses 15. Fix: New 'page property' subcommand wraps GET /v1/pages/:id/properties/:id and walks next_cursor until has_more is false, merging results[] into a single response. Interface: notion page property notion page property --name "References" # resolve id by name notion page property --page-size 50 Implementation: - fetchPagePropertyAllPages handles both paginated ('list' object) and non-paginated ('property_item' object) response shapes. - findPropertyIDByName scans page.properties map for --name lookups; error message includes the list of available names. - renderPageProperty prints a human-friendly summary; summarizePropertyItem covers relation / rich_text / title / people / number types with a JSON fallback for anything else. Tests: - TestFetchPagePropertyAllPages_FollowsCursors spins up a 3-page httptest server and asserts all 4 items are merged with has_more=false. - Passthrough test for 'property_item' (non-paginated) shape. - findPropertyIDByName: found / missing / malformed page. Smoke-tested against a real database: --name resolves correctly, positional property-id works, 'title' non-paginated path returns the expected single item, and both conflict / not-found errors fire cleanly. --- cmd/page.go | 4 + cmd/page_property.go | 237 ++++++++++++++++++++++++++++++++++++++ cmd/page_property_test.go | 172 +++++++++++++++++++++++++++ 3 files changed, 413 insertions(+) create mode 100644 cmd/page_property.go create mode 100644 cmd/page_property_test.go diff --git a/cmd/page.go b/cmd/page.go index 18033f5..aba7e6d 100644 --- a/cmd/page.go +++ b/cmd/page.go @@ -962,9 +962,13 @@ func init() { pageCmd.AddCommand(pageOpenCmd) pageCmd.AddCommand(pageSetCmd) pageCmd.AddCommand(pagePropsCmd) + pageCmd.AddCommand(pagePropertyCmd) pageCmd.AddCommand(pageLinkCmd) pageCmd.AddCommand(pageUnlinkCmd) pageCmd.AddCommand(pageEditCmd) + + 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)") } // openBrowser opens a URL in the default browser. diff --git a/cmd/page_property.go b/cmd/page_property.go new file mode 100644 index 0000000..d462831 --- /dev/null +++ b/cmd/page_property.go @@ -0,0 +1,237 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/url" + + "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" +) + +// pagePropertyCmd wraps GET /v1/pages/:id/properties/:property_id and +// fully paginates the results, which the page-level endpoint silently +// truncates at 25 items. This is the correctness fix for #38. +// +// This is distinct from `page props` which prints a one-line summary of +// every property (and inherits the same 25-item truncation from +// GET /v1/pages/:id). `page property` dives into a single property and +// returns the full value. +var pagePropertyCmd = &cobra.Command{ + Use: "property [property-id]", + Short: "Retrieve a single property value (auto-paginates relation / rollup / rich_text)", + Long: `Retrieve a single page property, following pagination cursors until +every item has been fetched. + +This is the correct way to read properties that may have >25 items: + - relation lists + - rollup arrays + - long rich_text / title values + +The property id is usually visible in 'notion db view ' +(schema) or 'notion page props ' (per-property JSON). Prefer +--name when you only know the human-readable property name. + +Examples: + notion page property + notion page property --name "References" + notion page property --format json + notion page property --page-size 50`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + token, err := getToken() + if err != nil { + return err + } + + pageID := util.ResolveID(args[0]) + name, _ := cmd.Flags().GetString("name") + pageSize, _ := cmd.Flags().GetInt("page-size") + if pageSize < 1 || pageSize > 100 { + return fmt.Errorf("--page-size must be between 1 and 100") + } + + var propID string + if len(args) == 2 { + propID = args[1] + } + if propID == "" && name == "" { + return fmt.Errorf("provide a property-id positional arg or use --name ") + } + if propID != "" && name != "" { + return fmt.Errorf("pass either a property-id positional arg OR --name, not both") + } + + c := client.New(token) + c.SetDebug(debugMode) + + // Resolve --name to an id by looking at the page's property map. + if name != "" { + page, err := c.GetPage(pageID) + if err != nil { + return fmt.Errorf("get page: %w", err) + } + propID, err = findPropertyIDByName(page, name) + if err != nil { + return err + } + } + + result, err := fetchPagePropertyAllPages(c, pageID, propID, pageSize) + if err != nil { + return err + } + + if outputFormat == "json" { + return render.JSON(result) + } + + renderPageProperty(result) + return nil + }, +} + +// fetchPagePropertyAllPages walks every page of a page-property response +// and returns a single merged object. For non-paginated property types +// (title / number / select / ...) a single request is enough. +func fetchPagePropertyAllPages(c *client.Client, pageID, propID string, pageSize int) (map[string]interface{}, error) { + basePath := fmt.Sprintf("/v1/pages/%s/properties/%s", pageID, propID) + var merged map[string]interface{} + var allResults []interface{} + cursor := "" + + for { + path := fmt.Sprintf("%s?page_size=%d", basePath, pageSize) + if cursor != "" { + path += "&start_cursor=" + url.QueryEscape(cursor) + } + data, err := c.Get(path) + if err != nil { + return nil, fmt.Errorf("get property: %w", err) + } + var page map[string]interface{} + if err := json.Unmarshal(data, &page); err != nil { + return nil, fmt.Errorf("parse property response: %w", err) + } + + // Non-paginated property types have `object: "property_item"` at the + // top level — no results[] / has_more. Return as-is. + if page["object"] == "property_item" { + return page, nil + } + + // Paginated: object is "list" with results + has_more + next_cursor. + if merged == nil { + merged = page + } + results, _ := page["results"].([]interface{}) + allResults = append(allResults, results...) + + hasMore, _ := page["has_more"].(bool) + if !hasMore { + break + } + nextCursor, _ := page["next_cursor"].(string) + if nextCursor == "" { + break + } + cursor = nextCursor + } + + if merged != nil { + merged["results"] = allResults + merged["has_more"] = false + merged["next_cursor"] = nil + } + return merged, nil +} + +// findPropertyIDByName scans page.properties[] for a key matching name and +// returns its id. +func findPropertyIDByName(page map[string]interface{}, name string) (string, error) { + props, ok := page["properties"].(map[string]interface{}) + if !ok { + return "", fmt.Errorf("page has no properties map (is it a database row?)") + } + if prop, ok := props[name].(map[string]interface{}); ok { + if id, _ := prop["id"].(string); id != "" { + return id, nil + } + } + // Build a sorted list for the error message. + var names []string + for k := range props { + names = append(names, k) + } + return "", fmt.Errorf("no property named %q on this page; available: %v", name, names) +} + +// renderPageProperty prints a friendly summary of a property fetch result. +// Both shapes (single property_item, paginated list) land here. +func renderPageProperty(result map[string]interface{}) { + obj, _ := result["object"].(string) + propType, _ := result["type"].(string) + if propType == "" { + // Paginated list: grab type from first result. + if results, ok := result["results"].([]interface{}); ok && len(results) > 0 { + if first, ok := results[0].(map[string]interface{}); ok { + propType, _ = first["type"].(string) + } + } + } + + render.Title("📋", fmt.Sprintf("Property (%s)", propType)) + + if obj == "property_item" { + // Single-value: print the extracted value directly. + value := extractPropertyValue(result) + render.Field("Value", value) + return + } + + // Paginated list. + results, _ := result["results"].([]interface{}) + render.Field("Total items", fmt.Sprintf("%d", len(results))) + for i, r := range results { + item, ok := r.(map[string]interface{}) + if !ok { + continue + } + summary := summarizePropertyItem(item) + render.Field(fmt.Sprintf(" [%d]", i), summary) + } +} + +// summarizePropertyItem produces a single-line description of one element +// of a paginated property list (a relation ref, rollup item, rich_text chunk). +func summarizePropertyItem(item map[string]interface{}) string { + t, _ := item["type"].(string) + switch t { + case "relation": + rel, _ := item["relation"].(map[string]interface{}) + if id, _ := rel["id"].(string); id != "" { + return id + } + case "rich_text", "title": + if inner, ok := item[t].(map[string]interface{}); ok { + if pt, _ := inner["plain_text"].(string); pt != "" { + return pt + } + } + case "people": + if inner, ok := item["people"].(map[string]interface{}); ok { + if id, _ := inner["id"].(string); id != "" { + return id + } + } + case "number": + if n, ok := item["number"].(float64); ok { + return fmt.Sprintf("%v", n) + } + } + // Fallback: compact JSON. + data, _ := json.Marshal(item) + return string(data) +} diff --git a/cmd/page_property_test.go b/cmd/page_property_test.go new file mode 100644 index 0000000..350a87d --- /dev/null +++ b/cmd/page_property_test.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/4ier/notion-cli/internal/client" +) + +func TestFindPropertyIDByName_Found(t *testing.T) { + page := map[string]interface{}{ + "properties": map[string]interface{}{ + "Name": map[string]interface{}{"id": "title", "type": "title"}, + "References": map[string]interface{}{"id": "rE%3F%7B", "type": "relation"}, + }, + } + id, err := findPropertyIDByName(page, "References") + if err != nil { + t.Fatal(err) + } + if id != "rE%3F%7B" { + t.Errorf("id = %q", id) + } +} + +func TestFindPropertyIDByName_NotFound(t *testing.T) { + page := map[string]interface{}{ + "properties": map[string]interface{}{ + "Name": map[string]interface{}{"id": "title"}, + }, + } + _, err := findPropertyIDByName(page, "Missing") + if err == nil { + t.Fatal("expected error for missing name") + } + if !strings.Contains(err.Error(), "Missing") { + t.Errorf("error should mention missing name: %v", err) + } +} + +func TestFindPropertyIDByName_NoPropertiesMap(t *testing.T) { + page := map[string]interface{}{} + _, err := findPropertyIDByName(page, "x") + if err == nil || !strings.Contains(err.Error(), "no properties map") { + t.Errorf("expected no-properties error, got %v", err) + } +} + +func TestSummarizePropertyItem_Types(t *testing.T) { + cases := []struct { + name string + item map[string]interface{} + want string + }{ + { + "relation", + map[string]interface{}{"type": "relation", "relation": map[string]interface{}{"id": "abc-123"}}, + "abc-123", + }, + { + "rich_text", + map[string]interface{}{"type": "rich_text", "rich_text": map[string]interface{}{"plain_text": "hello"}}, + "hello", + }, + { + "title", + map[string]interface{}{"type": "title", "title": map[string]interface{}{"plain_text": "Page Title"}}, + "Page Title", + }, + { + "number", + map[string]interface{}{"type": "number", "number": float64(42)}, + "42", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := summarizePropertyItem(tc.item) + if got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// Integration: spin up a fake Notion that returns 3 pages of relation +// results and assert fetchPagePropertyAllPages walks all of them. +func TestFetchPagePropertyAllPages_FollowsCursors(t *testing.T) { + requests := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + cursor := r.URL.Query().Get("start_cursor") + var body map[string]interface{} + switch cursor { + case "": + body = map[string]interface{}{ + "object": "list", + "type": "property_item", + "results": []interface{}{ + map[string]interface{}{"type": "relation", "relation": map[string]interface{}{"id": "a"}}, + map[string]interface{}{"type": "relation", "relation": map[string]interface{}{"id": "b"}}, + }, + "has_more": true, + "next_cursor": "cursor-2", + } + case "cursor-2": + body = map[string]interface{}{ + "object": "list", + "results": []interface{}{ + map[string]interface{}{"type": "relation", "relation": map[string]interface{}{"id": "c"}}, + }, + "has_more": true, + "next_cursor": "cursor-3", + } + case "cursor-3": + body = map[string]interface{}{ + "object": "list", + "results": []interface{}{ + map[string]interface{}{"type": "relation", "relation": map[string]interface{}{"id": "d"}}, + }, + "has_more": false, + "next_cursor": nil, + } + } + json.NewEncoder(w).Encode(body) + })) + defer srv.Close() + + c := client.NewWithBaseURL("fake-token", srv.URL) + result, err := fetchPagePropertyAllPages(c, "page-x", "prop-y", 100) + if err != nil { + t.Fatal(err) + } + if requests != 3 { + t.Errorf("expected 3 API calls, got %d", requests) + } + results, _ := result["results"].([]interface{}) + if len(results) != 4 { + t.Errorf("expected 4 merged results, got %d", len(results)) + } + if result["has_more"] != false { + t.Error("merged result should report has_more=false") + } +} + +func TestFetchPagePropertyAllPages_PropertyItemNoPagination(t *testing.T) { + // Non-paginated property (title, number, select) returns a single + // object with `object: "property_item"`. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]interface{}{ + "object": "property_item", + "type": "number", + "number": float64(42), + }) + })) + defer srv.Close() + + c := client.NewWithBaseURL("fake-token", srv.URL) + result, err := fetchPagePropertyAllPages(c, "page-x", "prop-y", 100) + if err != nil { + t.Fatal(err) + } + if result["object"] != "property_item" { + t.Errorf("expected property_item passthrough, got %v", result["object"]) + } + if result["number"].(float64) != 42 { + t.Errorf("expected number=42") + } +}