Skip to content

Commit 64cd18a

Browse files
committed
feat: implement chunked processing for Notion API blocks and add tests
1 parent 987c4dc commit 64cd18a

File tree

3 files changed

+143
-16
lines changed

3 files changed

+143
-16
lines changed

internal/notion/client.go

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ const (
1919
MaxRetries = 3
2020
BaseBackoff = 1 * time.Second
2121
MaxBackoff = 16 * time.Second
22+
// BlockChunkSize defines the maximum number of blocks to send in a single API call
23+
// Notion's limit is 100, but we use 50 for better reliability with large documents
24+
BlockChunkSize = 50
2225
)
2326

2427
// Client handles Notion API interactions
@@ -61,46 +64,62 @@ func (c *Client) formatPageID(pageID string) string {
6164
cleaned[0:8], cleaned[8:12], cleaned[12:16], cleaned[16:20], cleaned[20:32])
6265
}
6366

64-
// AppendBlockChildren appends blocks to a page or block
65-
func (c *Client) AppendBlockChildren(ctx context.Context, blockID string, blocks []Block) error {
66-
formattedID := c.formatPageID(blockID)
67+
// processBlocksInChunks processes blocks in chunks to respect Notion's API limits
68+
// Notion's API has a limit of 100 blocks per request. This function splits
69+
// blocks into smaller chunks and processes them sequentially with a small
70+
// delay between chunks to be gentle on the API.
71+
func (c *Client) processBlocksInChunks(ctx context.Context, blocks []Block, processFn func(ctx context.Context, chunk []Block) error) error {
72+
if len(blocks) == 0 {
73+
return nil
74+
}
6775

68-
// Split blocks into chunks of 25 for better reliability with large documents
69-
chunkSize := 25
70-
for i := 0; i < len(blocks); i += chunkSize {
71-
end := i + chunkSize
76+
for i := 0; i < len(blocks); i += BlockChunkSize {
77+
end := i + BlockChunkSize
7278
if end > len(blocks) {
7379
end = len(blocks)
7480
}
7581

7682
chunk := blocks[i:end]
77-
req := AppendBlockChildrenRequest{Children: chunk}
78-
79-
if err := c.makeRequest(ctx, "PATCH", fmt.Sprintf("/blocks/%s/children", formattedID), req, nil); err != nil {
80-
return fmt.Errorf("failed to append blocks (chunk %d-%d): %w", i+1, end, err)
83+
if err := processFn(ctx, chunk); err != nil {
84+
return fmt.Errorf("failed to process blocks (chunk %d-%d): %w", i+1, end, err)
8185
}
8286

8387
if c.verbose {
84-
fmt.Fprintf(os.Stderr, "Uploaded %d blocks (chunk %d-%d)\n", len(chunk), i+1, end)
88+
fmt.Fprintf(os.Stderr, "Processed %d blocks (chunk %d-%d)\n", len(chunk), i+1, end)
8589
}
8690

8791
// Small pause between chunks to be nice to the API
8892
if end < len(blocks) {
89-
time.Sleep(100 * time.Millisecond)
93+
time.Sleep(10 * time.Millisecond)
9094
}
9195
}
9296

9397
return nil
9498
}
9599

100+
// AppendBlockChildren appends blocks to a page or block
101+
// Blocks are automatically split into chunks to respect Notion's 100-block limit per API call.
102+
// Uses a chunk size of 50 for better reliability with large documents.
103+
func (c *Client) AppendBlockChildren(ctx context.Context, blockID string, blocks []Block) error {
104+
formattedID := c.formatPageID(blockID)
105+
106+
return c.processBlocksInChunks(ctx, blocks, func(ctx context.Context, chunk []Block) error {
107+
req := AppendBlockChildrenRequest{Children: chunk}
108+
return c.makeRequest(ctx, "PATCH", fmt.Sprintf("/blocks/%s/children", formattedID), req, nil)
109+
})
110+
}
111+
96112
// CreatePage creates a new page under a parent page
113+
// The page is created first without children, then blocks are appended in chunks
114+
// to avoid Notion's 100-block limit per API call.
97115
func (c *Client) CreatePage(ctx context.Context, parentID, title string, blocks []Block) (*PageResponse, error) {
98116
formattedParentID := c.formatPageID(parentID)
99117
titleText := []RichText{{
100118
Type: "text",
101119
Text: &Text{Content: title},
102120
}}
103121

122+
// Create the page without children first to avoid the 100-block limit
104123
req := CreatePageRequest{
105124
Parent: Parent{
106125
Type: "page_id",
@@ -109,14 +128,53 @@ func (c *Client) CreatePage(ctx context.Context, parentID, title string, blocks
109128
Properties: PageProperties{
110129
Title: TitleProperty{Title: titleText},
111130
},
112-
Children: blocks,
131+
// Don't include children in the initial creation
113132
}
114133

115134
var resp PageResponse
116135
if err := c.makeRequest(ctx, "POST", "/pages", req, &resp); err != nil {
117136
return nil, fmt.Errorf("failed to create page: %w", err)
118137
}
119138

139+
// If there are blocks to add, append them in chunks after page creation
140+
if len(blocks) > 0 {
141+
if err := c.AppendBlockChildren(ctx, resp.ID, blocks); err != nil {
142+
return nil, fmt.Errorf("failed to add content to page: %w", err)
143+
}
144+
}
145+
146+
return &resp, nil
147+
}
148+
149+
// CreatePageInDatabase creates a new page in a database
150+
// The page is created first without children, then blocks are appended in chunks
151+
// to avoid Notion's 100-block limit per API call.
152+
// Note: When creating in a database, the properties must match the database schema
153+
func (c *Client) CreatePageInDatabase(ctx context.Context, databaseID string, properties PageProperties, blocks []Block) (*PageResponse, error) {
154+
formattedDatabaseID := c.formatPageID(databaseID)
155+
156+
// Create the page without children first to avoid the 100-block limit
157+
req := CreatePageRequest{
158+
Parent: Parent{
159+
Type: "database_id",
160+
DatabaseID: formattedDatabaseID,
161+
},
162+
Properties: properties,
163+
// Don't include children in the initial creation
164+
}
165+
166+
var resp PageResponse
167+
if err := c.makeRequest(ctx, "POST", "/pages", req, &resp); err != nil {
168+
return nil, fmt.Errorf("failed to create page in database: %w", err)
169+
}
170+
171+
// If there are blocks to add, append them in chunks after page creation
172+
if len(blocks) > 0 {
173+
if err := c.AppendBlockChildren(ctx, resp.ID, blocks); err != nil {
174+
return nil, fmt.Errorf("failed to add content to page: %w", err)
175+
}
176+
}
177+
120178
return &resp, nil
121179
}
122180

internal/notion/client_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package notion
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
func TestProcessBlocksInChunks(t *testing.T) {
9+
client := &Client{verbose: false}
10+
11+
// Test with empty blocks
12+
err := client.processBlocksInChunks(context.Background(), []Block{}, func(ctx context.Context, chunk []Block) error {
13+
t.Error("Function should not be called with empty blocks")
14+
return nil
15+
})
16+
if err != nil {
17+
t.Errorf("Expected nil error for empty blocks, got %v", err)
18+
}
19+
20+
// Test with blocks smaller than chunk size
21+
smallBlocks := make([]Block, 25)
22+
for i := range smallBlocks {
23+
smallBlocks[i] = Block{Type: "paragraph"}
24+
}
25+
26+
callCount := 0
27+
err = client.processBlocksInChunks(context.Background(), smallBlocks, func(ctx context.Context, chunk []Block) error {
28+
callCount++
29+
if len(chunk) != 25 {
30+
t.Errorf("Expected chunk size 25, got %d", len(chunk))
31+
}
32+
return nil
33+
})
34+
if err != nil {
35+
t.Errorf("Expected nil error, got %v", err)
36+
}
37+
if callCount != 1 {
38+
t.Errorf("Expected 1 call, got %d", callCount)
39+
}
40+
41+
// Test with blocks larger than chunk size
42+
largeBlocks := make([]Block, 126) // This simulates the original problem
43+
for i := range largeBlocks {
44+
largeBlocks[i] = Block{Type: "paragraph"}
45+
}
46+
47+
callCount = 0
48+
totalProcessed := 0
49+
err = client.processBlocksInChunks(context.Background(), largeBlocks, func(ctx context.Context, chunk []Block) error {
50+
callCount++
51+
totalProcessed += len(chunk)
52+
if len(chunk) > BlockChunkSize {
53+
t.Errorf("Chunk size %d exceeds maximum %d", len(chunk), BlockChunkSize)
54+
}
55+
return nil
56+
})
57+
if err != nil {
58+
t.Errorf("Expected nil error, got %v", err)
59+
}
60+
61+
expectedCalls := (126 + BlockChunkSize - 1) / BlockChunkSize // Ceiling division
62+
if callCount != expectedCalls {
63+
t.Errorf("Expected %d calls, got %d", expectedCalls, callCount)
64+
}
65+
if totalProcessed != 126 {
66+
t.Errorf("Expected to process 126 blocks, processed %d", totalProcessed)
67+
}
68+
}

internal/notion/types.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,9 @@ type CreatePageRequest struct {
120120

121121
// Parent specifies the parent of a page
122122
type Parent struct {
123-
Type string `json:"type"`
124-
PageID string `json:"page_id,omitempty"`
123+
Type string `json:"type"`
124+
PageID string `json:"page_id,omitempty"`
125+
DatabaseID string `json:"database_id,omitempty"`
125126
}
126127

127128
// PageProperties contains page metadata

0 commit comments

Comments
 (0)