Files
傅洋 7abb018a88 feat(block): auto-batch >100 children and split oversize rich_text (#29)
Notion rejects PATCH /v1/blocks/<id>/children with either:
  - body.children.length > 100, or
  - body.children[N].<type>.rich_text[0].text.content.length > 2000

Both limits are fixed and known at parse time, so the CLI can absorb
them. Previously even a moderately long markdown file (a postmortem, a
design doc with one ~2KB stack trace, …) required manual splitting.

New helpers in cmd/block_limits.go:
  - chunkChildren: slice the children list into groups of ≤100.
  - appendChildrenBatched: PATCH each chunk sequentially, preserving
    order. The `after` anchor is attached to the FIRST batch only,
    because Notion won't let subsequent inserts reference a block from
    the same transaction. Progress is printed to stderr (not stdout, so
    --format json still pipes cleanly).
  - handleOversizedBlocks + parseOversizeMode: split oversize code
    blocks on newline boundaries by default, with `--on-oversize=
    truncate|fail` escape hatches. Code language and to_do checked
    state are preserved across split chunks.

Partial-failure message now tells the user exactly which batch failed
and how many blocks were already written:

    batch 2/3 failed after writing 100 block(s): <api error>

Wired into both `block append` and `block insert`.

Tests cover: chunk sizes, oversize split/truncate/fail modes, newline
boundary preference, order preservation across batches, `after` anchor
placement, partial-failure error text, and an end-to-end pipeline with a
150-bullet + 3000-char-code-block document.

Closes #21
2026-04-30 12:58:56 +08:00

233 lines
7.7 KiB
Go

package cmd
import (
"fmt"
"os"
"strings"
)
// Notion API hard limits that the CLI absorbs transparently when possible.
// See: https://developers.notion.com/reference/request-limits
const (
// maxChildrenPerRequest is the maximum `children.length` a single
// PATCH /v1/blocks/<id>/children call will accept.
maxChildrenPerRequest = 100
// maxRichTextContentLen is the maximum length of a single rich_text
// item's text.content (applies to code blocks too).
maxRichTextContentLen = 2000
)
// oversizeMode controls behavior when a single block's text exceeds
// maxRichTextContentLen. Values come from --on-oversize.
type oversizeMode string
const (
oversizeSplit oversizeMode = "split"
oversizeTruncate oversizeMode = "truncate"
oversizeFail oversizeMode = "fail"
)
// parseOversizeMode validates the flag value and returns the canonical enum.
func parseOversizeMode(raw string) (oversizeMode, error) {
switch strings.TrimSpace(strings.ToLower(raw)) {
case "", "split":
return oversizeSplit, nil
case "truncate":
return oversizeTruncate, nil
case "fail":
return oversizeFail, nil
default:
return "", fmt.Errorf("--on-oversize must be one of: split, truncate, fail (got %q)", raw)
}
}
// handleOversizedBlocks walks `blocks` and returns a new slice where every
// block is within Notion's per-item content limit. Today only code blocks
// (which are by far the most common offender) are split along newline
// boundaries; other rich_text blocks are split by character.
//
// In `fail` mode, the first oversize block returns an error with a clear
// message so the caller can print it verbatim.
func handleOversizedBlocks(blocks []map[string]interface{}, mode oversizeMode) ([]map[string]interface{}, error) {
out := make([]map[string]interface{}, 0, len(blocks))
for idx, b := range blocks {
split, err := splitBlockIfOversize(b, idx, mode)
if err != nil {
return nil, err
}
out = append(out, split...)
}
return out, nil
}
func splitBlockIfOversize(block map[string]interface{}, idx int, mode oversizeMode) ([]map[string]interface{}, error) {
blockType, _ := block["type"].(string)
data, ok := block[blockType].(map[string]interface{})
if !ok {
return []map[string]interface{}{block}, nil
}
content, segments := extractContentFromBlock(data)
if segments != 1 || len(content) <= maxRichTextContentLen {
return []map[string]interface{}{block}, nil
}
switch mode {
case oversizeFail:
return nil, fmt.Errorf(
"block[%d] of type %q exceeds Notion's %d-char rich_text limit (%d chars); re-run with --on-oversize=split or truncate",
idx, blockType, maxRichTextContentLen, len(content),
)
case oversizeTruncate:
truncated := content[:maxRichTextContentLen]
newBlock := cloneBlockWithContent(block, blockType, truncated)
return []map[string]interface{}{newBlock}, nil
}
// default: split
parts := splitContentForNotion(content, blockType == "code")
result := make([]map[string]interface{}, 0, len(parts))
for _, part := range parts {
result = append(result, cloneBlockWithContent(block, blockType, part))
}
return result, nil
}
// extractContentFromBlock returns the first rich_text's text.content and
// the number of rich_text segments in the block. When the block has more
// than one segment (rich formatting), we conservatively skip splitting —
// the user likely crafted that carefully and it's rarely the offender.
func extractContentFromBlock(data map[string]interface{}) (string, int) {
// rich_text can be []interface{} (from API response) or
// []map[string]interface{} (from our parser). Handle both.
if rt, ok := data["rich_text"].([]map[string]interface{}); ok {
if len(rt) == 0 {
return "", 0
}
textObj, _ := rt[0]["text"].(map[string]interface{})
content, _ := textObj["content"].(string)
return content, len(rt)
}
if rt, ok := data["rich_text"].([]interface{}); ok {
if len(rt) == 0 {
return "", 0
}
first, _ := rt[0].(map[string]interface{})
textObj, _ := first["text"].(map[string]interface{})
content, _ := textObj["content"].(string)
return content, len(rt)
}
return "", 0
}
// splitContentForNotion slices content into ≤maxRichTextContentLen chunks.
// For code blocks we try to cut on newline boundaries so syntax highlighting
// still looks reasonable after the split.
func splitContentForNotion(content string, preferNewlineBoundary bool) []string {
var parts []string
remaining := content
for len(remaining) > maxRichTextContentLen {
cut := maxRichTextContentLen
if preferNewlineBoundary {
if nl := strings.LastIndex(remaining[:maxRichTextContentLen], "\n"); nl > maxRichTextContentLen/2 {
// Only prefer the newline if it's not absurdly early
// (avoids emitting tiny first chunks for one-huge-line inputs).
cut = nl
}
}
parts = append(parts, remaining[:cut])
remaining = strings.TrimPrefix(remaining[cut:], "\n")
}
if remaining != "" {
parts = append(parts, remaining)
}
if len(parts) == 0 {
return []string{""}
}
return parts
}
// cloneBlockWithContent builds a new block with the same type and language
// (if a code block) but replaces its rich_text[0].text.content with `content`.
func cloneBlockWithContent(orig map[string]interface{}, blockType, content string) map[string]interface{} {
newData := map[string]interface{}{
"rich_text": []map[string]interface{}{
{"text": map[string]interface{}{"content": content}},
},
}
// Preserve language on code blocks.
if origData, ok := orig[blockType].(map[string]interface{}); ok {
if lang, ok := origData["language"].(string); ok {
newData["language"] = lang
}
// Preserve to_do checked state
if v, ok := origData["checked"]; ok {
newData["checked"] = v
}
}
return map[string]interface{}{
"object": "block",
"type": blockType,
blockType: newData,
}
}
// chunkChildren slices children into groups of at most maxChildrenPerRequest.
func chunkChildren(children []map[string]interface{}) [][]map[string]interface{} {
if len(children) <= maxChildrenPerRequest {
return [][]map[string]interface{}{children}
}
var out [][]map[string]interface{}
for i := 0; i < len(children); i += maxChildrenPerRequest {
end := i + maxChildrenPerRequest
if end > len(children) {
end = len(children)
}
out = append(out, children[i:end])
}
return out
}
// blockAppender is the minimal client surface appendChildrenBatched needs.
// Keeping it as an interface makes the batching logic testable without
// hitting the network.
type blockAppender interface {
Patch(path string, body interface{}) ([]byte, error)
}
// appendChildrenBatched PATCHes children in groups of ≤100, preserving
// order. When `afterID` is non-empty it's used for the FIRST batch only
// (Notion only accepts `after` for the initial insertion; subsequent
// batches rely on sequential append to stay in order).
//
// Progress is printed to stderr when there is more than one batch, so
// stdout can still be piped to jq etc.
func appendChildrenBatched(c blockAppender, parentID, afterID string, children []map[string]interface{}) ([]byte, error) {
batches := chunkChildren(children)
var lastResp []byte
var err error
if len(batches) > 1 {
fmt.Fprintf(os.Stderr, "note: appending %d blocks in %d batches of ≤%d...\n",
len(children), len(batches), maxChildrenPerRequest)
}
for i, batch := range batches {
reqBody := map[string]interface{}{
"children": batch,
}
if i == 0 && afterID != "" {
reqBody["after"] = afterID
}
lastResp, err = c.Patch(fmt.Sprintf("/v1/blocks/%s/children", parentID), reqBody)
if err != nil {
return nil, fmt.Errorf("batch %d/%d failed after writing %d block(s): %w",
i+1, len(batches), i*maxChildrenPerRequest, err)
}
if len(batches) > 1 {
fmt.Fprintf(os.Stderr, " ✓ batch %d/%d (%d blocks)\n", i+1, len(batches), len(batch))
}
}
return lastResp, nil
}