mirror of
https://github.com/kovetskiy/mark.git
synced 2026-03-16 07:17:36 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8205794e7b | ||
|
|
664a99cd00 | ||
|
|
5e1d40d910 | ||
|
|
d68f8c3bb3 | ||
|
|
1fc553f102 | ||
|
|
aa16e7ae26 | ||
|
|
66120b937e | ||
|
|
4e3b90c03c | ||
|
|
06c3afef25 | ||
|
|
e68a9f64ff | ||
|
|
9e4c4bde30 |
4
Makefile
4
Makefile
@ -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
|
||||
|
||||
51
main_test.go
51
main_test.go
@ -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
520
mark.go
Normal 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))
|
||||
}
|
||||
514
util/cli.go
514
util/cli.go
@ -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))
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user