diff --git a/CLAUDE.md b/CLAUDE.md index c4936bb..9528ed0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,9 +46,11 @@ Module `github.com/mozilla/markfluence` (`go 1.25`). `main.go` is a shim to `cmd ### Layout -- `cmd/root.go` — the cobra root: `--url`/`--username`/`--debug`/`--no-color` persistent flags, version from `internal/buildinfo`, and registration of the four subcommands. `Execute()` prints cobra-generated errors (bad args/flags) but not `ui.ErrSilent`, which marks a failure a command already reported. -- `cmd/{update,create,fix,info}/` — one package per command (each exports `Cmd`), orchestrating the `internal` packages and `internal/ui` output. `create` is two-phase and transactional (validate all, then create parents-first in topological order); `fix` is read-only on the server. -- `internal/client` — `ConfluenceClient` over `net/http` with basic auth. Built from a `Config` (site URL, cloud ID, username, token) via `New`; it carries **two bases**: `BaseURL()` is where requests go (the gateway when a cloud ID is set) and `SiteURL()` is always the site. Anything a reader sees uses `SiteURL()` — printed page URLs and, critically, the `baseURL` handed to `convert.MdToConfluence`, since rewritten links are published *into* the page. Pages are Confluence **v2**; attachment writes and the user lookup are **v1** (`/wiki/rest/api/...`). Typed `HTTPError`, per-attempt context timeouts, centralized retry/backoff in `send` (429 for any method honoring `Retry-After`, plus 502/503/504 and network errors for idempotent methods only; exponential backoff capped), `SetContentProperty` retry-once on top (recovers a lost create-POST response), `SyncAttachments` (skip/update by a SHA-256 recorded in the attachment's comment, alongside the source path so `read` recovers image paths exactly; the comment is `markfluence: sha256=… path=…`, and the legacy `mzcld:checksum: …` form is still parsed — the skip test compares the *parsed* checksum, so the format change doesn't force a re-upload), `_links.next` pagination. `config.go` holds `Resolve` and the `.env` reader. +- `cmd/root.go` — the cobra root: `--url`/`--username`/`--debug`/`--no-color` persistent flags, version from `internal/buildinfo`, and registration of every subcommand. `Execute()` prints cobra-generated errors (bad args/flags) but not `ui.ErrSilent`, which marks a failure a command already reported. +- `cmd/{update,create,fix,info,read}/` — one package per command (each exports `Cmd`), orchestrating the `internal` packages and `internal/ui` output. `create` is two-phase and transactional (validate all, then create parents-first in topological order); `fix` is read-only on the server. +- `cmd/attachment{list,upload,download}/` — the flat `attachment-list`/`attachment-upload`/`attachment-download` commands (noun-first so cobra's alphabetized help keeps them together and `attachment-` completes as a group). `upload` reuses the checksum skip/update logic, with `--force` (`client.ForceUploadAttachments`) and `--dry-run` (`PlanAttachments`); its `--name` takes a *path* and encodes it, and the recorded `path=` is always the decode of the stored name, so a later publish can't create a duplicate under a different name. `download` restores an attachment to its recorded `path=` (never a decode of the stored name — a hand-uploaded `a%2Fb.png` is indistinguishable from a published one), with `--flat` to opt out; `destPath` is the only place server data becomes a filesystem path and clamps to `--dest`, refusing rather than clipping an escape, since `..` is legitimate in a source path. +- `internal/pageref` — `Resolve`, the single page-argument resolver: a numeric id, a Confluence page URL, or a `.md` file whose frontmatter has a `page_id` (stat'd first, so `123.md` is a file). Every command taking a page uses it. +- `internal/client` — `ConfluenceClient` over `net/http` with basic auth. Built from a `Config` (site URL, cloud ID, username, token) via `New`; it carries **two bases**: `BaseURL()` is where requests go (the gateway when a cloud ID is set) and `SiteURL()` is always the site. Anything a reader sees uses `SiteURL()` — printed page URLs and, critically, the `baseURL` handed to `convert.MdToConfluence`, since rewritten links are published *into* the page. Pages are Confluence **v2**; attachment writes and the user lookup are **v1** (`/wiki/rest/api/...`). Typed `HTTPError`, per-attempt context timeouts, centralized retry/backoff in `send` (429 for any method honoring `Retry-After`, plus 502/503/504 and network errors for idempotent methods only; exponential backoff capped), `SetContentProperty` retry-once on top (recovers a lost create-POST response), `SyncAttachments` (skip/update by a SHA-256 recorded in the attachment's comment, alongside the source path so `read` recovers image paths exactly; the comment is `markfluence: sha256=… path=…`, and the legacy `mzcld:checksum: …` form is still parsed — the skip test compares the *parsed* checksum, so the format change doesn't force a re-upload), `_links.next` pagination. `ListAttachments` expands `metadata.comment,version,extensions` (`fileSize`/`mediaType` live under `extensions`) and pages by `start`/`limit` offset instead — a v1 collection omits `_links.next` when the results fit one page, and its `next` is `/wiki`-context-relative rather than the v2 paths `resolveNext` handles. `DownloadAttachment` goes through `send` (so it inherits retry/backoff) against `_links.download`, which is an API path — `/rest/api/content/{page}/child/attachment/{id}/download`, not the `/download/attachments/...` UI path — and therefore works through the gateway; it 302s to Atlassian's media host, which carries its own token, and Go drops `Authorization` cross-host, so **never** add a `CheckRedirect` that forwards headers. `config.go` holds `Resolve` and the `.env` reader. - `internal/convert` — the converter (the crux). `MdToConfluence(md *frontmatter.MarkdownFile, baseURL, spaceKey, version string) (*ConfluencePage, error)`. It parses with goldmark (GFM) and renders through a custom `storageRenderer` registered at priority 100 (below the default HTML=1000 and table=500 renderers) that emits Confluence storage format. `shield.go` renames raw `ac:`/`ri:` tags to colon-free sentinels around the goldmark step so pasted storage passes through; `callouts.go` is an AST transformer + blockquote renderer for GitHub alerts; `attachname.go` owns the source-path↔attachment-name mapping (percent-encoding `%`→`%25` then `/`→`%2F`, which is **bijective** — that is what makes the dedupe collision-free and lets `read` recover an image's original path; decode refuses an absolute result, which markfluence never produces); `images.go` (resolution stays page-relative like GitHub; the documentation root — cwd — bounds what may be published, and an image above it is `IMAGE BROKEN`), `links.go` (sibling-file scans, GitHub/Confluence slugs, doc-link + anchor rewriting), `tables.go` (the `` tag, stamped with Confluence's `data-layout="align-start"` so tables auto-size to their content and left-align, plus cells: an AST transformer consumes a leading `` comment in a cell and `renderTableCell` emits it as `data-highlight-colour` — rows still fall through to the GFM renderer), and `renderer.go` (code macros, text soft-break→space, images, links) do the rest. The `` and `` token substitutions happen **inside** `MdToConfluence`. - `internal/frontmatter` — flat YAML frontmatter parse/quote/`UpdateField`, and the `MarkdownFile` type (`Parse`/`ParseFile`, exported `Filename`/`Content`/`Frontmatter`/`Body`, and `Title`/`PageID`/`Space`/`Parent` accessors that normalize missing/blank/`"null"`). - `internal/pagewidth` — the `page_width` `Width` enum (`narrow`/`wide`/`max`, default `max`), `Declared`, the vocab↔content-property maps, `WidthFromProperties`, and `Apply`/`Read` against the client. @@ -69,4 +71,4 @@ The design target is **semantic** (not byte-for-byte) equivalence to valid Confl ### Frontmatter-driven publishing -Each markdown file is one page. Frontmatter carries `title`, `page_id`, `space` (a key), `parent` (`null` / a `.md` path / a page id), and `page_width`. `update` requires a `page_id` (from frontmatter or `--page-id`) and errors without one; `--title`/`--page-id` override the frontmatter (single FILE only), `--page-width` overrides too (batch allowed), and `update` never writes back to files. It asserts `page_width` only when set via flag or frontmatter (otherwise the live width is left alone), and skips a file whose mtime predates the page's last version unless `--force`. `create` takes `--title`/`--page-width` overrides (`--title` single-FILE only; `--page-width` batch-ok, default `max`) and, unless `--no-persist` is given, writes `title`/`space`/`parent`/`page_id`/`page_width` back after creating; `fix` reconciles all of these (plus `page_width`) from the live page. `update`, `create`, and `fix` all take `--dry-run`, which previews the actions (pages, attachment uploads, width changes, and — for `create` — frontmatter write-backs) without writing to Confluence or files; per-file human output is identical to a real run, distinguished only by a leading `DRY RUN` banner and a `dry_run: true` field in `--json`. In a `create` dry-run a previewed page has no id/URL yet, and an in-set child's `parent` is null; the parent's source `.md` is always reported in the `parent_file` output field. Commands process multiple files (except `info`, single-arg) and exit non-zero if any fail. +Each markdown file is one page. Frontmatter carries `title`, `page_id`, `space` (a key), `parent` (`null` / a `.md` path / a page id), and `page_width`. `update` requires a `page_id` (from frontmatter or `--page-id`) and errors without one; `--title`/`--page-id` override the frontmatter (single FILE only), `--page-width` overrides too (batch allowed), and `update` never writes back to files. It asserts `page_width` only when set via flag or frontmatter (otherwise the live width is left alone), and skips a file whose mtime predates the page's last version unless `--force`. `create` takes `--title`/`--page-width` overrides (`--title` single-FILE only; `--page-width` batch-ok, default `max`) and, unless `--no-persist` is given, writes `title`/`space`/`parent`/`page_id`/`page_width` back after creating; `fix` reconciles all of these (plus `page_width`) from the live page. `update`, `create`, and `fix` all take `--dry-run`, which previews the actions (pages, attachment uploads, width changes, and — for `create` — frontmatter write-backs) without writing to Confluence or files; per-file human output is identical to a real run, distinguished only by a leading `DRY RUN` banner and a `dry_run: true` field in `--json`. In a `create` dry-run a previewed page has no id/URL yet, and an in-set child's `parent` is null; the parent's source `.md` is always reported in the `parent_file` output field. Commands process multiple files (except `info`/`read`/`attachment-list`, single-arg) and exit non-zero if any fail. A command that names a page takes it three ways via `internal/pageref` (id / URL / `.md` with a `page_id`). diff --git a/README.md b/README.md index 30cdf89..3d79cc4 100644 --- a/README.md +++ b/README.md @@ -85,8 +85,15 @@ delete scopes. This might change in the future. ## Usage +General: + ```sh markfluence --help +``` + +Manipulating Confluence pages: + +```sh markfluence create --help markfluence update --help markfluence fix --help @@ -94,6 +101,17 @@ markfluence info --help markfluence read --help ``` +Manipulating Confluence page attachments: + +```sh +markfluence attachment-list --help +markfluence attachment-upload --help +markfluence attachment-download --help +``` + +Every command that takes a page accepts it three ways: a numeric page id, a +Confluence page URL, or a Markdown file whose frontmatter has a `page_id`. + ### `create` ``` @@ -198,13 +216,13 @@ markfluence fix docs/foo.md --dry-run ### `info` ``` -Usage: markfluence info ARG [flags] +Usage: markfluence info PAGE [flags] ``` Print a page's metadata (id, title, status, space, parent, version, page width, -authors, dates, url). `ARG` is a numeric page id or a Markdown file whose -frontmatter has a `page_id`. `--properties` also lists all of the page's content -properties. +authors, dates, url). `PAGE` is a numeric page id, a Confluence page URL, or a +Markdown file whose frontmatter has a `page_id`. `--properties` also lists all of +the page's content properties. ```sh markfluence info 1234567890 @@ -214,12 +232,13 @@ markfluence info docs/foo.md --properties ### `read` ``` -Usage: markfluence read ARG [flags] +Usage: markfluence read PAGE [flags] ``` -Fetch a Confluence page and print its body to stdout. `ARG` is a numeric page id -or a Confluence page URL (the modern `/wiki/.../pages//...` form or a legacy -`?pageId=` URL). It composes with shell redirection. +Fetch a Confluence page and print its body to stdout. `PAGE` is a numeric page id, +a Confluence page URL (the modern `/wiki/.../pages//...` form or a legacy +`?pageId=` URL), or a Markdown file whose frontmatter has a `page_id`. It +composes with shell redirection. `--format` selects the output: @@ -244,6 +263,90 @@ markfluence read 1234567890 --format storage > page.storage.xml markfluence read "https://org.atlassian.net/wiki/spaces/ENG/pages/1234567890/Title" ``` +### `attachment-list` + +``` +Usage: markfluence attachment-list PAGE [flags] +``` + +List a page's attachments. + +```console +$ markfluence attachment-list 1234567890 +NAME SIZE VER TYPE SOURCE +assets%2Fdiagram.png 24.1 KB 3 image/png assets/diagram.png +notes.pdf 1.2 MB 1 application/pdf - +``` + +`NAME` is the name Confluence stores. For an image markfluence published that is +the percent-encoded source path (see [Body](#body)), and `SOURCE` is the Markdown +image path it came from — so the table shows at a glance which attachments a +publish manages and which it will leave alone. + +`SOURCE` is a dash when no source path is recorded: either the attachment was +uploaded by hand, or it was published before markfluence recorded source paths. +Those two look the same here; `--json` has a `managed` field that tells them +apart. Attachments left behind by the encoding change show up this way, which is +how you find them. + +### `attachment-upload` + +``` +Usage: markfluence attachment-upload PAGE FILE... [flags] +``` + +Upload or replace attachments on a page, complementing the automatic sync that +`create` and `update` perform for a page's images. + +Each file is attached under its base name. A file whose contents already match +what's on the page is skipped, using the same checksum bookkeeping +`create`/`update` use, so uploading by hand and publishing agree on what's +current. `--force` uploads anyway (bumping the attachment's version), which is +how you repair an attachment whose stored bytes drifted while its checksum still +matches. `--dry-run` previews without writing. + +`--name` sets the attachment name for a single file and takes a **path**, which +markfluence encodes for you — so `--name assets/x.png` produces the attachment +that an image written as `![](assets/x.png)` resolves to. The recorded source +path always matches the stored name, so a later publish won't create a duplicate +under a different one. + +```sh +markfluence attachment-upload 1234567890 diagram.png +markfluence attachment-upload 1234567890 report.pdf notes.txt +markfluence attachment-upload 1234567890 img.png --name assets/diagram.png +markfluence attachment-upload 1234567890 diagram.png --force +``` + +### `attachment-download` + +``` +Usage: markfluence attachment-download PAGE [NAME...] [flags] +``` + +Download a page's attachments. Each `NAME` is an attachment name as +`attachment-list` reports it; with no `NAME`, every attachment is downloaded. + +An attachment markfluence published is written back to the Markdown image path +recorded in its comment, so the downloaded tree matches what the page's Markdown +references and previews locally: + +```console +$ markfluence attachment-download 1234567890 --dest ./out +downloaded /out/assets/diagram.png +downloaded /out/notes.pdf +``` + +An attachment with no recorded path — hand-uploaded, or published before +markfluence recorded them — is written under its stored name. `--flat` writes +everything under stored names. `--dest` defaults to the current directory and is +created if missing. An existing file is skipped unless `--force`, and +`--dry-run` previews without writing. + +A recorded path that would resolve outside `--dest` is refused for that +attachment: the path comes from an attachment comment, which anyone who can edit +the page controls. + ### `--json` output The persistent `--json` flag makes any command emit a single machine-readable @@ -297,8 +400,14 @@ Notes on the schema: shapes (empty values are `null` or `[]`); the key *set* differs per command. `schema_version` is bumped on any breaking change. - **Status verbs** are per-command: `published`/`skipped` (`update`), - `created`/`not_created` (`create`), `changed`/`consistent` (`fix`), plus - `failed`. `info`/`read` results carry data only (no status verb). + `created`/`not_created` (`create`), `changed`/`consistent` (`fix`), + `created`/`updated`/`skipped` (`attachment-upload`), + `downloaded`/`skipped` (`attachment-download`), plus `failed`. `info`, `read`, + and `attachment-list` results carry data only (no status verb). +- **One result per target**, and the target is per-command: the page for + `info`/`read` (always one), the file for `update`/`create`/`fix`, and the + attachment for the three `attachment-*` commands — so + `.results[] | .filename` works and `summary.total` is the attachment count. - **Compound values are objects**, never display strings — `version`, `page_width`, and the `created`/`updated` author stamps on `info`. - **`create`'s two-phase abort** (a validation failure means nothing is created) diff --git a/_plans/019_attachment-subcommands.md b/_plans/019_attachment-subcommands.md new file mode 100644 index 0000000..069c14b --- /dev/null +++ b/_plans/019_attachment-subcommands.md @@ -0,0 +1,271 @@ +# Plan: attachment subcommands + +Expose standalone attachment management — `attachment-list`, `attachment-upload`, +`attachment-download` — complementing the automatic sync that `update`/`create` +already perform. Closes #9. + +018 paused this work on one unsettled question: what remote name a standalone +upload should use. Bijective percent-encoding plus the `path=` comment answers +it, so the naming rule below is a consequence of 018 rather than a new invention. + +018 also deferred "clamping `..` when writing files" to #37, on the reasoning that +`read` only prints text. That deferral expires here: `attachment-download` +restores directory layout by default, so it is the first code that writes +attachment bytes to paths derived from server data. **The clamp lands in this +plan.** + +## Out of scope (deliberately) + +- **`attachment-delete`.** #9 does not ask for it and markfluence holds no delete + scopes. Removing an orphan is still a Confluence-UI operation; `attachment-list` + exists so users can *see* orphans, which is what 018 promised. +- **`export` (#37).** This plan builds the two pieces export needs — a download + client method and a layout-restoring writer — but ships no multi-file, image-src + reconciling command. +- **Filtering flags on `list`** (`--managed`, `--pattern`). `--pattern` belongs to + #37, which specifies it; filtering a table is `jq`'s or `grep`'s job in v1. +- **`--comment` on upload.** The comment slot is markfluence bookkeeping. A + user-supplied comment would either destroy the checksum (breaking skip) or need + a sub-field, and nothing asks for it yet. + +## Decisions locked + +### Flat, noun-first command names + +`attachment-list`, `attachment-upload`, `attachment-download`. + +Cobra alphabetizes `--help`, so a noun-first prefix keeps the three adjacent in +the listing and makes `attachment-` a completion group for #14. The JSON +`command` field is the command name verbatim, so it stays derivable from what the +user typed. + +**Rejected: an `attachment` parent command with `list`/`upload`/`download` +children.** The issue's own phrasing, and what confluence-cli does. It would make +these the only nested commands in the CLI and force a two-word JSON +discriminator. + +**Rejected: verb-first** (`list-attachments`, `upload-attachment`). Reads better +in a sentence, but scatters the three across the help listing under l/u/d. + +### One page-argument resolver for the whole CLI + +Two resolvers exist today and neither is a superset: + +| | numeric id | page URL | `.md` with `page_id` | +|---|---|---|---| +| `info.resolvePageID` | ✅ | ❌ | ✅ | +| `read.parsePageID` | ✅ | ✅ | ❌ | + +New `internal/pageref` accepts all three; `info` and `read` are retrofitted onto +it. Both changes are purely additive — each command gains the form it lacked — so +no existing invocation changes meaning. #37 and #44 inherit it. + +### `list` shows what a publish will and will not touch + +Columns `NAME`, `SIZE`, `VER`, `TYPE`, `SOURCE`, with human-readable sizes. +`NAME` is the **stored** name (`assets%2Fx.png`), because that is what identifies +the attachment to `attachment-download` and what appears in Confluence's own UI. +`SOURCE` is `Meta().Source`; an unmanaged attachment shows `—`. + +That em-dash is the point of the command: it distinguishes attachments a publish +will overwrite from hand-uploaded ones it will leave alone, and it surfaces the +orphans 018 chose not to warn about. + +**Rejected: decoding `NAME` for display.** Prettier, and makes `SOURCE` +redundant, but it lies about server state and hides the string you need in order +to download the file. + +**Rejected: emitting `download_url`.** Built on `SiteURL()` it 401s under a +scoped token; built on `BaseURL()` it leaks the gateway host into reader-facing +output, which the two-bases rule exists to prevent. A URL that works or does not +depending on the reader's token type is a footgun, and `attachment-download` is +the supported way to fetch bytes. + +### Upload: basename by default, `--name` takes a path + +``` +attachment-upload 123 docs/assets/x.png → x.png (path=x.png) +attachment-upload 123 docs/assets/x.png --name assets/x.png → assets%2Fx.png (path=assets/x.png) +``` + +`--name` accepts a **path** and markfluence encodes it, so percent-escapes never +leak into the UI. It is single-file only, like `--title` on `update`/`create`. + +**The recorded `path=` is always the decode of the stored name.** If the two were +allowed to drift — name `x.png`, path `docs/assets/x.png` — then a later publish +from `docs/assets/x.png` would encode to `docs%2Fassets%2Fx.png` and create a +*second* attachment, while `download` restored the first to a location the +markdown never references. One truth, enforced by construction: both derive from +the same input. + +**Rejected: encoding the given path by default.** Makes a hand-upload agree with +a publish for free, but 018's `normalizeSrc` strips a leading `/`, so +`~/Downloads/report.pdf` becomes `Users%2Fyou%2FDownloads%2Freport.pdf`. Upload +takes arbitrary filesystem paths; image `src`s are page-relative by construction. +The two are not the same input space. + +**Rejected: relative-when-under-cwd, basename otherwise.** Does the right thing +in both common cases with no flag, at the cost of a name that depends on where +the user is standing — the instability 018 rejected model A to avoid. + +Sync semantics are reused wholesale: created/updated/skipped by checksum, so a +hand upload and a publish agree on state. `--force` uploads regardless (bumping +the version), recovering an attachment whose bytes drifted server-side while its +comment still matches. `--dry-run` is nearly free via the existing +`PlanAttachments`. + +### Download: restore layout by default, from `path=` and never from the name + +A **managed** attachment is written to its recorded `path=`. An **unmanaged** one +is written under its literal stored name. `--flat` writes everything under +literal stored names. + +Restoring from the comment rather than by decoding the name sidesteps the +ambiguity `attachname.go` warns about: there is no way to tell a hand-uploaded +`a%2Fb.png` from one we published, so a decode-by-default would scatter a +literally-named file into `a/b.png`. The comment is truth; the name is inference. + +Restore is the default because round-trip is the product thesis. A download +yielding `docs%2Fassets%2Fx.png` leaks Confluence's no-slash restriction into the +user's filesystem and breaks local preview in GitHub or VSCode until every file +is renamed by hand. + +**The traversal clamp.** 018 model B legitimately produces `..%2Fassets%2Flogo.png`, +so `..` cannot be refused outright at decode. But a destination path resolving +outside `--dest` is a **hard error for that file**, not a silent clip: the +attachment comment is server-controlled data, and `path=../../../.ssh/authorized_keys` +must not write there. Absolute paths are already refused by +`attachmentSource`. Failing the single file rather than the run keeps one hostile +attachment from blocking a legitimate download. + +`--dest` defaults to `.` and is created if missing. An existing file is skipped +with a warning unless `--force`. Zero names means all attachments; a named +attachment absent from the page is a per-item failure. + +### `DownloadAttachment` goes through `send` + +```go +func (c *ConfluenceClient) DownloadAttachment(att Attachment, w io.Writer) error +``` + +Reusing `send` inherits retry/backoff, per-attempt timeouts, and typed +`HTTPError` for roughly twenty lines. It buffers the whole attachment in memory — +acceptable for docs and images, revisited in #37 if large exports appear. + +A new `timeoutDownload` (120s, matching upload) replaces `timeoutRead`'s 30s, +which is thin for a large file. + +**Redirect handling is load-bearing and correct by default.** The endpoint 302s +to `api.media.atlassian.com` with its own short-lived `token=`. `send` sets basic +auth via `req.SetBasicAuth` on a stock `&http.Client{}`, and Go strips +`Authorization` on a cross-host redirect — so Confluence credentials are never +sent to the media host, which does not want them anyway. This wants a comment; +adding a custom `CheckRedirect` that forwarded headers would be a credential +leak. + +**Rejected: streaming with its own retry loop.** Constant memory at any size, at +the cost of a second retry/backoff implementation parallel to `send` — the kind +of duplication that drifts. + +### `ListAttachments` paginates by offset + +The collection carries `size`/`limit`/`start` and **omits `_links.next` when +results fit one page** (018, confirmed again below). `resolveNext` is also written +for v2's `/wiki/api/v2/...` links, while v1's `next` is relative to the `/wiki` +context, so reusing it would drop `/wiki`. Loop on `start += limit` until a short +page. Today's hardcoded `limit=250` with no pagination silently truncates a page +with more attachments. + +### The name codec is exported from `internal/convert` + +`AttachmentFilename` and `AttachmentSource` become exported. The commands need to +encode a `--name` path and decode for `--flat`. One owner, and `cmd/read` already +imports `convert`. + +**Rejected: a new `internal/attachname` package.** The codec is intimately tied +to the converter's image handling, and 018 deliberately put it there. + +### JSON contract + +`command` is the command name verbatim. Status verbs follow the existing +per-command pattern: + +| command | verbs | summary | +|---|---|---| +| `attachment-list` | *(none — data only, like `info`/`read`)* | `basicSummary` | +| `attachment-upload` | `created` / `updated` / `skipped` / `failed` | `attachmentSummary` | +| `attachment-download` | `downloaded` / `skipped` / `failed` | `attachmentSummary` | + +`attachmentSummary` is `total`/`succeeded`/`failed`/`skipped`, shared by the two +write commands. `list` emits one result per attachment — target is the +attachment, so `summary.total` is the attachment count and `.results[] | +.filename` works directly. A page-level failure (not found, fetch error) uses the +existing `singleOpFailure` shape. + +Download results carry the local `dest_path` actually written. Per-item failures +are `{ok:false, error, code}` with exit 1 if any item failed; fatal/pre-flight +failures keep the stderr error object and exit 2. + +**Each command commit carries its own `schema/json-output/v1.json` update**, +because `schematest`'s `additionalProperties:false` makes code and schema fail +together otherwise. + +## Verified against the live API + +018 recorded incidental findings for #9; they were re-probed against page +`2848423944` before writing this plan, and one assumption was wrong. + +- **`extensions.fileSize` (171) and `extensions.mediaType` (`image/png`)** + confirmed; `metadata.mediaType` duplicates the latter. +- **`version.number`** confirmed, alongside `version.when` and + `version.by.displayName`. `version.message` duplicates the comment. +- **`_links.download` is `/rest/api/content/{pageId}/child/attachment/{attId}/download`** — + *not* the classic `/download/attachments/…` UI path. Absolute URL is + `baseURL + "/wiki" + link`. Because it is an API path, it works through the + `api.atlassian.com` gateway, which the UI path would not. +- **It 302s to `api.media.atlassian.com/file/{fileId}/binary?token=…`**, a + different host with a short-lived token — the finding that drove the redirect + decision above. Following it yields 200 and 171 bytes of valid PNG. +- **The collection returns `size`/`limit`/`start` with no `_links.next`**, + confirming offset pagination. +- **018's scheme is live in production**: stored title + `assets%2Fmarkfluence-test.png`, comment + `markfluence: sha256=e733ac00… path=assets/markfluence-test.png`. + +## Testing + +- `internal/client/client_test.go`: decoding the expanded `Attachment` + (`fileSize`/`mediaType`/`version.number`/`_links.download`) from a recorded + payload shaped like the live one; offset pagination across a short final page; + `DownloadAttachment` writing bytes, including across a redirect to a second + test server, asserting the `Authorization` header does **not** reach it. +- `internal/pageref/pageref_test.go`: all three argument forms, plus the + rejections (empty, non-numeric, a directory, a `.md` with no `page_id`). +- `cmd/attachment-*/`: per-command `json_test.go` validating each new result and + summary shape against `v1.json` via `schematest`; unit tests for the + size formatter, the `--name` encode path, and the destination resolver. +- The destination resolver gets adversarial cases: `path=../../escape`, + `path=/etc/passwd`, a legitimate `..%2Fassets%2Flogo.png` under model B landing + inside `--dest`, and an unmanaged attachment falling back to its literal name. +- `make test && make lint && make vet`. + +## Docs + +- `README.md`: a usage section per subcommand alongside `info`/`read`, the + naming rule for `--name`, the restore-vs-`--flat` behavior, and the `--json` + notes for the three new `command` values. +- `CLAUDE.md`: the three commands in the layout list (and the stale "four + subcommands" phrasing in the `cmd/root.go` bullet), `internal/pageref`, and the + download method plus offset pagination on the `internal/client` bullet. + +## Commits + +1. `feat(client): expose attachment metadata and downloads` — expanded + `Attachment`, offset pagination, `DownloadAttachment`, `timeoutDownload`. +2. `refactor(convert): export the attachment name codec`. +3. `refactor(pageref): unify page argument resolution` — new package, `info` and + `read` retrofitted. +4. `feat(cmd): add attachment-list` (+ schema). +5. `feat(cmd): add attachment-upload` (+ schema). +6. `feat(cmd): add attachment-download` (+ schema). +7. `docs: document the attachment subcommands`. diff --git a/assets/markfluence-test.png b/assets/markfluence-test.png new file mode 100644 index 0000000..6f87dc6 Binary files /dev/null and b/assets/markfluence-test.png differ diff --git a/cmd/attachmentdownload/attachmentdownload.go b/cmd/attachmentdownload/attachmentdownload.go new file mode 100644 index 0000000..2df92ad --- /dev/null +++ b/cmd/attachmentdownload/attachmentdownload.go @@ -0,0 +1,290 @@ +// Package attachmentdownload implements the `markfluence attachment-download` +// command: write a page's attachments to the filesystem. +package attachmentdownload + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/pageref" + "github.com/mozilla/markfluence/internal/ui" + "github.com/spf13/cobra" +) + +// command is the name used in help and as the --json command discriminator. +const command = "attachment-download" + +// Per-file outcomes, mirroring attachment-upload's verbs. +const ( + statusDownloaded = "downloaded" + statusSkipped = "skipped" + statusFailed = "failed" +) + +var ( + dest string + flat bool + force bool + + dryRun bool +) + +// Cmd is the attachment-download command. +var Cmd = &cobra.Command{ + Use: command + " PAGE [NAME...]", + Short: "Download a Confluence page's attachments", + Long: "Download a Confluence page's attachments.\n\n" + + "PAGE is a numeric page id, a Confluence page URL, or a markdown file\n" + + "whose frontmatter has a page_id. Each NAME is an attachment name as\n" + + "attachment-list reports it; with no NAME, every attachment is\n" + + "downloaded.\n\n" + + "An attachment markfluence published records the markdown image path it\n" + + "came from, and is written back to that path under --dest, so the\n" + + "downloaded tree matches what the page's markdown references and\n" + + "previews locally. An attachment without a recorded path is written\n" + + "under its stored name. --flat writes everything under stored names.\n\n" + + "A file that already exists is skipped unless --force.", + Args: cobra.MinimumNArgs(1), + RunE: run, +} + +func init() { + Cmd.Flags().StringVar(&dest, "dest", ".", "Directory to write attachments into.") + Cmd.Flags().BoolVar(&flat, "flat", false, + "Write every attachment under its stored name, ignoring recorded paths.") + Cmd.Flags().BoolVar(&force, "force", false, "Overwrite files that already exist.") + Cmd.Flags().BoolVar(&dryRun, "dry-run", false, + "Preview what would be written without creating any files.") +} + +func run(cmd *cobra.Command, args []string) error { + url, _ := cmd.Flags().GetString("url") + username, _ := cmd.Flags().GetString("username") + cloudID, _ := cmd.Flags().GetString("cloud-id") + envFile, _ := cmd.Flags().GetString("env-file") + c, err := client.Resolve(client.Options{ + URL: url, Username: username, CloudID: cloudID, EnvFile: envFile, + }) + if err != nil { + return fatalFail(err.Error(), jsonout.CodeConfig) + } + + pageID, err := pageref.Resolve(args[0]) + if err != nil { + return fatalFail(err.Error(), jsonout.CodeValidation) + } + + attachments, err := c.ListAttachments(pageID) + if err != nil { + return operationalFail(pageID, err, jsonout.CodeFor(err)) + } + + wanted, missing := selectAttachments(attachments, args[1:]) + if dryRun && !ui.IsJSON() { + ui.Warn("DRY RUN — no files will be written.") + } + + root, err := filepath.Abs(dest) + if err != nil { + return fatalFail(err.Error(), jsonout.CodeIO) + } + + results := make([]outcome, 0, len(wanted)+len(missing)) + for _, a := range wanted { + results = append(results, download(c, a, root)) + } + // A name the page doesn't have is that name's failure, not the run's. + for _, name := range missing { + results = append(results, outcome{ + name: name, + status: statusFailed, + err: fmt.Errorf("no attachment named %q on page %s", name, pageID), + code: jsonout.CodeNotFound, + }) + } + return report(results) +} + +// outcome is what happened to one attachment. +type outcome struct { + name string // the stored attachment name + destPath string // the local path written (or that would be) + status string + err error + code jsonout.Code +} + +// selectAttachments picks the attachments named on the command line, preserving +// the order the names were given, and reports names the page doesn't have. With +// no names, every attachment is selected in server order. +func selectAttachments(attachments []client.Attachment, names []string) ( + wanted []client.Attachment, missing []string, +) { + if len(names) == 0 { + return attachments, nil + } + byName := make(map[string]client.Attachment, len(attachments)) + for _, a := range attachments { + byName[a.Title] = a + } + for _, n := range names { + if a, ok := byName[n]; ok { + wanted = append(wanted, a) + continue + } + missing = append(missing, n) + } + return wanted, missing +} + +// download resolves one attachment's destination and writes it. +func download(c *client.ConfluenceClient, a client.Attachment, root string) outcome { + res := outcome{name: a.Title} + + path, err := destPath(root, a, flat) + if err != nil { + res.status, res.err, res.code = statusFailed, err, jsonout.CodeValidation + return res + } + res.destPath = path + + if _, err := os.Stat(path); err == nil && !force { + res.status = statusSkipped + return res + } + if dryRun { + res.status = statusDownloaded + return res + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + res.status, res.err, res.code = statusFailed, err, jsonout.CodeIO + return res + } + f, err := os.Create(path) + if err != nil { + res.status, res.err, res.code = statusFailed, err, jsonout.CodeIO + return res + } + defer func() { _ = f.Close() }() + if err := c.DownloadAttachment(a, f); err != nil { + res.status, res.err, res.code = statusFailed, err, jsonout.CodeFor(err) + return res + } + res.status = statusDownloaded + return res +} + +// destPath resolves where an attachment is written, and is the only place a +// server-controlled string becomes a filesystem path. +// +// The recorded source path is used, not a decode of the attachment name: there +// is no way to tell a hand-uploaded "a%2Fb.png" from one markfluence published, +// so decoding by default would scatter a literally-named file into a/b.png. An +// attachment with no recorded source keeps its stored name. +// +// The result must stay inside root. A source path may legitimately contain ".." +// -- an image in a directory above its page is a supported layout -- so ".." +// cannot simply be refused; the resolved path is compared against root instead. +// Escaping is an error rather than a silent clip, because the path comes from an +// attachment comment, which anyone who can edit the page controls. +func destPath(root string, a client.Attachment, flat bool) (string, error) { + rel := a.Title + if !flat { + if src := a.Meta().Source; src != "" { + rel = src + } + } + // A stored name is a single path element by construction; guard anyway, since + // it is server data. + if filepath.IsAbs(rel) { + return "", fmt.Errorf("attachment %q resolves to the absolute path %q", a.Title, rel) + } + + path := filepath.Join(root, filepath.FromSlash(rel)) + if path != root && !strings.HasPrefix(path, root+string(os.PathSeparator)) { + return "", fmt.Errorf("attachment %q resolves to %q, outside the destination directory", + a.Title, path) + } + if path == root { + return "", fmt.Errorf("attachment %q has no filename", a.Title) + } + return path, nil +} + +// report prints the per-attachment outcomes and returns the command's exit +// status: 1 if any attachment failed. +func report(results []outcome) error { + failed := 0 + skipped := 0 + for _, r := range results { + switch r.status { + case statusFailed: + failed++ + case statusSkipped: + skipped++ + } + } + + if ui.IsJSON() { + out := make([]any, 0, len(results)) + for _, r := range results { + out = append(out, buildResult(r)) + } + env := jsonout.NewEnvelope(command, out, map[string]int{ + "total": len(results), "succeeded": len(results) - failed, + "failed": failed, "skipped": skipped, + }) + if err := jsonout.Emit(os.Stdout, env); err != nil { + return err + } + if failed > 0 { + return ui.SilentExit(1) + } + return nil + } + + for _, r := range results { + switch r.status { + case statusSkipped: + ui.Dim(fmt.Sprintf("%-10s %s (exists; --force to overwrite)", r.status, r.destPath)) + case statusFailed: + ui.Error(fmt.Sprintf("%-10s %s: %s", r.status, r.name, r.err)) + default: + ui.Success(fmt.Sprintf("%-10s %s", r.status, r.destPath)) + } + } + if failed > 0 { + return ui.SilentExit(1) + } + return nil +} + +// fatalFail reports a config/usage/pre-flight failure: a JSON error object on +// stderr under --json, else a human error line, exiting 2. +func fatalFail(msg string, code jsonout.Code) error { + if ui.IsJSON() { + _ = jsonout.EmitError(os.Stderr, command, msg, code) + } else { + ui.Error(msg) + } + return ui.SilentExit(2) +} + +// operationalFail reports a failure against the page: under --json a results[0] +// entry {ok:false,error,code}, else a human error line, exiting 1. +func operationalFail(pageID string, err error, code jsonout.Code) error { + if ui.IsJSON() { + res := map[string]any{"ok": false, "page_id": pageID, "error": err.Error(), "code": code} + env := jsonout.NewEnvelope(command, []any{res}, + map[string]int{"total": 1, "succeeded": 0, "failed": 1, "skipped": 0}) + _ = jsonout.Emit(os.Stdout, env) + } else { + ui.Error(err.Error()) + } + return ui.SilentExit(1) +} diff --git a/cmd/attachmentdownload/attachmentdownload_test.go b/cmd/attachmentdownload/attachmentdownload_test.go new file mode 100644 index 0000000..2a8daed --- /dev/null +++ b/cmd/attachmentdownload/attachmentdownload_test.go @@ -0,0 +1,145 @@ +package attachmentdownload + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/mozilla/markfluence/internal/client" +) + +// managed builds an attachment carrying a recorded source path. +func managed(title, source string) client.Attachment { + a := client.Attachment{ID: "att1", Title: title} + a.Metadata.Comment = "markfluence: sha256=abc path=" + source + return a +} + +func TestDestPathUsesRecordedSource(t *testing.T) { + root := filepath.Clean("/tmp/dest") + got, err := destPath(root, managed("assets%2Fx.png", "assets/x.png"), false) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(root, "assets", "x.png") + if got != want { + t.Errorf("destPath = %q, want %q", got, want) + } +} + +// TestDestPathIgnoresNameWhenUnmanaged is why restoration reads the comment and +// never decodes the stored name: a hand-uploaded file literally named +// "a%2Fb.png" must not be scattered into a/b.png. +func TestDestPathIgnoresNameWhenUnmanaged(t *testing.T) { + root := filepath.Clean("/tmp/dest") + got, err := destPath(root, client.Attachment{Title: "a%2Fb.png"}, false) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(root, "a%2Fb.png") + if got != want { + t.Errorf("destPath = %q, want the literal stored name %q", got, want) + } +} + +func TestDestPathFlatIgnoresSource(t *testing.T) { + root := filepath.Clean("/tmp/dest") + got, err := destPath(root, managed("assets%2Fx.png", "assets/x.png"), true) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(root, "assets%2Fx.png") + if got != want { + t.Errorf("destPath = %q, want %q", got, want) + } +} + +// TestDestPathAllowsLegitimateParent covers the supported shared-asset layout: +// an image above its page encodes with "..", and as long as it still lands +// inside --dest it is fine. +func TestDestPathAllowsLegitimateParent(t *testing.T) { + root := filepath.Clean("/tmp/dest") + got, err := destPath(root, managed("..%2Fassets%2Flogo.png", "../assets/logo.png"), false) + if err == nil { + t.Fatalf("destPath = %q; a source escaping the root must be refused", got) + } + + // The same path is fine when the page sits a directory deeper. + got, err = destPath(root, managed("x", "docs/../assets/logo.png"), false) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(root, "assets", "logo.png"); got != want { + t.Errorf("destPath = %q, want %q", got, want) + } +} + +// TestDestPathRefusesEscapes is the clamp. A source path comes from an +// attachment comment, which anyone able to edit the page controls. +func TestDestPathRefusesEscapes(t *testing.T) { + root := filepath.Clean("/tmp/dest") + cases := []string{ + "../../escape.png", + "../../../.ssh/authorized_keys", + "/etc/passwd", + "/tmp/dest-sibling/x.png", + "..", + ".", + } + for _, src := range cases { + if got, err := destPath(root, managed("n.png", src), false); err == nil { + t.Errorf("destPath(path=%q) = %q, nil; want a refusal", src, got) + } + } +} + +// TestDestPathRefusesRootPrefixSibling guards a string-prefix mistake: +// "/tmp/destmore" starts with "/tmp/dest" but is not inside it. +func TestDestPathRefusesRootPrefixSibling(t *testing.T) { + root := filepath.Clean("/tmp/dest") + if got, err := destPath(root, managed("n.png", "../destmore/x.png"), false); err == nil { + t.Errorf("destPath = %q, nil; want a refusal for a sibling sharing the root's prefix", got) + } +} + +func TestSelectAttachmentsAll(t *testing.T) { + all := []client.Attachment{{Title: "a.png"}, {Title: "b.png"}} + got, missing := selectAttachments(all, nil) + if len(got) != 2 || len(missing) != 0 { + t.Fatalf("got %d wanted / %d missing, want 2/0", len(got), len(missing)) + } +} + +func TestSelectAttachmentsByNamePreservesRequestOrder(t *testing.T) { + all := []client.Attachment{{Title: "a.png"}, {Title: "b.png"}, {Title: "c.png"}} + got, missing := selectAttachments(all, []string{"c.png", "a.png"}) + if len(missing) != 0 { + t.Fatalf("missing = %v, want none", missing) + } + if got[0].Title != "c.png" || got[1].Title != "a.png" { + t.Errorf("order = %q/%q, want c.png/a.png", got[0].Title, got[1].Title) + } +} + +func TestSelectAttachmentsReportsMissing(t *testing.T) { + all := []client.Attachment{{Title: "a.png"}} + got, missing := selectAttachments(all, []string{"a.png", "nope.png"}) + if len(got) != 1 { + t.Errorf("wanted = %d, want 1", len(got)) + } + if len(missing) != 1 || missing[0] != "nope.png" { + t.Errorf("missing = %v, want [nope.png]", missing) + } +} + +// TestDestPathEscapeMessageNamesTheAttachment keeps the failure actionable: the +// user needs to know which attachment was refused. +func TestDestPathEscapeMessageNamesTheAttachment(t *testing.T) { + _, err := destPath(filepath.Clean("/tmp/dest"), managed("evil.png", "../../x"), false) + if err == nil { + t.Fatal("want an error") + } + if !strings.Contains(err.Error(), "evil.png") { + t.Errorf("error %q does not name the attachment", err) + } +} diff --git a/cmd/attachmentdownload/json.go b/cmd/attachmentdownload/json.go new file mode 100644 index 0000000..0c0f33a --- /dev/null +++ b/cmd/attachmentdownload/json.go @@ -0,0 +1,42 @@ +package attachmentdownload + +// jsonDownloadResult is attachment-download's --json result shape: one object +// per attachment. dest_path is the local path written, which is the piece a +// caller cannot derive itself -- it depends on the recorded source path, --flat, +// and --dest. It is null only when resolving the path is what failed. +type jsonDownloadResult struct { + OK bool `json:"ok"` + Status string `json:"status"` + DryRun bool `json:"dry_run"` + Filename string `json:"filename"` + DestPath *string `json:"dest_path"` + Error *string `json:"error"` + Code *string `json:"code"` +} + +func buildResult(r outcome) jsonDownloadResult { + res := jsonDownloadResult{ + OK: r.status != statusFailed, + Status: r.status, + DryRun: dryRun, + Filename: r.name, + DestPath: nullable(r.destPath), + } + if r.err != nil { + msg := r.err.Error() + res.Error = &msg + } + if r.code != "" { + code := string(r.code) + res.Code = &code + } + return res +} + +// nullable maps an empty string to a JSON null, else a pointer to the value. +func nullable(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/cmd/attachmentdownload/json_test.go b/cmd/attachmentdownload/json_test.go new file mode 100644 index 0000000..6ea1f1d --- /dev/null +++ b/cmd/attachmentdownload/json_test.go @@ -0,0 +1,83 @@ +package attachmentdownload + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/schematest" +) + +func TestSchemaConformance(t *testing.T) { + results := []any{ + buildResult(outcome{name: "assets%2Fx.png", destPath: "out/assets/x.png", status: statusDownloaded}), + buildResult(outcome{name: "notes.pdf", destPath: "out/notes.pdf", status: statusSkipped}), + buildResult(outcome{ + name: "evil.png", status: statusFailed, + err: errors.New("outside the destination directory"), code: jsonout.CodeValidation, + }), + } + env := jsonout.NewEnvelope(command, results, + map[string]int{"total": 3, "succeeded": 2, "failed": 1, "skipped": 1}) + var buf bytes.Buffer + if err := jsonout.Emit(&buf, env); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) + + failRes := map[string]any{"ok": false, "page_id": "9", "error": "page 9 not found", "code": jsonout.CodeNotFound} + failEnv := jsonout.NewEnvelope(command, []any{failRes}, + map[string]int{"total": 1, "succeeded": 0, "failed": 1, "skipped": 0}) + buf.Reset() + if err := jsonout.Emit(&buf, failEnv); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) +} + +func TestBuildResultFailureCarriesErrorAndNullDest(t *testing.T) { + res := buildResult(outcome{ + name: "evil.png", status: statusFailed, + err: errors.New("boom"), code: jsonout.CodeValidation, + }) + if res.OK { + t.Error("ok = true, want false for a failure") + } + b, err := json.Marshal(res) + if err != nil { + t.Fatal(err) + } + var round map[string]any + if err := json.Unmarshal(b, &round); err != nil { + t.Fatal(err) + } + if round["dest_path"] != nil { + t.Errorf("dest_path = %v, want null when path resolution failed", round["dest_path"]) + } + if round["error"] != "boom" || round["code"] != "VALIDATION" { + t.Errorf("error/code = %v/%v", round["error"], round["code"]) + } +} + +func TestJSONDownloadResultMarshal(t *testing.T) { + b, err := json.MarshalIndent( + buildResult(outcome{name: "assets%2Fx.png", destPath: "out/assets/x.png", status: statusDownloaded}), + "", " ") + if err != nil { + t.Fatal(err) + } + want := `{ + "ok": true, + "status": "downloaded", + "dry_run": false, + "filename": "assets%2Fx.png", + "dest_path": "out/assets/x.png", + "error": null, + "code": null +}` + if string(b) != want { + t.Errorf("download result mismatch:\n got:\n%s\n want:\n%s", b, want) + } +} diff --git a/cmd/attachmentlist/attachmentlist.go b/cmd/attachmentlist/attachmentlist.go new file mode 100644 index 0000000..6edc45d --- /dev/null +++ b/cmd/attachmentlist/attachmentlist.go @@ -0,0 +1,171 @@ +// Package attachmentlist implements the `markfluence attachment-list` command: +// list a page's attachments. +package attachmentlist + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/pageref" + "github.com/mozilla/markfluence/internal/ui" + "github.com/spf13/cobra" +) + +// command is the name used in help and as the --json command discriminator. +const command = "attachment-list" + +// Cmd is the attachment-list command. +var Cmd = &cobra.Command{ + Use: command + " PAGE", + Short: "List a Confluence page's attachments", + Long: "List a Confluence page's attachments.\n\n" + + "PAGE is a numeric page id, a Confluence page URL, or a markdown file\n" + + "whose frontmatter has a page_id.\n\n" + + "The NAME column is the name Confluence stores, which is what\n" + + "attachment-download takes. For an image markfluence published that is\n" + + "the encoded source path, and the SOURCE column shows the markdown\n" + + "image path it came from.\n\n" + + "SOURCE is a dash when no source path is recorded: the attachment was\n" + + "uploaded by hand, or it was published before markfluence recorded one.\n" + + "Use --json, whose managed field tells those two apart.", + Args: cobra.ExactArgs(1), + RunE: run, +} + +func run(cmd *cobra.Command, args []string) error { + url, _ := cmd.Flags().GetString("url") + username, _ := cmd.Flags().GetString("username") + cloudID, _ := cmd.Flags().GetString("cloud-id") + envFile, _ := cmd.Flags().GetString("env-file") + c, err := client.Resolve(client.Options{ + URL: url, Username: username, CloudID: cloudID, EnvFile: envFile, + }) + if err != nil { + return fatalFail(err.Error(), jsonout.CodeConfig) + } + + pageID, err := pageref.Resolve(args[0]) + if err != nil { + return fatalFail(err.Error(), jsonout.CodeValidation) + } + + attachments, err := c.ListAttachments(pageID) + if err != nil { + return operationalFail(pageID, err, jsonout.CodeFor(err)) + } + + if ui.IsJSON() { + results := make([]any, 0, len(attachments)) + for _, a := range attachments { + results = append(results, buildResult(a)) + } + env := jsonout.NewEnvelope(command, results, + map[string]int{"total": len(attachments), "succeeded": len(attachments), "failed": 0}) + return jsonout.Emit(os.Stdout, env) + } + + if len(attachments) == 0 { + ui.Info("No attachments.") + return nil + } + fmt.Println(table(attachments)) + return nil +} + +// fatalFail reports a config/usage/pre-flight failure: a JSON error object on +// stderr under --json, else a human error line, exiting 2. +func fatalFail(msg string, code jsonout.Code) error { + if ui.IsJSON() { + _ = jsonout.EmitError(os.Stderr, command, msg, code) + } else { + ui.Error(msg) + } + return ui.SilentExit(2) +} + +// operationalFail reports an operational failure for the page: under --json a +// results[0] entry {ok:false,error,code}, else a human error line, exiting 1. +func operationalFail(pageID string, err error, code jsonout.Code) error { + if ui.IsJSON() { + res := map[string]any{"ok": false, "page_id": pageID, "error": err.Error(), "code": code} + env := jsonout.NewEnvelope(command, []any{res}, + map[string]int{"total": 1, "succeeded": 0, "failed": 1}) + _ = jsonout.Emit(os.Stdout, env) + } else { + ui.Error(err.Error()) + } + return ui.SilentExit(1) +} + +// table renders the attachments as aligned columns. Widths are measured over the +// rows rather than fixed, so a page of short names doesn't print a sparse table. +func table(attachments []client.Attachment) string { + rows := make([][5]string, 0, len(attachments)+1) + rows = append(rows, [5]string{"NAME", "SIZE", "VER", "TYPE", "SOURCE"}) + for _, a := range attachments { + source := "-" + if m := a.Meta(); m.Source != "" { + source = m.Source + } + rows = append(rows, [5]string{ + a.Title, + humanSize(a.Extensions.FileSize), + strconv.Itoa(a.Version.Number), + a.Extensions.MediaType, + source, + }) + } + + var widths [5]int + for _, r := range rows { + for i, cell := range r { + if n := len([]rune(cell)); n > widths[i] { + widths[i] = n + } + } + } + + var b strings.Builder + for i, r := range rows { + if i > 0 { + b.WriteByte('\n') + } + for j, cell := range r { + if j > 0 { + b.WriteString(" ") + } + // The last column needs no padding, which also avoids trailing spaces. + if j == len(r)-1 { + b.WriteString(cell) + continue + } + pad := widths[j] - len([]rune(cell)) + // SIZE and VER are numeric, so right-align them. + if j == 1 || j == 2 { + b.WriteString(strings.Repeat(" ", pad) + cell) + } else { + b.WriteString(cell + strings.Repeat(" ", pad)) + } + } + } + return b.String() +} + +// humanSize renders a byte count in the largest unit that keeps it under 1024, +// with one decimal place above bytes. +func humanSize(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit && exp < 3; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]) +} diff --git a/cmd/attachmentlist/attachmentlist_test.go b/cmd/attachmentlist/attachmentlist_test.go new file mode 100644 index 0000000..815b3ea --- /dev/null +++ b/cmd/attachmentlist/attachmentlist_test.go @@ -0,0 +1,81 @@ +package attachmentlist + +import ( + "strings" + "testing" + + "github.com/mozilla/markfluence/internal/client" +) + +func TestHumanSize(t *testing.T) { + cases := []struct { + n int64 + want string + }{ + {0, "0 B"}, + {171, "171 B"}, + {1023, "1023 B"}, + {1024, "1.0 KB"}, + {24680, "24.1 KB"}, + {1024 * 1024, "1.0 MB"}, + {1258291, "1.2 MB"}, + {1024 * 1024 * 1024, "1.0 GB"}, + {1024 * 1024 * 1024 * 1024, "1.0 TB"}, + } + for _, c := range cases { + if got := humanSize(c.n); got != c.want { + t.Errorf("humanSize(%d) = %q, want %q", c.n, got, c.want) + } + } +} + +func TestTable(t *testing.T) { + managed := client.Attachment{ID: "att1", Title: "assets%2Fx.png"} + managed.Metadata.Comment = "markfluence: sha256=abc path=assets/x.png" + managed.Version.Number = 3 + managed.Extensions.MediaType = "image/png" + managed.Extensions.FileSize = 24680 + + hand := client.Attachment{ID: "att2", Title: "notes.pdf"} + hand.Version.Number = 1 + hand.Extensions.MediaType = "application/pdf" + hand.Extensions.FileSize = 171 + + got := table([]client.Attachment{managed, hand}) + lines := strings.Split(got, "\n") + if len(lines) != 3 { + t.Fatalf("got %d lines, want a header plus 2 rows:\n%s", len(lines), got) + } + if !strings.HasPrefix(lines[0], "NAME") || !strings.Contains(lines[0], "SOURCE") { + t.Errorf("header = %q", lines[0]) + } + if !strings.Contains(lines[1], "assets/x.png") { + t.Errorf("managed row is missing its source: %q", lines[1]) + } + // A hand-uploaded attachment reads as a dash, not a blank. + if !strings.HasSuffix(lines[2], "-") { + t.Errorf("hand-uploaded row should end in a dash: %q", lines[2]) + } + for i, l := range lines { + if l != strings.TrimRight(l, " ") { + t.Errorf("line %d has trailing whitespace: %q", i, l) + } + } +} + +// TestTableAlignsColumns checks the columns actually line up, which is the only +// reason to measure widths at all. +func TestTableAlignsColumns(t *testing.T) { + short := client.Attachment{ID: "a", Title: "a.png"} + short.Extensions.MediaType = "image/png" + long := client.Attachment{ID: "b", Title: "a-considerably-longer-name.png"} + long.Extensions.MediaType = "image/png" + + lines := strings.Split(table([]client.Attachment{short, long}), "\n") + col := strings.Index(lines[0], "SIZE") + for i, l := range lines[1:] { + if strings.Index(l, "B") < col { + t.Errorf("row %d size column starts before the header's: %q", i, l) + } + } +} diff --git a/cmd/attachmentlist/json.go b/cmd/attachmentlist/json.go new file mode 100644 index 0000000..e0b3d37 --- /dev/null +++ b/cmd/attachmentlist/json.go @@ -0,0 +1,55 @@ +package attachmentlist + +import "github.com/mozilla/markfluence/internal/client" + +// jsonListResult is attachment-list's --json result shape: one object per +// attachment, so `.results[] | .filename` works directly and summary.total is +// the attachment count. +// +// comment is the raw stored comment, and managed/sha256/source are what +// markfluence parses out of it -- both are emitted so a script never has to +// re-parse the comment itself, and nothing markfluence knows is hidden. +// +// source is null for a hand-uploaded attachment and also for one published +// before source paths were recorded; managed distinguishes them, which is why +// it is a field of its own rather than inferred from source being null. +// +// There is deliberately no download_url: built on the site URL it fails under a +// scoped token, and built on the request base it would leak the gateway host +// into reader-facing output. attachment-download is how bytes are fetched. +type jsonListResult struct { + OK bool `json:"ok"` + ID string `json:"id"` + Filename string `json:"filename"` + Size int64 `json:"size"` + MediaType string `json:"media_type"` + Version int `json:"version"` + Comment string `json:"comment"` + Managed bool `json:"managed"` + SHA256 *string `json:"sha256"` + Source *string `json:"source"` +} + +func buildResult(a client.Attachment) jsonListResult { + m := a.Meta() + return jsonListResult{ + OK: true, + ID: a.ID, + Filename: a.Title, + Size: a.Extensions.FileSize, + MediaType: a.Extensions.MediaType, + Version: a.Version.Number, + Comment: a.Metadata.Comment, + Managed: m.Managed, + SHA256: nullable(m.SHA256), + Source: nullable(m.Source), + } +} + +// nullable maps an empty string to a JSON null, else a pointer to the value. +func nullable(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/cmd/attachmentlist/json_test.go b/cmd/attachmentlist/json_test.go new file mode 100644 index 0000000..a8cf3d0 --- /dev/null +++ b/cmd/attachmentlist/json_test.go @@ -0,0 +1,112 @@ +package attachmentlist + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/schematest" +) + +func TestSchemaConformance(t *testing.T) { + managed := client.Attachment{ID: "att1", Title: "assets%2Fx.png"} + managed.Metadata.Comment = "markfluence: sha256=abc path=assets/x.png" + managed.Version.Number = 3 + managed.Extensions.MediaType = "image/png" + managed.Extensions.FileSize = 171 + + hand := client.Attachment{ID: "att2", Title: "notes.pdf"} + hand.Extensions.MediaType = "application/pdf" + hand.Extensions.FileSize = 2048 + hand.Version.Number = 1 + + env := jsonout.NewEnvelope(command, + []any{buildResult(managed), buildResult(hand)}, + map[string]int{"total": 2, "succeeded": 2, "failed": 0}) + var buf bytes.Buffer + if err := jsonout.Emit(&buf, env); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) + + failRes := map[string]any{"ok": false, "page_id": "9", "error": "page 9 not found", "code": jsonout.CodeNotFound} + failEnv := jsonout.NewEnvelope(command, []any{failRes}, + map[string]int{"total": 1, "succeeded": 0, "failed": 1}) + buf.Reset() + if err := jsonout.Emit(&buf, failEnv); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) + + // A page with no attachments still has to conform. + emptyEnv := jsonout.NewEnvelope(command, nil, + map[string]int{"total": 0, "succeeded": 0, "failed": 0}) + buf.Reset() + if err := jsonout.Emit(&buf, emptyEnv); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) +} + +func TestBuildResultManaged(t *testing.T) { + a := client.Attachment{ID: "att1", Title: "assets%2Fx.png"} + a.Metadata.Comment = "markfluence: sha256=abc path=assets/x.png" + res := buildResult(a) + if !res.Managed { + t.Error("managed = false, want true") + } + if res.Source == nil || *res.Source != "assets/x.png" { + t.Errorf("source = %v, want assets/x.png", res.Source) + } + if res.SHA256 == nil || *res.SHA256 != "abc" { + t.Errorf("sha256 = %v, want abc", res.SHA256) + } + // The stored name is reported as-is; it is what identifies the attachment. + if res.Filename != "assets%2Fx.png" { + t.Errorf("filename = %q, want the stored name", res.Filename) + } +} + +// TestBuildResultLegacyManaged covers the attachment every page published +// before the encoding change still carries: a legacy checksum comment, so it is +// managed and has a checksum but no recorded source. It must not be reported as +// hand-uploaded -- managed is what tells the two apart. +func TestBuildResultLegacyManaged(t *testing.T) { + a := client.Attachment{ID: "att3", Title: "assets_x.png"} + a.Metadata.Comment = "mzcld:checksum: e733ac00" + res := buildResult(a) + if !res.Managed { + t.Error("managed = false, want true for a legacy comment") + } + if res.SHA256 == nil || *res.SHA256 != "e733ac00" { + t.Errorf("sha256 = %v, want e733ac00", res.SHA256) + } + if res.Source != nil { + t.Errorf("source = %v, want null", res.Source) + } +} + +// TestBuildResultHandUploadedNullsMetadata is the signal attachment-list exists +// to give: an attachment publishing will not touch. +func TestBuildResultHandUploadedNullsMetadata(t *testing.T) { + a := client.Attachment{ID: "att2", Title: "notes.pdf"} + res := buildResult(a) + if res.Managed { + t.Error("managed = true, want false") + } + b, err := json.Marshal(res) + if err != nil { + t.Fatal(err) + } + var round map[string]any + if err := json.Unmarshal(b, &round); err != nil { + t.Fatal(err) + } + for _, k := range []string{"sha256", "source"} { + if round[k] != nil { + t.Errorf("%s = %v, want null", k, round[k]) + } + } +} diff --git a/cmd/attachmentupload/attachmentupload.go b/cmd/attachmentupload/attachmentupload.go new file mode 100644 index 0000000..a0ada27 --- /dev/null +++ b/cmd/attachmentupload/attachmentupload.go @@ -0,0 +1,218 @@ +// Package attachmentupload implements the `markfluence attachment-upload` +// command: upload or replace a page's attachments. +package attachmentupload + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/convert" + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/pageref" + "github.com/mozilla/markfluence/internal/ui" + "github.com/spf13/cobra" +) + +// command is the name used in help and as the --json command discriminator. +const command = "attachment-upload" + +var ( + nameFlag string + force bool + dryRun bool +) + +// Cmd is the attachment-upload command. +var Cmd = &cobra.Command{ + Use: command + " PAGE FILE...", + Short: "Upload or replace attachments on a Confluence page", + Long: "Upload or replace attachments on a Confluence page.\n\n" + + "PAGE is a numeric page id, a Confluence page URL, or a markdown file\n" + + "whose frontmatter has a page_id.\n\n" + + "Each file is attached under its base name. A file whose contents\n" + + "already match the attachment on the page is skipped, using the same\n" + + "checksum bookkeeping create/update use, so uploading by hand and\n" + + "publishing agree on what is current; --force uploads anyway.\n\n" + + "--name sets the attachment name for a single file, and takes a path:\n" + + "markfluence encodes it the way publishing would, so `--name\n" + + "assets/x.png` produces the attachment an image written as\n" + + "![](assets/x.png) resolves to.", + Args: cobra.MinimumNArgs(2), + RunE: run, +} + +func init() { + Cmd.Flags().StringVar(&nameFlag, "name", "", + "Attachment name, given as a path (requires a single FILE).") + Cmd.Flags().BoolVar(&force, "force", false, + "Upload even when the checksum shows the attachment is unchanged.") + Cmd.Flags().BoolVar(&dryRun, "dry-run", false, + "Preview what would be uploaded without writing to Confluence.") +} + +func run(cmd *cobra.Command, args []string) error { + files := args[1:] + if nameFlag != "" && len(files) != 1 { + return fatalFail("--name applies to a single attachment; pass exactly one FILE", + jsonout.CodeValidation) + } + + url, _ := cmd.Flags().GetString("url") + username, _ := cmd.Flags().GetString("username") + cloudID, _ := cmd.Flags().GetString("cloud-id") + envFile, _ := cmd.Flags().GetString("env-file") + c, err := client.Resolve(client.Options{ + URL: url, Username: username, CloudID: cloudID, EnvFile: envFile, + }) + if err != nil { + return fatalFail(err.Error(), jsonout.CodeConfig) + } + + pageID, err := pageref.Resolve(args[0]) + if err != nil { + return fatalFail(err.Error(), jsonout.CodeValidation) + } + + attachments, err := localAttachments(files, nameFlag) + if err != nil { + return fatalFail(err.Error(), jsonout.CodeIO) + } + + if dryRun && !ui.IsJSON() { + ui.Warn("DRY RUN — no changes will be written.") + } + + actions, err := plan(c, pageID, attachments) + if err != nil { + return operationalFail(pageID, err, jsonout.CodeFor(err)) + } + return report(actions) +} + +// plan performs the upload, or -- under --dry-run -- only the classification. +// --force turns every non-skip into an upload by classifying nothing as +// unchanged, which is why it is applied here rather than inside the client. +func plan(c *client.ConfluenceClient, pageID string, attachments []client.LocalAttachment) ( + []client.SyncAction, error, +) { + if dryRun { + actions, err := c.PlanAttachments(pageID, attachments) + if err != nil { + return nil, err + } + if force { + actions = forced(actions) + } + return actions, nil + } + if force { + return c.ForceUploadAttachments(pageID, attachments) + } + return c.SyncAttachments(pageID, attachments) +} + +// forced rewrites a dry-run plan for --force: what would have been skipped is +// instead updated, matching what a forced run actually does. +func forced(actions []client.SyncAction) []client.SyncAction { + out := make([]client.SyncAction, len(actions)) + for i, a := range actions { + if a.Action == "skipped" { + a.Action = "updated" + } + out[i] = a + } + return out +} + +// localAttachments resolves each file into an upload, checking readability up +// front so a batch fails before it has half-uploaded. +// +// The attachment name is the file's base name, or the encoding of --name. The +// recorded source is always the decode of the name, never the local path: if +// the two disagreed, a later publish would upload a second attachment under the +// name it computes while a download restored this one somewhere the markdown +// never references. +func localAttachments(files []string, name string) ([]client.LocalAttachment, error) { + out := make([]client.LocalAttachment, 0, len(files)) + for _, f := range files { + info, err := os.Stat(f) + if err != nil { + return nil, err + } + if info.IsDir() { + return nil, fmt.Errorf("%s is a directory", f) + } + source := filepath.Base(f) + if name != "" { + source = name + } + filename := convert.AttachmentFilename(source) + if filename == "" { + return nil, fmt.Errorf("%q is not a usable attachment name", source) + } + // Round-trip the name so source is exactly what a decode yields. + if decoded, ok := convert.AttachmentSource(filename); ok { + source = decoded + } + out = append(out, client.LocalAttachment{Path: f, Filename: filename, Source: source}) + } + return out, nil +} + +// report prints the per-file actions and returns the command's exit status. +func report(actions []client.SyncAction) error { + if ui.IsJSON() { + results := make([]any, 0, len(actions)) + skipped := 0 + for _, a := range actions { + if a.Action == "skipped" { + skipped++ + } + results = append(results, buildResult(a)) + } + env := jsonout.NewEnvelope(command, results, map[string]int{ + "total": len(actions), "succeeded": len(actions), "failed": 0, "skipped": skipped, + }) + return jsonout.Emit(os.Stdout, env) + } + for _, a := range actions { + line := fmt.Sprintf("%-8s %s", a.Action, a.Filename) + if a.Action == "skipped" { + ui.Dim(line + " (unchanged)") + continue + } + ui.Success(line) + } + return nil +} + +// fatalFail reports a config/usage/pre-flight failure: a JSON error object on +// stderr under --json, else a human error line, exiting 2. +func fatalFail(msg string, code jsonout.Code) error { + if ui.IsJSON() { + _ = jsonout.EmitError(os.Stderr, command, msg, code) + } else { + ui.Error(msg) + } + return ui.SilentExit(2) +} + +// operationalFail reports a failure against the page: under --json a results[0] +// entry {ok:false,error,code}, else a human error line, exiting 1. +func operationalFail(pageID string, err error, code jsonout.Code) error { + if ui.IsJSON() { + res := map[string]any{"ok": false, "page_id": pageID, "error": err.Error(), "code": code} + env := jsonout.NewEnvelope(command, []any{res}, + map[string]int{"total": 1, "succeeded": 0, "failed": 1, "skipped": 0}) + _ = jsonout.Emit(os.Stdout, env) + } else { + ui.Error(err.Error()) + } + return ui.SilentExit(1) +} + +// decodeName is convert.AttachmentSource, wrapped so tests can assert the +// lockstep invariant without importing the converter. +func decodeName(filename string) (string, bool) { return convert.AttachmentSource(filename) } diff --git a/cmd/attachmentupload/attachmentupload_test.go b/cmd/attachmentupload/attachmentupload_test.go new file mode 100644 index 0000000..6475405 --- /dev/null +++ b/cmd/attachmentupload/attachmentupload_test.go @@ -0,0 +1,117 @@ +package attachmentupload + +import ( + "os" + "path/filepath" + "testing" + + "github.com/mozilla/markfluence/internal/client" +) + +func writeFile(t *testing.T, dir, name string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("bytes"), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLocalAttachmentsUsesBaseName(t *testing.T) { + dir := t.TempDir() + path := writeFile(t, dir, "docs/assets/x.png") + + got, err := localAttachments([]string{path}, "") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("got %d attachments, want 1", len(got)) + } + if got[0].Filename != "x.png" { + t.Errorf("filename = %q, want x.png", got[0].Filename) + } + if got[0].Source != "x.png" { + t.Errorf("source = %q, want x.png", got[0].Source) + } + if got[0].Path != path { + t.Errorf("path = %q, want %q", got[0].Path, path) + } +} + +// TestLocalAttachmentsNameEncodesPath is the point of --name taking a path: the +// user writes a path and markfluence produces the attachment a publish of +// ![](assets/x.png) would resolve to, without them typing an escape. +func TestLocalAttachmentsNameEncodesPath(t *testing.T) { + dir := t.TempDir() + path := writeFile(t, dir, "somewhere/else.png") + + got, err := localAttachments([]string{path}, "assets/x.png") + if err != nil { + t.Fatal(err) + } + if got[0].Filename != "assets%2Fx.png" { + t.Errorf("filename = %q, want assets%%2Fx.png", got[0].Filename) + } + if got[0].Source != "assets/x.png" { + t.Errorf("source = %q, want assets/x.png", got[0].Source) + } +} + +// TestLocalAttachmentsSourceIsAlwaysTheDecodedName is the lockstep invariant: +// if the recorded path and the stored name could disagree, a later publish +// would upload a second attachment while a restoring download put this one +// where the markdown never references it. +func TestLocalAttachmentsSourceIsAlwaysTheDecodedName(t *testing.T) { + dir := t.TempDir() + path := writeFile(t, dir, "f.png") + + for _, name := range []string{"", "assets/x.png", "./a/./b.png", "../shared/logo.png", "plain.png"} { + got, err := localAttachments([]string{path}, name) + if err != nil { + t.Fatalf("--name %q: %v", name, err) + } + decoded, ok := decodeName(got[0].Filename) + if !ok { + t.Errorf("--name %q: stored name %q does not decode", name, got[0].Filename) + continue + } + if decoded != got[0].Source { + t.Errorf("--name %q: source %q != decode of %q (%q)", + name, got[0].Source, got[0].Filename, decoded) + } + } +} + +func TestLocalAttachmentsRejectsMissingAndDirs(t *testing.T) { + dir := t.TempDir() + if _, err := localAttachments([]string{filepath.Join(dir, "nope.png")}, ""); err == nil { + t.Error("want an error for a missing file") + } + if _, err := localAttachments([]string{dir}, ""); err == nil { + t.Error("want an error for a directory") + } +} + +// TestForcedRewritesSkips covers the dry-run forecast for --force: what would +// have been skipped is reported as updated, matching what a forced run does. +func TestForcedRewritesSkips(t *testing.T) { + in := []client.SyncAction{ + {Filename: "a.png", Action: "skipped"}, + {Filename: "b.png", Action: "created"}, + {Filename: "c.png", Action: "updated"}, + } + got := forced(in) + want := []string{"updated", "created", "updated"} + for i := range want { + if got[i].Action != want[i] { + t.Errorf("[%d] action = %q, want %q", i, got[i].Action, want[i]) + } + } + if in[0].Action != "skipped" { + t.Error("forced mutated its input") + } +} diff --git a/cmd/attachmentupload/json.go b/cmd/attachmentupload/json.go new file mode 100644 index 0000000..e1198f7 --- /dev/null +++ b/cmd/attachmentupload/json.go @@ -0,0 +1,25 @@ +package attachmentupload + +import "github.com/mozilla/markfluence/internal/client" + +// jsonUploadResult is attachment-upload's --json result shape: one object per +// file. status uses the same created/updated/skipped verbs the attachments +// array on update and create already reports, so a script that understands one +// understands the other. +type jsonUploadResult struct { + OK bool `json:"ok"` + Status string `json:"status"` + DryRun bool `json:"dry_run"` + Filename string `json:"filename"` + Error *string `json:"error"` + Code *string `json:"code"` +} + +func buildResult(a client.SyncAction) jsonUploadResult { + return jsonUploadResult{ + OK: true, + Status: a.Action, + DryRun: dryRun, + Filename: a.Filename, + } +} diff --git a/cmd/attachmentupload/json_test.go b/cmd/attachmentupload/json_test.go new file mode 100644 index 0000000..6629dcf --- /dev/null +++ b/cmd/attachmentupload/json_test.go @@ -0,0 +1,70 @@ +package attachmentupload + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/schematest" +) + +func TestSchemaConformance(t *testing.T) { + results := []any{ + buildResult(client.SyncAction{Filename: "a.png", Action: "created"}), + buildResult(client.SyncAction{Filename: "b.png", Action: "updated"}), + buildResult(client.SyncAction{Filename: "c.png", Action: "skipped"}), + } + env := jsonout.NewEnvelope(command, results, + map[string]int{"total": 3, "succeeded": 3, "failed": 0, "skipped": 1}) + var buf bytes.Buffer + if err := jsonout.Emit(&buf, env); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) + + failRes := map[string]any{"ok": false, "page_id": "9", "error": "page 9 not found", "code": jsonout.CodeNotFound} + failEnv := jsonout.NewEnvelope(command, []any{failRes}, + map[string]int{"total": 1, "succeeded": 0, "failed": 1, "skipped": 0}) + buf.Reset() + if err := jsonout.Emit(&buf, failEnv); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) +} + +func TestSchemaConformanceDryRun(t *testing.T) { + dryRun = true + t.Cleanup(func() { dryRun = false }) + + res := buildResult(client.SyncAction{Filename: "a.png", Action: "created"}) + if !res.DryRun { + t.Error("dry_run = false, want true") + } + env := jsonout.NewEnvelope(command, []any{res}, + map[string]int{"total": 1, "succeeded": 1, "failed": 0, "skipped": 0}) + var buf bytes.Buffer + if err := jsonout.Emit(&buf, env); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) +} + +func TestJSONUploadResultMarshal(t *testing.T) { + b, err := json.MarshalIndent(buildResult(client.SyncAction{Filename: "a.png", Action: "created"}), "", " ") + if err != nil { + t.Fatal(err) + } + want := `{ + "ok": true, + "status": "created", + "dry_run": false, + "filename": "a.png", + "error": null, + "code": null +}` + if string(b) != want { + t.Errorf("upload result mismatch:\n got:\n%s\n want:\n%s", b, want) + } +} diff --git a/cmd/info/info.go b/cmd/info/info.go index 096647e..96be4b3 100644 --- a/cmd/info/info.go +++ b/cmd/info/info.go @@ -10,8 +10,8 @@ import ( "strings" "github.com/mozilla/markfluence/internal/client" - "github.com/mozilla/markfluence/internal/frontmatter" "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/pageref" "github.com/mozilla/markfluence/internal/pagewidth" "github.com/mozilla/markfluence/internal/ui" "github.com/spf13/cobra" @@ -24,10 +24,11 @@ var showProperties bool // Cmd is the info command. var Cmd = &cobra.Command{ - Use: "info ARG", + Use: "info PAGE", Short: "Print metadata about a Confluence page", Long: "Print metadata about a Confluence page.\n\n" + - "ARG is a numeric page id or a markdown file whose frontmatter has a page_id.", + "PAGE is a numeric page id, a Confluence page URL, or a markdown file\n" + + "whose frontmatter has a page_id.", Args: cobra.ExactArgs(1), RunE: run, } @@ -49,7 +50,7 @@ func run(cmd *cobra.Command, args []string) error { return fatalFail(err.Error(), jsonout.CodeConfig) } - pageID, err := resolvePageID(args[0]) + pageID, err := pageref.Resolve(args[0]) if err != nil { return fatalFail(err.Error(), jsonout.CodeValidation) } @@ -100,25 +101,6 @@ func operationalFail(pageID string, err error, code jsonout.Code) error { return ui.SilentExit(1) } -// resolvePageID resolves the CLI argument to a page id: a markdown file's -// frontmatter page_id, or a bare numeric id. -func resolvePageID(arg string) (string, error) { - if info, err := os.Stat(arg); err == nil && !info.IsDir() { - mf, err := frontmatter.ParseFile(arg) - if err != nil { - return "", err - } - if mf.PageID() == "" { - return "", fmt.Errorf("no page_id in frontmatter of %s", arg) - } - return mf.PageID(), nil - } - if isDigits(arg) { - return arg, nil - } - return "", fmt.Errorf("%s is not a file or a numeric page id", arg) -} - // report is the resolved metadata for a page, feeding both the human "label: // value" renderer and the JSON result. Fields are captured raw (empty when // absent); each renderer decides how to present or omit them. @@ -303,15 +285,3 @@ func versionNumber(n int) string { } return strconv.Itoa(n) } - -func isDigits(s string) bool { - if s == "" { - return false - } - for _, c := range s { - if c < '0' || c > '9' { - return false - } - } - return true -} diff --git a/cmd/read/read.go b/cmd/read/read.go index 73ccc3d..367c58b 100644 --- a/cmd/read/read.go +++ b/cmd/read/read.go @@ -4,15 +4,14 @@ package read import ( "fmt" - "net/url" "os" - "regexp" "strings" "github.com/mozilla/markfluence/internal/client" "github.com/mozilla/markfluence/internal/convert" "github.com/mozilla/markfluence/internal/frontmatter" "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/pageref" "github.com/mozilla/markfluence/internal/pagewidth" "github.com/mozilla/markfluence/internal/ui" "github.com/spf13/cobra" @@ -29,11 +28,12 @@ var formatFlag string // Cmd is the read command. var Cmd = &cobra.Command{ - Use: "read ARG", + Use: "read PAGE", Short: "Fetch a Confluence page and print its body", Long: "Fetch a Confluence page and print its body to stdout.\n\n" + - "ARG is a numeric page id or a Confluence page URL (the modern\n" + - "/wiki/.../pages//... form or a legacy ?pageId= URL).\n\n" + + "PAGE is a numeric page id, a Confluence page URL (the modern\n" + + "/wiki/.../pages//... form or a legacy ?pageId= URL), or a\n" + + "markdown file whose frontmatter has a page_id.\n\n" + "The default markdown output carries title/page_id/space/page_width\n" + "frontmatter and is a best-effort inverse of what create/update publish.", Args: cobra.ExactArgs(1), @@ -51,7 +51,7 @@ func run(cmd *cobra.Command, args []string) error { formatFlag, formatMarkdown, formatStorage), jsonout.CodeValidation) } - pageID, err := parsePageID(args[0]) + pageID, err := pageref.Resolve(args[0]) if err != nil { return fatalFail(err.Error(), jsonout.CodeValidation) } @@ -189,31 +189,3 @@ func renderFrontmatter(title, space, parent, pageID, width string) string { } return fm } - -// pagePathRE matches the numeric id in a modern Confluence page URL path, -// e.g. /wiki/spaces/ENG/pages/123456/Some+Title (the trailing slug is optional). -var pagePathRE = regexp.MustCompile(`/pages/(\d+)(?:/|$)`) - -// parsePageID resolves the CLI argument to a numeric page id: a bare numeric id, -// or a Confluence URL carrying the id in its path or a pageId query parameter. -func parsePageID(arg string) (string, error) { - if isDigits(arg) { - return arg, nil - } - if u, err := url.Parse(arg); err == nil && u.Host != "" { - if id := u.Query().Get("pageId"); isDigits(id) { - return id, nil - } - if m := pagePathRE.FindStringSubmatch(u.Path); m != nil { - return m[1], nil - } - } - return "", fmt.Errorf("%q is not a numeric page id or a Confluence page URL", arg) -} - -func isDigits(s string) bool { - if s == "" { - return false - } - return strings.IndexFunc(s, func(r rune) bool { return r < '0' || r > '9' }) == -1 -} diff --git a/cmd/read/read_test.go b/cmd/read/read_test.go index c26f6d6..384005d 100644 --- a/cmd/read/read_test.go +++ b/cmd/read/read_test.go @@ -37,34 +37,3 @@ func TestRenderFrontmatterQuotesWhenNeeded(t *testing.T) { t.Errorf("renderFrontmatter =\n%q\nwant\n%q", got, want) } } - -func TestParsePageID(t *testing.T) { - tests := []struct { - name string - arg string - want string // "" means expect an error - }{ - {"bare numeric id", "123456", "123456"}, - {"modern path with slug", "https://org.atlassian.net/wiki/spaces/ENG/pages/123456/Some+Title", "123456"}, - {"modern path no slug", "https://org.atlassian.net/wiki/spaces/ENG/pages/123456", "123456"}, - {"legacy pageId query", "https://org.atlassian.net/wiki/pages/viewpage.action?pageId=987654", "987654"}, - {"query wins over path only if numeric", "https://org.atlassian.net/wiki/x?pageId=42", "42"}, - {"non-numeric arg", "not-a-page", ""}, - {"url without an id", "https://org.atlassian.net/wiki/spaces/ENG/overview", ""}, - {"empty", "", ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parsePageID(tt.arg) - if tt.want == "" { - if err == nil { - t.Fatalf("parsePageID(%q) = %q, nil; want error", tt.arg, got) - } - return - } - if err != nil || got != tt.want { - t.Fatalf("parsePageID(%q) = %q, %v; want %q", tt.arg, got, err, tt.want) - } - }) - } -} diff --git a/cmd/root.go b/cmd/root.go index c61e5c8..451521c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,9 @@ import ( "os" "strings" + "github.com/mozilla/markfluence/cmd/attachmentdownload" + "github.com/mozilla/markfluence/cmd/attachmentlist" + "github.com/mozilla/markfluence/cmd/attachmentupload" "github.com/mozilla/markfluence/cmd/create" "github.com/mozilla/markfluence/cmd/fix" "github.com/mozilla/markfluence/cmd/info" @@ -136,4 +139,7 @@ func init() { rootCmd.AddCommand(fix.Cmd) rootCmd.AddCommand(info.Cmd) rootCmd.AddCommand(read.Cmd) + rootCmd.AddCommand(attachmentlist.Cmd) + rootCmd.AddCommand(attachmentupload.Cmd) + rootCmd.AddCommand(attachmentdownload.Cmd) } diff --git a/internal/client/client.go b/internal/client/client.go index 326d15c..81a6282 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -55,6 +55,8 @@ const ( timeoutRead = 30 * time.Second timeoutWrite = 60 * time.Second timeoutUpload = 120 * time.Second + // timeoutDownload matches timeoutUpload: 30s is thin for a large attachment. + timeoutDownload = 120 * time.Second ) const ( @@ -179,13 +181,30 @@ type Links struct { Next string `json:"next"` } -// Attachment is a page attachment (v1), with the checksum comment expanded. +// Attachment is a page attachment (v1), with the comment, version, and +// extensions expanded. Title is the name Confluence stores, which for an +// attachment markfluence published is the encoded source path (see +// convert.AttachmentFilename). type Attachment struct { ID string `json:"id"` Title string `json:"title"` Metadata struct { Comment string `json:"comment"` } `json:"metadata"` + Version struct { + Number int `json:"number"` + When string `json:"when"` + } `json:"version"` + Extensions struct { + MediaType string `json:"mediaType"` + FileSize int64 `json:"fileSize"` + } `json:"extensions"` + Links struct { + // Download is context-relative ("/rest/api/content/{page}/child/ + // attachment/{id}/download"), not the /download/attachments/... UI path. + // Being an API path, it also works through the gateway. + Download string `json:"download"` + } `json:"_links"` } // Property is a page content property (v2). Value is decoded as-is (page @@ -550,18 +569,66 @@ func (c *ConfluenceClient) GetUser(accountID string) string { // --- attachments (v1) -------------------------------------------------------- -// ListAttachments lists a page's attachments with the checksum comment expanded. +// attachmentPageSize is the per-request page size for ListAttachments. +const attachmentPageSize = 250 + +// ListAttachments lists all of a page's attachments, with the comment, version, +// and extensions expanded. +// +// Pagination is by start/limit offset rather than _links.next: a v1 collection +// omits next when the results fit one page, and its next is relative to the +// /wiki context rather than the v2 paths resolveNext is written for. func (c *ConfluenceClient) ListAttachments(pageID string) ([]Attachment, error) { - var out struct { - Results []Attachment `json:"results"` + var all []Attachment + for start := 0; ; start += attachmentPageSize { + var out struct { + Results []Attachment `json:"results"` + } + err := c.doJSON(http.MethodGet, + c.baseURL+"/wiki/rest/api/content/"+pageID+"/child/attachment", + url.Values{ + "expand": {"metadata.comment,version,extensions"}, + "limit": {strconv.Itoa(attachmentPageSize)}, + "start": {strconv.Itoa(start)}, + }, nil, &out, timeoutRead) + if err != nil { + return nil, err + } + all = append(all, out.Results...) + if len(out.Results) < attachmentPageSize { + return all, nil + } } - err := c.doJSON(http.MethodGet, - c.baseURL+"/wiki/rest/api/content/"+pageID+"/child/attachment", - url.Values{"expand": {"metadata.comment"}, "limit": {"250"}}, nil, &out, timeoutRead) +} + +// DownloadAttachment fetches an attachment's bytes and writes them to w. +// +// The download endpoint 302s to Atlassian's media host with its own short-lived +// token in the query string. Go's default redirect policy drops the +// Authorization header on a cross-host hop, so the site credentials are never +// sent to that host -- which does not want them. Do not install a CheckRedirect +// that forwards headers: it would leak the API token to a third-party host. +// +// The body is buffered in memory, like every other response send handles, in +// exchange for its retry/backoff and typed errors. +func (c *ConfluenceClient) DownloadAttachment(att Attachment, w io.Writer) error { + if att.Links.Download == "" { + return fmt.Errorf("attachment %s (%s) has no download link", att.Title, att.ID) + } + rawURL := c.baseURL + "/wiki" + att.Links.Download + req, err := http.NewRequest(http.MethodGet, rawURL, nil) if err != nil { - return nil, err + return err } - return out.Results, nil + status, body, err := c.send(req, timeoutDownload) + if err != nil { + return err + } + if status >= 400 { + return &HTTPError{StatusCode: status, Method: http.MethodGet, URL: rawURL, Body: string(body)} + } + _, err = w.Write(body) + return err } // attachmentPlan is the decision for one local attachment: the action to take @@ -604,6 +671,11 @@ func (c *ConfluenceClient) planAttachments(pageID string, attachments []LocalAtt } p := attachmentPlan{att: att, comment: comment, contentType: contentType} cur, ok := remote[att.Filename] + if ok { + // Recorded even for a skip, so a forced upload can replace in place + // rather than having to re-derive the id. + p.existingID = cur.ID + } switch { case !ok: p.action = "created" @@ -614,7 +686,6 @@ func (c *ConfluenceClient) planAttachments(pageID string, attachments []LocalAtt p.action = "skipped" default: p.action = "updated" - p.existingID = cur.ID } plans = append(plans, p) } @@ -639,12 +710,33 @@ func (c *ConfluenceClient) PlanAttachments(pageID string, attachments []LocalAtt // local files, using a SHA-256 stored in each attachment's comment to detect // changes. Returns one action per file. func (c *ConfluenceClient) SyncAttachments(pageID string, attachments []LocalAttachment) ([]SyncAction, error) { + return c.syncAttachments(pageID, attachments, false) +} + +// ForceUploadAttachments uploads every file regardless of its checksum, +// bumping each attachment's version. It exists for `attachment-upload --force`, +// which is how a user repairs an attachment whose stored bytes drifted while +// its recorded checksum still matches. +func (c *ConfluenceClient) ForceUploadAttachments(pageID string, attachments []LocalAttachment) ( + []SyncAction, error, +) { + return c.syncAttachments(pageID, attachments, true) +} + +// syncAttachments executes a plan. When force is set, a file the checksum says +// is unchanged is uploaded anyway and reported as updated. +func (c *ConfluenceClient) syncAttachments(pageID string, attachments []LocalAttachment, force bool) ( + []SyncAction, error, +) { plans, err := c.planAttachments(pageID, attachments) if err != nil { return nil, err } var actions []SyncAction for _, p := range plans { + if force && p.action == "skipped" { + p.action = "updated" + } switch p.action { case "created": if err := c.uploadAttachment( diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 39d8f76..84efbd3 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -1,6 +1,9 @@ package client import ( + "bytes" + "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -426,6 +429,164 @@ func TestPlanAttachmentsClassifiesWithoutUploading(t *testing.T) { } } +// TestListAttachmentsDecodesMetadata pins the fields against a payload shaped +// like a real one (verified against Cloud): fileSize and mediaType live under +// extensions, and the download link is an API path, not /download/attachments. +func TestListAttachmentsDecodesMetadata(t *testing.T) { + list := `{"results":[{` + + `"id":"att99","title":"assets%2Fx.png",` + + `"metadata":{"comment":"` + attachmentCommentPrefix + `sha256=abc path=assets/x.png"},` + + `"version":{"number":3,"when":"2026-08-05T22:17:28.040Z"},` + + `"extensions":{"mediaType":"image/png","fileSize":171},` + + `"_links":{"download":"/rest/api/content/1/child/attachment/att99/download"}` + + `}]}` + c, _ := newServer(t, resp{200, list}) + got, err := c.ListAttachments("1") + if err != nil || len(got) != 1 { + t.Fatalf("ListAttachments = %v, %v", got, err) + } + a := got[0] + if a.Extensions.FileSize != 171 || a.Extensions.MediaType != "image/png" { + t.Errorf("extensions = %+v, want 171/image/png", a.Extensions) + } + if a.Version.Number != 3 { + t.Errorf("version.number = %d, want 3", a.Version.Number) + } + if a.Links.Download != "/rest/api/content/1/child/attachment/att99/download" { + t.Errorf("download = %q", a.Links.Download) + } + if m := a.Meta(); !m.Managed || m.Source != "assets/x.png" || m.SHA256 != "abc" { + t.Errorf("meta = %+v", m) + } +} + +// TestListAttachmentsPaginates covers the offset loop: a full first page means +// there may be more, and a short page ends it. Without this, a page with more +// than attachmentPageSize attachments is silently truncated. +func TestListAttachmentsPaginates(t *testing.T) { + var full strings.Builder + full.WriteString(`{"results":[`) + for i := range attachmentPageSize { + if i > 0 { + full.WriteByte(',') + } + fmt.Fprintf(&full, `{"id":"a%d","title":"f%d.png"}`, i, i) + } + full.WriteString(`]}`) + + c, s := newServer(t, + resp{200, full.String()}, + resp{200, `{"results":[{"id":"last","title":"last.png"}]}`}, + ) + got, err := c.ListAttachments("1") + if err != nil { + t.Fatal(err) + } + if len(got) != attachmentPageSize+1 { + t.Fatalf("len = %d, want %d", len(got), attachmentPageSize+1) + } + if got[len(got)-1].Title != "last.png" { + t.Errorf("last title = %q, want last.png", got[len(got)-1].Title) + } + if len(s.calls) != 2 { + t.Errorf("calls = %v, want 2 requests", s.calls) + } +} + +// TestListAttachmentsStopsOnShortFirstPage guards the common case: one request, +// not a speculative second. +func TestListAttachmentsStopsOnShortFirstPage(t *testing.T) { + c, s := newServer(t, resp{200, `{"results":[{"id":"a1","title":"x.png"}]}`}) + if _, err := c.ListAttachments("1"); err != nil { + t.Fatal(err) + } + if len(s.calls) != 1 { + t.Errorf("calls = %v, want a single request", s.calls) + } +} + +func TestDownloadAttachmentWritesBytes(t *testing.T) { + c, _ := newServer(t, resp{200, "PNGBYTES"}) + var att Attachment + att.Links.Download = "/rest/api/content/1/child/attachment/att1/download" + var buf bytes.Buffer + if err := c.DownloadAttachment(att, &buf); err != nil { + t.Fatal(err) + } + if buf.String() != "PNGBYTES" { + t.Errorf("body = %q, want PNGBYTES", buf.String()) + } +} + +func TestDownloadAttachmentRequiresLink(t *testing.T) { + c, _ := newServer(t) + if err := c.DownloadAttachment(Attachment{Title: "x.png"}, io.Discard); err == nil { + t.Fatal("want an error when the attachment has no download link") + } +} + +func TestDownloadAttachmentPropagatesHTTPError(t *testing.T) { + c, _ := newServer(t, resp{404, "gone"}) + var att Attachment + att.Links.Download = "/rest/api/content/1/child/attachment/att1/download" + err := c.DownloadAttachment(att, io.Discard) + var he *HTTPError + if !errors.As(err, &he) || he.StatusCode != 404 { + t.Fatalf("err = %v, want *HTTPError 404", err) + } +} + +// TestDownloadAttachmentDoesNotLeakCredentialsOnRedirect is the security +// assertion behind reusing send: the real endpoint 302s to Atlassian's media +// host, which carries its own token in the query string and must never receive +// the site credentials. Go's default policy drops Authorization on a cross-host +// hop -- this fails if anyone adds a CheckRedirect that forwards headers. +// +// The media server is addressed as "localhost" while the origin is +// "127.0.0.1". Both resolve to the same listener, but Go compares hostnames +// with the port stripped, so two httptest servers would otherwise look like the +// same host and the header would be forwarded -- making the test pass +// vacuously. +func TestDownloadAttachmentDoesNotLeakCredentialsOnRedirect(t *testing.T) { + var mediaAuth string + var reached bool + media := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = true + mediaAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte("MEDIABYTES")) + })) + t.Cleanup(media.Close) + mediaURL := strings.Replace(media.URL, "127.0.0.1", "localhost", 1) + if mediaURL == media.URL { + t.Fatalf("expected a 127.0.0.1 test URL to rewrite, got %q", media.URL) + } + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + t.Error("origin request lost its Authorization header") + } + http.Redirect(w, r, mediaURL+"/file/abc/binary?token=xyz", http.StatusFound) + })) + t.Cleanup(origin.Close) + + c := New(Config{SiteURL: origin.URL, Username: "u", Token: "secret"}) + var att Attachment + att.Links.Download = "/rest/api/content/1/child/attachment/att1/download" + var buf bytes.Buffer + if err := c.DownloadAttachment(att, &buf); err != nil { + t.Fatal(err) + } + if !reached { + t.Fatal("redirect was not followed") + } + if buf.String() != "MEDIABYTES" { + t.Errorf("body = %q, want the redirected bytes", buf.String()) + } + if mediaAuth != "" { + t.Errorf("Authorization leaked to the media host: %q", mediaAuth) + } +} + // --- misc -------------------------------------------------------------------- func TestLoadDotenv(t *testing.T) { diff --git a/internal/convert/attachname.go b/internal/convert/attachname.go index ec398ff..e190a87 100644 --- a/internal/convert/attachname.go +++ b/internal/convert/attachname.go @@ -13,6 +13,11 @@ package convert // Confluence stores these names verbatim and matches ri:filename literally -- // verified against Cloud, where "a%2Fb.png" resolves and renders with the name // re-escaped as "a%252Fb.png" in the image URL. +// +// The codec is exported because the attachment subcommands share it: upload +// encodes a --name path, and download decodes a stored name. It lives here +// rather than in its own package because the converter's image handling is what +// defines the mapping. import ( "path" @@ -24,25 +29,25 @@ const ( pctSlash = "%2F" // a "/" path separator ) -// attachmentFilename derives the Confluence attachment name for a markdown image +// AttachmentFilename derives the Confluence attachment name for a markdown image // src. The src is normalized first so a name can never decode to an absolute // path, then "%" is encoded before "/" -- in that order, so the escapes // introduced for "/" are not themselves escaped. -func attachmentFilename(src string) string { +func AttachmentFilename(src string) string { rel := normalizeSrc(src) rel = strings.ReplaceAll(rel, "%", pctEscape) return strings.ReplaceAll(rel, "/", pctSlash) } -// attachmentSource inverts attachmentFilename, recovering the source path an +// AttachmentSource inverts AttachmentFilename, recovering the source path an // attachment was published from. It reports false when the name could not have // come from markfluence -- currently when it decodes to an empty or absolute -// path, which attachmentFilename never produces -- so callers fall back to +// path, which AttachmentFilename never produces -- so callers fall back to // treating the attachment name as the path. // // A name markfluence did not create is decoded on a best-effort basis: there is // no way to tell a hand-uploaded "a%2Fb.png" from one we published. -func attachmentSource(filename string) (string, bool) { +func AttachmentSource(filename string) (string, bool) { // Decode "%2F" first and "%25" last. Replacing "%2F" only ever removes text // and cannot spell a new "%25", while "%25" must be replaced last precisely // so its output is not rescanned -- that is how a literal "%2F" in the source @@ -58,7 +63,7 @@ func attachmentSource(filename string) (string, bool) { // normalizeSrc reduces a markdown image src to a clean relative path: "./a/x.png" // and "a/./x.png" both become "a/x.png". A leading "/" is dropped because image // resolution joins src onto the page's directory anyway, so an absolute-looking -// src was never actually absolute -- and dropping it keeps attachmentFilename +// src was never actually absolute -- and dropping it keeps AttachmentFilename // from ever producing a name that decodes to an absolute path. // // A ".." prefix is preserved: an image in a shared directory above the page diff --git a/internal/convert/attachname_test.go b/internal/convert/attachname_test.go index 219ef7b..3f90110 100644 --- a/internal/convert/attachname_test.go +++ b/internal/convert/attachname_test.go @@ -36,16 +36,16 @@ func TestAttachmentNameRoundTrip(t *testing.T) { {"my docs/a b.png", "my docs%2Fa b.png"}, } for _, c := range cases { - if got := attachmentFilename(c.src); got != c.name { - t.Errorf("attachmentFilename(%q) = %q, want %q", c.src, got, c.name) + if got := AttachmentFilename(c.src); got != c.name { + t.Errorf("AttachmentFilename(%q) = %q, want %q", c.src, got, c.name) } - got, ok := attachmentSource(c.name) + got, ok := AttachmentSource(c.name) if !ok { - t.Errorf("attachmentSource(%q) refused a name we produced", c.name) + t.Errorf("AttachmentSource(%q) refused a name we produced", c.name) continue } if got != c.src { - t.Errorf("attachmentSource(%q) = %q, want %q (round trip)", c.name, got, c.src) + t.Errorf("AttachmentSource(%q) = %q, want %q (round trip)", c.name, got, c.src) } } } @@ -59,7 +59,7 @@ func TestAttachmentFilenameIsInjective(t *testing.T) { } seen := map[string]string{} for _, src := range srcs { - name := attachmentFilename(src) + name := AttachmentFilename(src) if prev, dup := seen[name]; dup { t.Errorf("%q and %q both encode to %q", prev, src, name) } @@ -80,8 +80,8 @@ func TestAttachmentFilenameNormalizes(t *testing.T) { {"/assets/x.png", "assets%2Fx.png"}, } for _, c := range cases { - if got := attachmentFilename(c.src); got != c.want { - t.Errorf("attachmentFilename(%q) = %q, want %q", c.src, got, c.want) + if got := AttachmentFilename(c.src); got != c.want { + t.Errorf("AttachmentFilename(%q) = %q, want %q", c.src, got, c.want) } } } @@ -91,8 +91,8 @@ func TestAttachmentFilenameNormalizes(t *testing.T) { // path (which is what #37's export would then write to). func TestAttachmentSourceRefusesAbsolute(t *testing.T) { for _, name := range []string{"%2Fetc%2Fpasswd.png", "%2F.png", ""} { - if got, ok := attachmentSource(name); ok { - t.Errorf("attachmentSource(%q) = %q, true; want refusal", name, got) + if got, ok := AttachmentSource(name); ok { + t.Errorf("AttachmentSource(%q) = %q, true; want refusal", name, got) } } } @@ -106,9 +106,9 @@ func TestAttachmentSourceDecodesForeignNames(t *testing.T) { {"screenshot 2026.png", "screenshot 2026.png"}, {"..%2Fup.png", "../up.png"}, } { - got, ok := attachmentSource(c.name) + got, ok := AttachmentSource(c.name) if !ok || got != c.want { - t.Errorf("attachmentSource(%q) = %q, %v; want %q, true", c.name, got, ok, c.want) + t.Errorf("AttachmentSource(%q) = %q, %v; want %q, true", c.name, got, ok, c.want) } } } diff --git a/internal/convert/images.go b/internal/convert/images.go index 0f04189..e73d66c 100644 --- a/internal/convert/images.go +++ b/internal/convert/images.go @@ -60,7 +60,7 @@ func (r *storageRenderer) renderImage( _, _ = w.WriteString(html.EscapeString(msg)) default: - filename := attachmentFilename(src) + filename := AttachmentFilename(src) if !r.seen[filename] { if r.seen == nil { r.seen = map[string]bool{} diff --git a/internal/convert/storage_to_md.go b/internal/convert/storage_to_md.go index a4057ce..dd69e54 100644 --- a/internal/convert/storage_to_md.go +++ b/internal/convert/storage_to_md.go @@ -68,7 +68,7 @@ func (r *mdRenderer) sourceFor(filename string) string { if src, ok := r.sources[filename]; ok && src != "" && !path.IsAbs(src) { return src } - if src, ok := attachmentSource(filename); ok { + if src, ok := AttachmentSource(filename); ok { return src } return filename diff --git a/internal/pageref/pageref.go b/internal/pageref/pageref.go new file mode 100644 index 0000000..7314ddd --- /dev/null +++ b/internal/pageref/pageref.go @@ -0,0 +1,75 @@ +// Package pageref resolves the way a user names a Confluence page on the command +// line into a page id. +// +// Three spellings are accepted, because all three are things a user naturally +// has to hand: a bare numeric id, a Confluence page URL (pasted from a browser), +// and a markdown file whose frontmatter carries a page_id. Every command that +// takes a page argument accepts all three, so the meaning of that argument does +// not depend on which command it was given to. +package pageref + +import ( + "fmt" + "net/url" + "os" + "regexp" + "strings" + + "github.com/mozilla/markfluence/internal/frontmatter" +) + +// pagePathRE matches the numeric id in a modern Confluence page URL path, +// e.g. /wiki/spaces/ENG/pages/123456/Some+Title (the trailing slug is optional). +var pagePathRE = regexp.MustCompile(`/pages/(\d+)(?:/|$)`) + +// Resolve turns a command-line page argument into a page id. +// +// An existing file is tried first, so a numerically-named markdown file is read +// as a file rather than mistaken for an id. +func Resolve(arg string) (string, error) { + if arg == "" { + return "", fmt.Errorf("no page given") + } + if info, err := os.Stat(arg); err == nil && !info.IsDir() { + mf, err := frontmatter.ParseFile(arg) + if err != nil { + return "", err + } + if mf.PageID() == "" { + return "", fmt.Errorf("no page_id in frontmatter of %s", arg) + } + return mf.PageID(), nil + } + if IsDigits(arg) { + return arg, nil + } + if id, ok := fromURL(arg); ok { + return id, nil + } + return "", fmt.Errorf( + "%q is not a numeric page id, a Confluence page URL, or a markdown file with a page_id", arg) +} + +// fromURL pulls a page id out of a Confluence URL: the modern +// /wiki/.../pages//... path form, or a legacy ?pageId= query parameter. +func fromURL(arg string) (string, bool) { + u, err := url.Parse(arg) + if err != nil || u.Host == "" { + return "", false + } + if id := u.Query().Get("pageId"); IsDigits(id) { + return id, true + } + if m := pagePathRE.FindStringSubmatch(u.Path); m != nil { + return m[1], true + } + return "", false +} + +// IsDigits reports whether s is a non-empty run of ASCII digits. +func IsDigits(s string) bool { + if s == "" { + return false + } + return strings.IndexFunc(s, func(r rune) bool { return r < '0' || r > '9' }) == -1 +} diff --git a/internal/pageref/pageref_test.go b/internal/pageref/pageref_test.go new file mode 100644 index 0000000..0df11dc --- /dev/null +++ b/internal/pageref/pageref_test.go @@ -0,0 +1,95 @@ +package pageref + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveNumericID(t *testing.T) { + got, err := Resolve("123456") + if err != nil || got != "123456" { + t.Fatalf("Resolve = %q, %v; want 123456", got, err) + } +} + +func TestResolveURL(t *testing.T) { + cases := []struct { + arg, want string + }{ + {"https://org.atlassian.net/wiki/spaces/ENG/pages/123456/Some+Title", "123456"}, + {"https://org.atlassian.net/wiki/spaces/ENG/pages/123456", "123456"}, + {"https://org.atlassian.net/wiki/spaces/ENG/pages/123456/", "123456"}, + {"https://org.atlassian.net/wiki/pages/viewpage.action?pageId=987", "987"}, + // A query id wins over a path that has none. + {"https://org.atlassian.net/wiki/x?pageId=42", "42"}, + } + for _, c := range cases { + got, err := Resolve(c.arg) + if err != nil || got != c.want { + t.Errorf("Resolve(%q) = %q, %v; want %q", c.arg, got, err, c.want) + } + } +} + +func TestResolveMarkdownFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "page.md") + if err := os.WriteFile(path, []byte("---\ntitle: T\npage_id: 555\n---\n\nBody\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := Resolve(path) + if err != nil || got != "555" { + t.Fatalf("Resolve = %q, %v; want 555", got, err) + } +} + +// TestResolvePrefersFileOverID pins the precedence: a file named "123.md" is a +// file. Statting first is what makes that unambiguous. +func TestResolvePrefersFileOverID(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "123.md") + if err := os.WriteFile(path, []byte("---\npage_id: 999\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := Resolve(path) + if err != nil || got != "999" { + t.Fatalf("Resolve = %q, %v; want the frontmatter id 999", got, err) + } +} + +func TestResolveRejects(t *testing.T) { + dir := t.TempDir() + noID := filepath.Join(dir, "noid.md") + if err := os.WriteFile(noID, []byte("---\ntitle: T\n---\n"), 0o644); err != nil { + t.Fatal(err) + } + + cases := []struct{ name, arg string }{ + {"empty", ""}, + {"not a number or url", "banana"}, + {"a directory", dir}, + {"file without page_id", noID}, + {"url with no page id", "https://org.atlassian.net/wiki/spaces/ENG"}, + {"negative", "-5"}, + {"id with whitespace", "12 34"}, + } + for _, c := range cases { + if got, err := Resolve(c.arg); err == nil { + t.Errorf("%s: Resolve(%q) = %q, nil; want an error", c.name, c.arg, got) + } + } +} + +func TestIsDigits(t *testing.T) { + for _, s := range []string{"0", "123456"} { + if !IsDigits(s) { + t.Errorf("IsDigits(%q) = false, want true", s) + } + } + for _, s := range []string{"", "12a", "-1", " 1", "1 "} { + if IsDigits(s) { + t.Errorf("IsDigits(%q) = true, want false", s) + } + } +} diff --git a/round-trip-page.md b/round-trip-page.md new file mode 100644 index 0000000..08183cc --- /dev/null +++ b/round-trip-page.md @@ -0,0 +1,14 @@ +--- +title: round trip page +space: ~60c36d0718e9f60071326951 +parent: null +page_id: 2904981621 +--- + +This is a round trip page. + + + +This is a thing in a section. + + diff --git a/schema/json-output/v1.json b/schema/json-output/v1.json index 6a1cf19..5c8b75b 100644 --- a/schema/json-output/v1.json +++ b/schema/json-output/v1.json @@ -2,14 +2,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/mozilla/markfluence/schema/json-output/v1.json", "title": "markfluence --json output (schema_version 1)", - "description": "The single JSON document markfluence writes to stdout under --json. results holds one object per target (a single element for info/read); its item shape and the summary shape depend on command. The typed error object written to stderr on a fatal/pre-flight failure conforms to #/$defs/errorObject.", + "description": "The single JSON document markfluence writes to stdout under --json. results holds one object per target (a single element for info/read; one per attachment for attachment-list); its item shape and the summary shape depend on command. The typed error object written to stderr on a fatal/pre-flight failure conforms to #/$defs/errorObject.", "type": "object", "additionalProperties": false, "required": ["schema_version", "markfluence_version", "command", "results", "summary"], "properties": { "schema_version": { "const": 1 }, "markfluence_version": { "type": "string" }, - "command": { "enum": ["info", "read", "update", "create", "fix"] }, + "command": { "enum": ["info", "read", "update", "create", "fix", "attachment-list", "attachment-upload", "attachment-download"] }, "results": { "type": "array" }, "summary": { "type": "object" } }, @@ -58,6 +58,45 @@ "summary": { "$ref": "#/$defs/fixSummary" } } } + }, + { + "if": { "properties": { "command": { "const": "attachment-upload" } }, "required": ["command"] }, + "then": { + "properties": { + "results": { + "items": { + "oneOf": [{ "$ref": "#/$defs/attachmentUploadResult" }, { "$ref": "#/$defs/singleOpFailure" }] + } + }, + "summary": { "$ref": "#/$defs/attachmentSummary" } + } + } + }, + { + "if": { "properties": { "command": { "const": "attachment-download" } }, "required": ["command"] }, + "then": { + "properties": { + "results": { + "items": { + "oneOf": [{ "$ref": "#/$defs/attachmentDownloadResult" }, { "$ref": "#/$defs/singleOpFailure" }] + } + }, + "summary": { "$ref": "#/$defs/attachmentSummary" } + } + } + }, + { + "if": { "properties": { "command": { "const": "attachment-list" } }, "required": ["command"] }, + "then": { + "properties": { + "results": { + "items": { + "oneOf": [{ "$ref": "#/$defs/attachmentListResult" }, { "$ref": "#/$defs/singleOpFailure" }] + } + }, + "summary": { "$ref": "#/$defs/basicSummary" } + } + } } ], "$defs": { @@ -270,8 +309,67 @@ "code": { "$ref": "#/$defs/codeOrNull" } } }, + "attachmentUploadResult": { + "description": "One uploaded file. status uses the same verbs as the attachments array on update/create.", + "type": "object", + "additionalProperties": false, + "required": ["ok", "status", "dry_run", "filename", "error", "code"], + "properties": { + "ok": { "type": "boolean" }, + "status": { "enum": ["created", "updated", "skipped", "failed"] }, + "dry_run": { "type": "boolean" }, + "filename": { "type": "string" }, + "error": { "$ref": "#/$defs/stringOrNull" }, + "code": { "$ref": "#/$defs/codeOrNull" } + } + }, + "attachmentDownloadResult": { + "description": "One attachment written to disk. filename is the stored attachment name; dest_path is the local path written, which depends on the recorded source path, --flat, and --dest. dest_path is null only when resolving it is what failed.", + "type": "object", + "additionalProperties": false, + "required": ["ok", "status", "dry_run", "filename", "dest_path", "error", "code"], + "properties": { + "ok": { "type": "boolean" }, + "status": { "enum": ["downloaded", "skipped", "failed"] }, + "dry_run": { "type": "boolean" }, + "filename": { "type": "string" }, + "dest_path": { "$ref": "#/$defs/stringOrNull" }, + "error": { "$ref": "#/$defs/stringOrNull" }, + "code": { "$ref": "#/$defs/codeOrNull" } + } + }, + "attachmentListResult": { + "description": "One attachment on the page. filename is the name Confluence stores; for an attachment markfluence published that is the encoded source path, and source is the markdown image path it came from. managed is false for a hand-uploaded attachment (sha256 and source both null). source may also be null on a managed attachment published before markfluence recorded source paths, in which case sha256 is still set.", + "type": "object", + "additionalProperties": false, + "required": ["ok", "id", "filename", "size", "media_type", "version", "comment", "managed", "sha256", "source"], + "properties": { + "ok": { "const": true }, + "id": { "type": "string" }, + "filename": { "type": "string" }, + "size": { "type": "integer" }, + "media_type": { "type": "string" }, + "version": { "type": "integer" }, + "comment": { "type": "string" }, + "managed": { "type": "boolean" }, + "sha256": { "$ref": "#/$defs/stringOrNull" }, + "source": { "$ref": "#/$defs/stringOrNull" } + } + }, + "attachmentSummary": { + "description": "attachment-upload/attachment-download batch summary.", + "type": "object", + "additionalProperties": false, + "required": ["total", "succeeded", "failed", "skipped"], + "properties": { + "total": { "type": "integer" }, + "succeeded": { "type": "integer" }, + "failed": { "type": "integer" }, + "skipped": { "type": "integer" } + } + }, "basicSummary": { - "description": "info/read batch summary (always total:1).", + "description": "info/read batch summary (total:1), and attachment-list (total: the attachment count).", "type": "object", "additionalProperties": false, "required": ["total", "succeeded", "failed"], diff --git a/table-cell-bg-probe.md b/table-cell-bg-probe.md new file mode 100644 index 0000000..ec91ce7 --- /dev/null +++ b/table-cell-bg-probe.md @@ -0,0 +1,44 @@ +--- +title: markfluence table cell background probe +space: ~60c36d0718e9f60071326951 +parent: null +page_id: 2942664752 +page_width: max +--- + +# markfluence table cell background probe + +Published by markfluence from `table-cell-bg-probe.md` to verify that a +`` cell marker reaches ADF as a cell `background`. Each cell below +is labeled with the swatch name that produced it; compare against the editor's +picker on "Confluence tables test". + +## All 21 swatches + +| Light | Medium | Bold | +| --- | --- | --- | +| white | light-grey | grey | +| light-blue | blue | bold-blue | +| light-teal | teal | bold-teal | +| light-green | green | bold-green | +| light-yellow | yellow | bold-yellow | +| light-red | red | bold-red | +| light-purple | purple | bold-purple | + +## Edge cases + +A colored header cell, an off-palette hex, a marker-only (empty) cell, and +alignment alongside a color: + +| colored header | off-palette hex | +| :--- | ---: | +| | right-aligned, uncolored | +| shouty marker | aligned and colored | + +These publish uncolored (each emits a warning): an unknown name, and a marker that +isn't first in its cell. + +| Cell | Result | +| --- | --- | +| unknown name | no background | +| trailing marker | no background | diff --git a/table-probe-real.md b/table-probe-real.md new file mode 100644 index 0000000..a6b58d8 --- /dev/null +++ b/table-probe-real.md @@ -0,0 +1,128 @@ +--- +title: markfluence table layout probe (real tables) +space: ~60c36d0718e9f60071326951 +parent: 76646878 +page_id: 2914451497 +page_width: max +--- + +The 29 tables from "Incident Analysis: Q2 2026" (page 2874736735), republished with +`data-layout="align-start"` and no `data-table-width` or colgroup, to see whether +auto-sizing does the right thing on real content. Compare against the original: +https://mozilla-hub.atlassian.net/wiki/spaces/~60c36d0718e9f60071326951/pages/2874736735 + +## Table 1 (6 columns) + +
Metric 2025q3 2025q4 2026q1 2026q2 Percent change
Total incidents 22 22 33 45 +36%
Total entities 14 18 15 30 +100%
S1/S2 % 30% 25% 17% 17% +0%
Automation detection % 14% 36% 21% 30% +39%
MTT-Alerted 5d 4h 19h 59m 22d 23h † 1d 1h −95% †
MTT-Mitigated 7d 16h 2d 4h 157d 14h † 3d 18h −98% †
MTT-Resolved 22d 12h 13d 4h 165d 5h † 6d 6h −96% †
+ +## Table 2 (6 columns) + +
Metric 2025q3 2025q4 2026q1 2026q2 Percent change
Total incidents 22 21 19 27 +42%
Total entities 14 17 13 27 +108%
S1/S2 % 30% 25% 33% 25% −25%
Automation detection % 14% 38% 37% 46% +25%
MTT-Alerted 5d 4h 11h 42m 2d 10h 1d 0h −58%
MTT-Mitigated 7d 16h 1d 6h 3d 18h 2d 4h −42%
MTT-Resolved 22d 12h 12d 3h 5d 9h 5d 17h +6%
+ +## Table 3 (6 columns) + +
Metric 2025q3 2025q4 2026q1 2026q2 Percent change
Total incidents 0 1 14 18 +29%
Total entities 0 1 4 4 +0%
S1/S2 % 0% 0% 0% n/a
Automation detection % 0% 0% 6% n/a
MTT-Alerted 7d 17h 62d 0h † 1d 3h −98% †
MTT-Mitigated 21d 7h 482d 7h † 6d 14h −99% †
MTT-Resolved 35d 13h 484d 19h † 7d 1h −99% †
+ +## Table 4 (4 columns) + +
Manual Automation Total
Service 14 (52%) 12 (44%) 27
Product 17 (94%) 1 (6%) 18
Total 31 (69%) 13 (29%) 45
+ +## Table 5 (4 columns) + +
Entity # incidents % S1/S2 Link
firefox 17 53% view incidents
amo 5 40% view incidents
fenix 3 33% view incidents
firefox-ios 3 33% view incidents
lando 3 67% view incidents
vpn 3 67% view incidents
ads 2 100% view incidents
fxa 2 50% view incidents
relay 2 100% view incidents
taskcluster 2 50% view incidents
+ +## Table 6 (4 columns) + +
Key Incident title Incident entities Detail
IIM-153 Ingestion Sink Subscription Retention Regression ads, datashared, ingestion Stale PR auto-applied by Atlantis reverted a load-bearing Pub/Sub retention setting
IIM-156 Firefox.com outage due to failing database migration springfield Failing database migration took firefox.com down globally
IIM-158 Persistent 502s from Lando lando Static assets missing from GCS after a build-process change; no distributed cache
IIM-160 VPN Nimbus Rollout Collision vpn Reused messaging-component name suppressed enrollment for ~20M users
IIM-164 OHTTP Gateway unavailable for ten minutes ads, firefox, newtab MozCloud Helm chart migration hit an immutable-field patch error
IIM-169 llm-proxy prod outage for ~86 min firefox, llm-proxy Shared managed cert regenerated when one domain was removed
IIM-288 AMO Database stability issues amo Replica overload during a change window
+ +## Table 7 (4 columns) + +
Key Incident title Incident entities Detail
IIM-151 AWS SES Complaint Rate increase > 0.5% monitor No response owner/runbook for a newly-added alert class; ~28h to engage
IIM-153 Ingestion Sink Subscription Retention Regression ads, datashared, ingestion Alert metric saturated at retention, masking the failure; detected downstream
IIM-163 Weather widget / OHTTP unavailable ~13.5h firefox, merino No alerting for TLS CA changes; cloud engineering was not paged
IIM-175 VPN FxA Token Refresh Stopped vpn No alert for the subscription-deactivation rate
IIM-294 FxA WAF 4xx rate-limit blocked Guardian for 110min browser-proxy, guardian No WAF alerting; 1–2h data lag on the WAF dashboard
+ +## Table 8 (4 columns) + +
Key Incident title Incident entities Detail
IIM-153 Ingestion Sink Subscription Retention Regression ads, datashared, ingestion v1→v2 sink migration; stale PR reverted retention
IIM-156 Firefox.com outage due to failing database migration springfield Database migration failure
IIM-163 Weather widget / OHTTP unavailable ~13.5h firefox, merino Balrog migration to MozCloud altered the services.mozilla.com CAA record
IIM-164 OHTTP Gateway unavailable for ten minutes ads, firefox, newtab MozCloud Helm chart migration, immutable-field error
+ +## Table 9 (4 columns) + +
Key Incident title Incident entities Detail
IIM-157 Try pushes not created; hg replication out of sync hg, taskcluster, treeherder hg.mozilla.org replication breakage; recovery slowed by load + bots
IIM-173 Relay service degradation relay Fx151 client bug surged traffic to the Relay origin
IIM-284 Lando fails with 502/503 while pushing to try lando Bot-like traffic overloaded the database
IIM-288 AMO Database stability issues amo Replica capacity/lag under load
IIM-290 Bugzilla Cloud SQL replica at 100% CPU bugzilla Traffic spikes / unoptimized search queries; Fastly DDoS enabled
+ +## Table 10 (4 columns) + +
Key Incident title Incident entities Detail
IIM-167 TapClicks BigQuery integration failure tapclicks Third-party TapClicks→BigQuery integration failed
IIM-175 VPN FxA Token Refresh Stopped vpn FxA token revocation tripped Guardian's revoked-access path
IIM-282 Fx-CI unauthenticated RCE in web-server taskcluster RCE via the upstream sift library
IIM-294 FxA WAF 4xx rate-limit blocked Guardian for 110min browser-proxy, guardian Guardian depends on the FxA/browser-proxy path the WAF rule blocked
+ +## Table 11 (4 columns) + +
Key Incident title Incident entities Detail
IIM-159 facebook-not-loading firefox Fx150 shipped SCONE enabled-by-side-effect, tripping Facebook + Bitdefender
IIM-162 Reports of YouTube not loading on 150 firefox Style-computation / hardware-acceleration playback regression
IIM-178 Firefox Android deleting user downloads after update fenix An update / deletion-dialog behavior change lost user files
IIM-286 Sports widget missing opening World Cup game firefox Desktop/Mobile widget endpoint-contract mismatch
IIM-289 World Cup widget in iOS polling live every 15mn firefox-ios Live scores updated ~15 min late
IIM-297 World Cup widget removed with update to Fx 152.0.3 firefox Nimbus rollout ordering / targeting-query error unenrolled users
+ +## Table 12 (4 columns) + +
Key Incident title Incident entities Detail
IIM-159 facebook-not-loading firefox neqo emits the SCONE indicator unconditionally regardless of scone_enabled()
IIM-172 webserial failure to open port firefox Client regression opening serial ports
IIM-286 Sports widget missing opening World Cup game firefox Desktop/Mobile widget logic mismatch
IIM-287 Nightly-as-Release SP3 scores regressed ~10% firefox Necko disk-cache / Windows Defender disk-I/O interaction
IIM-293 Android AI Controls not blocking Shake to Summarize firefox Nimbus re-entrancy / default-value bug
IIM-296 Homepage Freeze Incident firefox-ios Swift concurrency thread contention
+ +## Table 13 (4 columns) + +
Key Incident title Incident entities Detail
IIM-283 Public disclosure of Firefox Focus for iOS UXSS bug focus-ios Publicly disclosed UXSS bug (S1)
IIM-293 Android AI Controls not blocking Shake to Summarize firefox AI Controls neither listed nor blocked a GenAI feature
+ +## Table 14 (4 columns) + +
Key Incident title Incident entities Detail
IIM-178 Firefox Android deleting user downloads after update fenix Update behavior change shipped without catching data loss
IIM-289 World Cup widget in iOS polling live every 15mn firefox-ios Polling-cadence regression shipped to release
+ +## Table 15 (4 columns) + +
Key Incident title Incident entities Detail
IIM-176 Smartcard certificates not loading in Firefox 151 firefox Client certificate handling regression
IIM-281 No push notifications to iOS firefox-ios Expired APNS certificate not deployed to production
+ +## Table 16 (3 columns) + +
Key Incident title Entities
IIM-150 Rating abuse for Metamask amo
IIM-156 Firefox.com outage due to failing database migration springfield
IIM-161 Yahoo-search-results-not-displaying firefox
IIM-162 Reports of Youtube not Loading on 150 firefox
IIM-168 bugbug pods degraded bugbug
IIM-170 Wikipedia article images not loading on first load fenix, firefox
IIM-172 webserial failure to open port firefox
IIM-174 Clear PBM session button not enabled in Fx151 firefox
IIM-176 Smartcard certificates not loading in Firefox 151 firefox
IIM-178 Firefox Android deleting user downloads after update fenix
IIM-280 Bouncer (download.mozilla.org) is down bouncer, firefox, thunderbird
IIM-283 Public disclosure of Firefox Focus for iOS UXSS bug focus-ios
IIM-284 Lando fails with 502 or 503 while pushing to try lando
IIM-285 Developers cannot submit versions on AMO amo, firefox
IIM-291 FxA elevated 4xx responses fxa
IIM-295 Startup Hangs/Extreme memory Usage firefox
IIM-297 World Cup widget removed with update to Firefox 152.0.3 firefox
+ +## Table 17 (6 columns) + +
Category Count % of Total Done Open In Progress
Improve monitoring/alerting 26 30% 11 (42%) 12 (46%) 3 (12%)
Capacity/infra change 20 23% 12 (60%) 7 (35%) 1 (5%)
Investigate/spike 10 11% 2 (20%) 7 (70%) 0
Fix code/config bug 10 11% 5 (50%) 1 (10%) 4 (40%)
Add automation 8 9% 3 (38%) 5 (63%) 0
Update runbook/docs/process 4 5% 0 3 (75%) 0
Process/access change 2 2% 2 (100%) 0 0
Other 7 8% 5 (71%) 2 (29%) 0
+ +## Table 18 (4 columns) + +
Entity # action items Done % done
amo 19 7 37%
firefox 15 6 40%
ads 12 5 42%
datashared 10 4 40%
ingestion 10 4 40%
relay 10 4 40%
vpn 9 4 44%
firefox-ios 7 4 57%
monitor 6 2 33%
merino 6 2 33%
browser-proxy 5 3 60%
guardian 5 3 60%
bugbug 5 4 80%
taskcluster 4 2 50%
lando 3 3 100%
fxa 3 1 33%
+ +## Table 19 (3 columns) + +
Type Count Examples
User-facing service degradation/outage 19 IIM-156, IIM-163, IIM-294
Data loss or data integrity issue 4 IIM-153, IIM-178, IIM-279
Internal tooling/productivity impact 3 IIM-157, IIM-158, IIM-284
Security/privacy issue 2 IIM-282, IIM-293
Telemetry/reporting impact 1 IIM-153
Revenue impact 1 IIM-164
+ +## Table 20 (4 columns) + +
Key Incident title Severity Duration
IIM-160 VPN Nimbus Rollout Collision S3 28d 15h
IIM-296 Homepage Freeze Incident S3 23d 3h
IIM-161 Yahoo-search-results-not-displaying S3 20d 19h
IIM-287 Nightly as Release SP3 scores regressed ~10% S4 20d 15h
IIM-279 atomicmail.io accounts erroneously deleted S2 9d 15h
+ +## Table 21 (3 columns) + +
Key Incident title Impact
IIM-160 VPN Nimbus Rollout Collision ~20M eligible users did not receive the VPN feature in Fx149
IIM-163 Weather widget and Online Suggest unavailable (Merino/OHTTP) 160,000–240,000 users lost OHTTP-dependent Merino features for 13.5h
IIM-294 FxA WAF 4xx rate-limit blocked Guardian for 110min All VPN users unable to connect/refresh for 110 minutes
IIM-281 No Push notifications to iOS All Firefox iOS users received no push notifications for ~2h19m
IIM-279 atomicmail.io accounts erroneously deleted 2,037 accounts erroneously deleted
+ +## Table 22 (3 columns) + +
Key Incident title Entities
IIM-150 Rating abuse for Metamask amo
IIM-161 Yahoo-search-results-not-displaying firefox
IIM-162 Reports of Youtube not Loading on 150 firefox
IIM-168 bugbug pods degraded bugbug
IIM-170 Wikipedia article images not loading on first load fenix, firefox
IIM-172 webserial failure to open port firefox
IIM-174 Clear PBM session button not enabled in Fx151 firefox
IIM-176 Smartcard certificates not loading in Firefox 151 firefox
IIM-280 Bouncer (download.mozilla.org) is down bouncer, firefox, thunderbird
IIM-283 Public disclosure of Firefox Focus for iOS UXSS bug focus-ios
IIM-285 Developers cannot submit versions on AMO amo, firefox
IIM-287 Nightly as Release SP3 scores regressed ~10% firefox
IIM-288 AMO Database stability issues amo
IIM-291 FxA elevated 4xx responses fxa
IIM-295 Startup Hangs/Extreme memory Usage firefox
+ +## Table 23 (3 columns) + +
Name Times as IC Incidents
Marco Castelluccio 4 IIM-168, IIM-177, IIM-284, IIM-290
William Durand 3 IIM-150, IIM-285, IIM-288
Sebastian Hengst 3 IIM-157, IIM-163, IIM-286
Gregory Hess 2 IIM-159, IIM-161
Dianna Smith 2 IIM-162, IIM-295
Amri Toufali 2 IIM-291, IIM-294
+ +## Table 24 (3 columns) + +
Name Incidents Roles
Hristo Ganchev 5 IC x1, Participant x4
Jon Buckley 5 IC x1, Participant x4
Mathieu Pillard 5 Participant x5
André Honeiser 4 Participant x4
Brett Kochendorfer 4 IC x1, Participant x3
Eric Maydeck 4 Participant x4
Jason Thomas 4 Participant x4
Marco Castelluccio 4 IC x4
William Durand 4 IC x3, Participant x1
Emmett Lynch 3 IC x1, Participant x2
Joe Hermann 3 Participant x3
Joe Zhou 3 IC x1, Participant x2
Luke Crouch 3 Participant x3
Sebastian Hengst 3 IC x3
Zeid Zabaneh 3 Participant x3
+ +## Table 25 (4 columns) + +
Key Incident title Entities Members and roles
IIM-151 AWS SES Complaint Rate increase > 0.5% monitor Brandon Wells (IC)
IIM-153 Ingestion Sink Subscription Retention Regression ads, datashared, ingestion Mikael Ducharme (P), Wesley Dawson (P)
IIM-156 Firefox.com outage due to failing database migration springfield Jon Buckley (IC), Hristo Ganchev (P), Rachael Crook (P), Steven Prokopienko (P)
IIM-158 Persistent 502s from Lando lando Steven Prokopienko (P)
IIM-163 Weather widget / OHTTP unavailable ~13.5h firefox, merino Brett Kochendorfer (P), Jason Thomas (P), Jon Buckley (P)
IIM-164 OHTTP Gateway unavailable for ten minutes ads, firefox, newtab Brett Kochendorfer (IC), Jason Thomas (P)
IIM-168 bugbug pods degraded bugbug Eric Maydeck (P)
IIM-169 llm-proxy prod outage for ~86 min firefox, llm-proxy Brett Kochendorfer (P), Eric Maydeck (P)
IIM-173 Relay service degradation relay Brett Kochendorfer (P), Eric Maydeck (P)
IIM-177 Phabricator is down lando, phabricator Nate Tade (P)
IIM-280 Bouncer (download.mozilla.org) is down bouncer, firefox, thunderbird Hristo Ganchev (IC), James Francis (P), Jason Thomas (P), Jon Buckley (P), Nate Tade (P)
IIM-284 Lando fails with 502 or 503 while pushing to try lando Andre Honeiser (P)
IIM-288 AMO Database stability issues amo Andre Honeiser (P), Brandon Wells (P), Hristo Ganchev (P), Wesley Dawson (P)
IIM-290 Bugzilla Cloud SQL replica at 100% CPU bugzilla Andre Honeiser (P), Hristo Ganchev (P)
IIM-291 FxA elevated 4xx responses fxa Andre Honeiser (P), Hristo Ganchev (P), Jon Buckley (P)
IIM-294 FxA WAF 4xx rate-limit blocked Guardian for 110min browser-proxy, guardian Jason Thomas (P)
+ +## Table 26 (3 columns) + +
Member Incidents Roles
Hristo Ganchev 5 IC x1, P x4
Jon Buckley 4 IC x1, P x3
Jason Thomas 4 P x4
Brett Kochendorfer 4 IC x1, P x3
Andre Honeiser 4 P x4
Eric Maydeck 3 P x3
Brandon Wells 2 IC x1, P x1
Wesley Dawson 2 P x2
Steven Prokopienko 2 P x2
Nate Tade 2 P x2
Mikael Ducharme 1 P x1
Rachael Crook 1 P x1
James Francis 1 P x1
+ +## Table 27 (3 columns) + +
Key Incident title What needs to be done?
IIM-150 Rating abuse for Metamask Add an impact statement; add contributing factors; expand the description
IIM-156 Firefox.com outage due to failing database migration Add contributing factors; expand the description; file at least one action item
IIM-161 Yahoo-search-results-not-displaying Add an impact statement; add contributing factors; expand the description; file at least one action item
IIM-162 Reports of Youtube not Loading on 150 Add an impact statement; add contributing factors; expand the description; file at least one action item
IIM-168 bugbug pods degraded Add an impact statement; add contributing factors; expand the description
IIM-170 Wikipedia article images not loading on first load Add an impact statement; add contributing factors; expand the description; file at least one action item
IIM-172 webserial failure to open port Add an impact statement; add contributing factors; file at least one action item
IIM-174 Clear PBM session button not enabled in Fx151 Add an impact statement; add contributing factors; expand the description; file at least one action item
IIM-176 Smartcard certificates not loading in Firefox 151 Add an impact statement; add contributing factors; expand the description; file at least one action item
IIM-280 Bouncer (download.mozilla.org) is down Add an impact statement; add contributing factors; expand the description
IIM-283 Public disclosure of Firefox Focus for iOS UXSS bug Add an impact statement; add contributing factors; expand the description; file at least one action item
IIM-285 Developers cannot submit versions on AMO Add an impact statement; add contributing factors; expand the description
IIM-291 FxA elevated 4xx responses Add an impact statement; add contributing factors; expand the description
IIM-295 Startup Hangs/Extreme memory Usage Add an impact statement; add contributing factors; expand the description; file at least one action item
+ +## Table 28 (3 columns) + +
Key Incident title What needs to be done?
IIM-159 facebook-not-loading File at least one action item
IIM-178 Firefox Android deleting user downloads after update Add contributing factors; file at least one action item
IIM-284 Lando fails with 502 or 503 while pushing to try Add contributing factors
IIM-287 Nightly as Release SP3 scores regressed ~10% Add an impact statement; file at least one action item
IIM-288 AMO Database stability issues Add an impact statement
IIM-289 World Cup Widget in iOS polling live every 15mn Expand the description; file at least one action item
IIM-297 World Cup widget removed with update to Firefox 152.0.3 Add contributing factors; file at least one action item
+ +## Table 29 (7 columns) + +
Period (UTC) Period (Eastern Time) Period (Pacific Time) Incidents % Manual Detection Automated Detection
00:00–04:00 20:00–00:00 (evening) 17:00–21:00 (evening) 9 20% 8 1
04:00–08:00 00:00–04:00 (overnight) 21:00–01:00 (late evening) 1 2% 0 1
08:00–12:00 04:00–08:00 (early morning) 01:00–05:00 (overnight) 6 13% 2 4
12:00–16:00 08:00–12:00 (business hours) 05:00–09:00 (early morning) 7 16% 5 2
16:00–20:00 12:00–16:00 (business hours) 09:00–13:00 (business hours) 12 27% 7 5
20:00–24:00 16:00–20:00 (evening) 13:00–17:00 (business hours) 6 13% 6 0
Unknown 4 9% 3 0
diff --git a/table-probe.md b/table-probe.md new file mode 100644 index 0000000..b3d38fb --- /dev/null +++ b/table-probe.md @@ -0,0 +1,97 @@ +--- +title: markfluence table storage probe +space: ~60c36d0718e9f60071326951 +parent: 76646878 +page_id: 2913796912 +page_width: max +--- + +Each table below is hand-written raw storage testing one hypothesis about +Confluence's table attributes. Everything is pasted storage (no blank lines inside +a table, so each stays a single CommonMark HTML block and passes through verbatim). + +## T1 — bare table, no attributes (baseline) + +

T1 head A

T1 head B

bare

no attrs at all

+ +## T2 — data-layout="center", no data-table-width + +

T2 head A

T2 head B

layout=center

no width attr

+ +## T3 — data-layout="align-start", no data-table-width + +

T3 head A

T3 head B

layout=align-start

no width attr

+ +## T4 — data-layout="wide" (undocumented guess) + +

T4 head A

T4 head B

layout=wide

is this value accepted?

+ +## T5 — data-layout="full-width" (undocumented guess) + +

T5 head A

T5 head B

layout=full-width

is this value accepted?

+ +## T6 — align-start + data-table-width="1110" (known-good control) + +

T6 head A

T6 head B

layout+width

copied from the editor-authored page

+ +## T7 — colgroup in percentages instead of px + +

T7 head A

T7 head B

25%

75% — do percentage widths work?

+ +## T8 — colgroup px with no data-table-width + +

T8 head A

T8 head B

200px

600px, but no data-table-width

+ +## T9 — cell background: hex, named, and on a th + +

T9 head, hex on th

T9 head B

hex #ffebe6

named "grey" (Server-era spelling)

+ +## T10 — what markfluence emits today, plus legacy align attributes + +
T10 head AT10 head B
thead, no p-wrappersalign="right" on the td
+ +## T11 — text-align on a header cell's paragraph + +

T11 right-aligned head

T11 centered head

does header alignment

survive?

+ +## T12 — numbered column (isNumberColumnEnabled guesses) + +

T12 head A

T12 head B

data-number-column

="true"

+ +## T13 — control: an ordinary GFM table through the converter + +| Feature | Works? | +| :------ | -----: | +| GFM | yes | + +## T14 — percentage colgroup with NO data-table-width + +

T14 head A

T14 head B

30%

70%, no data-table-width — does % resolve?

+ +## T15 — full-width with percentage colgroup + +

T15 head A

T15 head B

20%

80% under full-width

+ +## T16 — full-width + data-table-width + percentage colgroup + +

T16 A (20%)

T16 B (80%)

narrow col

wide col — are the 20/80 proportions honored under full-width?

+ +## T17 — full-width + data-table-width + px colgroup + +

T17 A (200px)

T17 B (910px)

narrow col

wide col — px proportions under full-width?

+ +## T18 — wide + data-table-width + percentage colgroup (control) + +

T18 A (20%)

T18 B (80%)

narrow col

wide col — same, but layout=wide

+ +## T19 — full-width, NO data-table-width, px colgroup + +

T19 A (200px)

T19 B (910px)

px widths

no data-table-width, full-width layout

+ +## T20 — wide, NO data-table-width, px colgroup + +

T20 A (200px)

T20 B (910px)

px widths

no data-table-width, wide layout

+ +## T21 — bare table, NO layout, NO data-table-width, px colgroup + +

T21 A (200px)

T21 B (910px)

px widths

no layout at all

diff --git a/test-codeblock.md b/test-codeblock.md new file mode 100644 index 0000000..c72b9cb --- /dev/null +++ b/test-codeblock.md @@ -0,0 +1,37 @@ +--- +title: markfluence test codeblock +space: ~60c36d0718e9f60071326951 +parent: null +page_id: 2941386773 +page_width: max +--- + +# Narrow Code block + +```python +def hello(name): + print(f"hello, {name}") +``` + + +# Wide code block + +```go +// Package client is an HTTP client for the Confluence REST API. It wraps +// net/http with basic auth and the handful of calls markfluence needs. +// +// Requests are built as absolute URLs off the base URL. Pages and content +// properties use the Confluence v2 API; attachment writes and the user lookup +// use v1 (/wiki/rest/api/...) since v2 doesn't cover them. +// +// A client carries two bases. baseURL is where requests go; siteURL is the +// human-facing site the pages live on. They differ only when a cloud ID selects +// the platform API gateway (see Config): a scoped API token -- the kind a service +// account gets -- is rejected against the site domain and must go through +// https://api.atlassian.com/ex/confluence/{cloudId} instead. The path suffixes are +// identical under the gateway, so every call below is written against baseURL +// unchanged. siteURL exists because the gateway host must never reach a reader: +// it's wrong in printed URLs and, worse, would be written into published page +// content by the converter's link rewriting. +package client +``` diff --git a/test-page-no-frontmatter.md b/test-page-no-frontmatter.md new file mode 100644 index 0000000..b1b6e73 --- /dev/null +++ b/test-page-no-frontmatter.md @@ -0,0 +1,113 @@ + + +# Overview + +This page exercises markfluence's conversion features so you can eyeball the +published result. It has **bold**, _italic_, `inline code`, and a +[link to Atlassian](https://www.atlassian.com). + + + +- A bullet +- Another bullet + - Nested bullet +1. Ordered one +2. Ordered two + +## GitHub-style callout + +> [!NOTE] +> This is a GitHub-style callout; markfluence converts it to a Confluence panel. + +## Code block + +```python +def hello(name): + print(f"hello, {name}") +``` + +## Table + +| Feature | Works? | +| --------- | ------ | +| Tables | yes | +| Callouts | yes | + +# Raw Confluence storage format + +Everything below is pasted storage format (`ac:` elements), emitted verbatim. + +## Info panel with converted markdown body + + + + +This is an **info** panel. The body is markdown — note the blank lines around it — +so this [link](https://example.com) and list convert: + +- alpha +- beta + + + + +## Status macro (parameters, no body) + + +Green +DONE + + +## Expand macro + + +Click to expand + + +Hidden **markdown** content, revealed on click. + + + + +## Two-column layout + + + + + +### Left column + +Some **markdown** in the left cell, with a +[link](https://developer.atlassian.com). + + + + +### Right column + +- first +- second + + + + + +## A storage example in a code fence (should stay literal, not activate) + +```xml + +``` + +# Images + +A **local** image (uploaded as a page attachment): + +![Local test image](assets/markfluence-test.png) + +The **same local image sized and centered** via the JSON-in-title properties: + +![Sized local image](assets/markfluence-test.png '{"width":"64","align":"center"}') + +A **remote** image (referenced by URL, not attached): + +![Remote placeholder](https://placehold.co/150x60/png) diff --git a/test-page-read.md b/test-page-read.md new file mode 100644 index 0000000..c2a86d2 --- /dev/null +++ b/test-page-read.md @@ -0,0 +1,113 @@ +--- +title: markfluence test page - round trip +space: ~60c36d0718e9f60071326951 +parent: 76646878 +page_id: 2904948757 +page_width: max +--- + + + +# Overview + +This page exercises markfluence's conversion features so you can eyeball the published result. It has **bold**, *italic*, `inline code`, and a [link to Atlassian](https://www.atlassian.com). + +markfluence vdev 2026-07-22T21:14:28Z + +- A bullet +- Another bullet + - Nested bullet + +1. Ordered one +2. Ordered two + +## GitHub-style callout + +> [!NOTE] +> This is a GitHub-style callout; markfluence converts it to a Confluence panel. + +## Code block + +```python +def hello(name): + print(f"hello, {name}") +``` + +## Table + +| Feature | Works? | +| --- | --- | +| Tables | yes | +| Callouts | yes | + +# Raw Confluence storage format + +Everything below is pasted storage format (`ac:` elements), emitted verbatim. + +## Info panel with converted markdown body + +> [!NOTE] +> This is an **info** panel. The body is markdown — note the blank lines around it — so this [link](https://example.com) and list convert: +> +> - alpha +> - beta + +## Status macro (parameters, no body) + + +Green +DONE + + +## Expand macro + + +Click to expand + + +Hidden **markdown** content, revealed on click. + + + + +## Two-column layout + + + + + +### Left column + +Some **markdown** in the left cell, with a [link](https://developer.atlassian.com). + + + + +### Right column + +- first +- second + + + + + +## A storage example in a code fence (should stay literal, not activate) + +```xml + +``` + +# Images + +A **local** image (uploaded as a page attachment): + +![Local test image](assets_markfluence-test.png) + +The **same local image sized and centered** via the JSON-in-title properties: + +![Sized local image](assets_markfluence-test.png '{"width":64,"align":"center"}') + +A **remote** image (referenced by URL, not attached): + +![Remote placeholder](https://placehold.co/150x60/png) diff --git a/test-page.md b/test-page.md new file mode 100644 index 0000000..8bb4fbd --- /dev/null +++ b/test-page.md @@ -0,0 +1,121 @@ +--- +title: markfluence test page +space: ~60c36d0718e9f60071326951 +parent: 76646878 +page_id: 2848423944 +page_width: max +--- + + + +# Overview + +This page exercises markfluence's conversion features so you can eyeball the +published result. It has **bold**, _italic_, `inline code`, and a +[link to Atlassian](https://www.atlassian.com). + + + +- A bullet +- Another bullet + - Nested bullet +1. Ordered one +2. Ordered two + +## GitHub-style callout + +> [!NOTE] +> This is a GitHub-style callout; markfluence converts it to a Confluence panel. + +## Code block + +```python +def hello(name): + print(f"hello, {name}") +``` + +## Table + +| Feature | Works? | +| --------- | ------ | +| Tables | yes | +| Callouts | yes | + +# Raw Confluence storage format + +Everything below is pasted storage format (`ac:` elements), emitted verbatim. + +## Info panel with converted markdown body + + + + +This is an **info** panel. The body is markdown — note the blank lines around it — +so this [link](https://example.com) and list convert: + +- alpha +- beta + + + + +## Status macro (parameters, no body) + + +Green +DONE + + +## Expand macro + + +Click to expand + + +Hidden **markdown** content, revealed on click. + + + + +## Two-column layout + + + + + +### Left column + +Some **markdown** in the left cell, with a +[link](https://developer.atlassian.com). + + + + +### Right column + +- first +- second + + + + + +## A storage example in a code fence (should stay literal, not activate) + +```xml + +``` + +# Images + +A **local** image (uploaded as a page attachment): + +![Local test image](assets/markfluence-test.png) + +The **same local image sized and centered** via the JSON-in-title properties: + +![Sized local image](assets/markfluence-test.png '{"width":"64","align":"center"}') + +A **remote** image (referenced by URL, not attached): + +![Remote placeholder](https://placehold.co/150x60/png)