Files
傅洋 7d0d5f8b77 feat(block): --image-file / --image-upload (and friends) for append/insert (#30)
Before this change there was no first-class CLI path to turn an uploaded
file into a block. Users had to run `notion file upload`, capture the
id, then hand-craft a PATCH /v1/blocks/<id>/children JSON body and send
it via `notion api`. The existing --image-url only accepted external
http(s) links.

This PR adds a symmetric triple for every media type Notion supports:

  --image-url / --image-file / --image-upload
  --file-url  / --file-file  / --file-upload
  --video-url / --video-file / --video-upload
  --audio-url / --audio-file / --audio-upload
  --pdf-url   / --pdf-file   / --pdf-upload

Semantics:
  --<kind>-url     http(s) external URL           → block.<kind>.type = external
  --<kind>-file    local path                     → upload then embed
  --<kind>-upload  existing file_upload id        → embed directly

Flags are mutually exclusive with each other and with --file/positional
text. --caption works with any of them.

Implementation lives in cmd/media_source.go:
  - registerMediaFlags: adds the 15 flags + --caption to a cobra command.
  - resolveMediaSource: validates, returns the single active source.
  - mediaSource.Build: performs the upload (for --*-file) and assembles
    the final block map.

The legacy --image-url flag and its validator/builder are now thin
back-compat wrappers around the new helpers, so existing tests and
usage keep working byte-for-byte.

Closes #23
2026-04-30 12:59:43 +08:00

171 lines
5.6 KiB
Go

package cmd
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
// Media block kinds supported by Notion. The string is both the
// user-facing flag prefix ("image", "file", "video", "audio", "pdf") and
// the Notion block type on the wire.
var mediaKinds = []string{"image", "file", "video", "audio", "pdf"}
// mediaClient is the minimal client surface mediaBuilder needs. Kept as an
// interface so media flag resolution is testable without a real HTTP client.
type mediaClient interface {
fileUploadAPI
}
// mediaSource describes a user-chosen way to produce a media block.
// Exactly one source is active per CLI invocation.
type mediaSource struct {
kind string // "image" | "file" | "video" | "audio" | "pdf"
mode string // "external" | "file" | "upload"
value string // URL / local path / file_upload id
caption string
}
// IsActive reports whether any media flag was set.
func (m *mediaSource) IsActive() bool {
return m != nil && m.kind != ""
}
// Build performs any required upload step and returns the assembled block.
// For "external" sources there's no network I/O; for "file" sources we
// upload the local path and then reference the returned file_upload id.
func (m *mediaSource) Build(c mediaClient) (map[string]interface{}, error) {
switch m.mode {
case "external":
return buildExternalMediaBlock(m.kind, m.value, m.caption), nil
case "upload":
return buildFileUploadMediaBlock(m.kind, m.value, m.caption), nil
case "file":
outcome, err := uploadFile(c, m.value, "")
if err != nil {
return nil, fmt.Errorf("upload %s: %w", m.value, err)
}
return buildFileUploadMediaBlock(m.kind, outcome.UploadID, m.caption), nil
default:
return nil, fmt.Errorf("internal: unknown media mode %q", m.mode)
}
}
// registerMediaFlags adds all media source flags to a cobra command.
// The flag names follow a strict pattern:
//
// --image-url http(s) URL (external image)
// --image-file local path (upload + embed)
// --image-upload existing file_upload id (embed only)
//
// The same triple exists for file / video / audio / pdf, minus the -url
// variant for non-image types (image is the only one that previously
// shipped --image-url, and the Notion API accepts external URLs for every
// media type — we keep parity by also adding -url for the others, so
// e.g. `--video-url https://…/clip.mp4` works).
func registerMediaFlags(cmd *cobra.Command) {
cmd.Flags().String("caption", "", "Caption for the media block (applies to any of the --*-url/--*-file/--*-upload flags)")
for _, kind := range mediaKinds {
cmd.Flags().String(kind+"-url", "", fmt.Sprintf("External %s URL (http/https)", kind))
cmd.Flags().String(kind+"-file", "", fmt.Sprintf("Local %s file to upload and embed", kind))
cmd.Flags().String(kind+"-upload", "", fmt.Sprintf("Existing file_upload ID to embed as %s", kind))
}
}
// resolveMediaSource inspects every media flag on the command and returns
// the single active source, or nil if none were used. It returns an error
// when more than one source is set, or when a source collides with --file
// (markdown) or positional text.
func resolveMediaSource(cmd *cobra.Command, filePath, text string) (*mediaSource, error) {
caption, _ := cmd.Flags().GetString("caption")
var picked []string
var active *mediaSource
for _, kind := range mediaKinds {
for _, mode := range []string{"url", "file", "upload"} {
flag := kind + "-" + mode
v, _ := cmd.Flags().GetString(flag)
v = strings.TrimSpace(v)
if v == "" {
continue
}
picked = append(picked, "--"+flag)
normalizedMode := mode
if mode == "url" {
normalizedMode = "external"
}
active = &mediaSource{
kind: kind,
mode: normalizedMode,
value: v,
caption: caption,
}
}
}
if len(picked) == 0 {
if caption != "" && filePath == "" && text == "" {
return nil, fmt.Errorf("--caption requires one of --<media>-url/--<media>-file/--<media>-upload")
}
return nil, nil
}
if len(picked) > 1 {
return nil, fmt.Errorf("at most one media source may be set, got: %s", strings.Join(picked, ", "))
}
if filePath != "" {
return nil, fmt.Errorf("%s cannot be combined with --file", picked[0])
}
if text != "" {
return nil, fmt.Errorf("%s cannot be combined with a positional text argument", picked[0])
}
if active.mode == "external" {
if !strings.HasPrefix(active.value, "http://") && !strings.HasPrefix(active.value, "https://") {
return nil, fmt.Errorf("%s must be an http:// or https:// URL", picked[0])
}
}
return active, nil
}
// buildExternalMediaBlock produces a block whose media source is an
// external URL (no upload needed).
func buildExternalMediaBlock(kind, url, caption string) map[string]interface{} {
media := map[string]interface{}{
"type": "external",
"external": map[string]interface{}{"url": url},
}
if caption != "" {
media["caption"] = captionRichText(caption)
}
return map[string]interface{}{
"object": "block",
"type": kind,
kind: media,
}
}
// buildFileUploadMediaBlock produces a block that references a previously
// created file_upload by id.
func buildFileUploadMediaBlock(kind, uploadID, caption string) map[string]interface{} {
media := map[string]interface{}{
"type": "file_upload",
"file_upload": map[string]interface{}{"id": uploadID},
}
if caption != "" {
media["caption"] = captionRichText(caption)
}
return map[string]interface{}{
"object": "block",
"type": kind,
kind: media,
}
}
func captionRichText(caption string) []map[string]interface{} {
return []map[string]interface{}{
{"type": "text", "text": map[string]interface{}{"content": caption}},
}
}