mark/mark.go
Manuel Rüger 06c3afef25 fix: skip label sync when metadata is absent
When PageID mode is used (meta == nil), labels is nil and calling
updateLabels unconditionally treats that as an empty desired set,
silently removing all existing global labels from the page. Guard
the call so label syncing only runs when metadata is present.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00

496 lines
12 KiB
Go

package mark
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/kovetskiy/mark/attachment"
"github.com/kovetskiy/mark/confluence"
"github.com/kovetskiy/mark/includes"
"github.com/kovetskiy/mark/macro"
markmd "github.com/kovetskiy/mark/markdown"
"github.com/kovetskiy/mark/metadata"
"github.com/kovetskiy/mark/page"
"github.com/kovetskiy/mark/stdlib"
"github.com/kovetskiy/mark/types"
"github.com/kovetskiy/mark/vfs"
"github.com/reconquest/karma-go"
"github.com/reconquest/pkg/log"
)
// Config holds all configuration options for running Mark.
type Config struct {
// Connection settings
BaseURL string
Username string
Password string
PageID string
InsecureSkipTLSVerify bool
// File selection
Files string
// Behaviour
CompileOnly bool
DryRun bool
ContinueOnError bool
CI bool
// Page content
Space string
Parents []string
TitleFromH1 bool
TitleFromFilename bool
TitleAppendGeneratedHash bool
ContentAppearance string
// Page updates
MinorEdit bool
VersionMessage string
EditLock bool
ChangesOnly bool
// Rendering
DropH1 bool
StripLinebreaks bool
MermaidScale float64
D2Scale float64
Features []string
ImageAlign string
IncludePath string
}
// Run processes all files matching Config.Files and publishes them to Confluence.
func Run(config Config) error {
api := confluence.NewAPI(config.BaseURL, config.Username, config.Password, config.InsecureSkipTLSVerify)
files, err := doublestar.FilepathGlob(config.Files)
if err != nil {
return err
}
if len(files) == 0 {
msg := "no files matched"
if config.CI {
log.Warning(msg)
} else {
return fmt.Errorf("%s", msg)
}
}
for _, file := range files {
log.Infof(nil, "processing %s", file)
target, err := ProcessFile(file, api, config)
if err != nil {
if config.ContinueOnError {
log.Errorf(err, "processing %s", file)
continue
}
return err
}
if target != nil {
log.Infof(nil, "page successfully updated: %s", config.BaseURL+target.Links.Full)
fmt.Println(config.BaseURL + target.Links.Full)
}
}
return nil
}
// ProcessFile processes a single markdown file and publishes it to Confluence.
// Returns nil for the page info when compile-only or dry-run mode is active.
func ProcessFile(file string, api *confluence.API, config Config) (*confluence.PageInfo, error) {
markdown, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("unable to read file %q: %w", file, err)
}
markdown = bytes.ReplaceAll(markdown, []byte("\r\n"), []byte("\n"))
meta, markdown, err := metadata.ExtractMeta(
markdown,
config.Space,
config.TitleFromH1,
config.TitleFromFilename,
file,
config.Parents,
config.TitleAppendGeneratedHash,
config.ContentAppearance,
)
if err != nil {
return nil, fmt.Errorf("unable to extract metadata from file %q: %w", file, err)
}
if config.PageID != "" && meta != nil {
log.Warning(
`specified file contains metadata, ` +
`but it will be ignored due specified command line URL`,
)
meta = nil
}
if config.PageID == "" && meta == nil {
return nil, fmt.Errorf(
"specified file doesn't contain metadata and URL is not specified " +
"via command line or doesn't contain pageId GET-parameter",
)
}
if meta != nil {
if meta.Space == "" {
return nil, fmt.Errorf(
"space is not set ('Space' header is not set and '--space' option is not set)",
)
}
if meta.Title == "" {
return nil, fmt.Errorf(
"page title is not set: use the 'Title' header, " +
"or the --title-from-h1 / --title-from-filename flags",
)
}
}
std, err := stdlib.New(api)
if err != nil {
return nil, fmt.Errorf("unable to retrieve standard library: %w", err)
}
templates := std.Templates
var recurse bool
for {
templates, markdown, recurse, err = includes.ProcessIncludes(
filepath.Dir(file),
config.IncludePath,
markdown,
templates,
)
if err != nil {
return nil, fmt.Errorf("unable to process includes: %w", err)
}
if !recurse {
break
}
}
macros, markdown, err := macro.ExtractMacros(
filepath.Dir(file),
config.IncludePath,
markdown,
templates,
)
if err != nil {
return nil, fmt.Errorf("unable to extract macros: %w", err)
}
for _, m := range macros {
markdown, err = m.Apply(markdown)
if err != nil {
return nil, fmt.Errorf("unable to apply macro: %w", err)
}
}
links, err := page.ResolveRelativeLinks(
api,
meta,
markdown,
filepath.Dir(file),
config.Space,
config.TitleFromH1,
config.TitleFromFilename,
config.Parents,
config.TitleAppendGeneratedHash,
)
if err != nil {
return nil, fmt.Errorf("unable to resolve relative links: %w", err)
}
markdown = page.SubstituteLinks(markdown, links)
if config.DryRun {
if _, _, err := page.ResolvePage(true, api, meta); err != nil {
return nil, fmt.Errorf("unable to resolve page location: %w", err)
}
}
if config.CompileOnly || config.DryRun {
if config.DropH1 {
log.Info("the leading H1 heading will be excluded from the Confluence output")
}
imageAlign, err := getImageAlign(config.ImageAlign, meta)
if err != nil {
return nil, fmt.Errorf("unable to determine image-align: %w", err)
}
cfg := types.MarkConfig{
MermaidScale: config.MermaidScale,
D2Scale: config.D2Scale,
DropFirstH1: config.DropH1,
StripNewlines: config.StripLinebreaks,
Features: config.Features,
ImageAlign: imageAlign,
}
html, _ := markmd.CompileMarkdown(markdown, std, file, cfg)
fmt.Println(html)
return nil, nil
}
var target *confluence.PageInfo
if meta != nil {
parent, pg, err := page.ResolvePage(false, api, meta)
if err != nil {
return nil, karma.Describe("title", meta.Title).Reason(err)
}
if pg == nil {
pg, err = api.CreatePage(meta.Space, meta.Type, parent, meta.Title, ``)
if err != nil {
return nil, fmt.Errorf("can't create %s %q: %w", meta.Type, meta.Title, err)
}
// A delay between the create and update call helps mitigate a 409
// conflict that can occur when attempting to update a page just
// after it was created. See issues/139.
time.Sleep(1 * time.Second)
}
target = pg
} else {
pg, err := api.GetPageByID(config.PageID)
if err != nil {
return nil, fmt.Errorf("unable to retrieve page by id: %w", err)
}
target = pg
}
// Collect attachments declared via <!-- Attachment: --> directives.
var declaredAttachments []string
if meta != nil {
declaredAttachments = meta.Attachments
}
localAttachments, err := attachment.ResolveLocalAttachments(
vfs.LocalOS,
filepath.Dir(file),
declaredAttachments,
)
if err != nil {
return nil, fmt.Errorf("unable to locate attachments: %w", err)
}
attaches, err := attachment.ResolveAttachments(api, target, localAttachments)
if err != nil {
return nil, fmt.Errorf("unable to create/update attachments: %w", err)
}
markdown = attachment.CompileAttachmentLinks(markdown, attaches)
if config.DropH1 {
log.Info("the leading H1 heading will be excluded from the Confluence output")
}
imageAlign, err := getImageAlign(config.ImageAlign, meta)
if err != nil {
return nil, fmt.Errorf("unable to determine image-align: %w", err)
}
cfg := types.MarkConfig{
MermaidScale: config.MermaidScale,
D2Scale: config.D2Scale,
DropFirstH1: config.DropH1,
StripNewlines: config.StripLinebreaks,
Features: config.Features,
ImageAlign: imageAlign,
}
html, inlineAttachments := markmd.CompileMarkdown(markdown, std, file, cfg)
if _, err = attachment.ResolveAttachments(api, target, inlineAttachments); err != nil {
return nil, fmt.Errorf("unable to create/update attachments: %w", err)
}
var layout, sidebar string
var labels []string
var contentAppearance, emoji string
if meta != nil {
layout = meta.Layout
sidebar = meta.Sidebar
labels = meta.Labels
contentAppearance = meta.ContentAppearance
emoji = meta.Emoji
}
{
var buffer bytes.Buffer
err := std.Templates.ExecuteTemplate(
&buffer,
"ac:layout",
struct {
Layout string
Sidebar string
Body string
}{
Layout: layout,
Sidebar: sidebar,
Body: html,
},
)
if err != nil {
return nil, fmt.Errorf("unable to execute layout template: %w", err)
}
html = buffer.String()
}
var finalVersionMessage string
shouldUpdatePage := true
if config.ChangesOnly {
contentHash := sha1Hash(html)
log.Debugf(nil, "content hash: %s", contentHash)
re := regexp.MustCompile(`\[v([a-f0-9]{40})]$`)
if matches := re.FindStringSubmatch(target.Version.Message); len(matches) > 1 {
log.Debugf(nil, "previous content hash: %s", matches[1])
if matches[1] == contentHash {
log.Infof(nil, "page %q is already up to date", target.Title)
shouldUpdatePage = false
}
}
finalVersionMessage = fmt.Sprintf("%s [v%s]", config.VersionMessage, contentHash)
} else {
finalVersionMessage = config.VersionMessage
}
if shouldUpdatePage {
err = api.UpdatePage(
target,
html,
config.MinorEdit,
finalVersionMessage,
labels,
contentAppearance,
emoji,
)
if err != nil {
return nil, fmt.Errorf("unable to update page: %w", err)
}
}
if meta != nil {
if err := updateLabels(api, target, labels); err != nil {
return nil, err
}
}
if config.EditLock {
log.Infof(
nil,
`edit locked on page %q by user %q to prevent manual edits`,
target.Title,
config.Username,
)
if err := api.RestrictPageUpdates(target, config.Username); err != nil {
return nil, fmt.Errorf("unable to restrict page updates: %w", err)
}
}
return target, nil
}
func updateLabels(api *confluence.API, target *confluence.PageInfo, metaLabels []string) error {
labelInfo, err := api.GetPageLabels(target, "global")
if err != nil {
return err
}
log.Debug("Page Labels:")
log.Debug(labelInfo.Labels)
log.Debug("Meta Labels:")
log.Debug(metaLabels)
delLabels := determineLabelsToRemove(labelInfo, metaLabels)
log.Debug("Del Labels:")
log.Debug(delLabels)
addLabels := determineLabelsToAdd(metaLabels, labelInfo)
log.Debug("Add Labels:")
log.Debug(addLabels)
if len(addLabels) > 0 {
if _, err = api.AddPageLabels(target, addLabels); err != nil {
return fmt.Errorf("error adding labels: %w", err)
}
}
for _, label := range delLabels {
if _, err = api.DeletePageLabel(target, label); err != nil {
return fmt.Errorf("error deleting label %q: %w", label, err)
}
}
return nil
}
func determineLabelsToRemove(labelInfo *confluence.LabelInfo, metaLabels []string) []string {
var labels []string
for _, label := range labelInfo.Labels {
if !slices.ContainsFunc(metaLabels, func(metaLabel string) bool {
return strings.EqualFold(metaLabel, label.Name)
}) {
labels = append(labels, label.Name)
}
}
return labels
}
func determineLabelsToAdd(metaLabels []string, labelInfo *confluence.LabelInfo) []string {
var labels []string
for _, metaLabel := range metaLabels {
if !slices.ContainsFunc(labelInfo.Labels, func(label confluence.Label) bool {
return strings.EqualFold(label.Name, metaLabel)
}) {
labels = append(labels, metaLabel)
}
}
return labels
}
func getImageAlign(align string, meta *metadata.Meta) (string, error) {
if meta != nil && meta.ImageAlign != "" {
align = meta.ImageAlign
}
if align != "" {
align = strings.ToLower(strings.TrimSpace(align))
if align != "left" && align != "center" && align != "right" {
return "", fmt.Errorf(
`unknown image-align %q, expected one of: left, center, right`,
align,
)
}
return align, nil
}
return "", nil
}
func sha1Hash(input string) string {
h := sha1.New()
h.Write([]byte(input))
return hex.EncodeToString(h.Sum(nil))
}