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 <confluence-url> [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)
}