Compare commits

..

No commits in common. "master" and "v15.4.0" have entirely different histories.

23 changed files with 659 additions and 814 deletions

View File

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

View File

@ -61,7 +61,7 @@ func ResolveAttachments(
remotes, err := api.GetAttachments(page.ID) remotes, err := api.GetAttachments(page.ID)
if err != nil { if err != nil {
return nil, karma.Format(err, "unable to get attachments for page %s", page.ID) panic(err)
} }
existing := []Attachment{} existing := []Attachment{}
@ -153,7 +153,7 @@ func ResolveAttachments(
} }
for i := range existing { for i := range existing {
log.Infof(nil, "keeping unmodified attachment: %q", existing[i].Name) log.Infof(nil, "keeping unmodified attachment: %q", attachments[i].Name)
} }
attachments = []Attachment{} attachments = []Attachment{}
@ -170,16 +170,16 @@ func ResolveLocalAttachments(opener vfs.Opener, base string, replacements []stri
return nil, err return nil, err
} }
for i := range attachments { for _, attachment := range attachments {
checksum, err := GetChecksum(bytes.NewReader(attachments[i].FileBytes)) checksum, err := GetChecksum(bytes.NewReader(attachment.FileBytes))
if err != nil { if err != nil {
return nil, karma.Format( return nil, karma.Format(
err, err,
"unable to get checksum for attachment: %q", attachments[i].Name, "unable to get checksum for attachment: %q", attachment.Name,
) )
} }
attachments[i].Checksum = checksum attachment.Checksum = checksum
} }
return attachments, err return attachments, err
} }

View File

@ -27,7 +27,7 @@ type virtualOpener struct {
PathToBuf map[string]*bufferCloser PathToBuf map[string]*bufferCloser
} }
func (o *virtualOpener) Open(name string) (io.ReadCloser, error) { func (o *virtualOpener) Open(name string) (io.ReadWriteCloser, error) {
if buf, ok := o.PathToBuf[name]; ok { if buf, ok := o.PathToBuf[name]; ok {
return buf, nil return buf, nil
} }

View File

@ -108,9 +108,6 @@ func NewAPI(baseURL string, username string, password string, insecureSkipVerify
} }
} }
// Normalize baseURL once before building all derived endpoints.
baseURL = strings.TrimSuffix(baseURL, "/")
var httpClient *http.Client var httpClient *http.Client
if insecureSkipVerify { if insecureSkipVerify {
httpClient = &http.Client{ httpClient = &http.Client{
@ -140,7 +137,7 @@ func NewAPI(baseURL string, username string, password string, insecureSkipVerify
return &API{ return &API{
rest: rest, rest: rest,
json: json, json: json,
BaseURL: baseURL, BaseURL: strings.TrimSuffix(baseURL, "/"),
} }
} }
@ -183,7 +180,7 @@ func (api *API) FindHomePage(space string) (*PageInfo, error) {
return nil, err return nil, err
} }
if request.Raw.StatusCode != http.StatusOK { if request.Raw.StatusCode == http.StatusNotFound || request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request) return nil, newErrorStatusNotOK(request)
} }
@ -440,55 +437,38 @@ func getAttachmentPayload(name, comment string, reader io.Reader) (*form, error)
} }
func (api *API) GetAttachments(pageID string) ([]AttachmentInfo, error) { func (api *API) GetAttachments(pageID string) ([]AttachmentInfo, error) {
type page struct { result := struct {
Links struct { Links struct {
Context string `json:"context"` Context string `json:"context"`
Next string `json:"next"`
} `json:"_links"` } `json:"_links"`
Results []AttachmentInfo `json:"results"` Results []AttachmentInfo `json:"results"`
}{}
payload := map[string]string{
"expand": "version,container",
"limit": "1000",
} }
const pageSize = 100 request, err := api.rest.Res(
var all []AttachmentInfo "content/"+pageID+"/child/attachment", &result,
start := 0 ).Get(payload)
if err != nil {
for { return nil, err
var result page
payload := map[string]string{
"expand": "version,container",
"limit": fmt.Sprintf("%d", pageSize),
"start": fmt.Sprintf("%d", start),
}
request, err := api.rest.Res(
"content/"+pageID+"/child/attachment", &result,
).Get(payload)
if err != nil {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
for i, info := range result.Results {
if info.Links.Context == "" {
info.Links.Context = result.Links.Context
}
result.Results[i] = info
}
all = append(all, result.Results...)
if len(result.Results) < pageSize || result.Links.Next == "" {
break
}
start += len(result.Results)
} }
return all, nil if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
for i, info := range result.Results {
if info.Links.Context == "" {
info.Links.Context = result.Links.Context
}
result.Results[i] = info
}
return result.Results, nil
} }
func (api *API) GetPageByID(pageID string) (*PageInfo, error) { func (api *API) GetPageByID(pageID string) (*PageInfo, error) {
@ -554,7 +534,7 @@ func (api *API) CreatePage(
return request.Response.(*PageInfo), nil return request.Response.(*PageInfo), nil
} }
func (api *API) UpdatePage(page *PageInfo, newContent string, minorEdit bool, versionMessage string, appearance string, emojiString string) error { func (api *API) UpdatePage(page *PageInfo, newContent string, minorEdit bool, versionMessage string, newLabels []string, appearance string, emojiString string) error {
nextPageVersion := page.Version.Number + 1 nextPageVersion := page.Version.Number + 1
oldAncestors := []map[string]interface{}{} oldAncestors := []map[string]interface{}{}
@ -577,10 +557,7 @@ func (api *API) UpdatePage(page *PageInfo, newContent string, minorEdit bool, ve
} }
if emojiString != "" { if emojiString != "" {
r, size := utf8.DecodeRuneInString(emojiString) r, _ := utf8.DecodeRuneInString(emojiString)
if r == utf8.RuneError && size <= 1 {
return fmt.Errorf("invalid UTF-8 in emoji: %q", emojiString)
}
unicodeHex := fmt.Sprintf("%x", r) unicodeHex := fmt.Sprintf("%x", r)
properties["emoji-title-draft"] = map[string]interface{}{ properties["emoji-title-draft"] = map[string]interface{}{
@ -664,11 +641,7 @@ func (api *API) DeletePageLabel(page *PageInfo, label string) (*LabelInfo, error
return nil, err return nil, err
} }
if request.Raw.StatusCode == http.StatusNoContent { if request.Raw.StatusCode != http.StatusOK && request.Raw.StatusCode != http.StatusNoContent {
return nil, nil
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request) return nil, newErrorStatusNotOK(request)
} }
@ -676,46 +649,18 @@ func (api *API) DeletePageLabel(page *PageInfo, label string) (*LabelInfo, error
} }
func (api *API) GetPageLabels(page *PageInfo, prefix string) (*LabelInfo, error) { func (api *API) GetPageLabels(page *PageInfo, prefix string) (*LabelInfo, error) {
type labelPage struct {
Links struct { request, err := api.rest.Res(
Next string `json:"next"` "content/"+page.ID+"/label", &LabelInfo{},
} `json:"_links"` ).Get(map[string]string{"prefix": prefix})
Labels []Label `json:"results"` if err != nil {
Size int `json:"number"` return nil, err
} }
const pageSize = 50 if request.Raw.StatusCode != http.StatusOK {
var all []Label return nil, newErrorStatusNotOK(request)
start := 0
for {
var result labelPage
request, err := api.rest.Res(
"content/"+page.ID+"/label", &result,
).Get(map[string]string{
"prefix": prefix,
"limit": fmt.Sprintf("%d", pageSize),
"start": fmt.Sprintf("%d", start),
})
if err != nil {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
all = append(all, result.Labels...)
if len(result.Labels) < pageSize || result.Links.Next == "" {
break
}
start += len(result.Labels)
} }
return request.Response.(*LabelInfo), nil
return &LabelInfo{Labels: all, Size: len(all)}, nil
} }
func (api *API) GetUserByName(name string) (*User, error) { func (api *API) GetUserByName(name string) (*User, error) {
@ -726,7 +671,7 @@ func (api *API) GetUserByName(name string) (*User, error) {
} }
// Try the new path first // Try the new path first
request, err := api.rest. _, err := api.rest.
Res("search"). Res("search").
Res("user", &response). Res("user", &response).
Get(map[string]string{ Get(map[string]string{
@ -735,13 +680,10 @@ func (api *API) GetUserByName(name string) (*User, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
// Try old path // Try old path
if len(response.Results) == 0 { if len(response.Results) == 0 {
request, err := api.rest. _, err := api.rest.
Res("search", &response). Res("search", &response).
Get(map[string]string{ Get(map[string]string{
"cql": fmt.Sprintf("user.fullname~%q", name), "cql": fmt.Sprintf("user.fullname~%q", name),
@ -749,9 +691,6 @@ func (api *API) GetUserByName(name string) (*User, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
} }
if len(response.Results) == 0 { if len(response.Results) == 0 {
@ -769,7 +708,7 @@ func (api *API) GetUserByName(name string) (*User, error) {
func (api *API) GetCurrentUser() (*User, error) { func (api *API) GetCurrentUser() (*User, error) {
var user User var user User
request, err := api.rest. _, err := api.rest.
Res("user"). Res("user").
Res("current", &user). Res("current", &user).
Get() Get()
@ -777,10 +716,6 @@ func (api *API) GetCurrentUser() (*User, error) {
return nil, err return nil, err
} }
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
return &user, nil return &user, nil
} }
@ -788,16 +723,9 @@ func (api *API) RestrictPageUpdatesCloud(
page *PageInfo, page *PageInfo,
allowedUser string, allowedUser string,
) error { ) error {
user, err := api.GetUserByName(allowedUser) user, err := api.GetCurrentUser()
if err != nil { if err != nil {
// Fall back to the currently authenticated user if the specified return err
// user cannot be resolved by name (e.g. on Confluence Cloud where
// only accountId is accepted and name lookup may fail).
currentUser, currentErr := api.GetCurrentUser()
if currentErr != nil {
return fmt.Errorf("unable to resolve user %q: %w", allowedUser, err)
}
user = currentUser
} }
var result interface{} var result interface{}
@ -884,10 +812,6 @@ func (api *API) RestrictPageUpdates(
} }
func newErrorStatusNotOK(request *gopencils.Resource) error { func newErrorStatusNotOK(request *gopencils.Resource) error {
defer func() {
_ = request.Raw.Body.Close()
}()
if request.Raw.StatusCode == http.StatusUnauthorized { if request.Raw.StatusCode == http.StatusUnauthorized {
return errors.New( return errors.New(
"the Confluence API returned unexpected status: 401 (Unauthorized)", "the Confluence API returned unexpected status: 401 (Unauthorized)",
@ -901,6 +825,9 @@ func newErrorStatusNotOK(request *gopencils.Resource) error {
} }
output, _ := io.ReadAll(request.Raw.Body) output, _ := io.ReadAll(request.Raw.Body)
defer func() {
_ = request.Raw.Body.Close()
}()
return fmt.Errorf( return fmt.Errorf(
"the Confluence API returned unexpected status: %v, "+ "the Confluence API returned unexpected status: %v, "+

View File

@ -113,7 +113,7 @@ func ProcessIncludes(
contents, contents,
func(spec []byte) []byte { func(spec []byte) []byte {
if err != nil { if err != nil {
return spec return nil
} }
groups := reIncludeDirective.FindSubmatch(spec) groups := reIncludeDirective.FindSubmatch(spec)
@ -143,7 +143,7 @@ func ProcessIncludes(
"unable to unmarshal template data config", "unable to unmarshal template data config",
) )
return spec return nil
} }
log.Tracef(vardump(facts, data), "including template %q", path) log.Tracef(vardump(facts, data), "including template %q", path)
@ -151,7 +151,7 @@ func ProcessIncludes(
templates, err = LoadTemplate(base, includePath, path, left, right, templates) templates, err = LoadTemplate(base, includePath, path, left, right, templates)
if err != nil { if err != nil {
err = facts.Format(err, "unable to load template") err = facts.Format(err, "unable to load template")
return spec return nil
} }
var buffer bytes.Buffer var buffer bytes.Buffer
@ -163,7 +163,7 @@ func ProcessIncludes(
"unable to execute template", "unable to execute template",
) )
return spec return nil
} }
recurse = true recurse = true

View File

@ -47,7 +47,6 @@ func (macro *Macro) Apply(
err, err,
"unable to unmarshal macros config template", "unable to unmarshal macros config template",
) )
return match
} }
var buffer bytes.Buffer var buffer bytes.Buffer
@ -61,7 +60,6 @@ func (macro *Macro) Apply(
err, err,
"unable to execute macros template", "unable to execute macros template",
) )
return match
} }
return buffer.Bytes() return buffer.Bytes()

51
main_test.go Normal file
View File

@ -0,0 +1,51 @@
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())
}
})
}
}

534
mark.go
View File

@ -1,534 +0,0 @@
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)
}
}
var hasErrors bool
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)
hasErrors = true
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
}
}
}
if hasErrors {
return fmt.Errorf("one or more files failed to process")
}
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, _, err := markmd.CompileMarkdown(markdown, std, file, cfg)
if err != nil {
return nil, fmt.Errorf("unable to compile markdown: %w", err)
}
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)
}
if pg == nil {
return nil, fmt.Errorf("page with id %q not found", config.PageID)
}
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, err := markmd.CompileMarkdown(markdown, std, file, cfg)
if err != nil {
return nil, fmt.Errorf("unable to compile markdown: %w", err)
}
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,
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

@ -90,7 +90,7 @@ func (c *ConfluenceExtension) Extend(m goldmark.Markdown) {
)) ))
} }
func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, cfg types.MarkConfig) (string, []attachment.Attachment, error) { func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, cfg types.MarkConfig) (string, []attachment.Attachment) {
log.Tracef(nil, "rendering markdown:\n%s", string(markdown)) log.Tracef(nil, "rendering markdown:\n%s", string(markdown))
confluenceExtension := NewConfluenceExtension(stdlib, path, cfg) confluenceExtension := NewConfluenceExtension(stdlib, path, cfg)
@ -119,12 +119,12 @@ func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, cfg types
err := converter.Convert(markdown, &buf, parser.WithContext(ctx)) err := converter.Convert(markdown, &buf, parser.WithContext(ctx))
if err != nil { if err != nil {
return "", nil, err panic(err)
} }
html := buf.Bytes() html := buf.Bytes()
log.Tracef(nil, "rendered markdown to html:\n%s", string(html)) log.Tracef(nil, "rendered markdown to html:\n%s", string(html))
return string(html), confluenceExtension.Attachments, nil return string(html), confluenceExtension.Attachments
} }

View File

@ -67,7 +67,7 @@ func TestCompileMarkdown(t *testing.T) {
Features: []string{"mkdocsadmonitions", "mention"}, Features: []string{"mkdocsadmonitions", "mention"},
} }
actual, _, _ := mark.CompileMarkdown(markdown, lib, filename, cfg) actual, _ := mark.CompileMarkdown(markdown, lib, filename, cfg)
test.EqualValues(strings.TrimSuffix(string(html), "\n"), strings.TrimSuffix(actual, "\n"), filename+" vs "+htmlname) test.EqualValues(strings.TrimSuffix(string(html), "\n"), strings.TrimSuffix(actual, "\n"), filename+" vs "+htmlname)
} }
} }
@ -109,7 +109,7 @@ func TestCompileMarkdownDropH1(t *testing.T) {
Features: []string{"mkdocsadmonitions", "mention"}, Features: []string{"mkdocsadmonitions", "mention"},
} }
actual, _, _ := mark.CompileMarkdown(markdown, lib, filename, cfg) actual, _ := mark.CompileMarkdown(markdown, lib, filename, cfg)
test.EqualValues(strings.TrimSuffix(string(html), "\n"), strings.TrimSuffix(actual, "\n"), filename+" vs "+htmlname) test.EqualValues(strings.TrimSuffix(string(html), "\n"), strings.TrimSuffix(actual, "\n"), filename+" vs "+htmlname)
} }
@ -153,7 +153,7 @@ func TestCompileMarkdownStripNewlines(t *testing.T) {
Features: []string{"mkdocsadmonitions", "mention"}, Features: []string{"mkdocsadmonitions", "mention"},
} }
actual, _, _ := mark.CompileMarkdown(markdown, lib, filename, cfg) actual, _ := mark.CompileMarkdown(markdown, lib, filename, cfg)
test.EqualValues(strings.TrimSuffix(string(html), "\n"), strings.TrimSuffix(actual, "\n"), filename+" vs "+htmlname) test.EqualValues(strings.TrimSuffix(string(html), "\n"), strings.TrimSuffix(actual, "\n"), filename+" vs "+htmlname)
} }
@ -181,8 +181,5 @@ func TestContinueOnError(t *testing.T) {
} }
err := cmd.Run(context.TODO(), argList) err := cmd.Run(context.TODO(), argList)
// --continue-on-error processes all files even when some fail, but still assert.NoError(t, err, "App should run without errors when continue-on-error is enabled")
// returns an error to allow callers/CI to detect partial failures.
assert.Error(t, err, "App should report partial failure when continue-on-error is enabled and some files fail")
assert.ErrorContains(t, err, "one or more files failed to process")
} }

View File

@ -63,6 +63,10 @@ func ExtractMeta(data []byte, spaceFromCli string, titleFromH1 bool, titleFromFi
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
if err := scanner.Err(); err != nil {
return nil, nil, err
}
offset += len(line) + 1 offset += len(line) + 1
matches := reHeaderPatternV2.FindStringSubmatch(line) matches := reHeaderPatternV2.FindStringSubmatch(line)
@ -84,7 +88,7 @@ func ExtractMeta(data []byte, spaceFromCli string, titleFromH1 bool, titleFromFi
header := cases.Title(language.English).String(matches[1]) header := cases.Title(language.English).String(matches[1])
var value string var value string
if len(matches) > 2 { if len(matches) > 1 {
value = strings.TrimSpace(matches[2]) value = strings.TrimSpace(matches[2])
} }
@ -143,10 +147,6 @@ func ExtractMeta(data []byte, spaceFromCli string, titleFromH1 bool, titleFromFi
} }
} }
if err := scanner.Err(); err != nil {
return nil, nil, err
}
if titleFromH1 || titleFromFilename || spaceFromCli != "" { if titleFromH1 || titleFromFilename || spaceFromCli != "" {
if meta == nil { if meta == nil {
meta = &Meta{} meta = &Meta{}

View File

@ -102,9 +102,6 @@ func resolveLink(
} }
linkContents, err := os.ReadFile(filepath) linkContents, err := os.ReadFile(filepath)
if err != nil {
return "", karma.Format(err, "read file: %s", filepath)
}
contentType := http.DetectContentType(linkContents) contentType := http.DetectContentType(linkContents)
// Check if the MIME type starts with "text/" // Check if the MIME type starts with "text/"
@ -113,6 +110,10 @@ func resolveLink(
return "", nil return "", nil
} }
if err != nil {
return "", karma.Format(err, "read file: %s", filepath)
}
linkContents = bytes.ReplaceAll( linkContents = bytes.ReplaceAll(
linkContents, linkContents,
[]byte("\r\n"), []byte("\r\n"),
@ -185,13 +186,8 @@ func SubstituteLinks(markdown []byte, links []LinkSubstitution) []byte {
} }
func parseLinks(markdown string) []markdownLink { func parseLinks(markdown string) []markdownLink {
// Matches markdown links but not inline images (![ ... ]). // Matches links but not inline images
// Group 1: full link target (path + optional hash) re := regexp.MustCompile(`[^\!]\[.+\]\((([^\)#]+)?#?([^\)]+)?)\)`)
// Group 2: file path portion
// Group 3: hash portion
// The leading (?:^|[^!]) anchor prevents matching image syntax without
// consuming a character that belongs to a preceding link or word.
re := regexp.MustCompile(`(?:^|[^!])\[.+\]\((([^\)#]+)?#?([^\)]+)?)\)`)
matches := re.FindAllStringSubmatch(markdown, -1) matches := re.FindAllStringSubmatch(markdown, -1)
links := make([]markdownLink, len(matches)) links := make([]markdownLink, len(matches))

View File

@ -72,7 +72,7 @@ func ResolvePage(
log.Warningf( log.Warningf(
nil, nil,
"page %q is not found ", "page %q is not found ",
ancestry[len(ancestry)-1], meta.Parents[len(ancestry)-1],
) )
} }

View File

@ -72,7 +72,7 @@ func ParseTitle(lang string) string {
// it's found, check if title is given and return it // it's found, check if title is given and return it
start := index + 6 start := index + 6
if len(lang) > start { if len(lang) > start {
return strings.TrimSpace(lang[start:]) return lang[start:]
} }
} }
return "" return ""

View File

@ -2,7 +2,6 @@ package renderer
import ( import (
"bytes" "bytes"
"fmt"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@ -144,9 +143,6 @@ func (r *ConfluenceImageRenderer) renderImage(writer util.BufWriter, source []by
}, },
) )
} else { } else {
if len(attachments) == 0 {
return ast.WalkStop, fmt.Errorf("no attachment resolved for %q", string(n.Destination))
}
r.Attachments.Attach(attachments[0]) r.Attachments.Attach(attachments[0])

View File

@ -2,7 +2,6 @@ package renderer
import ( import (
"fmt" "fmt"
stdhtml "html"
"strconv" "strconv"
parser "github.com/stefanfritsch/goldmark-admonitions" parser "github.com/stefanfritsch/goldmark-admonitions"
@ -19,12 +18,14 @@ var MkDocsAdmonitionAttributeFilter = html.GlobalAttributeFilter
// nodes as (X)HTML. // nodes as (X)HTML.
type ConfluenceMkDocsAdmonitionRenderer struct { type ConfluenceMkDocsAdmonitionRenderer struct {
html.Config html.Config
LevelMap MkDocsAdmonitionLevelMap
} }
// NewConfluenceMkDocsAdmonitionRenderer creates a new instance of the ConfluenceRenderer // NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
func NewConfluenceMkDocsAdmonitionRenderer(opts ...html.Option) renderer.NodeRenderer { func NewConfluenceMkDocsAdmonitionRenderer(opts ...html.Option) renderer.NodeRenderer {
return &ConfluenceMkDocsAdmonitionRenderer{ return &ConfluenceMkDocsAdmonitionRenderer{
Config: html.NewConfig(), Config: html.NewConfig(),
LevelMap: nil,
} }
} }
@ -48,6 +49,12 @@ func (t MkDocsAdmonitionType) String() string {
return []string{"info", "note", "warning", "tip", "none"}[t] return []string{"info", "note", "warning", "tip", "none"}[t]
} }
type MkDocsAdmonitionLevelMap map[ast.Node]int
func (m MkDocsAdmonitionLevelMap) Level(node ast.Node) int {
return m[node]
}
func ParseMkDocsAdmonitionType(node ast.Node) MkDocsAdmonitionType { func ParseMkDocsAdmonitionType(node ast.Node) MkDocsAdmonitionType {
n, ok := node.(*parser.Admonition) n, ok := node.(*parser.Admonition)
if !ok { if !ok {
@ -68,13 +75,42 @@ func ParseMkDocsAdmonitionType(node ast.Node) MkDocsAdmonitionType {
} }
} }
// renderMkDocsAdmonition renders an admonition node as a Confluence structured macro. // GenerateMkDocsAdmonitionLevel walks a given node and returns a map of blockquote levels
// All admonitions (including nested ones) are rendered as Confluence macros. func GenerateMkDocsAdmonitionLevel(someNode ast.Node) MkDocsAdmonitionLevelMap {
func (r *ConfluenceMkDocsAdmonitionRenderer) renderMkDocsAdmonition(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*parser.Admonition)
admonitionType := ParseMkDocsAdmonitionType(node)
if entering && admonitionType != ANone { // We define state variable that tracks BlockQuote level while we walk the tree
admonitionLevel := 0
AdmonitionLevelMap := make(map[ast.Node]int)
rootNode := someNode
for rootNode.Parent() != nil {
rootNode = rootNode.Parent()
}
_ = ast.Walk(rootNode, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if node.Kind() == ast.KindBlockquote && entering {
AdmonitionLevelMap[node] = admonitionLevel
admonitionLevel += 1
}
if node.Kind() == ast.KindBlockquote && !entering {
admonitionLevel -= 1
}
return ast.WalkContinue, nil
})
return AdmonitionLevelMap
}
// renderBlockQuote will render a BlockQuote
func (r *ConfluenceMkDocsAdmonitionRenderer) renderMkDocsAdmonition(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
// Initialize BlockQuote level map
n := node.(*parser.Admonition)
if r.LevelMap == nil {
r.LevelMap = GenerateMkDocsAdmonitionLevel(node)
}
admonitionType := ParseMkDocsAdmonitionType(node)
admonitionLevel := r.LevelMap.Level(node)
if admonitionLevel == 0 && entering && admonitionType != ANone {
prefix := fmt.Sprintf("<ac:structured-macro ac:name=\"%s\"><ac:parameter ac:name=\"icon\">true</ac:parameter><ac:rich-text-body>\n", admonitionType) prefix := fmt.Sprintf("<ac:structured-macro ac:name=\"%s\"><ac:parameter ac:name=\"icon\">true</ac:parameter><ac:rich-text-body>\n", admonitionType)
if _, err := writer.Write([]byte(prefix)); err != nil { if _, err := writer.Write([]byte(prefix)); err != nil {
return ast.WalkStop, err return ast.WalkStop, err
@ -82,7 +118,7 @@ func (r *ConfluenceMkDocsAdmonitionRenderer) renderMkDocsAdmonition(writer util.
title, _ := strconv.Unquote(string(n.Title)) title, _ := strconv.Unquote(string(n.Title))
if title != "" { if title != "" {
titleHTML := fmt.Sprintf("<p><strong>%s</strong></p>\n", stdhtml.EscapeString(title)) titleHTML := fmt.Sprintf("<p><strong>%s</strong></p>\n", title)
if _, err := writer.Write([]byte(titleHTML)); err != nil { if _, err := writer.Write([]byte(titleHTML)); err != nil {
return ast.WalkStop, err return ast.WalkStop, err
} }
@ -90,7 +126,7 @@ func (r *ConfluenceMkDocsAdmonitionRenderer) renderMkDocsAdmonition(writer util.
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
if !entering && admonitionType != ANone { if admonitionLevel == 0 && !entering && admonitionType != ANone {
suffix := "</ac:rich-text-body></ac:structured-macro>\n" suffix := "</ac:rich-text-body></ac:structured-macro>\n"
if _, err := writer.Write([]byte(suffix)); err != nil { if _, err := writer.Write([]byte(suffix)); err != nil {
return ast.WalkStop, err return ast.WalkStop, err

View File

@ -24,9 +24,8 @@ func (r *ConfluenceParagraphRenderer) RegisterFuncs(reg renderer.NodeRendererFun
} }
func (r *ConfluenceParagraphRenderer) renderParagraph(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { func (r *ConfluenceParagraphRenderer) renderParagraph(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
firstChild := n.FirstChild()
if entering { if entering {
if firstChild == nil || firstChild.Kind() != ast.KindRawHTML { if n.FirstChild().Kind() != ast.KindRawHTML {
if n.Attributes() != nil { if n.Attributes() != nil {
_, _ = w.WriteString("<p") _, _ = w.WriteString("<p")
html.RenderAttributes(w, n, html.ParagraphAttributeFilter) html.RenderAttributes(w, n, html.ParagraphAttributeFilter)
@ -36,7 +35,7 @@ func (r *ConfluenceParagraphRenderer) renderParagraph(w util.BufWriter, source [
} }
} }
} else { } else {
if firstChild == nil || firstChild.Kind() != ast.KindRawHTML { if n.FirstChild().Kind() != ast.KindRawHTML {
_, _ = w.WriteString("</p>") _, _ = w.WriteString("</p>")
} }
_, _ = w.WriteString("\n") _, _ = w.WriteString("\n")

View File

@ -1,7 +1,6 @@
package stdlib package stdlib
import ( import (
"html"
"strings" "strings"
"text/template" "text/template"
@ -26,6 +25,10 @@ func New(api *confluence.API) (*Lib, error) {
return nil, err return nil, err
} }
if err != nil {
return nil, err
}
return &lib, nil return &lib, nil
} }
@ -64,9 +67,6 @@ func templates(api *confluence.API) (*template.Template, error) {
"_", "_",
) )
}, },
"xmlesc": func(s string) string {
return html.EscapeString(s)
},
}, },
) )
@ -90,20 +90,20 @@ func templates(api *confluence.API) (*template.Template, error) {
// This template is used for rendering code in ``` // This template is used for rendering code in ```
`ac:code`: text( `ac:code`: text(
`<ac:structured-macro ac:name="code">`, `<ac:structured-macro ac:name="code">`,
/**/ `<ac:parameter ac:name="language">{{ .Language | xmlesc }}</ac:parameter>`, /**/ `<ac:parameter ac:name="language">{{ .Language }}</ac:parameter>`,
/**/ `<ac:parameter ac:name="collapse">{{ .Collapse }}</ac:parameter>`, /**/ `<ac:parameter ac:name="collapse">{{ .Collapse }}</ac:parameter>`,
/**/ `{{ if .Theme }}<ac:parameter ac:name="theme">{{ .Theme | xmlesc }}</ac:parameter>{{ end }}`, /**/ `{{ if .Theme }}<ac:parameter ac:name="theme">{{ .Theme }}</ac:parameter>{{ end }}`,
/**/ `{{ if .Linenumbers }}<ac:parameter ac:name="linenumbers">{{ .Linenumbers }}</ac:parameter>{{ end }}`, /**/ `{{ if .Linenumbers }}<ac:parameter ac:name="linenumbers">{{ .Linenumbers }}</ac:parameter>{{ end }}`,
/**/ `{{ if .Firstline }}<ac:parameter ac:name="firstline">{{ .Firstline }}</ac:parameter>{{ end }}`, /**/ `{{ if .Firstline }}<ac:parameter ac:name="firstline">{{ .Firstline }}</ac:parameter>{{ end }}`,
/**/ `{{ if .Title }}<ac:parameter ac:name="title">{{ .Title | xmlesc }}</ac:parameter>{{ end }}`, /**/ `{{ if .Title }}<ac:parameter ac:name="title">{{ .Title }}</ac:parameter>{{ end }}`,
/**/ `<ac:plain-text-body><![CDATA[{{ .Text | cdata }}]]></ac:plain-text-body>`, /**/ `<ac:plain-text-body><![CDATA[{{ .Text | cdata }}]]></ac:plain-text-body>`,
`</ac:structured-macro>`, `</ac:structured-macro>`,
), ),
`ac:status`: text( `ac:status`: text(
`<ac:structured-macro ac:name="status">`, `<ac:structured-macro ac:name="status">`,
`<ac:parameter ac:name="colour">{{ or .Color "Grey" | xmlesc }}</ac:parameter>`, `<ac:parameter ac:name="colour">{{ or .Color "Grey" }}</ac:parameter>`,
`<ac:parameter ac:name="title">{{ or .Title .Color | xmlesc }}</ac:parameter>`, `<ac:parameter ac:name="title">{{ or .Title .Color }}</ac:parameter>`,
`<ac:parameter ac:name="subtle">{{ or .Subtle false }}</ac:parameter>`, `<ac:parameter ac:name="subtle">{{ or .Subtle false }}</ac:parameter>`,
`</ac:structured-macro>`, `</ac:structured-macro>`,
), ),
@ -161,7 +161,7 @@ func templates(api *confluence.API) (*template.Template, error) {
`ac:box`: text( `ac:box`: text(
`<ac:structured-macro ac:name="{{ .Name }}">`, `<ac:structured-macro ac:name="{{ .Name }}">`,
`<ac:parameter ac:name="icon">{{ or .Icon "false" }}</ac:parameter>`, `<ac:parameter ac:name="icon">{{ or .Icon "false" }}</ac:parameter>`,
`{{ if .Title }}<ac:parameter ac:name="title">{{ .Title | xmlesc }}</ac:parameter>{{ end }}`, `{{ if .Title }}<ac:parameter ac:name="title">{{ .Title }}</ac:parameter>{{ end }}`,
`<ac:rich-text-body>{{ .Body }}</ac:rich-text-body>`, `<ac:rich-text-body>{{ .Body }}</ac:rich-text-body>`,
`</ac:structured-macro>`, `</ac:structured-macro>`,
), ),

View File

@ -46,7 +46,7 @@ func GetCredentials(
) )
} }
password = strings.TrimSpace(string(stdin)) password = string(stdin)
} }
if compileOnly && targetURL == "" { if compileOnly && targetURL == "" {

View File

@ -1,14 +1,31 @@
package util package util
import ( import (
"bytes"
"context" "context"
"crypto/sha1"
"encoding/hex"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"slices"
"strings" "strings"
"time"
"github.com/bmatcuk/doublestar/v4"
"github.com/kovetskiy/lorg" "github.com/kovetskiy/lorg"
mark "github.com/kovetskiy/mark" "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"
"github.com/reconquest/pkg/log" "github.com/reconquest/pkg/log"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@ -27,17 +44,26 @@ func RunMark(ctx context.Context, cmd *cli.Command) error {
log.GetLogger().SetOutput(os.Stderr) log.GetLogger().SetOutput(os.Stderr)
} }
creds, err := GetCredentials( creds, err := GetCredentials(cmd.String("username"), cmd.String("password"), cmd.String("target-url"), cmd.String("base-url"), cmd.Bool("compile-only"))
cmd.String("username"),
cmd.String("password"),
cmd.String("target-url"),
cmd.String("base-url"),
cmd.Bool("compile-only"),
)
if err != nil { if err != nil {
return err 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:") log.Debug("config:")
for _, f := range cmd.Flags { for _, f := range cmd.Flags {
flag := f.Names() flag := f.Names()
@ -48,46 +74,439 @@ func RunMark(ctx context.Context, cmd *cli.Command) error {
} }
} }
parents := strings.Split(cmd.String("parents"), cmd.String("parents-delimiter")) fatalErrorHandler := NewErrorHandler(cmd.Bool("continue-on-error"))
config := mark.Config{ // Loop through files matched by glob pattern
BaseURL: creds.BaseURL, for _, file := range files {
Username: creds.Username, log.Infof(
Password: creds.Password, nil,
PageID: creds.PageID, "processing %s",
InsecureSkipTLSVerify: cmd.Bool("insecure-skip-tls-verify"), file,
)
Files: cmd.String("files"), target := processFile(file, api, cmd, creds.PageID, creds.Username, fatalErrorHandler)
CompileOnly: cmd.Bool("compile-only"), if target != nil { // on dry-run or compile-only, the target is nil
DryRun: cmd.Bool("dry-run"), log.Infof(
ContinueOnError: cmd.Bool("continue-on-error"), nil,
CI: cmd.Bool("ci"), "page successfully updated: %s",
creds.BaseURL+target.Links.Full,
)
fmt.Println(creds.BaseURL + target.Links.Full)
}
}
return nil
}
Space: cmd.String("space"), func processFile(
Parents: parents, file string,
TitleFromH1: cmd.Bool("title-from-h1"), api *confluence.API,
TitleFromFilename: cmd.Bool("title-from-filename"), cmd *cli.Command,
TitleAppendGeneratedHash: cmd.Bool("title-append-generated-hash"), pageID string,
ContentAppearance: cmd.String("content-appearance"), username string,
fatalErrorHandler *FatalErrorHandler,
MinorEdit: cmd.Bool("minor-edit"), ) *confluence.PageInfo {
VersionMessage: cmd.String("version-message"), markdown, err := os.ReadFile(file)
EditLock: cmd.Bool("edit-lock"), if err != nil {
ChangesOnly: cmd.Bool("changes-only"), fatalErrorHandler.Handle(err, "unable to read file %q", file)
return nil
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,
} }
return mark.Run(config) 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
}
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
} }
func ConfigFilePath() string { func ConfigFilePath() string {
@ -116,6 +535,13 @@ func SetLogLevel(cmd *cli.Command) error {
default: default:
return fmt.Errorf("unknown log level: %s", logLevel) return fmt.Errorf("unknown log level: %s", logLevel)
} }
log.GetLevel()
return nil return nil
} }
func getSHA1Hash(input string) string {
hash := sha1.New()
hash.Write([]byte(input))
return hex.EncodeToString(hash.Sum(nil))
}

View File

@ -4,8 +4,6 @@ import (
"context" "context"
"testing" "testing"
"github.com/reconquest/pkg/log"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
@ -76,46 +74,3 @@ 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())
}
})
}
}

View File

@ -6,13 +6,13 @@ import (
) )
type Opener interface { type Opener interface {
Open(name string) (io.ReadCloser, error) Open(name string) (io.ReadWriteCloser, error)
} }
type LocalOSOpener struct { type LocalOSOpener struct {
} }
func (o LocalOSOpener) Open(name string) (io.ReadCloser, error) { func (o LocalOSOpener) Open(name string) (io.ReadWriteCloser, error) {
return os.Open(name) return os.Open(name)
} }