~bigbes/confluence-md-utilities

798bc95fe93b3671ccba5819c7020d3086aa725a — Eugene Blikh 2 months ago e0e81bc
feat: preview and confirm before push

`mdcx push` now prints the exact PUT request (URL, masked auth header,
title, version bump, body size delta, and a 20-line body excerpt) and
prompts for confirmation before sending. Add `--yes`/`-y` to skip the
prompt and `--dry-run` to preview only. Non-interactive stdin without
`--yes` errors out instead of hanging.

Splits `Client.UpdateContent` into `BuildUpdate` + send so the CLI can
render the same payload that will be transmitted.
3 files changed, 130 insertions(+), 16 deletions(-)

M api/client_test.go
M api/content.go
M cmd/mdcx/push.go
M api/client_test.go => api/client_test.go +1 -1
@@ 89,7 89,7 @@ func TestUpdateContent(t *testing.T) {
		assert.Equal(t, "/rest/api/content/12345", r.URL.Path)
		assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))

		var req updateRequest
		var req UpdateRequest
		err := json.NewDecoder(r.Body).Decode(&req)
		require.NoError(t, err)


M api/content.go => api/content.go +13 -6
@@ 35,8 35,8 @@ type StorageBody struct {
	Representation string `json:"representation"`
}

// updateRequest is the JSON payload for updating a page.
type updateRequest struct {
// UpdateRequest is the JSON payload for updating a page.
type UpdateRequest struct {
	Version Version `json:"version"`
	Title   string  `json:"title"`
	Type    string  `json:"type"`


@@ 117,9 117,10 @@ func (c *Client) GetPage(ref *PageRef) (*ContentResponse, error) {
	return c.FindContent(ref.SpaceKey, ref.Title)
}

// UpdateContent updates a page's storage body, incrementing the version.
func (c *Client) UpdateContent(pageID string, current *ContentResponse, newBody string, message string) error {
	payload := updateRequest{
// BuildUpdate returns the API path and payload that UpdateContent would send,
// without making any HTTP request. The CLI uses this to preview the request.
func (c *Client) BuildUpdate(pageID string, current *ContentResponse, newBody, message string) (path string, payload UpdateRequest) {
	payload = UpdateRequest{
		Version: Version{
			Number:  current.Version.Number + 1,
			Message: message,


@@ 133,13 134,19 @@ func (c *Client) UpdateContent(pageID string, current *ContentResponse, newBody 
			},
		},
	}
	path = fmt.Sprintf("/rest/api/content/%s", pageID)
	return path, payload
}

// UpdateContent updates a page's storage body, incrementing the version.
func (c *Client) UpdateContent(pageID string, current *ContentResponse, newBody string, message string) error {
	path, payload := c.BuildUpdate(pageID, current, newBody, message)

	data, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("marshaling update: %w", err)
	}

	path := fmt.Sprintf("/rest/api/content/%s", pageID)
	req, err := c.newRequest(http.MethodPut, path)
	if err != nil {
		return err

M cmd/mdcx/push.go => cmd/mdcx/push.go +116 -9
@@ 1,9 1,11 @@
package main

import (
	"bufio"
	"fmt"
	"io"
	"os"
	"strings"

	"github.com/spf13/cobra"



@@ 13,11 15,13 @@ import (
)

var (
	pushMessage        string
	pushRaw            bool
	pushTemplate       bool
	pushMarkerStart    string
	pushMarkerEnd      string
	pushMessage     string
	pushRaw         bool
	pushTemplate    bool
	pushMarkerStart string
	pushMarkerEnd   string
	pushYes         bool
	pushDryRun      bool
)

var pushCmd = &cobra.Command{


@@ 36,6 40,9 @@ With --raw, the input is treated as Confluence storage XML (no conversion).

Reads from stdin if no input file is specified.

Before sending, mdcx prints the exact request it will make and asks for
confirmation. Use --yes to skip the prompt or --dry-run to preview only.

Authentication via --token flag or CONFLUENCE_TOKEN environment variable.`,
	Args: cobra.RangeArgs(1, 2),
	RunE: func(cmd *cobra.Command, args []string) error {


@@ 79,9 86,6 @@ Authentication via --token flag or CONFLUENCE_TOKEN environment variable.`,
			return err
		}

		fmt.Fprintf(os.Stderr, "Updating page: %s (id=%s, version=%d -> %d)\n",
			page.Title, page.ID, page.Version.Number, page.Version.Number+1)

		// If template mode, embed into existing page body
		body := newXML
		if pushTemplate {


@@ 91,20 95,123 @@ Authentication via --token flag or CONFLUENCE_TOKEN environment variable.`,
			}
		}

		path, payload := client.BuildUpdate(page.ID, page, body, pushMessage)
		printPushPreview(client.BaseURL+path, page, payload)

		if pushDryRun {
			fmt.Fprintln(os.Stderr, "(dry-run, not sending)")
			return nil
		}

		if !pushYes {
			ok, err := confirmPush()
			if err != nil {
				return err
			}
			if !ok {
				fmt.Fprintln(os.Stderr, "Aborted.")
				return nil
			}
		}

		if err := client.UpdateContent(page.ID, page, body, pushMessage); err != nil {
			return err
		}

		fmt.Fprintf(os.Stderr, "Page updated successfully\n")
		fmt.Fprintln(os.Stderr, "Page updated successfully")
		return nil
	},
}

// printPushPreview writes an API-oriented summary of the upcoming PUT to stderr.
func printPushPreview(fullURL string, page *api.ContentResponse, payload api.UpdateRequest) {
	w := os.Stderr
	fmt.Fprintf(w, "PUT %s\n", fullURL)
	fmt.Fprintln(w, "  Content-Type: application/json")
	fmt.Fprintln(w, "  Authorization: Bearer ********")
	fmt.Fprintln(w)
	fmt.Fprintf(w, "Page:    %q  (id=%s, type=%s)\n", payload.Title, page.ID, payload.Type)
	fmt.Fprintf(w, "Version: %d -> %d\n", page.Version.Number, payload.Version.Number)
	if payload.Version.Message == "" {
		fmt.Fprintln(w, "Message: (none)")
	} else {
		fmt.Fprintf(w, "Message: %q\n", payload.Version.Message)
	}
	oldSize := len(page.Body.Storage.Value)
	newSize := len(payload.Body.Storage.Value)
	fmt.Fprintf(w, "Body:    %s bytes -> %s bytes  (storage format)\n",
		formatInt(oldSize), formatInt(newSize))
	if pushTemplate {
		fmt.Fprintf(w, "         (template embed: replaced region between %s and %s)\n",
			pushMarkerStart, pushMarkerEnd)
	}
	fmt.Fprintln(w)

	const maxLines = 20
	lines := strings.Split(payload.Body.Storage.Value, "\n")
	fmt.Fprintf(w, "--- new body (first %d lines) ---\n", min(maxLines, len(lines)))
	for i := 0; i < len(lines) && i < maxLines; i++ {
		fmt.Fprintln(w, lines[i])
	}
	if len(lines) > maxLines {
		fmt.Fprintf(w, "--- (%d more lines) ---\n", len(lines)-maxLines)
	} else {
		fmt.Fprintln(w, "--- (end) ---")
	}
	fmt.Fprintln(w)
}

// confirmPush prompts the user on a TTY. Returns an error when stdin is
// non-interactive and --yes was not given.
func confirmPush() (bool, error) {
	stat, err := os.Stdin.Stat()
	if err != nil {
		return false, fmt.Errorf("stat stdin: %w", err)
	}
	if stat.Mode()&os.ModeCharDevice == 0 {
		return false, fmt.Errorf("non-interactive stdin: pass --yes to confirm or --dry-run to preview only")
	}

	fmt.Fprint(os.Stderr, "Proceed with update? [y/N]: ")
	reader := bufio.NewReader(os.Stdin)
	line, err := reader.ReadString('\n')
	if err != nil && err != io.EOF {
		return false, fmt.Errorf("reading confirmation: %w", err)
	}
	answer := strings.ToLower(strings.TrimSpace(line))
	return answer == "y" || answer == "yes", nil
}

// formatInt returns n as a comma-separated decimal string ("23,481").
func formatInt(n int) string {
	s := fmt.Sprintf("%d", n)
	if len(s) <= 3 {
		return s
	}
	var b strings.Builder
	pre := len(s) % 3
	if pre > 0 {
		b.WriteString(s[:pre])
		if len(s) > pre {
			b.WriteByte(',')
		}
	}
	for i := pre; i < len(s); i += 3 {
		b.WriteString(s[i : i+3])
		if i+3 < len(s) {
			b.WriteByte(',')
		}
	}
	return b.String()
}

func init() {
	pushCmd.Flags().StringVarP(&pushMessage, "message", "m", "", "Version message for the update")
	pushCmd.Flags().BoolVar(&pushRaw, "raw", false, "Input is raw Confluence storage XML (skip conversion)")
	pushCmd.Flags().BoolVar(&pushTemplate, "template", false, "Embed content into existing page body between markers")
	pushCmd.Flags().StringVar(&pushMarkerStart, "marker-start", template.DefaultMarkerStart, "Start marker comment (with --template)")
	pushCmd.Flags().StringVar(&pushMarkerEnd, "marker-end", template.DefaultMarkerEnd, "End marker comment (with --template)")
	pushCmd.Flags().BoolVarP(&pushYes, "yes", "y", false, "Skip confirmation prompt")
	pushCmd.Flags().BoolVar(&pushDryRun, "dry-run", false, "Show the request that would be sent and exit")
	rootCmd.AddCommand(pushCmd)
}