@@ 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
@@ 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)
}