diff --git a/README.md b/README.md index 00669c0..6429d3b 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ The `` 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. +When `--json` is used and an official API token is configured (via `auth api setup` or `NOTION_API_TOKEN`), the output is enriched with metadata that MCP does not expose: `CreatedTime`, `LastEditedTime`, `CreatedBy`, `LastEditedBy`, `ParentType`, `ParentID`, `Archived`, and `Icon`. Without a token, these fields remain empty and the command behaves as before. + ### Search ```bash diff --git a/cmd/page.go b/cmd/page.go index 34fc7b8..8297833 100644 --- a/cmd/page.go +++ b/cmd/page.go @@ -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" @@ -26,6 +27,7 @@ type PageCmd struct { var loadPageViewCommentsFn = loadPageViewComments var printViewedPageFn = output.PrintViewedPage var printWarningFn = output.PrintWarning +var loadPageMetadataFn = loadPageMetadata type PageListCmd struct { Query string `help:"Filter pages by name" short:"q"` @@ -134,6 +136,9 @@ func renderFetchedPageView(bgCtx context.Context, ctx *Context, client *mcp.Clie } if ctx.JSON { + if meta := loadPageMetadataFn(bgCtx, ctx, fetchID); meta != nil { + applyPageMetadata(&pageOutput, meta) + } return printViewedPageFn(pageOutput, comments, true) } @@ -686,3 +691,71 @@ func runPageSync(ctx *Context, file, title, parent, parentDB, icon string) error } return nil } + +// loadPageMetadata fetches page metadata (timestamps, authors, parent, icon) +// via the official REST API when an API token is configured. The MCP +// notion-fetch tool does not expose these fields, so this is a best-effort +// enrichment for JSON output. Any failure (missing token, API error) is +// silently ignored and the page view falls back to MCP-only data. +func loadPageMetadata(ctx context.Context, cmdCtx *Context, pageID string) *api.PageMetadata { + apiClient, err := cli.RequireOfficialAPIClient(officialAPIOverrides(cmdCtx)) + if err != nil { + return nil + } + meta, err := apiClient.GetPageMetadata(ctx, pageID) + if err != nil { + return nil + } + return meta +} + +// applyPageMetadata merges metadata from the official API into an output.Page +// built from the MCP fetch result. Fields already populated by MCP (ID, Title, +// URL, Content) are preserved; only the metadata-only fields are enriched. +func applyPageMetadata(p *output.Page, meta *api.PageMetadata) { + if meta == nil { + return + } + p.CreatedTime = meta.CreatedTime + p.LastEditedTime = meta.LastEditedTime + p.CreatedBy = meta.CreatedBy.ID + p.LastEditedBy = meta.LastEditedBy.ID + p.Archived = meta.Archived || meta.InTrash + p.ParentType, p.ParentID = parentFromMetadata(meta.Parent) + if icon := iconFromMetadata(meta.Icon); icon != "" { + p.Icon = icon + } +} + +func parentFromMetadata(parent api.PageParent) (parentType, parentID string) { + switch parent.Type { + case "page_id": + return "page", parent.PageID + case "database_id": + return "database", parent.DatabaseID + case "block_id": + return "block", parent.BlockID + case "workspace": + return "workspace", "" + } + return parent.Type, "" +} + +func iconFromMetadata(icon *api.PageIcon) string { + if icon == nil { + return "" + } + switch icon.Type { + case "emoji": + return icon.Emoji + case "external": + if icon.External != nil { + return icon.External.URL + } + case "file": + if icon.File != nil { + return icon.File.URL + } + } + return "" +} diff --git a/cmd/page_view_test.go b/cmd/page_view_test.go index fe96e54..c4c0fda 100644 --- a/cmd/page_view_test.go +++ b/cmd/page_view_test.go @@ -4,7 +4,9 @@ import ( "context" "errors" "testing" + "time" + "github.com/lox/notion-cli/internal/api" "github.com/lox/notion-cli/internal/mcp" "github.com/lox/notion-cli/internal/output" ) @@ -144,3 +146,191 @@ func TestRenderFetchedPageViewJSONSkipsWarningsWhenCommentsFail(t *testing.T) { t.Fatalf("expected JSON page output") } } + +func TestRenderFetchedPageViewJSONEnrichesWithAPIMetadata(t *testing.T) { + originalLoad := loadPageViewCommentsFn + originalPrintViewedPage := printViewedPageFn + originalLoadMetadata := loadPageMetadataFn + defer func() { + loadPageViewCommentsFn = originalLoad + printViewedPageFn = originalPrintViewedPage + loadPageMetadataFn = originalLoadMetadata + }() + + loadPageViewCommentsFn = func(_ context.Context, _ *mcp.Client, _ string, _ string, _ bool, _ bool, _ bool) ([]output.Comment, error) { + return nil, nil + } + + createdAt := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + editedAt := time.Date(2025, 6, 7, 8, 9, 10, 0, time.UTC) + loadPageMetadataFn = func(_ context.Context, _ *Context, pageID string) *api.PageMetadata { + if pageID != "page-123" { + t.Fatalf("unexpected page id %q", pageID) + } + return &api.PageMetadata{ + CreatedTime: createdAt, + LastEditedTime: editedAt, + CreatedBy: api.PartialUser{ID: "user-creator"}, + LastEditedBy: api.PartialUser{ID: "user-editor"}, + Archived: true, + Icon: &api.PageIcon{Type: "emoji", Emoji: "📝"}, + Parent: api.PageParent{Type: "database_id", DatabaseID: "db-parent"}, + } + } + + var captured output.Page + printViewedPageFn = func(page output.Page, _ []output.Comment, asJSON bool) error { + if !asJSON { + t.Fatalf("expected JSON rendering") + } + captured = page + return nil + } + + err := renderFetchedPageView(context.Background(), &Context{JSON: true}, nil, "page-123", &mcp.FetchResult{Content: "page body"}, false, true) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !captured.CreatedTime.Equal(createdAt) { + t.Fatalf("CreatedTime = %v, want %v", captured.CreatedTime, createdAt) + } + if !captured.LastEditedTime.Equal(editedAt) { + t.Fatalf("LastEditedTime = %v, want %v", captured.LastEditedTime, editedAt) + } + if captured.CreatedBy != "user-creator" { + t.Fatalf("CreatedBy = %q, want user-creator", captured.CreatedBy) + } + if captured.LastEditedBy != "user-editor" { + t.Fatalf("LastEditedBy = %q, want user-editor", captured.LastEditedBy) + } + if !captured.Archived { + t.Fatalf("Archived = false, want true") + } + if captured.Icon != "📝" { + t.Fatalf("Icon = %q, want 📝", captured.Icon) + } + if captured.ParentType != "database" || captured.ParentID != "db-parent" { + t.Fatalf("parent = (%q,%q), want (database,db-parent)", captured.ParentType, captured.ParentID) + } + if captured.Content != "page body" { + t.Fatalf("Content not preserved from MCP, got %q", captured.Content) + } +} + +func TestRenderFetchedPageViewJSONFallsBackWhenNoAPIToken(t *testing.T) { + originalLoad := loadPageViewCommentsFn + originalPrintViewedPage := printViewedPageFn + originalLoadMetadata := loadPageMetadataFn + defer func() { + loadPageViewCommentsFn = originalLoad + printViewedPageFn = originalPrintViewedPage + loadPageMetadataFn = originalLoadMetadata + }() + + loadPageViewCommentsFn = func(_ context.Context, _ *mcp.Client, _ string, _ string, _ bool, _ bool, _ bool) ([]output.Comment, error) { + return nil, nil + } + + loadPageMetadataFn = func(_ context.Context, _ *Context, _ string) *api.PageMetadata { + return nil + } + + var captured output.Page + printViewedPageFn = func(page output.Page, _ []output.Comment, _ bool) error { + captured = page + return nil + } + + err := renderFetchedPageView(context.Background(), &Context{JSON: true}, nil, "page-123", &mcp.FetchResult{Content: "page body", Title: "Hello", URL: "https://notion.so/x"}, false, true) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !captured.CreatedTime.IsZero() || !captured.LastEditedTime.IsZero() { + t.Fatalf("expected metadata to be empty when API unavailable, got created=%v edited=%v", captured.CreatedTime, captured.LastEditedTime) + } + if captured.Title != "Hello" || captured.URL != "https://notion.so/x" || captured.Content != "page body" { + t.Fatalf("MCP-sourced fields not preserved: %+v", captured) + } +} + +func TestRenderFetchedPageViewTextModeSkipsMetadataFetch(t *testing.T) { + originalLoad := loadPageViewCommentsFn + originalPrintViewedPage := printViewedPageFn + originalLoadMetadata := loadPageMetadataFn + defer func() { + loadPageViewCommentsFn = originalLoad + printViewedPageFn = originalPrintViewedPage + loadPageMetadataFn = originalLoadMetadata + }() + + loadPageViewCommentsFn = func(_ context.Context, _ *mcp.Client, _ string, _ string, _ bool, _ bool, _ bool) ([]output.Comment, error) { + return nil, nil + } + + metadataCalls := 0 + loadPageMetadataFn = func(_ context.Context, _ *Context, _ string) *api.PageMetadata { + metadataCalls++ + return nil + } + + printViewedPageFn = func(_ output.Page, _ []output.Comment, _ bool) error { + return nil + } + + err := renderFetchedPageView(context.Background(), &Context{JSON: false}, nil, "page-123", &mcp.FetchResult{Content: "page body"}, false, true) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if metadataCalls != 0 { + t.Fatalf("metadata should not be fetched in text mode, got %d calls", metadataCalls) + } +} + +func TestApplyPageMetadataHandlesParentVariants(t *testing.T) { + tests := []struct { + name string + parent api.PageParent + wantType string + wantID string + }{ + {"page parent", api.PageParent{Type: "page_id", PageID: "p1"}, "page", "p1"}, + {"database parent", api.PageParent{Type: "database_id", DatabaseID: "d1"}, "database", "d1"}, + {"block parent", api.PageParent{Type: "block_id", BlockID: "b1"}, "block", "b1"}, + {"workspace parent", api.PageParent{Type: "workspace", Workspace: true}, "workspace", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotType, gotID := parentFromMetadata(tc.parent) + if gotType != tc.wantType || gotID != tc.wantID { + t.Fatalf("parent = (%q,%q), want (%q,%q)", gotType, gotID, tc.wantType, tc.wantID) + } + }) + } +} + +func TestIconFromMetadataSupportsAllIconTypes(t *testing.T) { + emoji := &api.PageIcon{Type: "emoji", Emoji: "🚀"} + if got := iconFromMetadata(emoji); got != "🚀" { + t.Fatalf("emoji icon = %q, want 🚀", got) + } + + ext := &api.PageIcon{Type: "external"} + ext.External = &struct { + URL string `json:"url"` + }{URL: "https://example.com/icon.png"} + if got := iconFromMetadata(ext); got != "https://example.com/icon.png" { + t.Fatalf("external icon = %q", got) + } + + file := &api.PageIcon{Type: "file"} + file.File = &struct { + URL string `json:"url"` + }{URL: "https://notion.so/file.png"} + if got := iconFromMetadata(file); got != "https://notion.so/file.png" { + t.Fatalf("file icon = %q", got) + } + + if got := iconFromMetadata(nil); got != "" { + t.Fatalf("nil icon = %q, want empty", got) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index d05c705..25574f6 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -59,6 +59,45 @@ type PageMarkdown struct { UnknownBlockIDs []string `json:"unknown_block_ids,omitempty"` } +type PageMetadata struct { + Object string `json:"object"` + ID string `json:"id"` + CreatedTime time.Time `json:"created_time"` + LastEditedTime time.Time `json:"last_edited_time"` + CreatedBy PartialUser `json:"created_by"` + LastEditedBy PartialUser `json:"last_edited_by"` + Archived bool `json:"archived"` + InTrash bool `json:"in_trash"` + Icon *PageIcon `json:"icon,omitempty"` + Parent PageParent `json:"parent"` + URL string `json:"url"` + PublicURL string `json:"public_url,omitempty"` +} + +type PartialUser struct { + Object string `json:"object"` + ID string `json:"id"` +} + +type PageIcon struct { + Type string `json:"type"` + Emoji string `json:"emoji,omitempty"` + External *struct { + URL string `json:"url"` + } `json:"external,omitempty"` + File *struct { + URL string `json:"url"` + } `json:"file,omitempty"` +} + +type PageParent struct { + Type string `json:"type"` + PageID string `json:"page_id,omitempty"` + DatabaseID string `json:"database_id,omitempty"` + BlockID string `json:"block_id,omitempty"` + Workspace bool `json:"workspace,omitempty"` +} + type Block struct { ID string `json:"id"` Object string `json:"object"` @@ -124,6 +163,23 @@ func (c *Client) GetPageMarkdown(ctx context.Context, pageID string) (*PageMarkd return &out, nil } +// GetPageMetadata retrieves page metadata (timestamps, authors, parent, icon, +// archived state) via the Notion REST API. The MCP tool notion-fetch does not +// expose these fields, so this method is used to enrich page view output when +// an official API token is configured. +func (c *Client) GetPageMetadata(ctx context.Context, pageID string) (*PageMetadata, error) { + pageID = strings.TrimSpace(pageID) + if pageID == "" { + return nil, fmt.Errorf("page ID is required") + } + + var out PageMetadata + if err := c.doJSON(ctx, http.MethodGet, "/pages/"+pageID, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + func (c *Client) UploadFile(ctx context.Context, filename string, data []byte) (string, error) { filename = strings.TrimSpace(filepath.Base(filename)) if filename == "" { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 5078036..3d3fecf 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -240,3 +240,64 @@ func TestTrashPageUsesPatch(t *testing.T) { t.Fatalf("TrashPage: %v", err) } } + +func TestGetPageMetadataParsesResponse(t *testing.T) { + body := `{ + "object": "page", + "id": "page_123", + "created_time": "2025-01-02T03:04:05.000Z", + "last_edited_time": "2025-06-07T08:09:10.000Z", + "created_by": {"object": "user", "id": "user_creator"}, + "last_edited_by": {"object": "user", "id": "user_editor"}, + "archived": false, + "in_trash": false, + "icon": {"type": "emoji", "emoji": "📝"}, + "parent": {"type": "database_id", "database_id": "db_parent"}, + "url": "https://www.notion.so/page_123" + }` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/pages/page_123" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer secret-token" { + t.Fatalf("missing/invalid auth header: %q", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + client, err := NewClient(config.APIConfig{BaseURL: srv.URL + "/v1"}, "secret-token") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + meta, err := client.GetPageMetadata(context.Background(), "page_123") + if err != nil { + t.Fatalf("GetPageMetadata: %v", err) + } + if meta.ID != "page_123" { + t.Fatalf("ID = %q", meta.ID) + } + if meta.CreatedBy.ID != "user_creator" || meta.LastEditedBy.ID != "user_editor" { + t.Fatalf("authors not parsed: %+v / %+v", meta.CreatedBy, meta.LastEditedBy) + } + if meta.Parent.Type != "database_id" || meta.Parent.DatabaseID != "db_parent" { + t.Fatalf("parent not parsed: %+v", meta.Parent) + } + if meta.Icon == nil || meta.Icon.Type != "emoji" || meta.Icon.Emoji != "📝" { + t.Fatalf("icon not parsed: %+v", meta.Icon) + } +} + +func TestGetPageMetadataRequiresPageID(t *testing.T) { + client, err := NewClient(config.APIConfig{BaseURL: "https://example.invalid/v1"}, "secret-token") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if _, err := client.GetPageMetadata(context.Background(), " "); err == nil { + t.Fatal("expected error for blank page id") + } +} diff --git a/internal/output/types.go b/internal/output/types.go index ceaa5bc..9d9dc5c 100644 --- a/internal/output/types.go +++ b/internal/output/types.go @@ -8,6 +8,8 @@ type Page struct { URL string CreatedTime time.Time LastEditedTime time.Time + CreatedBy string + LastEditedBy string ParentType string ParentID string Archived bool