# =confluence — Atlassian Confluence Cloud (official Remote MCP,
# mcp.atlassian.com/v1/mcp — the SAME server as =jira-cloud).
#
# Written against the REAL Atlassian Remote MCP tool surface (verified against
# the live tool schema + official "Supported tools" docs, 2026-06-03):
#
#   searchConfluenceUsingCql(cloudId, cql, limit?, cqlcontext?, cursor?, expand?)
#     → CQL search. cql is REQUIRED. limit default 25, max 250.
#   getConfluencePage(cloudId, pageId, contentFormat?, contentType?)
#   getConfluenceSpaces(cloudId, keys?, limit?, ...)
#
# Response envelope: the underlying Confluence CQL search REST shape is
#   { results: [ { content: { id, type, title, status, space{key,name},
#                             _links: { webui } },
#                  title, excerpt, url, lastModified, friendlyLastModified,
#                  user{...} }, ... ],
#     _links: { base } }
# The MCP may re-wrap this under structured_content / data. The page URL is
# RELATIVE (content._links.webui, e.g. /spaces/ENG/pages/123/Title) — the
# absolute browse URL is _links.base (the wiki root) + that webui path. We
# build that in the transform and fall back to the result-level `url`.
#
# cloudId: REQUIRED on every Confluence call, but NOT set here — the bolt-api
#   dispatcher auto-resolves it once per user (getAccessibleAtlassianResources)
#   and injects it on every Jira/Confluence call, because the MCP id below
#   (`mcp-atlassian-com`) is recognized by is_atlassian_cloud_mcp
#   (src/routes/agent.rs). Do NOT add cloudId to args.
schema_version: 1
id: confluence
chip: =confluence
title: Confluence (Cloud)
icon: book-open
description: Search Confluence pages by space, title, label, type, author, or hierarchy (Atlassian Cloud), with a full rich page preview.
placeholder_examples:
  - "release notes"
  - "space:ENG architecture"
  - "space:HR,Marketing title:roadmap"
  - "label:policy sort:updated"
  - "type:blogpost author:alex"
  - "ancestor:123456  (children of a page)"
emits: Document
# Token lives under MCP id `mcp-atlassian-com` (where OAuth was completed) and
# the Confluence content scope. Using the generic `confluence_user` profile
# (provider `confluence`) yields a false needs_oauth even when the official
# Atlassian MCP is connected — same gotcha that drove jira_cloud_user_obo.
auth_profile_ref: confluence_cloud_user_obo

requires:
  - mcp: mcp-atlassian-com
    version: ">=1"
    tools:
      - searchConfluenceUsingCql
      - getConfluenceSpaces
    scopes:
      - read:confluence-content.summary
      - read:confluence-content.all

# Launcher trigger semantics. confirm = the right default for an MCP/network
# backend (don't hammer Atlassian on every keystroke). The empty default scope
# (recently-updated pages) always auto-loads; free-text search runs on Enter.
dispatch:
  mode: confirm
  confirm_label: 'Search Confluence for "{{query}}"'
  confirm_hint: Press Enter to search all of Confluence

scopes:
  # DEFAULT: recently-updated pages, newest first, top ~{{limit}}.
  - id: recent
    label: Recently updated
    default: true
    flow:
      # Resolve the user's site URL once per flow (mirrors the jira-cloud
      # manifest). getAccessibleAtlassianResources returns
      # [{id, url, scopes, avatarUrl}, ...] where `url` is the actual
      # browsable site root (e.g. https://your-site.atlassian.net). The
      # bolt-api dispatcher already calls this tool for cloudId injection;
      # the MCP server caches it so the second call is ~free. Threaded
      # through `out: site` and read from the shared transform via $siteUrl.
      # Without this, the CQL search response's `_links.base` field is
      # frequently null on the Remote MCP and row.url comes back empty —
      # Enter then has nothing to open and the launcher falls through to
      # the "Not available from the preview yet" disabled-action tooltip.
      - call: &resources_call
          kind: mcp
          mcp: mcp-atlassian-com
          tool: getAccessibleAtlassianResources
        out: site
      - call:
          kind: mcp
          mcp: mcp-atlassian-com
          tool: searchConfluenceUsingCql
          args:
            cql: 'type = page ORDER BY lastmodified DESC'
            limit: '{{limit}}'
            # Expand the result's content so space{key,name} + version.when
            # (ISO timestamp) + body.view (server-rendered page HTML for the
            # right preview pane) come back inline — one search call, no per-row
            # getConfluencePage fan-out. Without space/version the Remote MCP
            # omits the space name (card showed "—") and the modified date;
            # without body.view the preview pane has only the search excerpt.
            expand: 'content.space,content.version,content.body.view'
        out: r
      # Normalize whatever envelope the dispatcher/MCP produces
      # ({structured_content:{data:{results}}}, flat {results}, {nodes},
      # {items}, or bare array — `?` suppresses index-on-array errors) into a
      # flat, render-ready Document row. Flat top-level keys (title, url, …) are
      # what the entity_preview card binds to ({{row.x}}); they also back the
      # list presentation. Optional values default to "—".
      - transform: &pages_to_rows |
          ((.r.structured_content?.data? // .r.structured_content? // .r) // {}) as $p
          | ((.site.structured_content?.data? // .site.structured_content? // .site) // {}) as $sp
          | ( ($sp | if type == "array" then . else empty end)
              // ($sp.resources?)
              // ($sp.values?)
              // [] ) as $resources
          | (($resources[0] // {}) | (.url // "")) as $siteUrl
          | ( if ($siteUrl | length) > 0 then ($siteUrl + "/wiki") else "" end ) as $siteWiki
          | ($p._links?.base? // "") as $base
          | ( ($p.results?)
              // ($p.nodes?)
              // ($p.items?)
              // ($p.data?)
              // ($p | if type == "array" then . else empty end)
              // ($p | if (type == "object" and (has("content") or has("title"))) then [.] else empty end)
              // [] ) as $rows
          | ($rows | map(
                . as $res
                | ($res.content // $res) as $c
                | ($c.space // $res.space // {}) as $sp
                | ($res.user // $c.history?.createdBy? // {}) as $usr
                | ($c._links // {}) as $cl
                # URL precedence: search response's _links.base + content._links.webui
                # (most accurate, includes the page title slug); fall back to
                # $siteWiki ($siteUrl + "/wiki") + webui when the search
                # response didn't include _links.base (Remote MCP often
                # returns it null); final fallback to direct page-id route
                # /wiki/spaces/{spaceKey}/pages/{pageId} which always works
                # when we know the space + id.
                | ( ($c.id // $res.id // "") ) as $pageId
                | ( if (($base | length) > 0) and (($cl.webui? // "") | length) > 0
                      then ($base + $cl.webui)
                    elif ($siteWiki | length) > 0 and (($cl.webui? // "") | length) > 0
                      then ($siteWiki + $cl.webui)
                    elif ($siteWiki | length) > 0 and (($sp.key? // "") | length) > 0 and ($pageId | length) > 0
                      then ($siteWiki + "/spaces/" + $sp.key + "/pages/" + $pageId)
                    elif (($res.url? // "") | length) > 0 and ($base | length) > 0
                      then ($base + $res.url)
                    else "" end ) as $url
                # Prefer ISO timestamps (lastModified / version.when) so the
                # client `date:'rel'` filter can parse them into "2d ago".
                # friendlyLastModified ("2 days ago") is the last-resort fallback —
                # the client date filter passes unparseable strings through as-is.
                | ( ($res.lastModified
                     // $c.version?.when? // $c.history?.lastUpdated?.when?
                     // $res.friendlyLastModified // "—") ) as $modified
                | (($c.type // "page")) as $ctype
                # Humanize the content type for the card subtitle/facts.
                | ( if $ctype == "page" then "Page"
                    elif $ctype == "blogpost" then "Blog post"
                    else (($ctype[0:1] | ascii_upcase) + $ctype[1:]) end ) as $typeLabel
                # "Engineering (ENG)" — space name with key; "—" when absent.
                | ( if (($sp.name // "") | length) > 0
                      then ($sp.name + (if (($sp.key // "") | length) > 0
                                        then " (" + $sp.key + ")" else "" end))
                    else "—" end ) as $spaceLabel
                # Full rendered page body for the right preview pane. The CQL
                # search returns it inline when `content.body.view` is expanded
                # (view = the server-rendered XHTML — macros resolved, headings,
                # tables, lists, links). It rides through the client's
                # renderRichMarkdown(), which feeds it to marked (HTML passes
                # through) then DOMPurify — so this raw page HTML is SANITIZED at
                # the render chokepoint; no script/onload survives. Fall back to
                # the storage format, then the search excerpt, so a page with no
                # expandable view still shows something. Capped so a giant page
                # doesn't bloat every list row's payload.
                | ( ($c.body?.view?.value? // $c.body?.storage?.value?
                     // $res.excerpt? // "") ) as $bodyRaw
                | ( if ($bodyRaw | length) > 24000 then ($bodyRaw[0:24000] + "\n\n…") else $bodyRaw end ) as $bodyHtml
                | {
                    id: ($c.id // $res.id // ""),
                    title: ($c.title // $res.title // "(untitled)"),
                    url: $url,
                    _url: $url,
                    _siteUrl: $siteUrl,
                    contentType: $ctype,
                    typeLabel: $typeLabel,
                    spaceName: ($sp.name // "—"),
                    spaceKey: ($sp.key // ""),
                    spaceLabel: $spaceLabel,
                    excerpt: ((($res.excerpt // "") | gsub("[\\n\\r]+"; " "))
                              | if length > 280 then (.[0:277] + "…") else . end
                              | if . == "" then "No preview" else . end),
                    authorName: ($usr.displayName // $usr.publicName // "—"),
                    authorAvatar: ($usr.profilePicture?.path? // ""),
                    lastModified: $modified,
                    # Rich body for the right preview pane (renderRichMarkdown →
                    # sanitized). Empty when the page had no expandable body —
                    # the pane then simply omits the body section (no
                    # placeholder), and the Preview-excerpt fact still shows.
                    body: $bodyHtml,
                    subline: ([ ($sp.name // empty), $typeLabel ]
                              | map(select(. != null and . != "")) | join("  ·  "))
                  }
              ))
        out: enriched
      - emit: enriched

  # Typing after the chip searches all of Confluence.
  #
  # ONE converged CQL builder (replaces the old per-token forked branches —
  # space:/author:/text were three near-identical flows that drifted; §1). A
  # single jq pass parses ALL `key:value` tokens out of the raw query and
  # compiles ONE safe CQL string, so every token shares the same escaping,
  # type-restriction, and ordering rule — no path can skip a fix applied to
  # another. Supported tokens (compile to the real CQL surface):
  #   space:KEY          → space = "KEY"        (KEY,KEY2 → space IN (...))
  #   title:foo          → title ~ "foo*"
  #   label:policy       → label = "policy"     (a,b → label IN (...))
  #   type:page|blogpost → type = page          (overrides the default page-only)
  #   author:user        → creator = "user"     (alias: creator:)
  #   ancestor:<pageId>  → ancestor = <id>      (descendants → hierarchy view)
  #   parent:<pageId>    → parent = <id>        (direct children)
  #   sort:updated|created|title  → ORDER BY ... DESC  (alias: order:)
  #   <bare words>       → (title ~ "words*" OR text ~ "words")
  # All string literals are escaped for the CQL literal (\\ and " doubled),
  # mirroring jql_escape. Numeric ids (ancestor/parent) and the type/sort enums
  # are allow-listed, never interpolated raw, so the CQL can't be injected.
  - id: search
    label: Search
    match:
      - text: '{{query}}'
        flow:
          - call: *resources_call
            out: site
          - transform: &query_to_cql |
              # Mirrors the jira-cloud JQL builder (same scan→reduce→clauses
              # idiom, jaq-safe). All `key:value` tokens are scanned out, reduced
              # into clauses, and joined with AND; leftover bare words become a
              # fuzzy (title OR text) match. Every string value is doubled-quote
              # escaped inline. type/sort are allow-listed enums (never raw-
              # interpolated); ancestor/parent are digit-stripped — so the
              # composed CQL can't be injected.
                (.query // "") as $q
                | if ($q | test("(?i)( = | != | ~ | !~ | in |order by|currentuser\\(|now\\(|startofday\\()")) then { cql: $q }
                  else
                    ([ $q | split(" ")[] | select(test(":")) ]) as $pairs
                    | (reduce $pairs[] as $p ({clauses:[], ctype:null, order:null};
                        ($p | split(":")) as $kv
                        | ($kv[0] | ascii_downcase) as $k
                        | ($kv[1:] | join(":") | sub("^\"";"") | sub("\"$";"") | gsub("\\\\";"\\\\") | gsub("\"";"\\\"")) as $v
                        | if   $k=="space" then (($v|split(",")) as $ks | if ($ks|length)>1 then .clauses += ["space IN (" + ($ks|map("\"\(.)\"")|join(", ")) + ")"] else .clauses += ["space = \"\($v)\""] end)
                          elif $k=="label" then (($v|split(",")) as $ls | if ($ls|length)>1 then .clauses += ["label IN (" + ($ls|map("\"\(.)\"")|join(", ")) + ")"] else .clauses += ["label = \"\($v)\""] end)
                          elif $k=="title"   then .clauses += ["title ~ \"\($v)*\""]
                          elif $k=="text"    then .clauses += ["text ~ \"\($v)\""]
                          elif ($k=="author" or $k=="creator") then .clauses += ["creator = \"\($v)\""]
                          elif ($k=="contributor") then .clauses += ["contributor = \"\($v)\""]
                          elif $k=="type"    then .ctype = (if (["page","blogpost","attachment","comment"]|index([($v|ascii_downcase)])) then ($v|ascii_downcase) else .ctype end)
                          elif $k=="ancestor" then (($v|gsub("[^0-9]";"")) as $id | if ($id|length)>0 then .clauses += ["ancestor = \($id)"] else . end)
                          elif $k=="parent"   then (($v|gsub("[^0-9]";"")) as $id | if ($id|length)>0 then .clauses += ["parent = \($id)"]   else . end)
                          elif ($k=="sort" or $k=="order") then (($v|ascii_downcase) as $s | if (["created","title"]|index([$s])) then .order = "ORDER BY \($s) DESC" elif ($s=="updated" or $s=="lastmodified" or $s=="modified") then .order = "ORDER BY lastmodified DESC" else . end)
                          else . end)) as $acc
                    | ([ $q | split(" ")[] | select(test(":")|not) ] | join(" ") | gsub("\\\\";"\\\\") | gsub("\"";"\\\"")) as $text
                    | (if ($text|length)>0 then ["(title ~ \"\($text)*\" OR text ~ \"\($text)\")"] else [] end) as $tc
                    | (if $acc.ctype then "type = \($acc.ctype)" else "type = page" end) as $typeClause
                    | (($tc + $acc.clauses + [$typeClause]) | join(" AND ")) as $where
                    | { cql: ($where + " " + ($acc.order // "ORDER BY lastmodified DESC")) }
                  end
            out: q
          - call:
              kind: mcp
              mcp: mcp-atlassian-com
              tool: searchConfluenceUsingCql
              args:
                cql: '{{q.cql}}'
                limit: '{{limit}}'
                expand: 'content.space,content.version,content.body.view'
            out: r
          - transform: *pages_to_rows
            out: enriched
          - emit: enriched

filters:
  - id: space
    kind: enum
    label: Space
    apply: pre
    maps_to: cql.space
    populate:
      - call:
          kind: mcp
          mcp: mcp-atlassian-com
          tool: getConfluenceSpaces
          args:
            limit: 100
        out: r
      - transform: |
          ((.r.structured_content?.data? // .r.structured_content? // .r) // {}) as $p
          | ( ($p.results?) // ($p.values?) // ($p | if type == "array" then . else empty end) // [] )
          | map({ value: (.key // .id // ""), label: (.name // .key // "") })
        out: spaces
      - emit: spaces

actions:
  - id: open
    kind: url
    target: row
    label: Open in Confluence
    icon: external_link
    # Each row carries the absolute browse URL built in the transform
    # (_links.base + content._links.webui).
    url_template: '{{row.url}}'
  - id: copy
    kind: clipboard
    target: row
    label: Copy link
    icon: copy
    value: '{{row.url}}'

presentation:
  widget: list
  # Flat keys (emitted by the transform) so list projection + card binds both
  # resolve. subline packs space · type.
  title_field: title
  subtitle_field: subline
  list_fields:
    - title
    - spaceName
    - lastModified
  # Client-side refine/highlight across the shown fields.
  search_fields:
    - title
    - spaceName
    - spaceKey
    - excerpt
    - authorName
  sort_field: lastModified
  sort_order: desc
  row_key_field: id
  searchable: true
  filterable: true
  # Detail pane bound to the highlighted row. `kind: entity_preview` is the
  # wide pane (media + facts + sticky footer); engines that don't implement it
  # fall back to the standard card. All bind values use FLAT row keys
  # ({{row.x}}) so interpolation resolves without dotted-path support. Empty
  # values already arrive as "—" from the transform.
  # Clean facts card: no hero image (the Confluence author avatar is a relative
  # path that won't load, and a face is the wrong lead for a doc). subtitle packs
  # the "Space · Type" breadcrumb; the facts list carries the freshness signals;
  # the FULL page body (body.view HTML → renderRichMarkdown → sanitized) is the
  # last, richest block, scrollable, mirroring the jira-cloud descriptionFull
  # pattern. The Modified value uses the client `date:'rel'` filter to render
  # "2d ago". The truncated "Preview" excerpt fact was dropped — the real body
  # below supersedes it (and `body` falls back to the excerpt when a page has no
  # expandable view, so a body-less row still shows text).
  right_widget:
    kind: entity_preview
    mode: selected_row
    bind:
      title: '{{row.title}}'
      subtitle: '{{row.subline}}'
      fields:
        - { label: Space,    value: '{{row.spaceLabel}}' }
        - { label: Author,   value: '{{row.authorName}}' }
        - { label: Updated,  value: '{{row.lastModified | date:''rel''}}' }
      body: '{{row.body}}'
      actions:
        - open
        - copy

enabled: true
provisioning_mode: public
departments: []
roles: []
groups: []
