Compare commits

...

11 Commits

Author SHA1 Message Date
Manuel Rüger
8205794e7b Update Makefile
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
Manuel Rüger
664a99cd00 test: restore global log level after each SetLogLevel subtest
SetLogLevel mutates a process-global logger, leaking state into
subsequent tests and causing order-dependent failures. Save the
current level before each subtest and restore it via t.Cleanup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
Manuel Rüger
5e1d40d910 fix: remove no-op log.GetLevel() call in SetLogLevel
The return value was unused and had no effect on logger state,
misleading readers into thinking it was needed for initialization.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
Manuel Rüger
d68f8c3bb3 fix: use api.BaseURL instead of config.BaseURL for page URL output
confluence.NewAPI trims trailing slashes from the base URL into
api.BaseURL. Using config.BaseURL directly could produce double
slashes in the logged/printed URL when the caller passes a
trailing-slash BaseURL.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
Manuel Rüger
1fc553f102 style: fix indentation in Test_setLogLevel
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
Manuel Rüger
aa16e7ae26 fix: check error return from fmt.Fprintln
errcheck lint requires all error return values to be handled.
Propagate write errors from both Fprintln call sites.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
Manuel Rüger
66120b937e fix: handle nil meta in dry-run mode when PageID is set
page.ResolvePage requires non-nil metadata and would error immediately
when called with meta == nil (e.g. when --page-id is used and the file
has no metadata header, or when metadata is intentionally suppressed).

Guard the call: when meta != nil use ResolvePage as before; when meta
is nil but PageID is provided, validate the page exists via
api.GetPageByID instead; when neither is set the earlier mandatory-
field check already returns an error, so no further action is needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
Manuel Rüger
4e3b90c03c fix: route result output through Config.Output, not os.Stdout
mark.Run and ProcessFile were writing directly to os.Stdout via
fmt.Println, which is a surprising side-effect for library callers.

Add Config.Output io.Writer for callers to provide their own sink.
When nil the helper falls back to io.Discard, so library embedders
that do not set Output receive no implicit stdout writes. The CLI
layer sets Output: os.Stdout to preserve existing behaviour.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
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
Manuel Rüger
e68a9f64ff Refactor CLI to be a thin adapter over the library
- util/cli.go: RunMark() now maps CLI flags into mark.Config and
  delegates to mark.Run(); all core processing logic removed
- util/cli_test.go: absorb Test_setLogLevel from deleted main_test.go
- main.go, main_test.go: removed (entry point is now cmd/mark/main.go)
- Makefile: update build target to ./cmd/mark with -o mark

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
Manuel Rüger
9e4c4bde30 Add root library package with Config, Run and ProcessFile
Expose the core mark functionality as an importable Go library.
Library users can now import github.com/kovetskiy/mark and call:

  err := mark.Run(mark.Config{
      BaseURL:  "https://confluence.example.com",
      Username: "user",
      Password: "token",
      Files:    "docs/**/*.md",
      Features: []string{"mermaid", "mention"},
  })

The new package provides:
- Config struct: all options decoupled from the CLI framework
- Run(config Config) error: process all files matching Config.Files
- ProcessFile(file, api, config): process a single markdown file

Also moves the CLI entry point to cmd/mark/main.go following standard
Go convention for projects that serve as both a library and a binary.

Fixes a pre-existing nil-pointer dereference on meta.Attachments,
meta.Layout and related fields when using --target-url with a pageId
(meta was nil in that code path).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
6 changed files with 612 additions and 522 deletions

View File

@ -16,7 +16,9 @@ build:
@echo :: building go binary $(VERSION)
CGO_ENABLED=0 go build \
-ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" \
-gcflags "-trimpath $(GOPATH)/src"
-gcflags "-trimpath $(GOPATH)/src" \
-o $(NAME) \
./cmd/mark
test:
go test -race -coverprofile=profile.cov ./... -v

View File

@ -1,51 +0,0 @@
package main
import (
"testing"
"github.com/kovetskiy/mark/util"
"github.com/reconquest/pkg/log"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v3"
)
func Test_setLogLevel(t *testing.T) {
type args struct {
lvl string
}
tests := map[string]struct {
args args
want log.Level
expectedErr string
}{
"invalid": {args: args{lvl: "INVALID"}, want: log.LevelInfo, expectedErr: "unknown log level: INVALID"},
"empty": {args: args{lvl: ""}, want: log.LevelInfo, expectedErr: "unknown log level: "},
"info": {args: args{lvl: log.LevelInfo.String()}, want: log.LevelInfo},
"debug": {args: args{lvl: log.LevelDebug.String()}, want: log.LevelDebug},
"trace": {args: args{lvl: log.LevelTrace.String()}, want: log.LevelTrace},
"warning": {args: args{lvl: log.LevelWarning.String()}, want: log.LevelWarning},
"error": {args: args{lvl: log.LevelError.String()}, want: log.LevelError},
"fatal": {args: args{lvl: log.LevelFatal.String()}, want: log.LevelFatal},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
cmd := &cli.Command{
Name: "test",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "log-level",
Value: tt.args.lvl,
Usage: "set the log level. Possible values: TRACE, DEBUG, INFO, WARNING, ERROR, FATAL.",
},
},
}
err := util.SetLogLevel(cmd)
if tt.expectedErr != "" {
assert.EqualError(t, err, tt.expectedErr)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.want, log.GetLevel())
}
})
}
}

520
mark.go Normal file
View File

@ -0,0 +1,520 @@
package mark
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"fmt"
"io"
"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
// Output is the writer used for result output (e.g. published page URLs,
// compiled HTML). If nil, output is discarded; the CLI sets this to
// os.Stdout.
Output io.Writer
}
// output returns the configured writer, falling back to io.Discard so that
// library callers that do not set Output receive no implicit stdout writes.
func (c Config) output() io.Writer {
if c.Output != nil {
return c.Output
}
return io.Discard
}
// 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", api.BaseURL+target.Links.Full)
if _, err := fmt.Fprintln(config.output(), api.BaseURL+target.Links.Full); err != nil {
return err
}
}
}
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 meta != nil {
if _, _, err := page.ResolvePage(true, api, meta); err != nil {
return nil, fmt.Errorf("unable to resolve page location: %w", err)
}
} else if config.PageID != "" {
if _, err := api.GetPageByID(config.PageID); err != nil {
return nil, fmt.Errorf("unable to resolve page by ID: %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)
if _, err := fmt.Fprintln(config.output(), html); err != nil {
return nil, err
}
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))
}

View File

@ -1,31 +1,14 @@
package util
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/kovetskiy/lorg"
"github.com/kovetskiy/mark/attachment"
"github.com/kovetskiy/mark/confluence"
"github.com/kovetskiy/mark/includes"
"github.com/kovetskiy/mark/macro"
mark "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"
mark "github.com/kovetskiy/mark"
"github.com/reconquest/pkg/log"
"github.com/urfave/cli/v3"
)
@ -44,26 +27,17 @@ func RunMark(ctx context.Context, cmd *cli.Command) error {
log.GetLogger().SetOutput(os.Stderr)
}
creds, err := GetCredentials(cmd.String("username"), cmd.String("password"), cmd.String("target-url"), cmd.String("base-url"), cmd.Bool("compile-only"))
creds, err := GetCredentials(
cmd.String("username"),
cmd.String("password"),
cmd.String("target-url"),
cmd.String("base-url"),
cmd.Bool("compile-only"),
)
if err != nil {
return err
}
api := confluence.NewAPI(creds.BaseURL, creds.Username, creds.Password, cmd.Bool("insecure-skip-tls-verify"))
files, err := doublestar.FilepathGlob(cmd.String("files"))
if err != nil {
return err
}
if len(files) == 0 {
msg := "No files matched"
if cmd.Bool("ci") {
log.Warning(msg)
} else {
log.Fatal(msg)
}
}
log.Debug("config:")
for _, f := range cmd.Flags {
flag := f.Names()
@ -74,439 +48,46 @@ func RunMark(ctx context.Context, cmd *cli.Command) error {
}
}
fatalErrorHandler := NewErrorHandler(cmd.Bool("continue-on-error"))
// Loop through files matched by glob pattern
for _, file := range files {
log.Infof(
nil,
"processing %s",
file,
)
target := processFile(file, api, cmd, creds.PageID, creds.Username, fatalErrorHandler)
if target != nil { // on dry-run or compile-only, the target is nil
log.Infof(
nil,
"page successfully updated: %s",
creds.BaseURL+target.Links.Full,
)
fmt.Println(creds.BaseURL + target.Links.Full)
}
}
return nil
}
func processFile(
file string,
api *confluence.API,
cmd *cli.Command,
pageID string,
username string,
fatalErrorHandler *FatalErrorHandler,
) *confluence.PageInfo {
markdown, err := os.ReadFile(file)
if err != nil {
fatalErrorHandler.Handle(err, "unable to read file %q", file)
return nil
}
markdown = bytes.ReplaceAll(markdown, []byte("\r\n"), []byte("\n"))
parents := strings.Split(cmd.String("parents"), cmd.String("parents-delimiter"))
meta, markdown, err := metadata.ExtractMeta(markdown, cmd.String("space"), cmd.Bool("title-from-h1"), cmd.Bool("title-from-filename"), file, parents, cmd.Bool("title-append-generated-hash"), cmd.String("content-appearance"))
if err != nil {
fatalErrorHandler.Handle(err, "unable to extract metadata from file %q", file)
return nil
config := mark.Config{
BaseURL: creds.BaseURL,
Username: creds.Username,
Password: creds.Password,
PageID: creds.PageID,
InsecureSkipTLSVerify: cmd.Bool("insecure-skip-tls-verify"),
Files: cmd.String("files"),
CompileOnly: cmd.Bool("compile-only"),
DryRun: cmd.Bool("dry-run"),
ContinueOnError: cmd.Bool("continue-on-error"),
CI: cmd.Bool("ci"),
Space: cmd.String("space"),
Parents: parents,
TitleFromH1: cmd.Bool("title-from-h1"),
TitleFromFilename: cmd.Bool("title-from-filename"),
TitleAppendGeneratedHash: cmd.Bool("title-append-generated-hash"),
ContentAppearance: cmd.String("content-appearance"),
MinorEdit: cmd.Bool("minor-edit"),
VersionMessage: cmd.String("version-message"),
EditLock: cmd.Bool("edit-lock"),
ChangesOnly: cmd.Bool("changes-only"),
DropH1: cmd.Bool("drop-h1"),
StripLinebreaks: cmd.Bool("strip-linebreaks"),
MermaidScale: cmd.Float("mermaid-scale"),
D2Scale: cmd.Float("d2-scale"),
Features: cmd.StringSlice("features"),
ImageAlign: cmd.String("image-align"),
IncludePath: cmd.String("include-path"),
Output: os.Stdout,
}
if pageID != "" && meta != nil {
log.Warning(
`specified file contains metadata, ` +
`but it will be ignored due specified command line URL`,
)
meta = nil
}
if pageID == "" && meta == nil {
fatalErrorHandler.Handle(nil, "specified file doesn't contain metadata and URL is not specified via command line or doesn't contain pageId GET-parameter")
return nil
}
if meta != nil {
if meta.Space == "" {
fatalErrorHandler.Handle(nil, "space is not set ('Space' header is not set and '--space' option is not set)")
return nil
}
if meta.Title == "" {
fatalErrorHandler.Handle(nil, "page title is not set: use the 'Title' header, or the --title-from-h1 / --title-from-filename flags")
return nil
}
}
stdlib, err := stdlib.New(api)
if err != nil {
fatalErrorHandler.Handle(err, "unable to retrieve standard library")
return nil
}
templates := stdlib.Templates
var recurse bool
for {
templates, markdown, recurse, err = includes.ProcessIncludes(
filepath.Dir(file),
cmd.String("include-path"),
markdown,
templates,
)
if err != nil {
fatalErrorHandler.Handle(err, "unable to process includes")
return nil
}
if !recurse {
break
}
}
macros, markdown, err := macro.ExtractMacros(
filepath.Dir(file),
cmd.String("include-path"),
markdown,
templates,
)
if err != nil {
fatalErrorHandler.Handle(err, "unable to extract macros")
return nil
}
for _, macro := range macros {
markdown, err = macro.Apply(markdown)
if err != nil {
fatalErrorHandler.Handle(err, "unable to apply macro")
return nil
}
}
links, err := page.ResolveRelativeLinks(api, meta, markdown, filepath.Dir(file), cmd.String("space"), cmd.Bool("title-from-h1"), cmd.Bool("title-from-filename"), parents, cmd.Bool("title-append-generated-hash"))
if err != nil {
fatalErrorHandler.Handle(err, "unable to resolve relative links")
return nil
}
markdown = page.SubstituteLinks(markdown, links)
if cmd.Bool("dry-run") {
_, _, err := page.ResolvePage(cmd.Bool("dry-run"), api, meta)
if err != nil {
fatalErrorHandler.Handle(err, "unable to resolve page location")
return nil
}
}
if cmd.Bool("compile-only") || cmd.Bool("dry-run") {
if cmd.Bool("drop-h1") {
log.Info(
"the leading H1 heading will be excluded from the Confluence output",
)
}
imageAlign, err := getImageAlign(cmd, meta)
if err != nil {
fatalErrorHandler.Handle(err, "unable to determine image-align")
return nil
}
cfg := types.MarkConfig{
MermaidScale: cmd.Float("mermaid-scale"),
D2Scale: cmd.Float("d2-scale"),
DropFirstH1: cmd.Bool("drop-h1"),
StripNewlines: cmd.Bool("strip-linebreaks"),
Features: cmd.StringSlice("features"),
ImageAlign: imageAlign,
}
html, _ := mark.CompileMarkdown(markdown, stdlib, file, cfg)
fmt.Println(html)
return nil
}
var target *confluence.PageInfo
if meta != nil {
parent, page, err := page.ResolvePage(cmd.Bool("dry-run"), api, meta)
if err != nil {
fatalErrorHandler.Handle(karma.Describe("title", meta.Title).Reason(err), "unable to resolve %s", meta.Type)
return nil
}
if page == nil {
page, err = api.CreatePage(
meta.Space,
meta.Type,
parent,
meta.Title,
``,
)
if err != nil {
fatalErrorHandler.Handle(err, "can't create %s %q", meta.Type, meta.Title)
return nil
}
// (issues/139): 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.
time.Sleep(1 * time.Second)
}
target = page
} else {
if pageID == "" {
fatalErrorHandler.Handle(nil, "URL should provide 'pageId' GET-parameter")
return nil
}
page, err := api.GetPageByID(pageID)
if err != nil {
fatalErrorHandler.Handle(err, "unable to retrieve page by id")
return nil
}
target = page
}
// Resolve attachments created from <!-- Attachment: --> directive
localAttachments, err := attachment.ResolveLocalAttachments(vfs.LocalOS, filepath.Dir(file), meta.Attachments)
if err != nil {
fatalErrorHandler.Handle(err, "unable to locate attachments")
return nil
}
attaches, err := attachment.ResolveAttachments(
api,
target,
localAttachments,
)
if err != nil {
fatalErrorHandler.Handle(err, "unable to create/update attachments")
return nil
}
markdown = attachment.CompileAttachmentLinks(markdown, attaches)
if cmd.Bool("drop-h1") {
log.Info(
"the leading H1 heading will be excluded from the Confluence output",
)
}
imageAlign, err := getImageAlign(cmd, meta)
if err != nil {
fatalErrorHandler.Handle(err, "unable to determine image-align")
return nil
}
cfg := types.MarkConfig{
MermaidScale: cmd.Float("mermaid-scale"),
D2Scale: cmd.Float("d2-scale"),
DropFirstH1: cmd.Bool("drop-h1"),
StripNewlines: cmd.Bool("strip-linebreaks"),
Features: cmd.StringSlice("features"),
ImageAlign: imageAlign,
}
html, inlineAttachments := mark.CompileMarkdown(markdown, stdlib, file, cfg)
// Resolve attachements detected from markdown
_, err = attachment.ResolveAttachments(
api,
target,
inlineAttachments,
)
if err != nil {
fatalErrorHandler.Handle(err, "unable to create/update attachments")
return nil
}
{
var buffer bytes.Buffer
err := stdlib.Templates.ExecuteTemplate(
&buffer,
"ac:layout",
struct {
Layout string
Sidebar string
Body string
}{
Layout: meta.Layout,
Sidebar: meta.Sidebar,
Body: html,
},
)
if err != nil {
fatalErrorHandler.Handle(err, "unable to execute layout template")
return nil
}
html = buffer.String()
}
var finalVersionMessage string
var shouldUpdatePage = true
if cmd.Bool("changes-only") {
contentHash := getSHA1Hash(html)
log.Debugf(
nil,
"content hash: %s",
contentHash,
)
versionPattern := `\[v([a-f0-9]{40})]$`
re := regexp.MustCompile(versionPattern)
matches := re.FindStringSubmatch(target.Version.Message)
if 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]", cmd.String("version-message"), contentHash)
} else {
finalVersionMessage = cmd.String("version-message")
}
if shouldUpdatePage {
err = api.UpdatePage(target, html, cmd.Bool("minor-edit"), finalVersionMessage, meta.Labels, meta.ContentAppearance, meta.Emoji)
if err != nil {
fatalErrorHandler.Handle(err, "unable to update page")
return nil
}
}
if !updateLabels(api, target, meta, fatalErrorHandler) { // on error updating labels, return nil
return nil
}
if cmd.Bool("edit-lock") {
log.Infof(
nil,
`edit locked on page %q by user %q to prevent manual edits`,
target.Title,
username,
)
err := api.RestrictPageUpdates(target, username)
if err != nil {
fatalErrorHandler.Handle(err, "unable to restrict page updates")
return nil
}
}
return target
}
func updateLabels(api *confluence.API, target *confluence.PageInfo, meta *metadata.Meta, fatalErrorHandler *FatalErrorHandler) bool {
labelInfo, err := api.GetPageLabels(target, "global")
if err != nil {
log.Fatal(err)
}
log.Debug("Page Labels:")
log.Debug(labelInfo.Labels)
log.Debug("Meta Labels:")
log.Debug(meta.Labels)
delLabels := determineLabelsToRemove(labelInfo, meta)
log.Debug("Del Labels:")
log.Debug(delLabels)
addLabels := determineLabelsToAdd(meta, labelInfo)
log.Debug("Add Labels:")
log.Debug(addLabels)
if len(addLabels) > 0 {
_, err = api.AddPageLabels(target, addLabels)
if err != nil {
fatalErrorHandler.Handle(err, "error adding labels")
return false
}
}
for _, label := range delLabels {
_, err = api.DeletePageLabel(target, label)
if err != nil {
fatalErrorHandler.Handle(err, "error deleting labels")
return false
}
}
return true
}
// Page has label but label not in Metadata
func determineLabelsToRemove(labelInfo *confluence.LabelInfo, meta *metadata.Meta) []string {
var labels []string
for _, label := range labelInfo.Labels {
if !slices.ContainsFunc(meta.Labels, func(metaLabel string) bool {
return strings.EqualFold(metaLabel, label.Name)
}) {
labels = append(labels, label.Name)
}
}
return labels
}
// Metadata has label but Page does not have it
func determineLabelsToAdd(meta *metadata.Meta, labelInfo *confluence.LabelInfo) []string {
var labels []string
for _, metaLabel := range meta.Labels {
if !slices.ContainsFunc(labelInfo.Labels, func(label confluence.Label) bool {
return strings.EqualFold(label.Name, metaLabel)
}) {
labels = append(labels, metaLabel)
}
}
return labels
}
func getImageAlign(cmd *cli.Command, meta *metadata.Meta) (string, error) {
// Header comment takes precedence over CLI flag
align := cmd.String("image-align")
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
return mark.Run(config)
}
func ConfigFilePath() string {
@ -535,13 +116,6 @@ func SetLogLevel(cmd *cli.Command) error {
default:
return fmt.Errorf("unknown log level: %s", logLevel)
}
log.GetLevel()
return nil
}
func getSHA1Hash(input string) string {
hash := sha1.New()
hash.Write([]byte(input))
return hex.EncodeToString(hash.Sum(nil))
}

View File

@ -4,6 +4,8 @@ import (
"context"
"testing"
"github.com/reconquest/pkg/log"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v3"
)
@ -74,3 +76,46 @@ func TestContentAppearanceFlagValidation(t *testing.T) {
}
})
}
func Test_setLogLevel(t *testing.T) {
type args struct {
lvl string
}
tests := map[string]struct {
args args
want log.Level
expectedErr string
}{
"invalid": {args: args{lvl: "INVALID"}, want: log.LevelInfo, expectedErr: "unknown log level: INVALID"},
"empty": {args: args{lvl: ""}, want: log.LevelInfo, expectedErr: "unknown log level: "},
"info": {args: args{lvl: log.LevelInfo.String()}, want: log.LevelInfo},
"debug": {args: args{lvl: log.LevelDebug.String()}, want: log.LevelDebug},
"trace": {args: args{lvl: log.LevelTrace.String()}, want: log.LevelTrace},
"warning": {args: args{lvl: log.LevelWarning.String()}, want: log.LevelWarning},
"error": {args: args{lvl: log.LevelError.String()}, want: log.LevelError},
"fatal": {args: args{lvl: log.LevelFatal.String()}, want: log.LevelFatal},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
prev := log.GetLevel()
t.Cleanup(func() { log.SetLevel(prev) })
cmd := &cli.Command{
Name: "test",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "log-level",
Value: tt.args.lvl,
Usage: "set the log level. Possible values: TRACE, DEBUG, INFO, WARNING, ERROR, FATAL.",
},
},
}
err := SetLogLevel(cmd)
if tt.expectedErr != "" {
assert.EqualError(t, err, tt.expectedErr)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.want, log.GetLevel())
}
})
}
}