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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,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.

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
Expand Down
73 changes: 73 additions & 0 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 @@ -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"`
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Comment on lines +731 to +735
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle data_source parent IDs in metadata mapping

This switch only maps database_id, but with newer Notion API versions pages in databases commonly come back as parent.type = "data_source_id"; in that case parentFromMetadata falls through and returns an empty ParentID. The new page view --json enrichment therefore drops parent IDs for data-source-backed pages, which breaks the advertised ParentType/ParentID metadata for a large class of pages.

Useful? React with 👍 / 👎.

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 ""
}
190 changes: 190 additions & 0 deletions cmd/page_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}
56 changes: 56 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 == "" {
Expand Down
Loading