mirror of
https://github.com/4ier/notion-cli.git
synced 2026-09-14 20:17:00 +08:00
f1d4d119c6
multipart.FileContentDisposition was introduced in Go 1.25 but go.mod targets 1.24, causing CI build failures. Replace with manual Content-Disposition header construction with proper quote escaping.
376 lines
9.9 KiB
Go
376 lines
9.9 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/textproto"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
BaseURL = "https://api.notion.com"
|
|
NotionVersion = "2022-06-28"
|
|
DefaultTimeout = 30 * time.Second
|
|
UploadTimeout = 5 * time.Minute
|
|
)
|
|
|
|
type Client struct {
|
|
token string
|
|
httpClient *http.Client
|
|
debug bool
|
|
}
|
|
|
|
func New(token string) *Client {
|
|
return &Client{
|
|
token: token,
|
|
httpClient: &http.Client{
|
|
Timeout: DefaultTimeout,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *Client) SetDebug(debug bool) {
|
|
c.debug = debug
|
|
}
|
|
|
|
func (c *Client) do(method, path string, body interface{}) ([]byte, error) {
|
|
url := BaseURL + path
|
|
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request body: %w", err)
|
|
}
|
|
bodyReader = bytes.NewReader(data)
|
|
}
|
|
|
|
req, err := http.NewRequest(method, url, bodyReader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Notion-Version", NotionVersion)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
if c.debug {
|
|
fmt.Printf("→ %s %s\n", method, url)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if c.debug {
|
|
fmt.Printf("← %d %s (%d bytes)\n", resp.StatusCode, resp.Status, len(respBody))
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
var apiErr struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
if json.Unmarshal(respBody, &apiErr) == nil && apiErr.Message != "" {
|
|
hint := errorHint(apiErr.Code, apiErr.Message)
|
|
if hint != "" {
|
|
return nil, fmt.Errorf("%s: %s\n → %s", apiErr.Code, apiErr.Message, hint)
|
|
}
|
|
return nil, fmt.Errorf("%s: %s", apiErr.Code, apiErr.Message)
|
|
}
|
|
return nil, fmt.Errorf("API error: %s", resp.Status)
|
|
}
|
|
|
|
return respBody, nil
|
|
}
|
|
|
|
func (c *Client) Get(path string) ([]byte, error) {
|
|
return c.do("GET", path, nil)
|
|
}
|
|
|
|
func (c *Client) Post(path string, body interface{}) ([]byte, error) {
|
|
return c.do("POST", path, body)
|
|
}
|
|
|
|
func (c *Client) Patch(path string, body interface{}) ([]byte, error) {
|
|
return c.do("PATCH", path, body)
|
|
}
|
|
|
|
func (c *Client) Delete(path string) ([]byte, error) {
|
|
return c.do("DELETE", path, nil)
|
|
}
|
|
|
|
// GetMe returns the bot user info for the current token.
|
|
func (c *Client) GetMe() (map[string]interface{}, error) {
|
|
data, err := c.Get("/v1/users/me")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetUser retrieves a user by ID.
|
|
func (c *Client) GetUser(userID string) (map[string]interface{}, error) {
|
|
data, err := c.Get("/v1/users/" + userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Search performs a search across the workspace.
|
|
func (c *Client) Search(query string, filter string, pageSize int, startCursor string) (map[string]interface{}, error) {
|
|
body := map[string]interface{}{}
|
|
if query != "" {
|
|
body["query"] = query
|
|
}
|
|
if filter != "" {
|
|
body["filter"] = map[string]interface{}{
|
|
"value": filter,
|
|
"property": "object",
|
|
}
|
|
}
|
|
if pageSize > 0 {
|
|
body["page_size"] = pageSize
|
|
}
|
|
if startCursor != "" {
|
|
body["start_cursor"] = startCursor
|
|
}
|
|
|
|
data, err := c.Post("/v1/search", body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetPage retrieves a page by ID.
|
|
func (c *Client) GetPage(pageID string) (map[string]interface{}, error) {
|
|
data, err := c.Get("/v1/pages/" + pageID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetBlock retrieves a single block by ID.
|
|
func (c *Client) GetBlock(blockID string) (map[string]interface{}, error) {
|
|
data, err := c.Get("/v1/blocks/" + blockID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetBlockChildren retrieves children of a block.
|
|
func (c *Client) GetBlockChildren(blockID string, pageSize int, startCursor string) (map[string]interface{}, error) {
|
|
path := fmt.Sprintf("/v1/blocks/%s/children?page_size=%d", blockID, pageSize)
|
|
if startCursor != "" {
|
|
path += "&start_cursor=" + startCursor
|
|
}
|
|
data, err := c.Get(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetDatabase retrieves a database by ID.
|
|
func (c *Client) GetDatabase(dbID string) (map[string]interface{}, error) {
|
|
data, err := c.Get("/v1/databases/" + dbID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// QueryDatabase queries a database with filters and sorts.
|
|
func (c *Client) QueryDatabase(dbID string, body map[string]interface{}) (map[string]interface{}, error) {
|
|
data, err := c.Post("/v1/databases/"+dbID+"/query", body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetUsers lists all users.
|
|
func (c *Client) GetUsers(pageSize int, startCursor string) (map[string]interface{}, error) {
|
|
path := fmt.Sprintf("/v1/users?page_size=%d", pageSize)
|
|
if startCursor != "" {
|
|
path += "&start_cursor=" + startCursor
|
|
}
|
|
data, err := c.Get(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ListComments lists comments on a block/page.
|
|
func (c *Client) ListComments(blockID string, pageSize int, startCursor string) (map[string]interface{}, error) {
|
|
path := fmt.Sprintf("/v1/comments?block_id=%s&page_size=%d", blockID, pageSize)
|
|
if startCursor != "" {
|
|
path += "&start_cursor=" + startCursor
|
|
}
|
|
data, err := c.Get(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result map[string]interface{}
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// AddComment adds a comment to a page.
|
|
func (c *Client) AddComment(pageID, text string) ([]byte, error) {
|
|
body := map[string]interface{}{
|
|
"parent": map[string]interface{}{
|
|
"page_id": pageID,
|
|
},
|
|
"rich_text": []map[string]interface{}{
|
|
{"text": map[string]interface{}{"content": text}},
|
|
},
|
|
}
|
|
return c.Post("/v1/comments", body)
|
|
}
|
|
|
|
// UploadFileContent sends file content to an existing file upload via multipart form.
|
|
func (c *Client) UploadFileContent(uploadID, fileName, contentType string, fileBytes []byte) ([]byte, error) {
|
|
url := BaseURL + fmt.Sprintf("/v1/file_uploads/%s/send", uploadID)
|
|
|
|
// Build multipart form
|
|
body := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(body)
|
|
partHeader := make(textproto.MIMEHeader)
|
|
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(fileName)))
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
partHeader.Set("Content-Type", contentType)
|
|
part, err := writer.CreatePart(partHeader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create form file: %w", err)
|
|
}
|
|
if _, err := part.Write(fileBytes); err != nil {
|
|
return nil, fmt.Errorf("write file data: %w", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return nil, fmt.Errorf("finalize multipart body: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", url, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Notion-Version", NotionVersion)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
if c.debug {
|
|
fmt.Printf("→ POST %s (multipart, %d bytes)\n", url, body.Len())
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), UploadTimeout)
|
|
defer cancel()
|
|
resp, err := c.httpClient.Do(req.WithContext(ctx))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upload request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if c.debug {
|
|
fmt.Printf("← %d %s (%d bytes)\n", resp.StatusCode, resp.Status, len(respBody))
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("upload failed (%d): %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
return respBody, nil
|
|
}
|
|
|
|
// errorHint provides actionable suggestions for common API errors.
|
|
func errorHint(code, message string) string {
|
|
switch code {
|
|
case "object_not_found":
|
|
return "Check the ID is correct and the page/database is shared with your integration"
|
|
case "unauthorized":
|
|
return "Run 'notion auth login' to authenticate, or check your token"
|
|
case "restricted_resource":
|
|
return "Your integration doesn't have access. Share the page/database with your integration in Notion"
|
|
case "rate_limited":
|
|
return "Too many requests. Wait a moment and try again"
|
|
case "validation_error":
|
|
if strings.Contains(message, "is not a property") {
|
|
return "Check property names with 'notion db view <id>' or 'notion page props <id>'"
|
|
}
|
|
if strings.Contains(message, "body failed validation") {
|
|
return "Check your input format. Use --debug for request details"
|
|
}
|
|
case "conflict_error":
|
|
return "The resource was modified by another process. Retry the operation"
|
|
case "internal_server_error", "service_unavailable":
|
|
return "Notion's servers are having issues. Try again in a few minutes"
|
|
}
|
|
return ""
|
|
}
|