Skip to content
6 changes: 6 additions & 0 deletions tools/cfl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,8 @@ Content can be provided via:

**Markdown is the default format.** It is converted to ADF, or to storage XHTML with `--legacy`.

**Writes are read back.** With `--body-format adf` or `xhtml` the page is fetched again after a write and the stored body compared against what was sent, because Confluence can store something other than what it was given — it drops `__confluenceMetadata` from link marks, for example. Losing content is an error and exits non-zero; attributes the server normalizes away are reported on stderr and tolerated. Markdown is converted before sending, so there is nothing to compare it against and the check is skipped. Use `--no-verify` to skip the extra read.

```bash
# Open markdown editor
cfl page create --space DEV --title "My Page"
Expand Down Expand Up @@ -384,6 +386,7 @@ cfl page create -s DEV -t "Legacy Page" --file content.md --legacy
| `--editor` | | `false` | Force open in $EDITOR |
| `--body-format` | | `markdown` | Input format: `markdown`, exact `adf` JSON, or exact storage `xhtml` |
| `--legacy` | | `false` | Convert Markdown to storage XHTML instead of ADF; invalid with `adf` or `xhtml` |
| `--no-verify` | | `false` | Skip reading the page back after writing to confirm what Confluence stored (`adf`/`xhtml` only) |

The selected format applies equally to files, stdin, and editor input; file extensions do not override it.

Expand All @@ -400,6 +403,8 @@ Content can be provided via:

**Markdown is the default format.** It is converted to ADF, or to storage XHTML with `--legacy`.

**Writes are read back.** With `--body-format adf` or `xhtml` the page is fetched again after a write and the stored body compared against what was sent, because Confluence can store something other than what it was given — it drops `__confluenceMetadata` from link marks, for example. Losing content is an error and exits non-zero; attributes the server normalizes away are reported on stderr and tolerated. Markdown is converted before sending, so there is nothing to compare it against and the check is skipped. Use `--no-verify` to skip the extra read.

```bash
# Open editor with existing page content
cfl page edit 12345
Expand Down Expand Up @@ -438,6 +443,7 @@ cfl page view 12345 --body-format xhtml --content-only | \
| `--editor` | | `false` | Force open in $EDITOR |
| `--body-format` | | `markdown` | Input/editor format: `markdown`, exact `adf` JSON, or exact storage `xhtml` |
| `--legacy` | | `false` | Convert Markdown to storage XHTML instead of ADF; invalid with `adf` or `xhtml` |
| `--no-verify` | | `false` | Skip reading the page back after writing to confirm what Confluence stored (`adf`/`xhtml` only) |
Comment thread
piekstra marked this conversation as resolved.

**Arguments:**
- `<page-id>` - The page ID (**required**)
Expand Down
33 changes: 33 additions & 0 deletions tools/cfl/internal/cmd/OUTPUT_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ ID: <id>
URL: <url>
```

With `--body-format adf` or `xhtml` the page is read back after the write and
compared with what was sent, exactly as `page edit` does. See that section for
the stderr blocks emitted on normalization and on content loss.

## `page edit <page-id>`

Success:
Expand All @@ -256,6 +260,35 @@ Version: <version>
URL: <url>
```

With `--body-format adf` or `xhtml` the page is read back and compared with
Comment thread
piekstra marked this conversation as resolved.
what was sent. When the stored body differs, a warning is written to stderr
after the success block. Attribute normalization is reported and the command
still succeeds:

```text
Confluence normalized the stored <format> body. Content is intact; these attributes were dropped:
- <node>.attrs.<name> (<before>→<after>)
```

Content loss is reported the same way and the command exits non-zero. The
first line names what moved in the document — one of three shapes:

```text
Stored <format> body does not match what was sent: visible text went from <n> to <n> characters.
Stored <format> body does not match what was sent: visible text is unchanged at <n> characters, but embedded content differs.
Stored <format> body does not match what was sent: content differs at the same length of <n> characters.
```

followed by:

```text
The page was updated, but it does not hold the content supplied. Re-read the page before treating the change as applied.
first difference at offset <n> — sent "<excerpt>", stored "<excerpt>"
```

Counts are characters a reader sees and the offset is a character position,
both measured on the document rather than on any internal representation.

## `page copy <page-id>`

Success:
Expand Down
18 changes: 17 additions & 1 deletion tools/cfl/internal/cmd/page/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
)

type createOptions struct {
noVerify bool
*root.Options
space string
title string
Expand Down Expand Up @@ -95,6 +96,7 @@ without conversion.`,
cmd.Flags().BoolVar(&opts.editor, "editor", false, "Open editor for content")
cmd.Flags().StringVar(&opts.bodyFormat, "body-format", bodyFormatMarkdown, "Input format: markdown, adf, or xhtml")
cmd.Flags().BoolVar(&opts.legacy, "legacy", false, "Create page in legacy editor format (Markdown input only)")
cmd.Flags().BoolVar(&opts.noVerify, "no-verify", false, "Skip reading the page back to confirm what Confluence stored (adf/xhtml input)")

_ = cmd.MarkFlagRequired("title")

Expand Down Expand Up @@ -128,6 +130,7 @@ func runCreate(ctx context.Context, opts *createOptions) error {
if strings.TrimSpace(content) == "" {
return fmt.Errorf("page content cannot be empty")
}
sentContent := content
body, err := bodyForInput(content, bodyFormat, opts.legacy)
if err != nil {
return err
Expand Down Expand Up @@ -173,7 +176,20 @@ func runCreate(ctx context.Context, opts *createOptions) error {
return err
}

Comment thread
piekstra marked this conversation as resolved.
return cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentCreate(page, cfg.URL))
if err := cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentCreate(page, cfg.URL)); err != nil {
return err
}

// A create writes the same verbatim body an edit does, so it can suffer
// the same unseen server-side loss.
return verifyStoredBody(ctx, verifyRequest{
opts: opts.Options,
client: client,
pageID: page.ID,
bodyFormat: bodyFormat,
sentContent: sentContent,
enabled: !opts.noVerify,
})
}

func getContent(opts *createOptions, bodyFormat string) (string, error) {
Expand Down
53 changes: 53 additions & 0 deletions tools/cfl/internal/cmd/page/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ func TestRunCreate_HTMLFile_Legacy(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -147,6 +150,9 @@ func TestRunCreate_NoMarkdownFlag_Legacy(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -304,6 +310,9 @@ func TestRunCreate_WithParent(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -347,6 +356,9 @@ func TestRunCreate_MarkdownConversion_Legacy(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -396,6 +408,9 @@ func TestRunCreate_MarkdownToADF(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -462,6 +477,9 @@ func TestRunCreate_Stdin_ADF(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -505,6 +523,9 @@ func TestRunCreate_Stdin_Legacy(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -548,6 +569,9 @@ func TestRunCreate_Stdin_NoMarkdown_Legacy(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -590,6 +614,9 @@ func TestRunCreate_StorageFlag_Stdin(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -639,6 +666,9 @@ func TestRunCreate_StorageFlag_File(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -681,6 +711,9 @@ func TestRunCreate_ComplexMarkdown_ADF(t *testing.T) {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(receivedBody)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &receivedBody)
Expand Down Expand Up @@ -831,6 +864,9 @@ func mockCreateBodyServer(t *testing.T, received *map[string]any) *httptest.Serv
case r.Method == "GET" && strings.Contains(r.URL.Path, "/spaces"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results": [{"id": "123456", "key": "DEV"}]}`))
case r.Method == "GET" && strings.Contains(r.URL.Path, "/pages/"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(storedCreatedPageJSON(*received)))
case r.Method == "POST" && strings.Contains(r.URL.Path, "/pages"):
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, received)
Expand Down Expand Up @@ -987,3 +1023,20 @@ func TestRunCreate_FileDash_Stdin_Legacy(t *testing.T) {
testutil.Contains(t, content, "<strong>bold</strong>")
testutil.Nil(t, bodyMap["atlas_doc_format"])
}

// storedCreatedPageJSON reports a created page the way Confluence does: a GET
// after the write returns the body that was stored. runCreate now reads the
// page back to verify the write, so a fake without this branch answers 404
// for a create that in fact succeeded.
func storedCreatedPageJSON(received map[string]any) string {
fallback := `{"id":"99999","title":"Test","version":{"number":1}}`
body, ok := received["body"].(map[string]any)
if !ok {
return fallback
}
raw, err := json.Marshal(body)
if err != nil {
return fallback
}
return `{"id":"99999","title":"Test","version":{"number":1},"body":` + string(raw) + `}`
}
27 changes: 25 additions & 2 deletions tools/cfl/internal/cmd/page/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type editOptions struct {
bodyFormatExplicit bool
legacy bool
parent string
noVerify bool
}

func newEditCmd(rootOpts *root.Options) *cobra.Command {
Expand All @@ -46,7 +47,14 @@ Content can be provided via:

Content format is selected with --body-format markdown|adf|xhtml.
Omitting --body-format means Markdown. ADF and XHTML are validated or sent
without conversion.`,
without conversion.

For --body-format adf or xhtml the page is read back after writing and the
stored body compared against what was sent, because Confluence can store
something other than what it was given. Losing text is an error; attributes
the server normalizes away are reported and tolerated. Markdown is converted
before sending, so there is nothing to compare it against and the check is
skipped. Use --no-verify to skip the read.`,
Example: ` # Edit a page in the editor with current content
cfl page edit 12345 --editor

Expand Down Expand Up @@ -103,6 +111,7 @@ without conversion.`,
cmd.Flags().BoolVar(&opts.editor, "editor", false, "Open editor for content")
cmd.Flags().StringVar(&opts.bodyFormat, "body-format", bodyFormatMarkdown, "Input format: markdown, adf, or xhtml")
cmd.Flags().BoolVar(&opts.legacy, "legacy", false, "Edit page in legacy editor format (Markdown input only)")
cmd.Flags().BoolVar(&opts.noVerify, "no-verify", false, "Skip reading the page back to confirm what Confluence stored (adf/xhtml input)")
Comment thread
piekstra marked this conversation as resolved.

return cmd
}
Expand Down Expand Up @@ -132,6 +141,7 @@ func runEdit(ctx context.Context, opts *editOptions) error {
hasStdinData := (opts.Stdin != nil && opts.Stdin != os.Stdin) || hasPipedOSStdin(opts.Options)
hasNewContent := opts.file != "" || opts.editor || hasStdinData
var newBody *api.Body
var sentContent string
if hasNewContent && !opts.editor {
content, err := getEditContent(opts, nil, bodyFormat)
if err != nil {
Expand All @@ -140,6 +150,7 @@ func runEdit(ctx context.Context, opts *editOptions) error {
if strings.TrimSpace(content) == "" {
return fmt.Errorf("page content cannot be empty")
}
sentContent = content
newBody, err = bodyForInput(content, bodyFormat, opts.legacy)
if err != nil {
return err
Expand Down Expand Up @@ -181,6 +192,7 @@ func runEdit(ctx context.Context, opts *editOptions) error {
return fmt.Errorf("page content cannot be empty")
}

sentContent = content
newBody, err = bodyForInput(content, bodyFormat, opts.legacy)
if err != nil {
return err
Expand Down Expand Up @@ -214,7 +226,18 @@ func runEdit(ctx context.Context, opts *editOptions) error {
}
}

return cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentEdit(page, cfg.URL, opts.legacy && hasNewContent))
if err := cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentEdit(page, cfg.URL, opts.legacy && hasNewContent)); err != nil {
return err
}

return verifyStoredBody(ctx, verifyRequest{
Comment thread
piekstra marked this conversation as resolved.
opts: opts.Options,
client: client,
pageID: opts.pageID,
bodyFormat: bodyFormat,
sentContent: sentContent,
enabled: hasNewContent && !opts.noVerify,
})
}

func getEditContent(opts *editOptions, existingPage *api.Page, bodyFormat string) (string, error) {
Expand Down
Loading
Loading