packages feed

notion-client-0.8.0.0: src/Notion/V1/BlockContent.hs

-- | Typed block content for Notion API blocks.
--
-- Each Notion block carries a @type@ discriminator (e.g., @\"paragraph\"@,
-- @\"heading_1\"@, @\"code\"@) and the actual content nested under a key
-- matching that type name. This module replaces the untyped @Value@ with a
-- proper Haskell sum type so consumers can pattern-match on block types and
-- use smart constructors to build well-formed blocks.
module Notion.V1.BlockContent
  ( -- * Block content
    BlockContent (..),
    blockContentType,
    blockContentFields,
    parseBlockContent,

    -- * Block updates
    BlockUpdatePayload (..),
    BlockUpdateContent (..),
    ParagraphUpdate (..),
    HeadingUpdate (..),
    TextColorUpdate (..),
    ToDoUpdate (..),
    CodeUpdate (..),
    MediaUpdate (..),
    MediaSourceUpdate (..),
    UrlCaptionUpdate (..),
    TableUpdate (..),
    mkBlockUpdate,
    trashBlockUpdate,
    blockUpdateFromContent,

    -- * Supporting types
    CodeLanguage (..),
    MeetingNotesStatus (..),
    MeetingNotesChildren (..),
    MeetingCalendarEvent (..),
    MeetingRecording (..),
    FileSource (..),
    ListFormat (..),
    SyncedFrom (..),
    LinkTarget (..),

    -- * Smart constructors
    mkRichText,
    textBlock,
    paragraphBlock,
    headingBlock,
    bulletedListItemBlock,
    numberedListItemBlock,
    toDoBlock,
    toggleBlock,
    quoteBlock,
    calloutBlock,
    codeBlock,
    equationBlock,
    bookmarkBlock,
    dividerBlock,
    imageBlock,
    tabBlock,

    -- * Combinators
    withChildren,
  )
where

import Data.Aeson (object, (.:), (.:?), (.=))
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.Types (Pair, Parser)
import Data.Maybe (fromMaybe)
import Data.Vector qualified as Vector
import Notion.Prelude
import Notion.V1.Common (Color (..), ExternalFile (ExternalFile), File, Icon, UUID)
import Notion.V1.RichText (RichText (..), RichTextContent (..), TextContent (..), defaultAnnotations)

-- ---------------------------------------------------------------------------
-- Supporting types
-- ---------------------------------------------------------------------------

-- | Programming language for code blocks.
data CodeLanguage
  = Abap
  | Abc
  | Agda
  | Arduino
  | AsciiArt
  | Assembly
  | Bash
  | Basic
  | Bnf
  | C
  | Clojure
  | CoffeeScript
  | Coq
  | Cpp
  | CSharp
  | Css
  | Dart
  | Dhall
  | Diff
  | Docker
  | Ebnf
  | Elixir
  | Elm
  | Erlang
  | Flow
  | Fortran
  | FSharp
  | Gherkin
  | Glsl
  | Go
  | GraphQL
  | Groovy
  | Haskell
  | Hcl
  | Html
  | Idris
  | Java
  | JavaScript
  | Json
  | Julia
  | Kotlin
  | LaTeX
  | Less
  | Lisp
  | LiveScript
  | LlvmIr
  | Lua
  | Makefile
  | Markdown
  | Markup
  | Mathematica
  | Matlab
  | Mermaid
  | Nix
  | NotionFormula
  | ObjectiveC
  | OCaml
  | Pascal
  | Perl
  | Php
  | PlainText
  | PowerShell
  | Prolog
  | Protobuf
  | PureScript
  | Python
  | R
  | Racket
  | Reason
  | Ruby
  | Rust
  | Sass
  | Scala
  | Scheme
  | Scss
  | Shell
  | Smalltalk
  | Solidity
  | Sql
  | Swift
  | Toml
  | TypeScript
  | VbNet
  | Verilog
  | Vhdl
  | VisualBasic
  | WebAssembly
  | Xml
  | Yaml
  | JavaCCppCSharp
  | -- | A language this library does not know yet; holds the raw string.
    OtherLanguage Text
  deriving stock (Eq, Show, Generic)

instance FromJSON CodeLanguage where
  parseJSON = Aeson.withText "CodeLanguage" $ \case
    "abap" -> pure Abap
    "arduino" -> pure Arduino
    "bash" -> pure Bash
    "basic" -> pure Basic
    "c" -> pure C
    "clojure" -> pure Clojure
    "coffeescript" -> pure CoffeeScript
    "c++" -> pure Cpp
    "c#" -> pure CSharp
    "css" -> pure Css
    "dart" -> pure Dart
    "diff" -> pure Diff
    "docker" -> pure Docker
    "elixir" -> pure Elixir
    "elm" -> pure Elm
    "erlang" -> pure Erlang
    "flow" -> pure Flow
    "fortran" -> pure Fortran
    "f#" -> pure FSharp
    "gherkin" -> pure Gherkin
    "glsl" -> pure Glsl
    "go" -> pure Go
    "graphql" -> pure GraphQL
    "groovy" -> pure Groovy
    "haskell" -> pure Haskell
    "html" -> pure Html
    "java" -> pure Java
    "javascript" -> pure JavaScript
    "json" -> pure Json
    "julia" -> pure Julia
    "kotlin" -> pure Kotlin
    "latex" -> pure LaTeX
    "less" -> pure Less
    "lisp" -> pure Lisp
    "livescript" -> pure LiveScript
    "lua" -> pure Lua
    "makefile" -> pure Makefile
    "markdown" -> pure Markdown
    "markup" -> pure Markup
    "matlab" -> pure Matlab
    "mermaid" -> pure Mermaid
    "nix" -> pure Nix
    "objective-c" -> pure ObjectiveC
    "ocaml" -> pure OCaml
    "pascal" -> pure Pascal
    "perl" -> pure Perl
    "php" -> pure Php
    "plain text" -> pure PlainText
    "powershell" -> pure PowerShell
    "prolog" -> pure Prolog
    "protobuf" -> pure Protobuf
    "python" -> pure Python
    "r" -> pure R
    "reason" -> pure Reason
    "ruby" -> pure Ruby
    "rust" -> pure Rust
    "sass" -> pure Sass
    "scala" -> pure Scala
    "scheme" -> pure Scheme
    "scss" -> pure Scss
    "shell" -> pure Shell
    "sql" -> pure Sql
    "swift" -> pure Swift
    "typescript" -> pure TypeScript
    "vb.net" -> pure VbNet
    "verilog" -> pure Verilog
    "vhdl" -> pure Vhdl
    "visual basic" -> pure VisualBasic
    "webassembly" -> pure WebAssembly
    "xml" -> pure Xml
    "yaml" -> pure Yaml
    "abc" -> pure Abc
    "agda" -> pure Agda
    "ascii art" -> pure AsciiArt
    "assembly" -> pure Assembly
    "bnf" -> pure Bnf
    "coq" -> pure Coq
    "dhall" -> pure Dhall
    "ebnf" -> pure Ebnf
    "hcl" -> pure Hcl
    "idris" -> pure Idris
    "llvm ir" -> pure LlvmIr
    "mathematica" -> pure Mathematica
    "notion formula" -> pure NotionFormula
    "purescript" -> pure PureScript
    "racket" -> pure Racket
    "smalltalk" -> pure Smalltalk
    "solidity" -> pure Solidity
    "toml" -> pure Toml
    "java/c/c++/c#" -> pure JavaCCppCSharp
    other -> pure (OtherLanguage other)

instance ToJSON CodeLanguage where
  toJSON = \case
    Abap -> Aeson.String "abap"
    Arduino -> Aeson.String "arduino"
    Bash -> Aeson.String "bash"
    Basic -> Aeson.String "basic"
    C -> Aeson.String "c"
    Clojure -> Aeson.String "clojure"
    CoffeeScript -> Aeson.String "coffeescript"
    Cpp -> Aeson.String "c++"
    CSharp -> Aeson.String "c#"
    Css -> Aeson.String "css"
    Dart -> Aeson.String "dart"
    Diff -> Aeson.String "diff"
    Docker -> Aeson.String "docker"
    Elixir -> Aeson.String "elixir"
    Elm -> Aeson.String "elm"
    Erlang -> Aeson.String "erlang"
    Flow -> Aeson.String "flow"
    Fortran -> Aeson.String "fortran"
    FSharp -> Aeson.String "f#"
    Gherkin -> Aeson.String "gherkin"
    Glsl -> Aeson.String "glsl"
    Go -> Aeson.String "go"
    GraphQL -> Aeson.String "graphql"
    Groovy -> Aeson.String "groovy"
    Haskell -> Aeson.String "haskell"
    Html -> Aeson.String "html"
    Java -> Aeson.String "java"
    JavaScript -> Aeson.String "javascript"
    Json -> Aeson.String "json"
    Julia -> Aeson.String "julia"
    Kotlin -> Aeson.String "kotlin"
    LaTeX -> Aeson.String "latex"
    Less -> Aeson.String "less"
    Lisp -> Aeson.String "lisp"
    LiveScript -> Aeson.String "livescript"
    Lua -> Aeson.String "lua"
    Makefile -> Aeson.String "makefile"
    Markdown -> Aeson.String "markdown"
    Markup -> Aeson.String "markup"
    Matlab -> Aeson.String "matlab"
    Mermaid -> Aeson.String "mermaid"
    Nix -> Aeson.String "nix"
    ObjectiveC -> Aeson.String "objective-c"
    OCaml -> Aeson.String "ocaml"
    Pascal -> Aeson.String "pascal"
    Perl -> Aeson.String "perl"
    Php -> Aeson.String "php"
    PlainText -> Aeson.String "plain text"
    PowerShell -> Aeson.String "powershell"
    Prolog -> Aeson.String "prolog"
    Protobuf -> Aeson.String "protobuf"
    Python -> Aeson.String "python"
    R -> Aeson.String "r"
    Reason -> Aeson.String "reason"
    Ruby -> Aeson.String "ruby"
    Rust -> Aeson.String "rust"
    Sass -> Aeson.String "sass"
    Scala -> Aeson.String "scala"
    Scheme -> Aeson.String "scheme"
    Scss -> Aeson.String "scss"
    Shell -> Aeson.String "shell"
    Sql -> Aeson.String "sql"
    Swift -> Aeson.String "swift"
    TypeScript -> Aeson.String "typescript"
    VbNet -> Aeson.String "vb.net"
    Verilog -> Aeson.String "verilog"
    Vhdl -> Aeson.String "vhdl"
    VisualBasic -> Aeson.String "visual basic"
    WebAssembly -> Aeson.String "webassembly"
    Xml -> Aeson.String "xml"
    Yaml -> Aeson.String "yaml"
    Abc -> Aeson.String "abc"
    Agda -> Aeson.String "agda"
    AsciiArt -> Aeson.String "ascii art"
    Assembly -> Aeson.String "assembly"
    Bnf -> Aeson.String "bnf"
    Coq -> Aeson.String "coq"
    Dhall -> Aeson.String "dhall"
    Ebnf -> Aeson.String "ebnf"
    Hcl -> Aeson.String "hcl"
    Idris -> Aeson.String "idris"
    LlvmIr -> Aeson.String "llvm ir"
    Mathematica -> Aeson.String "mathematica"
    NotionFormula -> Aeson.String "notion formula"
    PureScript -> Aeson.String "purescript"
    Racket -> Aeson.String "racket"
    Smalltalk -> Aeson.String "smalltalk"
    Solidity -> Aeson.String "solidity"
    Toml -> Aeson.String "toml"
    JavaCCppCSharp -> Aeson.String "java/c/c++/c#"
    OtherLanguage t -> Aeson.String t

-- | Processing state of a meeting-notes block.
data MeetingNotesStatus
  = TranscriptionNotStarted
  | TranscriptionPaused
  | TranscriptionInProgress
  | TranscriptionFailed
  | SummaryInProgress
  | NotesReady
  | -- | A status this library does not know yet; holds the raw string.
    UnknownMeetingNotesStatus Text
  deriving stock (Eq, Show, Generic)

instance FromJSON MeetingNotesStatus where
  parseJSON = Aeson.withText "MeetingNotesStatus" $ \case
    "transcription_not_started" -> pure TranscriptionNotStarted
    "transcription_paused" -> pure TranscriptionPaused
    "transcription_in_progress" -> pure TranscriptionInProgress
    "transcription_failed" -> pure TranscriptionFailed
    "summary_in_progress" -> pure SummaryInProgress
    "notes_ready" -> pure NotesReady
    other -> pure (UnknownMeetingNotesStatus other)

instance ToJSON MeetingNotesStatus where
  toJSON = \case
    TranscriptionNotStarted -> Aeson.String "transcription_not_started"
    TranscriptionPaused -> Aeson.String "transcription_paused"
    TranscriptionInProgress -> Aeson.String "transcription_in_progress"
    TranscriptionFailed -> Aeson.String "transcription_failed"
    SummaryInProgress -> Aeson.String "summary_in_progress"
    NotesReady -> Aeson.String "notes_ready"
    UnknownMeetingNotesStatus t -> Aeson.String t

-- | IDs of the child blocks Notion creates under a meeting-notes block.
data MeetingNotesChildren = MeetingNotesChildren
  { summaryBlockId :: Maybe UUID,
    notesBlockId :: Maybe UUID,
    transcriptBlockId :: Maybe UUID
  }
  deriving stock (Eq, Show, Generic)

instance FromJSON MeetingNotesChildren where
  parseJSON = genericParseJSON aesonOptions

instance ToJSON MeetingNotesChildren where
  toJSON = genericToJSON aesonOptions

-- | Calendar event linked to a meeting; times are ISO 8601 strings as sent.
data MeetingCalendarEvent = MeetingCalendarEvent
  { calendarStartTime :: Text,
    calendarEndTime :: Text,
    calendarAttendees :: Maybe (Vector UUID)
  }
  deriving stock (Eq, Show, Generic)

instance FromJSON MeetingCalendarEvent where
  parseJSON = Aeson.withObject "MeetingCalendarEvent" $ \o -> do
    calendarStartTime <- o .: "start_time"
    calendarEndTime <- o .: "end_time"
    calendarAttendees <- o .:? "attendees"
    pure MeetingCalendarEvent {..}

instance ToJSON MeetingCalendarEvent where
  toJSON MeetingCalendarEvent {..} =
    object $
      ["start_time" .= calendarStartTime, "end_time" .= calendarEndTime]
        <> maybe [] (\as -> ["attendees" .= as]) calendarAttendees

-- | Recording window of a meeting; times are ISO 8601 strings as sent.
data MeetingRecording = MeetingRecording
  { recordingStartTime :: Maybe Text,
    recordingEndTime :: Maybe Text
  }
  deriving stock (Eq, Show, Generic)

instance FromJSON MeetingRecording where
  parseJSON = Aeson.withObject "MeetingRecording" $ \o -> do
    recordingStartTime <- o .:? "start_time"
    recordingEndTime <- o .:? "end_time"
    pure MeetingRecording {..}

instance ToJSON MeetingRecording where
  toJSON MeetingRecording {..} =
    object $
      maybe [] (\t -> ["start_time" .= t]) recordingStartTime
        <> maybe [] (\t -> ["end_time" .= t]) recordingEndTime

-- | File source for media blocks (image, video, audio, file, pdf).
--
-- The API uses a @type@ discriminator with values @\"external\"@, @\"file\"@,
-- or @\"file_upload\"@, and the content nested under the corresponding key.
data FileSource
  = ExternalSource ExternalFile
  | NotionSource File
  | FileUploadSource UUID
  deriving stock (Eq, Show)

parseFileSource :: Aeson.Object -> Parser FileSource
parseFileSource o = do
  srcType <- o .: "type"
  case srcType of
    "external" -> ExternalSource <$> o .: "external"
    "file" -> NotionSource <$> o .: "file"
    "file_upload" -> do
      uploadObj <- o .: "file_upload"
      FileUploadSource <$> uploadObj .: "id"
    other -> fail $ "Unknown file source type: " <> unpack (other :: Text)

instance FromJSON FileSource where
  parseJSON = \case
    Object o -> parseFileSource o
    _ -> fail "Expected object for FileSource"

instance ToJSON FileSource where
  toJSON = object . fileSourcePairs

fileSourcePairs :: FileSource -> [Pair]
fileSourcePairs = \case
  ExternalSource ef -> ["type" .= ("external" :: Text), "external" .= ef]
  NotionSource f -> ["type" .= ("file" :: Text), "file" .= f]
  FileUploadSource uid -> ["type" .= ("file_upload" :: Text), "file_upload" .= object ["id" .= uid]]

-- | List format for numbered list items.
data ListFormat
  = Numbers
  | Letters
  | Roman
  deriving stock (Eq, Show, Generic)

instance FromJSON ListFormat where
  parseJSON = Aeson.withText "ListFormat" $ \case
    "numbers" -> pure Numbers
    "letters" -> pure Letters
    "roman" -> pure Roman
    other -> fail $ "Unknown ListFormat: " <> unpack other

instance ToJSON ListFormat where
  toJSON = \case
    Numbers -> Aeson.String "numbers"
    Letters -> Aeson.String "letters"
    Roman -> Aeson.String "roman"

-- | Synced block origin. An original synced block has @synced_from: null@ in
-- the API; a reference points to the original block by ID.
data SyncedFrom
  = SyncedOriginal
  | SyncedReference UUID
  deriving stock (Eq, Show)

instance FromJSON SyncedFrom where
  parseJSON = \case
    Null -> pure SyncedOriginal
    Object o -> SyncedReference <$> o .: "block_id"
    _ -> fail "Expected null or object for SyncedFrom"

instance ToJSON SyncedFrom where
  toJSON SyncedOriginal = Null
  toJSON (SyncedReference bid) =
    object ["type" .= ("block_id" :: Text), "block_id" .= bid]

-- | Target of a @link_to_page@ block.
data LinkTarget
  = LinkToPage UUID
  | LinkToDatabase UUID
  | LinkToComment UUID
  deriving stock (Eq, Show)

instance FromJSON LinkTarget where
  parseJSON = \case
    Object o -> do
      t <- o .: "type"
      case t of
        "page_id" -> LinkToPage <$> o .: "page_id"
        "database_id" -> LinkToDatabase <$> o .: "database_id"
        "comment_id" -> LinkToComment <$> o .: "comment_id"
        other -> fail $ "Unknown LinkTarget type: " <> unpack (other :: Text)
    _ -> fail "Expected object for LinkTarget"

instance ToJSON LinkTarget where
  toJSON (LinkToPage pid) = object ["type" .= ("page_id" :: Text), "page_id" .= pid]
  toJSON (LinkToDatabase did) = object ["type" .= ("database_id" :: Text), "database_id" .= did]
  toJSON (LinkToComment cid) = object ["type" .= ("comment_id" :: Text), "comment_id" .= cid]

-- ---------------------------------------------------------------------------
-- BlockContent
-- ---------------------------------------------------------------------------

-- | Typed block content. Each constructor corresponds to one Notion block type.
--
-- Use the smart constructors ('paragraphBlock', 'headingBlock', 'codeBlock',
-- etc.) to build blocks conveniently, or construct values directly for full
-- control over all fields.
data BlockContent
  = -- | Paragraph block with rich text, color, and optional icon.
    ParagraphBlock
      { richText :: Vector RichText,
        color :: Color,
        paragraphIcon :: Maybe Icon,
        children :: Vector BlockContent
      }
  | -- | Heading level 1.
    --
    -- Children are only accepted by the API when @isToggleable@ is @True@.
    Heading1Block
      { richText :: Vector RichText,
        color :: Color,
        isToggleable :: Bool,
        children :: Vector BlockContent
      }
  | -- | Heading level 2.
    --
    -- Children are only accepted by the API when @isToggleable@ is @True@.
    Heading2Block
      { richText :: Vector RichText,
        color :: Color,
        isToggleable :: Bool,
        children :: Vector BlockContent
      }
  | -- | Heading level 3.
    --
    -- Children are only accepted by the API when @isToggleable@ is @True@.
    Heading3Block
      { richText :: Vector RichText,
        color :: Color,
        isToggleable :: Bool,
        children :: Vector BlockContent
      }
  | -- | Bulleted list item.
    BulletedListItemBlock
      { richText :: Vector RichText,
        color :: Color,
        children :: Vector BlockContent
      }
  | -- | Numbered list item with optional format and start index.
    NumberedListItemBlock
      { richText :: Vector RichText,
        color :: Color,
        listFormat :: Maybe ListFormat,
        listStartIndex :: Maybe Natural,
        children :: Vector BlockContent
      }
  | -- | To-do checkbox item.
    ToDoBlock
      { richText :: Vector RichText,
        color :: Color,
        checked :: Bool,
        children :: Vector BlockContent
      }
  | -- | Toggle block (content revealed on click).
    ToggleBlock
      { richText :: Vector RichText,
        color :: Color,
        children :: Vector BlockContent
      }
  | -- | Quote block.
    QuoteBlock
      { richText :: Vector RichText,
        color :: Color,
        children :: Vector BlockContent
      }
  | -- | Callout block with icon.
    CalloutBlock
      { richText :: Vector RichText,
        color :: Color,
        calloutIcon :: Maybe Icon,
        children :: Vector BlockContent
      }
  | -- | Code block with language.
    CodeBlock
      { richText :: Vector RichText,
        caption :: Vector RichText,
        language :: CodeLanguage
      }
  | -- | KaTeX equation block.
    EquationBlock
      { expression :: Text
      }
  | -- | Image block.
    ImageBlock
      { imageSource :: FileSource,
        caption :: Vector RichText
      }
  | -- | Video block.
    VideoBlock
      { videoSource :: FileSource,
        caption :: Vector RichText
      }
  | -- | Audio block.
    AudioBlock
      { audioSource :: FileSource,
        caption :: Vector RichText
      }
  | -- | File attachment block.
    FileBlock
      { fileSource :: FileSource,
        caption :: Vector RichText,
        fileName :: Maybe Text
      }
  | -- | PDF block.
    PdfBlock
      { pdfSource :: FileSource,
        caption :: Vector RichText
      }
  | -- | Bookmark block.
    BookmarkBlock
      { url :: Text,
        caption :: Vector RichText
      }
  | -- | Embed block.
    EmbedBlock
      { url :: Text,
        caption :: Vector RichText
      }
  | -- | Link to another page, database, or comment.
    LinkToPageBlock
      { linkTarget :: LinkTarget
      }
  | -- | Link preview (read-only).
    LinkPreviewBlock
      { url :: Text
      }
  | -- | Horizontal divider.
    DividerBlock
  | -- | Breadcrumb navigation.
    BreadcrumbBlock
  | -- | Table of contents.
    TableOfContentsBlock
      { color :: Color
      }
  | -- | Column list (container for columns).
    --
    -- Children must be 'ColumnBlock' values, with at least 2 entries.
    ColumnListBlock
      { children :: Vector BlockContent
      }
  | -- | Single column within a column list.
    ColumnBlock
      { widthRatio :: Maybe Double,
        children :: Vector BlockContent
      }
  | -- | Table block.
    --
    -- Children must be 'TableRowBlock' values.
    TableBlock
      { tableWidth :: Natural,
        hasColumnHeader :: Bool,
        hasRowHeader :: Bool,
        children :: Vector BlockContent
      }
  | -- | Table row.
    TableRowBlock
      { cells :: Vector (Vector RichText)
      }
  | -- | Child page reference (read-only, created via the Pages endpoint).
    ChildPageBlock
      { title :: Text
      }
  | -- | Child database reference (read-only, created via the Databases endpoint).
    ChildDatabaseBlock
      { title :: Text
      }
  | -- | Synced block (original or reference).
    --
    -- Children are only valid when @syncedFrom@ is 'SyncedOriginal'.
    SyncedBlockContent
      { syncedFrom :: SyncedFrom,
        children :: Vector BlockContent
      }
  | -- | Heading level 4.
    --
    -- Children are only accepted by the API when @isToggleable@ is @True@.
    Heading4Block
      { richText :: Vector RichText,
        color :: Color,
        isToggleable :: Bool,
        children :: Vector BlockContent
      }
  | -- | Tab block (container).
    TabBlock
      { children :: Vector BlockContent
      }
  | -- | Meeting notes block (read-only). Also decoded from the deprecated
    -- @transcription@ block type.
    MeetingNotesBlock
      { meetingTitle :: Maybe (Vector RichText),
        meetingStatus :: Maybe MeetingNotesStatus,
        calendarEvent :: Maybe MeetingCalendarEvent,
        recording :: Maybe MeetingRecording,
        meetingChildren :: Maybe MeetingNotesChildren
      }
  | -- | Template block (deprecated, but still returned by the API).
    TemplateBlock
      { richText :: Vector RichText,
        children :: Vector BlockContent
      }
  | -- | Block type the API does not support; carries the underlying
    -- @block_type@ when Notion reports it.
    UnsupportedBlock (Maybe Text)
  | -- | Fallback for block types not yet modeled.
    UnknownBlock Text Value
  deriving stock (Eq, Generic, Show)

-- ---------------------------------------------------------------------------
-- Serialization helpers
-- ---------------------------------------------------------------------------

-- | Include @\"children\"@ key only when the vector is non-empty.
childrenPairs :: Vector BlockContent -> [Pair]
childrenPairs cs
  | Vector.null cs = []
  | otherwise = ["children" .= cs]

-- | Extract the JSON type name from a 'BlockContent' value.
blockContentType :: BlockContent -> Text
blockContentType = fst . blockContentFields

-- | Decompose a 'BlockContent' into its JSON type name and inner content
-- value. This is the serialization primitive used by both 'ToJSON BlockContent'
-- (full format with @\"type\"@ key) and 'blockUpdateFromContent'.
blockContentFields :: BlockContent -> (Text, Value)
blockContentFields = \case
  ParagraphBlock {..} ->
    ( "paragraph",
      object $
        ["rich_text" .= richText, "color" .= color]
          <> maybe [] (\i -> ["icon" .= i]) paragraphIcon
          <> childrenPairs children
    )
  Heading1Block {..} ->
    ( "heading_1",
      object $
        ["rich_text" .= richText, "color" .= color, "is_toggleable" .= isToggleable]
          <> childrenPairs children
    )
  Heading2Block {..} ->
    ( "heading_2",
      object $
        ["rich_text" .= richText, "color" .= color, "is_toggleable" .= isToggleable]
          <> childrenPairs children
    )
  Heading3Block {..} ->
    ( "heading_3",
      object $
        ["rich_text" .= richText, "color" .= color, "is_toggleable" .= isToggleable]
          <> childrenPairs children
    )
  BulletedListItemBlock {..} ->
    ( "bulleted_list_item",
      object $
        ["rich_text" .= richText, "color" .= color]
          <> childrenPairs children
    )
  NumberedListItemBlock {..} ->
    ( "numbered_list_item",
      object $
        ["rich_text" .= richText, "color" .= color]
          <> maybe [] (\f -> ["list_format" .= f]) listFormat
          <> maybe [] (\i -> ["list_start_index" .= i]) listStartIndex
          <> childrenPairs children
    )
  ToDoBlock {..} ->
    ( "to_do",
      object $
        ["rich_text" .= richText, "color" .= color, "checked" .= checked]
          <> childrenPairs children
    )
  ToggleBlock {..} ->
    ( "toggle",
      object $
        ["rich_text" .= richText, "color" .= color]
          <> childrenPairs children
    )
  QuoteBlock {..} ->
    ( "quote",
      object $
        ["rich_text" .= richText, "color" .= color]
          <> childrenPairs children
    )
  CalloutBlock {..} ->
    ( "callout",
      object $
        ["rich_text" .= richText, "color" .= color]
          <> maybe [] (\i -> ["icon" .= i]) calloutIcon
          <> childrenPairs children
    )
  CodeBlock {..} ->
    ("code", object ["rich_text" .= richText, "caption" .= caption, "language" .= language])
  EquationBlock {..} ->
    ("equation", object ["expression" .= expression])
  ImageBlock {..} ->
    ("image", object $ fileSourcePairs imageSource <> ["caption" .= caption])
  VideoBlock {..} ->
    ("video", object $ fileSourcePairs videoSource <> ["caption" .= caption])
  AudioBlock {..} ->
    ("audio", object $ fileSourcePairs audioSource <> ["caption" .= caption])
  FileBlock {..} ->
    ( "file",
      object $
        fileSourcePairs fileSource
          <> ["caption" .= caption]
          <> maybe [] (\n -> ["name" .= n]) fileName
    )
  PdfBlock {..} ->
    ("pdf", object $ fileSourcePairs pdfSource <> ["caption" .= caption])
  BookmarkBlock {..} ->
    ("bookmark", object ["url" .= url, "caption" .= caption])
  EmbedBlock {..} ->
    ("embed", object ["url" .= url, "caption" .= caption])
  LinkToPageBlock {..} ->
    ("link_to_page", toJSON linkTarget)
  LinkPreviewBlock {..} ->
    ("link_preview", object ["url" .= url])
  DividerBlock ->
    ("divider", object [])
  BreadcrumbBlock ->
    ("breadcrumb", object [])
  TableOfContentsBlock {..} ->
    ("table_of_contents", object ["color" .= color])
  ColumnListBlock {..} ->
    ("column_list", object $ childrenPairs children)
  ColumnBlock {..} ->
    ( "column",
      object $
        maybe [] (\r -> ["width_ratio" .= r]) widthRatio
          <> childrenPairs children
    )
  TableBlock {..} ->
    ( "table",
      object $
        ["table_width" .= tableWidth, "has_column_header" .= hasColumnHeader, "has_row_header" .= hasRowHeader]
          <> childrenPairs children
    )
  TableRowBlock {..} ->
    ("table_row", object ["cells" .= cells])
  ChildPageBlock {..} ->
    ("child_page", object ["title" .= title])
  ChildDatabaseBlock {..} ->
    ("child_database", object ["title" .= title])
  SyncedBlockContent {..} ->
    ( "synced_block",
      object $
        ["synced_from" .= syncedFrom]
          <> childrenPairs children
    )
  Heading4Block {..} ->
    ( "heading_4",
      object $
        ["rich_text" .= richText, "color" .= color, "is_toggleable" .= isToggleable]
          <> childrenPairs children
    )
  TabBlock {..} ->
    ("tab", object $ childrenPairs children)
  MeetingNotesBlock {..} ->
    ( "meeting_notes",
      object $
        maybe [] (\t -> ["title" .= t]) meetingTitle
          <> maybe [] (\s -> ["status" .= s]) meetingStatus
          <> maybe [] (\ce -> ["calendar_event" .= ce]) calendarEvent
          <> maybe [] (\r -> ["recording" .= r]) recording
          <> maybe [] (\c -> ["children" .= c]) meetingChildren
    )
  TemplateBlock {..} ->
    ( "template",
      object $
        ["rich_text" .= richText]
          <> childrenPairs children
    )
  UnsupportedBlock blockType ->
    ("unsupported", object (maybe [] (\t -> ["block_type" .= t]) blockType))
  UnknownBlock typeName val ->
    (typeName, val)

-- ---------------------------------------------------------------------------
-- JSON instances
-- ---------------------------------------------------------------------------

-- | Parse block content from a type name and the inner JSON value (the value
-- under the type key). This is called by 'BlockObject'\'s 'FromJSON' instance
-- and by the standalone 'FromJSON BlockContent'.
parseBlockContent :: Text -> Value -> Parser BlockContent
parseBlockContent typeName val = case typeName of
  "paragraph" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    paragraphIcon <- o .:? "icon"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure ParagraphBlock {..}
  "heading_1" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    isToggleable <- fromMaybe False <$> o .:? "is_toggleable"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure Heading1Block {..}
  "heading_2" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    isToggleable <- fromMaybe False <$> o .:? "is_toggleable"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure Heading2Block {..}
  "heading_3" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    isToggleable <- fromMaybe False <$> o .:? "is_toggleable"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure Heading3Block {..}
  "bulleted_list_item" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure BulletedListItemBlock {..}
  "numbered_list_item" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    listFormat <- o .:? "list_format"
    listStartIndex <- o .:? "list_start_index"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure NumberedListItemBlock {..}
  "to_do" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    checked <- fromMaybe False <$> o .:? "checked"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure ToDoBlock {..}
  "toggle" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure ToggleBlock {..}
  "quote" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure QuoteBlock {..}
  "callout" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    calloutIcon <- o .:? "icon"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure CalloutBlock {..}
  "code" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    caption <- fromMaybe Vector.empty <$> o .:? "caption"
    language <- o .: "language"
    pure CodeBlock {..}
  "equation" -> parseObj $ \o -> do
    expression <- o .: "expression"
    pure EquationBlock {..}
  "image" -> parseObj $ \o -> do
    imageSource <- parseFileSource o
    caption <- fromMaybe Vector.empty <$> o .:? "caption"
    pure ImageBlock {..}
  "video" -> parseObj $ \o -> do
    videoSource <- parseFileSource o
    caption <- fromMaybe Vector.empty <$> o .:? "caption"
    pure VideoBlock {..}
  "audio" -> parseObj $ \o -> do
    audioSource <- parseFileSource o
    caption <- fromMaybe Vector.empty <$> o .:? "caption"
    pure AudioBlock {..}
  "file" -> parseObj $ \o -> do
    fileSource <- parseFileSource o
    caption <- fromMaybe Vector.empty <$> o .:? "caption"
    fileName <- o .:? "name"
    pure FileBlock {..}
  "pdf" -> parseObj $ \o -> do
    pdfSource <- parseFileSource o
    caption <- fromMaybe Vector.empty <$> o .:? "caption"
    pure PdfBlock {..}
  "bookmark" -> parseObj $ \o -> do
    url <- o .: "url"
    caption <- fromMaybe Vector.empty <$> o .:? "caption"
    pure BookmarkBlock {..}
  "embed" -> parseObj $ \o -> do
    url <- o .: "url"
    caption <- fromMaybe Vector.empty <$> o .:? "caption"
    pure EmbedBlock {..}
  "link_to_page" -> do
    linkTarget <- Aeson.parseJSON val
    pure LinkToPageBlock {..}
  "link_preview" -> parseObj $ \o -> do
    url <- o .: "url"
    pure LinkPreviewBlock {..}
  "divider" -> pure DividerBlock
  "breadcrumb" -> pure BreadcrumbBlock
  "table_of_contents" -> parseObj $ \o -> do
    color <- fromMaybe Default <$> o .:? "color"
    pure TableOfContentsBlock {..}
  "column_list" -> parseObj $ \o -> do
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure ColumnListBlock {..}
  "column" -> parseObj $ \o -> do
    widthRatio <- o .:? "width_ratio"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure ColumnBlock {..}
  "table" -> parseObj $ \o -> do
    tableWidth <- o .: "table_width"
    hasColumnHeader <- o .: "has_column_header"
    hasRowHeader <- o .: "has_row_header"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure TableBlock {..}
  "table_row" -> parseObj $ \o -> do
    cells <- o .: "cells"
    pure TableRowBlock {..}
  "child_page" -> parseObj $ \o -> do
    title <- o .: "title"
    pure ChildPageBlock {..}
  "child_database" -> parseObj $ \o -> do
    title <- o .: "title"
    pure ChildDatabaseBlock {..}
  "synced_block" -> parseObj $ \o -> do
    syncedFrom <- o .: "synced_from"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure SyncedBlockContent {..}
  "heading_4" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    color <- fromMaybe Default <$> o .:? "color"
    isToggleable <- fromMaybe False <$> o .:? "is_toggleable"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure Heading4Block {..}
  "tab" -> parseObj $ \o -> do
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure TabBlock {..}
  "meeting_notes" -> parseMeetingNotes
  "transcription" -> parseMeetingNotes
  "template" -> parseObj $ \o -> do
    richText <- o .: "rich_text"
    children <- fromMaybe Vector.empty <$> o .:? "children"
    pure TemplateBlock {..}
  "unsupported" -> case val of
    Object o -> UnsupportedBlock <$> o .:? "block_type"
    _ -> pure (UnsupportedBlock Nothing)
  _ -> pure (UnknownBlock typeName val)
  where
    parseMeetingNotes = parseObj $ \o -> do
      meetingTitle <- o .:? "title"
      meetingStatus <- o .:? "status"
      calendarEvent <- o .:? "calendar_event"
      recording <- o .:? "recording"
      meetingChildren <- o .:? "children"
      pure MeetingNotesBlock {..}
    parseObj :: (Aeson.Object -> Parser BlockContent) -> Parser BlockContent
    parseObj f = case val of
      Object o -> f o
      _ -> fail $ "Expected object for block type " <> unpack typeName

instance FromJSON BlockContent where
  parseJSON = \case
    Object o -> do
      typeName <- o .: "type"
      contentVal <- o .: Key.fromText typeName
      parseBlockContent typeName contentVal
    _ -> fail "Expected object for BlockContent"

instance ToJSON BlockContent where
  toJSON bc =
    let (typeName, inner) = blockContentFields bc
     in object ["type" .= typeName, Key.fromText typeName .= inner]

-- ---------------------------------------------------------------------------
-- Block updates
-- ---------------------------------------------------------------------------

-- | Body of @PATCH \/v1\/blocks\/{block_id}@. Every field is optional: send
-- only 'inTrash' to trash or restore a block ('trashBlockUpdate').
--
-- Updates are a different shape from block creation: no update accepts
-- @children@, a table update accepts only its header flags, and some block
-- types cannot be updated at all. Build one with 'mkBlockUpdate', or convert
-- full block content with 'blockUpdateFromContent'.
data BlockUpdatePayload = BlockUpdatePayload
  { -- | Named @updateContent@ so it does not clash with
    -- 'Notion.V1.Blocks.BlockObject'\'s @content@.
    updateContent :: Maybe BlockUpdateContent,
    inTrash :: Maybe Bool
  }
  deriving stock (Eq, Generic, Show)

-- | Paragraph and callout update. Every field is optional.
data ParagraphUpdate = ParagraphUpdate
  { richText :: Maybe (Vector RichText),
    color :: Maybe Color,
    icon :: Maybe Icon
  }
  deriving stock (Eq, Generic, Show)

-- | Heading update. The API requires the rich text.
data HeadingUpdate = HeadingUpdate
  { richText :: Vector RichText,
    color :: Maybe Color,
    isToggleable :: Maybe Bool
  }
  deriving stock (Eq, Generic, Show)

-- | List item, quote and toggle update. The API requires the rich text.
data TextColorUpdate = TextColorUpdate
  { richText :: Vector RichText,
    color :: Maybe Color
  }
  deriving stock (Eq, Generic, Show)

-- | To-do update. Every field is optional.
data ToDoUpdate = ToDoUpdate
  { richText :: Maybe (Vector RichText),
    checked :: Maybe Bool,
    color :: Maybe Color
  }
  deriving stock (Eq, Generic, Show)

-- | Code block update. Every field is optional.
data CodeUpdate = CodeUpdate
  { richText :: Maybe (Vector RichText),
    language :: Maybe CodeLanguage,
    caption :: Maybe (Vector RichText)
  }
  deriving stock (Eq, Generic, Show)

-- | New source for a media block. Notion-hosted files cannot be set directly.
data MediaSourceUpdate
  = UpdateExternalSource Text
  | UpdateFileUploadSource UUID
  deriving stock (Eq, Generic, Show)

-- | Image, video, PDF, audio and file update.
data MediaUpdate = MediaUpdate
  { caption :: Maybe (Vector RichText),
    source :: Maybe MediaSourceUpdate
  }
  deriving stock (Eq, Generic, Show)

-- | Embed and bookmark update.
data UrlCaptionUpdate = UrlCaptionUpdate
  { url :: Maybe Text,
    caption :: Maybe (Vector RichText)
  }
  deriving stock (Eq, Generic, Show)

-- | Table update: only the header flags can change.
data TableUpdate = TableUpdate
  { hasColumnHeader :: Maybe Bool,
    hasRowHeader :: Maybe Bool
  }
  deriving stock (Eq, Generic, Show)

-- | One constructor per block type the API allows updating.
data BlockUpdateContent
  = UpdateParagraph ParagraphUpdate
  | UpdateHeading1 HeadingUpdate
  | UpdateHeading2 HeadingUpdate
  | UpdateHeading3 HeadingUpdate
  | UpdateHeading4 HeadingUpdate
  | UpdateBulletedListItem TextColorUpdate
  | UpdateNumberedListItem TextColorUpdate
  | UpdateQuote TextColorUpdate
  | UpdateToggle TextColorUpdate
  | UpdateToDo ToDoUpdate
  | UpdateCallout ParagraphUpdate
  | UpdateTemplateBlock (Vector RichText)
  | UpdateCode CodeUpdate
  | UpdateEquation Text
  | UpdateImage MediaUpdate
  | UpdateVideo MediaUpdate
  | UpdatePdf MediaUpdate
  | UpdateAudio MediaUpdate
  | -- | File block; the 'Maybe Text' is the new file name.
    UpdateFile MediaUpdate (Maybe Text)
  | UpdateEmbed UrlCaptionUpdate
  | UpdateBookmark UrlCaptionUpdate
  | UpdateDivider
  | UpdateBreadcrumb
  | UpdateTab
  | UpdateTableOfContents (Maybe Color)
  | UpdateLinkToPage LinkTarget
  | UpdateTableRow (Vector (Vector RichText))
  | UpdateSyncedBlock SyncedFrom
  | UpdateTable TableUpdate
  | -- | Column width ratio between 0 and 1.
    UpdateColumn (Maybe Double)
  deriving stock (Eq, Generic, Show)

instance ToJSON BlockUpdatePayload where
  toJSON BlockUpdatePayload {..} =
    object $
      maybe [] (\c -> let (k, v) = blockUpdateFields c in [Key.fromText k .= v]) updateContent
        <> maybe [] (\t -> ["in_trash" .= t]) inTrash

-- | The block type key and inner object of an update.
blockUpdateFields :: BlockUpdateContent -> (Text, Value)
blockUpdateFields = \case
  UpdateParagraph u -> ("paragraph", paragraphUpdate u)
  UpdateHeading1 u -> ("heading_1", headingUpdate u)
  UpdateHeading2 u -> ("heading_2", headingUpdate u)
  UpdateHeading3 u -> ("heading_3", headingUpdate u)
  UpdateHeading4 u -> ("heading_4", headingUpdate u)
  UpdateBulletedListItem u -> ("bulleted_list_item", textColorUpdate u)
  UpdateNumberedListItem u -> ("numbered_list_item", textColorUpdate u)
  UpdateQuote u -> ("quote", textColorUpdate u)
  UpdateToggle u -> ("toggle", textColorUpdate u)
  UpdateToDo ToDoUpdate {..} ->
    ( "to_do",
      object $ opt "rich_text" richText <> opt "checked" checked <> opt "color" color
    )
  UpdateCallout u -> ("callout", paragraphUpdate u)
  UpdateTemplateBlock rt -> ("template", object ["rich_text" .= rt])
  UpdateCode CodeUpdate {..} ->
    ( "code",
      object $ opt "rich_text" richText <> opt "language" language <> opt "caption" caption
    )
  UpdateEquation e -> ("equation", object ["expression" .= e])
  UpdateImage u -> ("image", object (mediaUpdatePairs u))
  UpdateVideo u -> ("video", object (mediaUpdatePairs u))
  UpdatePdf u -> ("pdf", object (mediaUpdatePairs u))
  UpdateAudio u -> ("audio", object (mediaUpdatePairs u))
  UpdateFile u name -> ("file", object (mediaUpdatePairs u <> opt "name" name))
  UpdateEmbed u -> ("embed", urlCaptionUpdate u)
  UpdateBookmark u -> ("bookmark", urlCaptionUpdate u)
  UpdateDivider -> ("divider", object [])
  UpdateBreadcrumb -> ("breadcrumb", object [])
  UpdateTab -> ("tab", object [])
  UpdateTableOfContents c -> ("table_of_contents", object (opt "color" c))
  UpdateLinkToPage t -> ("link_to_page", toJSON t)
  UpdateTableRow cells -> ("table_row", object ["cells" .= cells])
  UpdateSyncedBlock sf -> ("synced_block", object ["synced_from" .= sf])
  UpdateTable TableUpdate {..} ->
    ( "table",
      object $ opt "has_column_header" hasColumnHeader <> opt "has_row_header" hasRowHeader
    )
  UpdateColumn r -> ("column", object (opt "width_ratio" r))
  where
    opt :: (ToJSON a) => Aeson.Key -> Maybe a -> [Pair]
    opt k = maybe [] (\v -> [k .= v])
    paragraphUpdate ParagraphUpdate {..} =
      object $ opt "rich_text" richText <> opt "color" color <> opt "icon" icon
    headingUpdate HeadingUpdate {..} =
      object $ ["rich_text" .= richText] <> opt "color" color <> opt "is_toggleable" isToggleable
    textColorUpdate TextColorUpdate {..} =
      object $ ["rich_text" .= richText] <> opt "color" color
    urlCaptionUpdate UrlCaptionUpdate {..} =
      object $ opt "url" url <> opt "caption" caption
    mediaUpdatePairs MediaUpdate {..} =
      opt "caption" caption
        <> case source of
          Nothing -> []
          Just (UpdateExternalSource u) -> ["external" .= object ["url" .= u]]
          Just (UpdateFileUploadSource i) -> ["file_upload" .= object ["id" .= i]]

-- | An update that changes the given block content.
mkBlockUpdate :: BlockUpdateContent -> BlockUpdatePayload
mkBlockUpdate c = BlockUpdatePayload {updateContent = Just c, inTrash = Nothing}

-- | An update that moves the block to the trash: @{"in_trash": true}@.
trashBlockUpdate :: BlockUpdatePayload
trashBlockUpdate = BlockUpdatePayload {updateContent = Nothing, inTrash = Just True}

-- | Convert full block content to the equivalent \"set every updatable
-- field\" update. Returns 'Nothing' for block types the API cannot update
-- (child_page, child_database, column_list, link_preview, meeting_notes,
-- unsupported, unknown). Read-only fields (@table_width@, @children@,
-- @list_format@, @list_start_index@, Notion-hosted file URLs) are dropped.
blockUpdateFromContent :: BlockContent -> Maybe BlockUpdateContent
blockUpdateFromContent = \case
  ParagraphBlock {..} -> Just (UpdateParagraph (ParagraphUpdate (Just richText) (Just color) paragraphIcon))
  Heading1Block {..} -> Just (UpdateHeading1 (HeadingUpdate richText (Just color) (Just isToggleable)))
  Heading2Block {..} -> Just (UpdateHeading2 (HeadingUpdate richText (Just color) (Just isToggleable)))
  Heading3Block {..} -> Just (UpdateHeading3 (HeadingUpdate richText (Just color) (Just isToggleable)))
  Heading4Block {..} -> Just (UpdateHeading4 (HeadingUpdate richText (Just color) (Just isToggleable)))
  BulletedListItemBlock {..} -> Just (UpdateBulletedListItem (TextColorUpdate richText (Just color)))
  NumberedListItemBlock {..} -> Just (UpdateNumberedListItem (TextColorUpdate richText (Just color)))
  ToDoBlock {..} -> Just (UpdateToDo (ToDoUpdate (Just richText) (Just checked) (Just color)))
  ToggleBlock {..} -> Just (UpdateToggle (TextColorUpdate richText (Just color)))
  QuoteBlock {..} -> Just (UpdateQuote (TextColorUpdate richText (Just color)))
  CalloutBlock {..} -> Just (UpdateCallout (ParagraphUpdate (Just richText) (Just color) calloutIcon))
  CodeBlock {..} -> Just (UpdateCode (CodeUpdate (Just richText) (Just language) (Just caption)))
  EquationBlock {..} -> Just (UpdateEquation expression)
  ImageBlock {..} -> Just (UpdateImage (media imageSource caption))
  VideoBlock {..} -> Just (UpdateVideo (media videoSource caption))
  AudioBlock {..} -> Just (UpdateAudio (media audioSource caption))
  PdfBlock {..} -> Just (UpdatePdf (media pdfSource caption))
  FileBlock {..} -> Just (UpdateFile (media fileSource caption) fileName)
  BookmarkBlock {..} -> Just (UpdateBookmark (UrlCaptionUpdate (Just url) (Just caption)))
  EmbedBlock {..} -> Just (UpdateEmbed (UrlCaptionUpdate (Just url) (Just caption)))
  LinkToPageBlock {..} -> Just (UpdateLinkToPage linkTarget)
  DividerBlock -> Just UpdateDivider
  BreadcrumbBlock -> Just UpdateBreadcrumb
  TableOfContentsBlock {..} -> Just (UpdateTableOfContents (Just color))
  ColumnBlock {..} -> Just (UpdateColumn widthRatio)
  TableBlock {..} -> Just (UpdateTable (TableUpdate (Just hasColumnHeader) (Just hasRowHeader)))
  TableRowBlock {..} -> Just (UpdateTableRow cells)
  SyncedBlockContent {..} -> Just (UpdateSyncedBlock syncedFrom)
  TabBlock {} -> Just UpdateTab
  TemplateBlock {..} -> Just (UpdateTemplateBlock richText)
  LinkPreviewBlock {} -> Nothing
  ColumnListBlock {} -> Nothing
  ChildPageBlock {} -> Nothing
  ChildDatabaseBlock {} -> Nothing
  MeetingNotesBlock {} -> Nothing
  UnsupportedBlock _ -> Nothing
  UnknownBlock _ _ -> Nothing
  where
    media src cap = MediaUpdate (Just cap) (sourceUpdate src)
    sourceUpdate = \case
      ExternalSource (ExternalFile u) -> Just (UpdateExternalSource u)
      FileUploadSource i -> Just (UpdateFileUploadSource i)
      NotionSource _ -> Nothing

-- ---------------------------------------------------------------------------
-- Smart constructors
-- ---------------------------------------------------------------------------

-- | Build a 'RichText' vector containing a single plain-text segment.
-- This is the most common way to create block content.
mkRichText :: Text -> Vector RichText
mkRichText t =
  Vector.singleton
    RichText
      { plainText = t,
        href = Nothing,
        annotations = defaultAnnotations,
        type_ = "text",
        content = TextContentWrapper (TextContent {content = t, link = Nothing})
      }

-- | Create a paragraph block from plain text.
textBlock :: Text -> BlockContent
textBlock = paragraphBlock . mkRichText

-- | Create a paragraph block.
paragraphBlock :: Vector RichText -> BlockContent
paragraphBlock rt = ParagraphBlock rt Default Nothing Vector.empty

-- | Create a heading block at the given level (1, 2, 3, or 4; defaults to 3).
headingBlock :: Int -> Vector RichText -> BlockContent
headingBlock 1 rt = Heading1Block rt Default False Vector.empty
headingBlock 2 rt = Heading2Block rt Default False Vector.empty
headingBlock 4 rt = Heading4Block rt Default False Vector.empty
headingBlock _ rt = Heading3Block rt Default False Vector.empty

-- | Create a bulleted list item block.
bulletedListItemBlock :: Vector RichText -> BlockContent
bulletedListItemBlock rt = BulletedListItemBlock rt Default Vector.empty

-- | Create a numbered list item block.
numberedListItemBlock :: Vector RichText -> BlockContent
numberedListItemBlock rt = NumberedListItemBlock rt Default Nothing Nothing Vector.empty

-- | Create a to-do block.
toDoBlock :: Vector RichText -> Bool -> BlockContent
toDoBlock rt isChecked = ToDoBlock rt Default isChecked Vector.empty

-- | Create a toggle block.
toggleBlock :: Vector RichText -> BlockContent
toggleBlock rt = ToggleBlock rt Default Vector.empty

-- | Create a quote block.
quoteBlock :: Vector RichText -> BlockContent
quoteBlock rt = QuoteBlock rt Default Vector.empty

-- | Create a callout block with an optional icon.
calloutBlock :: Vector RichText -> Maybe Icon -> BlockContent
calloutBlock rt icon = CalloutBlock rt Default icon Vector.empty

-- | Create a code block with a language.
codeBlock :: Vector RichText -> CodeLanguage -> BlockContent
codeBlock rt lang = CodeBlock rt Vector.empty lang

-- | Create an equation block from a KaTeX expression.
equationBlock :: Text -> BlockContent
equationBlock = EquationBlock

-- | Create a bookmark block from a URL.
bookmarkBlock :: Text -> BlockContent
bookmarkBlock u = BookmarkBlock u Vector.empty

-- | Create a divider block.
dividerBlock :: BlockContent
dividerBlock = DividerBlock

-- | Create an image block from a file source.
imageBlock :: FileSource -> BlockContent
imageBlock src = ImageBlock src Vector.empty

-- | Build a tab block. Each tab item is a paragraph (its title), an optional
-- icon, and the tab's content blocks — the only child shape the API accepts.
tabBlock :: Vector (Vector RichText, Maybe Icon, Vector BlockContent) -> BlockContent
tabBlock items =
  TabBlock (fmap (\(rt, ic, cs) -> ParagraphBlock rt Default ic cs) items)

-- | Attach children to a block. For constructors that do not support
-- children, the block is returned unchanged.
withChildren :: BlockContent -> Vector BlockContent -> BlockContent
withChildren block cs = case block of
  ParagraphBlock {} -> block {children = cs}
  Heading1Block {} -> block {children = cs}
  Heading2Block {} -> block {children = cs}
  Heading3Block {} -> block {children = cs}
  BulletedListItemBlock {} -> block {children = cs}
  NumberedListItemBlock {} -> block {children = cs}
  ToDoBlock {} -> block {children = cs}
  ToggleBlock {} -> block {children = cs}
  QuoteBlock {} -> block {children = cs}
  CalloutBlock {} -> block {children = cs}
  ColumnListBlock {} -> block {children = cs}
  ColumnBlock {} -> block {children = cs}
  TableBlock {} -> block {children = cs}
  SyncedBlockContent {} -> block {children = cs}
  Heading4Block {} -> block {children = cs}
  TabBlock {} -> block {children = cs}
  TemplateBlock {} -> block {children = cs}
  _ -> block