package main import ( "bufio" "fmt" "io" "os" "strings" "github.com/spf13/cobra" "go.bigb.es/confluence-md-utilities/api" "go.bigb.es/confluence-md-utilities/converter" "go.bigb.es/confluence-md-utilities/template" ) var ( pushMessage string pushRaw bool pushTemplate bool pushMarkerStart string pushMarkerEnd string pushYes bool pushDryRun bool ) var pushCmd = &cobra.Command{ Use: "push [input.md]", Short: "Push local Markdown to a Confluence page", Long: `Convert a local Markdown file to Confluence storage format and update the page at the given URL. By default, the entire page body is replaced with the converted content. With --template, the current page body is preserved as a template: the content between marker comments is replaced with the new content, keeping everything else (metadata table, changelog, etc.) intact. 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 { token := resolveToken() if token == "" { return fmt.Errorf("Confluence token required: use --token flag or set CONFLUENCE_TOKEN env var") } ref, err := api.ParsePageURL(args[0]) if err != nil { return err } // Read input var input []byte if len(args) > 1 { input, err = os.ReadFile(args[1]) } else { input, err = io.ReadAll(os.Stdin) } if err != nil { return fmt.Errorf("reading input: %w", err) } // Convert to Confluence XML if not raw var newXML string if pushRaw { newXML = string(input) } else { newXML, err = converter.MarkdownToConfluence(input) if err != nil { return fmt.Errorf("converting markdown: %w", err) } } client := api.NewClient(ref.BaseURL, token) // Fetch current page for version info (and template if needed) page, err := client.GetPage(ref) if err != nil { return err } // If template mode, embed into existing page body body := newXML if pushTemplate { body, err = template.Embed(page.Body.Storage.Value, newXML, pushMarkerStart, pushMarkerEnd) if err != nil { return fmt.Errorf("embedding into template: %w", err) } } 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.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) }