diff --git a/Makefile b/Makefile index 38e679a..42c3178 100644 --- a/Makefile +++ b/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 mark \ + ./cmd/mark test: go test -race -coverprofile=profile.cov ./... -v diff --git a/main.go b/main.go deleted file mode 100644 index 1c4d5e5..0000000 --- a/main.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - - "github.com/kovetskiy/mark/util" - "github.com/reconquest/pkg/log" - "github.com/urfave/cli/v3" -) - -var ( - version = "dev" - commit = "none" -) - -const ( - usage = "A tool for updating Atlassian Confluence pages from markdown." - description = `Mark is a tool to update Atlassian Confluence pages from markdown. Documentation is available here: https://github.com/kovetskiy/mark` -) - -func main() { - cmd := &cli.Command{ - Name: "mark", - Usage: usage, - Description: description, - Version: fmt.Sprintf("%s@%s", version, commit), - Flags: util.Flags, - EnableShellCompletion: true, - HideHelpCommand: true, - Before: util.CheckFlags, - Action: util.RunMark, - } - - if err := cmd.Run(context.TODO(), os.Args); err != nil { - log.Fatal(err) - } -} diff --git a/main_test.go b/main_test.go deleted file mode 100644 index a011396..0000000 --- a/main_test.go +++ /dev/null @@ -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()) - } - }) - } -} diff --git a/util/cli.go b/util/cli.go index 0d3039f..42ab36d 100644 --- a/util/cli.go +++ b/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,44 @@ 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"), } - 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 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 { @@ -539,9 +118,3 @@ func SetLogLevel(cmd *cli.Command) error { return nil } - -func getSHA1Hash(input string) string { - hash := sha1.New() - hash.Write([]byte(input)) - return hex.EncodeToString(hash.Sum(nil)) -} diff --git a/util/cli_test.go b/util/cli_test.go index cf5a8d0..a07b950 100644 --- a/util/cli_test.go +++ b/util/cli_test.go @@ -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,44 @@ 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) { +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()) +} +}) +} +}