Skip to content
This document describes intent at the time it was written. It is part of the design record, not user documentation, and may not reflect the current behaviour of the code.

009 - confluence command

Purpose: mdd confluence adds Confluence Cloud integration for exporting, creating, and updating pages.

Status: Implemented (2026-05-08)

mdd confluence exports spaces and pages to Markdown with round-trippable frontmatter, creates new Confluence pages from local Markdown, and pushes local edits — including image attachments — back to Confluence. The markdown form is the authoritative mirror; Confluence remains the source-of-truth for the published page.

Export. One .md per Confluence page. export page writes a single page; whole-space exports are covered by S14 sync space ... --read-only (the dedicated one-shot export space command has been removed). Each file is self-describing for round-trip: YAML frontmatter (schema below) plus a visible “Confluence export” callout at the top of the body with a clickable link back to source. Page attachments and embedded images download to <page-name>-attachments/ beside the .md, with the manifest recorded in frontmatter. --no-attachments skips all attachment downloads (text only) — useful for fast previews and for unblocking exports when a specific attachment fails repeatedly.

Create. Renders a local Markdown file to Confluence storage XHTML and POSTs it. Required: --space (or confluence.space_key in frontmatter) plus a title (--title, frontmatter, or H1 fallback). Optional --parent places the new page; otherwise it lands at space root. Any locally referenced images upload as attachments before the page POST so storage XHTML references resolve. On success, the local file’s frontmatter is rewritten with the full Confluence metadata so subsequent edits flow through update page. Idempotent guard: a page_id already in frontmatter is refused with a pointer at update page.

Update. Refuses to push if frontmatter version is older than the current Confluence version (conflict — abort) or if frontmatter / page_id is missing. It also refuses when Confluence reports the page in a different space than the frontmatter’s space_id or space_key (when both sides carry a space id the ids decide, since a space key can be changed; otherwise the keys are compared, case-insensitively). The frontmatter page_id picks the page, so a page elsewhere means the file is pointed at the wrong page. The refusal holds under --yes, and in sync-space it becomes a per-page failure in the run summary.

Before asking to push, update prints a summary to stderr, visible at the default log level:

Update: "<remote title>" (page <id>) in space <remote key>
new title: "<local title>" (only when the title changes)
page body: N lines changed (+A -R) (run with -v to see the diff)
attachments: changes will be uploaded (only when attachments change)

The title and space come from the fetched page, never from frontmatter. The space key is read from the page payload (_links.webui) or looked up by spaceId; when neither works the line says in space unknown. The full unified diff is logged at INFO, so -v shows it above the summary. Pushing then needs a y answer, or --yes (required when stdin is not a TTY). With --yes the summary is logged at INFO instead of printed. --dry-run prints the same summary and stops. The summary and the printed diff carry text from Confluence and from the mirror, so terminal control and bidi override characters in them are shown as U+FFFD (the S19 rule; CRLF line ends are kept as line breaks). Titles, page ids and space keys in the summary lines, and in the space-mismatch refusal, also have their line breaks shown as U+FFFD, so a title cannot add a line to the summary. This is display only: the diff used to decide whether to push, and the body that is pushed, are unchanged. Error messages quoting an HTTP error response body get the same treatment, line breaks included, so the quoted body stays on the error line. --message sets the version comment in Confluence page history. Image sync runs as part of update. On success, frontmatter is rewritten with the new version and updated attachment manifest.

Body-safety guards. Before rendering and pushing, update page refuses two shapes of body that look like accidental content loss:

  • An empty body (nothing but whitespace after the export header is stripped) — refused unless --allow-empty is passed.
  • A body that shrinks to under 10% of the remote body’s length (character count, storage XHTML on both sides) — refused unless --allow-shrink is passed.

Both are overridable per-invocation flags, not config; there is no way to disable the guard for every push from a repo. These are the last check between a bad local file (an accidental truncation, a botched merge, an empty export re-pushed by mistake) and wiping a live Confluence page — update page has no undo beyond Confluence’s own version history. mdd confluence sync-space’s content-push step reuses this same check (see S14 step 4f); there is only one implementation of the guard.

Header strip and footer insert apply to both create and update:

  1. Strip the “Confluence export” callout (the blockquote at the top of the markdown body) before rendering. It exists for readers of the git mirror, not for the Confluence page itself.

  2. Append an MDD footer to the rendered storage XHTML pointing at the mirror’s browse URL for that file — one short <sub><em> line. The footer is idempotent (replaces any previous footer rather than appending), and omitted with a warning when no URL is available. It is the only mdd-injected element on the Confluence page.

    The URL comes from the active mirror backend’s web_url(), not from any host mdd holds itself: which host is “ours” and what a browse URL looks like there is deployment knowledge. A backend with no remote, or no browse convention for its forge (the built-in generic git one), returns None and the footer is skipped. The shared git plumbing — read origin, reject a work-tree on another host, encode the branch and relative path — is mdd.mirror.web.git_blob_url.

Image attachment sync runs in both create and update. The renderer scans storage XHTML for image references and dispatches on three cases: external URLs pass through; existing Confluence-hosted URLs are left alone; local files are uploaded as page attachments (or versioned if their SHA-256 differs from the manifest, or skipped if unchanged). Uploads happen before the body POST/PUT so references resolve at render time, but in update only after the operator has confirmed: the sync first runs in plan-only mode, which logs every file that would be uploaded (name, size, SHA-256, local path) and any SVG that would be rasterized, and rewrites the body the way the real sync would so the printed diff matches what gets pushed. The upload pass runs once the prompt is answered y (or --yes is given); declining uploads nothing. When only attachments changed and the body diff is empty, confirming uploads the changed files without creating a new page version. Under --dry-run the plan-only pass is the whole run: no attachment is uploaded and no PNG is rasterized. The one local write a dry run still performs is rendering ```mermaid fences into <stem>-attachments/, a content-hash-named cache that a real push would produce identically. Filename collisions (two local files with the same basename) are a hard error — Confluence keys attachments by filename within a page. The v2 attachment endpoints are still maturing; the client falls back to v1 for multipart upload as needed. A local reference is only an upload source when it resolves inside the page’s own <stem>-attachments/ directory, in both create and update (and so in sync-space pushes), and no component of the path below that directory starts with a dot. A bare filename is looked up in <stem>-attachments/ first, and a path is resolved against the page’s directory, so for Page.md both ![x](a.png) and ![x](Page-attachments/a.png) upload Page-attachments/a.png. Everything else is skipped with a warning that names the reference and tells the operator to move the file into <stem>-attachments/: another page’s .md or -attachments/ files, configs/*.yaml, a hand-placed diagram.png or img/a.png beside the .md, .env, and any path through a symlink leading out of the directory (a symlinked <stem>-attachments/ itself included). The rule sits at the upload step rather than in the Markdown writer because an exported body can carry a path in several places a remote author controls (image src and attachment filenames, raw inline HTML, macro parameters, image alt text), and the scanner cannot tell those apart from a local edit. Every producer mdd ships already writes into <stem>-attachments/: export, the Office and PDF converters, and the mermaid renderer.

Confidentiality. mdd confluence commands consult the Confluence blacklist defined in S07 for a blacklisted space; see S07 for exactly which commands are gated and when the check fires.

Mirror convention. Confluence-mastered content is mirrored to one git repository per space. The remote URL comes from the active mirror backend, which maps a space key to a project — for the built-in generic-git backend that is whatever origin the work-tree already has; a deployment-specific backend derives it (e.g. https://<host>/<group>/<space-key>.git). sync space SPACE defaults --output to ./ when run inside a clone of that repository (detected by comparing origin with the URL the backend resolves); otherwise it suggests git clone <remote> && cd <space-key> first.

API surface — Confluence Cloud v2. REST v2 (/wiki/api/v2/) is the supported path for Cloud and exposes ADF and storage body formats cleanly. Read/write uses body.representation=storage (XHTML) — storage round-trips better than ADF for Markdown conversion because the macro vocabulary is documented and stable. Folders cannot be fetched as pages but appear as parents; the tree builder resolves them via GET /folders/{id} and includes them as directory-only nodes. Attachment listing uses v2 (/wiki/api/v2/pages/{id}/attachments); v1 carried a Warning: 299 - Deprecated API header. Attachment upload still uses the v1 multipart endpoint because v2 has no equivalent, and uses it with PUT, which creates the attachment or adds a version to one the page already has under that filename. POST is rejected with 400 for a known filename, which a re-rendered image hits whenever its bytes change under an unchanged name.

HTTP client. Direct httpx calls with a thin retry wrapper — avoiding the heavier atlassian-python-api dependency for control over v2 endpoints. Auth is HTTP Basic with (username, resolved_token), where the token is resolved on demand via mdd.utils.secrets.resolve_secret() (S07) and lives only in process memory. Retry policy: exponential backoff (1s, 2s, 4s, 8s; max 5 attempts) on 429, 503, 5xx, ConnectError, and all httpx.TimeoutException subclasses (Read/Connect/Write/Pool); no retry on other 4xx; X-Atlassian-Token: no-check set to bypass CSRF. Per-phase timeouts are explicit (Timeout(connect=30, read=30, write=30, pool=30)) — a bare timeout=30.0 did not fire on TLS-level read stalls observed in production.

Query-param conventions (v2 API). v2 query parameters are kebab-case: space-id, body-format, include-labels, include-version. The camel-case form (spaceId) is silently ignored by Atlassian rather than rejected, which produces the wrong result (typically an unfiltered tenant-wide listing). Body JSON in POST / PUT payloads still uses camel-case (spaceId, parentId).

Pagination loops. Every paginated fetch is bounded by a MAX_PAGINATION_ITERATIONS constant (9 today, deliberately tight) and raises ConfluenceError on overshoot rather than looping indefinitely. Follow-up pages pass params=None (not params={}) to httpx.Client.request — an empty dict would replace the URL’s query string, stripping the cursor embedded in _links.next.

Attachment downloads. The v2 attachment listing returns a downloadLink pointing at the legacy UI binary endpoint /wiki/download/attachments/<id>/<name>?.... That path is gated to OAuth-only on some Atlassian tenants (Atlassian’s 19-Nov-2025 changelog explicitly removed several /download/attachments/ internal API paths), returning 401 WWW-Authenticate: OAuth realm=... to Basic-auth callers. The client therefore ignores downloadLink entirely and constructs the URL from pageId and id on the v2 attachment dict:

/wiki/rest/api/content/{page_id}/child/attachment/{att_id}/download

This REST endpoint accepts Basic auth + API token even on OAuth-gated tenants. It 302-redirects to a CDN URL carrying a short-lived signed JWT in the query string; httpx follows the redirect with the Authorization header stripped automatically on the cross-origin hop. Both pageId and id are top-level fields on every v2 listing result and are validated as alphanumeric before path interpolation (SSRF / traversal guard).

Request tracing. The ConfluenceClient installs request/response event hooks on the underlying httpx.Client that log every call at the custom TRACE level (5) from mdd.utils.logging. CLI flags -v, -vv, -vvv / --trace, and --trace-bodies raise the verbosity; --trace-bodies additionally dumps request/response headers (with Authorization / Cookie masked as <scheme> <sha256:xxxxxxxx>) and body payloads truncated to 4 KiB. Useful for diagnosing tenant-policy oddities and redirect chains.

Filename sanitization. Page title → filename: replace <>:"/\\|?*\n\t\r with -, remove every other C0/C1 control character (NUL, ESC, DEL, …) and any lone surrogate code point, strip leading/trailing whitespace and dots, collapse runs of whitespace or -, truncate to 200 chars, fall back to "untitled" if empty. The result never contains .., never starts with ., -, ~ or a space, and never ends with ., - or a space. On collision (same sanitized title at the same level), append the page ID: Title (12345).md. The attachments directory is keyed on the final .md stem, so the colliding page gets Title (12345)-attachments/ rather than sharing Title-attachments/ with its sibling (deleting or renaming one page must not touch the other’s files). A page or folder with an empty title falls back to its ID (page-<id> for a page file name, the bare ID for a tree directory); the ID comes from the API response, so it goes through the same sanitization as a title, and so does the ID in a collision suffix. Siblings are sorted by position then title for deterministic ordering across runs. Folders become directories only; only type: "page" nodes get a .md file.

Attachment names. A remote attachment title is reduced to its basename before it is written under <page-name>-attachments/; empty, . and .. are dropped. Names git reads as repository control files (.git, .gitignore, .gitattributes, .gitmodules, .mailmap, compared case-insensitively) are skipped with a warning and left out of the manifest, so an attachment cannot change what git stages in the mirror.

Markdown ↔ storage XHTML. Storage format is XHTML with <ac:*> and <ri:*> namespaced elements. Conversion goes through the document IR (S28): storage XHTML parses into IR nodes (S29) and the IR renders to markdown (S30), and back. Structured macros without a dedicated IR node fall through to the generic ConfluenceMacro carrier, which the markdown writer emits as a :::confluence-macro {...} fenced div — see S30 for the exact shape. <ac:image> references rewrite to ![X](confluence-attachment:X) on export (a bare filename, resolved against <page-name>-attachments/ on import), and the reverse on import.

Diff strategy for update page. Re-render local md → storage XHTML, fetch current Confluence storage, normalize whitespace, unified diff. The default output is the summary shown above (the target line and a count of changed lines). The full diff is logged at INFO, visible with -v. Confluence’s own version history is the source of truth — no merge, conflict = abort. Planned attachment uploads are logged alongside.

mdd confluence export-page <page-id-or-url> [--config <file>] [--output <path>]
[--no-header] [--no-attachments]
mdd confluence create-page <markdown-file> --space <space-key> [--parent <id-or-url>]
[--title <title>] [--config <file>]
[--message <msg>]
mdd confluence update-page <markdown-file> [--config <file>] [--dry-run]
[--message <msg>] [--yes]

(Whole-space exports use mdd confluence sync-space — see S14. The dedicated export space command has been removed; sync space ... --read-only is the snapshot equivalent and adds rename / move / delete detection over the previous behaviour.)

  • export page — exports a single page by ID or URL.
  • create page — creates a new Confluence page from a local Markdown file, uploads any referenced local images as attachments, and writes full frontmatter back so future edits use update page.
  • update page — reads a local .md, looks up its source via frontmatter, diffs against Confluence, syncs changed image attachments, and pushes the update. --dry-run prints the target and change summary (the full diff and the planned attachment uploads with -v) without writing anything to Confluence.

export page prepends # {confluence.title} to the markdown body so the file is self-contained markdown (the page title is metadata in Confluence, not part of the storage XHTML). update page strips a leading H1 whose text equals confluence.title before rendering markdown back to storage — re-emitting it would produce a duplicate <h1>{title}</h1> in the page body.

The strip is whitespace-tolerant (trailing whitespace, optional closing #s) and only triggers on an exact text match against confluence.title; an H1 with a different heading or attribute-list suffix is left intact. Consequence: an author who intentionally writes # {title} as the first heading in the body (matching the page title verbatim) will see it disappear on the next push. That’s the round-trip cost of self-contained exported markdown; document the behaviour but do not override it per file.

update page MUST graft identity attributes from the remote storage back onto the freshly-parsed local IR before rendering — see S33 §“R3 — Storage → IR → Markdown → IR → Storage”. The remote storage is already fetched at the top of the command for the managed-page check and the diff display; the implementation re-uses it as the cached IR_a for reattach. Without this step, every round-trip strips local-id, macro-id, schema-version, and ac:breakout-* from layout / section / cell / macro / paragraph nodes — the markdown leg intentionally does not carry them.

Where the CLI takes a “page-id-or-url”, mdd accepts either a bare numeric ID or any of these Confluence Cloud URL shapes:

  • https://<host>/wiki/spaces/<SPACE>/pages/<id> (canonical)
  • https://<host>/wiki/spaces/<SPACE>/pages/<id>/<slug> (with title slug)
  • https://<host>/wiki/spaces/<SPACE>/pages/<id>/<slug>?focusedCommentId=... (query stripped)
  • https://<host>/wiki/x/<base62> (short URL — resolved via redirect)

The host from the URL must match confluence.url in config; mismatch is a hard error to prevent accidental cross-tenant operations. Only the page ID is pulled from the URL; everything else is informational.

confluence:
url: https://your-instance.atlassian.net
username: your-email@example.com
api_token: op://Employee/confluence-pat/token # 1Password reference
spaces:
ENGINEERING:
output_dir: ./output/engineering
PRODUCT:
output_dir: ./output/product
  • Default config search path: ./configs/confluence.yaml, then ~/.config/mdd/confluence.yaml.
  • api_token must be an op:// reference — see S07. Raw tokens, env-var interpolation for tokens, and .env files are not supported.
  • username is the Atlassian account email; not a secret.
  • spaces is nested under confluence:, not top-level — this is what mdd search reads to find every configured mirror root (confluence.spaces.<key>.output_dir). None of the mdd confluence subcommands above read output_dir from config: export-page takes --output directly, and sync-space (see S14) either takes --output or falls back to default_output_for_space()’s origin-URL heuristic — it detects when the current working tree is already a clone of the space’s mirror repo and defaults to . in that case. output_dir in config is mdd search’s bookkeeping for “where are my mirrors”, not a setting the sync/export path consults.

YAML between --- fences at top of file. Captures the readily-available metadata from the Confluence v2 API (population requires include-labels=true&include-version=true&body-format=storage plus a follow-up GET /users/{id} to resolve account IDs to display names, cached per-run).

confluence:
# Identity & location
url: https://example.atlassian.net/wiki/spaces/SPACE/pages/12345/Title
page_id: "12345"
space_key: SPACE
space_id: "98306"
parent_id: "12340" # null for root pages
title: My Page
status: CURRENT # CURRENT | DRAFT | ARCHIVED
# Versioning (used for conflict check on update)
version: 7
version_message: "Fixed typos" # optional
# People (account_id authoritative; display_name is convenience)
created_at: 2023-01-25T09:40:17Z
created_by:
account_id: "6237c985ee1b5a0070273f6b"
display_name: Leo Simons
updated_at: 2026-05-08T08:12:34Z
updated_by:
account_id: "5e3f...a1"
display_name: Jane Doe
# Discoverability
labels: [architecture, draft]
# Export bookkeeping (mdd-managed)
exported_at: 2026-05-08T10:30:00Z
source_format: storage
attachments: # tracked so image sync can skip unchanged
- filename: diagram.png
sha256: 9f86d081...
version: 2

created_* / updated_* / version* / status / labels / space_id come from the Confluence API and are refreshed on every export and after a successful create/update. Email is intentionally omitted — v2 gates email visibility on user privacy settings and mdd shouldn’t depend on it.

For pages created locally (not yet in Confluence), the user authors a minimal frontmatter and create page fills in the rest:

confluence:
space_key: SPACE
parent_id: "12340" # optional
title: New Page Title # optional; H1 used as fallback
  • Comments — neither read nor write. Keeps the model focused on page bodies; comment threading is its own beast and the use-cases for mirroring discussion belong in Confluence proper.
  • Server / Data Center compatibility
  • Bulk update space (do one page at a time first; bulk later)
  • Confluence label sync
  • Page deletion via mdd (use the Confluence UI; deletion is destructive shared-state)

Site built 2026-10-26.