mirror of
https://github.com/kovetskiy/mark.git
synced 2026-03-18 16:47:44 +08:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17436efd17 | ||
|
|
2b62ffd822 | ||
|
|
807d057f7b | ||
|
|
0d7caab5d8 | ||
|
|
14219aea59 | ||
|
|
7d4d0458ca | ||
|
|
d6b37affd3 | ||
|
|
ed6ae15500 | ||
|
|
0d735203dd | ||
|
|
d714bc9d2b | ||
|
|
cf0699c088 | ||
|
|
b7c9229da4 | ||
|
|
fef39dc1e0 | ||
|
|
4c81c81fb3 | ||
|
|
3e71d65f61 | ||
|
|
5b87945f23 | ||
|
|
566fd74efe | ||
|
|
2a505a24a8 | ||
|
|
594c1e4fa0 | ||
|
|
9184e91268 | ||
|
|
a4ef890247 | ||
|
|
a481424f7b | ||
|
|
32577c91f4 | ||
|
|
9e7f2cf9d5 | ||
|
|
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)
|
@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
|
||||||
|
|||||||
@ -61,7 +61,7 @@ func ResolveAttachments(
|
|||||||
|
|
||||||
remotes, err := api.GetAttachments(page.ID)
|
remotes, err := api.GetAttachments(page.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
return nil, karma.Format(err, "unable to get attachments for page %s", page.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
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", attachments[i].Name)
|
log.Infof(nil, "keeping unmodified attachment: %q", existing[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 _, attachment := range attachments {
|
for i := range attachments {
|
||||||
checksum, err := GetChecksum(bytes.NewReader(attachment.FileBytes))
|
checksum, err := GetChecksum(bytes.NewReader(attachments[i].FileBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, karma.Format(
|
return nil, karma.Format(
|
||||||
err,
|
err,
|
||||||
"unable to get checksum for attachment: %q", attachment.Name,
|
"unable to get checksum for attachment: %q", attachments[i].Name,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
attachment.Checksum = checksum
|
attachments[i].Checksum = checksum
|
||||||
}
|
}
|
||||||
return attachments, err
|
return attachments, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ type virtualOpener struct {
|
|||||||
PathToBuf map[string]*bufferCloser
|
PathToBuf map[string]*bufferCloser
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *virtualOpener) Open(name string) (io.ReadWriteCloser, error) {
|
func (o *virtualOpener) Open(name string) (io.ReadCloser, error) {
|
||||||
if buf, ok := o.PathToBuf[name]; ok {
|
if buf, ok := o.PathToBuf[name]; ok {
|
||||||
return buf, nil
|
return buf, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,6 +108,9 @@ 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{
|
||||||
@ -137,7 +140,7 @@ func NewAPI(baseURL string, username string, password string, insecureSkipVerify
|
|||||||
return &API{
|
return &API{
|
||||||
rest: rest,
|
rest: rest,
|
||||||
json: json,
|
json: json,
|
||||||
BaseURL: strings.TrimSuffix(baseURL, "/"),
|
BaseURL: baseURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,7 +183,7 @@ func (api *API) FindHomePage(space string) (*PageInfo, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.Raw.StatusCode == http.StatusNotFound || request.Raw.StatusCode != http.StatusOK {
|
if request.Raw.StatusCode != http.StatusOK {
|
||||||
return nil, newErrorStatusNotOK(request)
|
return nil, newErrorStatusNotOK(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -437,38 +440,55 @@ 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) {
|
||||||
result := struct {
|
type page 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",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
request, err := api.rest.Res(
|
const pageSize = 100
|
||||||
"content/"+pageID+"/child/attachment", &result,
|
var all []AttachmentInfo
|
||||||
).Get(payload)
|
start := 0
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.Raw.StatusCode != http.StatusOK {
|
for {
|
||||||
return nil, newErrorStatusNotOK(request)
|
var result page
|
||||||
}
|
|
||||||
|
|
||||||
for i, info := range result.Results {
|
payload := map[string]string{
|
||||||
if info.Links.Context == "" {
|
"expand": "version,container",
|
||||||
info.Links.Context = result.Links.Context
|
"limit": fmt.Sprintf("%d", pageSize),
|
||||||
|
"start": fmt.Sprintf("%d", start),
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Results[i] = info
|
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 result.Results, nil
|
return all, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) GetPageByID(pageID string) (*PageInfo, error) {
|
func (api *API) GetPageByID(pageID string) (*PageInfo, error) {
|
||||||
@ -534,7 +554,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, newLabels []string, appearance string, emojiString string) error {
|
func (api *API) UpdatePage(page *PageInfo, newContent string, minorEdit bool, versionMessage string, appearance string, emojiString string) error {
|
||||||
nextPageVersion := page.Version.Number + 1
|
nextPageVersion := page.Version.Number + 1
|
||||||
oldAncestors := []map[string]interface{}{}
|
oldAncestors := []map[string]interface{}{}
|
||||||
|
|
||||||
@ -557,7 +577,10 @@ func (api *API) UpdatePage(page *PageInfo, newContent string, minorEdit bool, ve
|
|||||||
}
|
}
|
||||||
|
|
||||||
if emojiString != "" {
|
if emojiString != "" {
|
||||||
r, _ := utf8.DecodeRuneInString(emojiString)
|
r, size := 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{}{
|
||||||
@ -641,7 +664,11 @@ func (api *API) DeletePageLabel(page *PageInfo, label string) (*LabelInfo, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.Raw.StatusCode != http.StatusOK && request.Raw.StatusCode != http.StatusNoContent {
|
if request.Raw.StatusCode == http.StatusNoContent {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.Raw.StatusCode != http.StatusOK {
|
||||||
return nil, newErrorStatusNotOK(request)
|
return nil, newErrorStatusNotOK(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -649,18 +676,46 @@ 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 {
|
||||||
request, err := api.rest.Res(
|
Links struct {
|
||||||
"content/"+page.ID+"/label", &LabelInfo{},
|
Next string `json:"next"`
|
||||||
).Get(map[string]string{"prefix": prefix})
|
} `json:"_links"`
|
||||||
if err != nil {
|
Labels []Label `json:"results"`
|
||||||
return nil, err
|
Size int `json:"number"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.Raw.StatusCode != http.StatusOK {
|
const pageSize = 50
|
||||||
return nil, newErrorStatusNotOK(request)
|
var all []Label
|
||||||
|
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) {
|
||||||
@ -671,7 +726,7 @@ func (api *API) GetUserByName(name string) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try the new path first
|
// Try the new path first
|
||||||
_, err := api.rest.
|
request, err := api.rest.
|
||||||
Res("search").
|
Res("search").
|
||||||
Res("user", &response).
|
Res("user", &response).
|
||||||
Get(map[string]string{
|
Get(map[string]string{
|
||||||
@ -680,10 +735,13 @@ 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 {
|
||||||
_, err := api.rest.
|
request, 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),
|
||||||
@ -691,6 +749,9 @@ 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 {
|
||||||
@ -708,7 +769,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
|
||||||
|
|
||||||
_, err := api.rest.
|
request, err := api.rest.
|
||||||
Res("user").
|
Res("user").
|
||||||
Res("current", &user).
|
Res("current", &user).
|
||||||
Get()
|
Get()
|
||||||
@ -716,6 +777,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -723,9 +788,16 @@ func (api *API) RestrictPageUpdatesCloud(
|
|||||||
page *PageInfo,
|
page *PageInfo,
|
||||||
allowedUser string,
|
allowedUser string,
|
||||||
) error {
|
) error {
|
||||||
user, err := api.GetCurrentUser()
|
user, err := api.GetUserByName(allowedUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
// Fall back to the currently authenticated user if the specified
|
||||||
|
// 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{}
|
||||||
@ -812,6 +884,10 @@ 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)",
|
||||||
@ -825,9 +901,6 @@ 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, "+
|
||||||
|
|||||||
@ -113,7 +113,7 @@ func ProcessIncludes(
|
|||||||
contents,
|
contents,
|
||||||
func(spec []byte) []byte {
|
func(spec []byte) []byte {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
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 nil
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
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 nil
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
var buffer bytes.Buffer
|
var buffer bytes.Buffer
|
||||||
@ -163,7 +163,7 @@ func ProcessIncludes(
|
|||||||
"unable to execute template",
|
"unable to execute template",
|
||||||
)
|
)
|
||||||
|
|
||||||
return nil
|
return spec
|
||||||
}
|
}
|
||||||
|
|
||||||
recurse = true
|
recurse = true
|
||||||
|
|||||||
@ -47,6 +47,7 @@ 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
|
||||||
@ -60,6 +61,7 @@ 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
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())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
534
mark.go
Normal file
534
mark.go
Normal file
@ -0,0 +1,534 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
@ -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) {
|
func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, cfg types.MarkConfig) (string, []attachment.Attachment, error) {
|
||||||
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 {
|
||||||
panic(err)
|
return "", nil, 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
|
return string(html), confluenceExtension.Attachments, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,5 +181,8 @@ func TestContinueOnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := cmd.Run(context.TODO(), argList)
|
err := cmd.Run(context.TODO(), argList)
|
||||||
assert.NoError(t, err, "App should run without errors when continue-on-error is enabled")
|
// --continue-on-error processes all files even when some fail, but still
|
||||||
|
// 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,10 +63,6 @@ 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)
|
||||||
@ -88,7 +84,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) > 1 {
|
if len(matches) > 2 {
|
||||||
value = strings.TrimSpace(matches[2])
|
value = strings.TrimSpace(matches[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,6 +143,10 @@ 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{}
|
||||||
|
|||||||
16
page/link.go
16
page/link.go
@ -102,6 +102,9 @@ 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/"
|
||||||
@ -110,10 +113,6 @@ 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"),
|
||||||
@ -186,8 +185,13 @@ func SubstituteLinks(markdown []byte, links []LinkSubstitution) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseLinks(markdown string) []markdownLink {
|
func parseLinks(markdown string) []markdownLink {
|
||||||
// Matches links but not inline images
|
// Matches markdown links but not inline images (![ ... ]).
|
||||||
re := regexp.MustCompile(`[^\!]\[.+\]\((([^\)#]+)?#?([^\)]+)?)\)`)
|
// Group 1: full link target (path + optional hash)
|
||||||
|
// 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))
|
||||||
|
|||||||
@ -72,7 +72,7 @@ func ResolvePage(
|
|||||||
log.Warningf(
|
log.Warningf(
|
||||||
nil,
|
nil,
|
||||||
"page %q is not found ",
|
"page %q is not found ",
|
||||||
meta.Parents[len(ancestry)-1],
|
ancestry[len(ancestry)-1],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 lang[start:]
|
return strings.TrimSpace(lang[start:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package renderer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -143,6 +144,9 @@ 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])
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package renderer
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
stdhtml "html"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
parser "github.com/stefanfritsch/goldmark-admonitions"
|
parser "github.com/stefanfritsch/goldmark-admonitions"
|
||||||
@ -18,14 +19,12 @@ var MkDocsAdmonitionAttributeFilter = html.GlobalAttributeFilter
|
|||||||
// nodes as (X)HTML.
|
// nodes as (X)HTML.
|
||||||
type ConfluenceMkDocsAdmonitionRenderer struct {
|
type ConfluenceMkDocsAdmonitionRenderer struct {
|
||||||
html.Config
|
html.Config
|
||||||
LevelMap MkDocsAdmonitionLevelMap
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfluenceRenderer creates a new instance of the ConfluenceRenderer
|
// NewConfluenceMkDocsAdmonitionRenderer 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,12 +48,6 @@ 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 {
|
||||||
@ -75,42 +68,13 @@ func ParseMkDocsAdmonitionType(node ast.Node) MkDocsAdmonitionType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateMkDocsAdmonitionLevel walks a given node and returns a map of blockquote levels
|
// renderMkDocsAdmonition renders an admonition node as a Confluence structured macro.
|
||||||
func GenerateMkDocsAdmonitionLevel(someNode ast.Node) MkDocsAdmonitionLevelMap {
|
// All admonitions (including nested ones) are rendered as Confluence macros.
|
||||||
|
|
||||||
// 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) {
|
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)
|
n := node.(*parser.Admonition)
|
||||||
if r.LevelMap == nil {
|
|
||||||
r.LevelMap = GenerateMkDocsAdmonitionLevel(node)
|
|
||||||
}
|
|
||||||
|
|
||||||
admonitionType := ParseMkDocsAdmonitionType(node)
|
admonitionType := ParseMkDocsAdmonitionType(node)
|
||||||
admonitionLevel := r.LevelMap.Level(node)
|
|
||||||
|
|
||||||
if admonitionLevel == 0 && entering && admonitionType != ANone {
|
if 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
|
||||||
@ -118,7 +82,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", title)
|
titleHTML := fmt.Sprintf("<p><strong>%s</strong></p>\n", stdhtml.EscapeString(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
|
||||||
}
|
}
|
||||||
@ -126,7 +90,7 @@ func (r *ConfluenceMkDocsAdmonitionRenderer) renderMkDocsAdmonition(writer util.
|
|||||||
|
|
||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
if admonitionLevel == 0 && !entering && admonitionType != ANone {
|
if !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
|
||||||
|
|||||||
@ -24,8 +24,9 @@ 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 n.FirstChild().Kind() != ast.KindRawHTML {
|
if firstChild == nil || 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)
|
||||||
@ -35,7 +36,7 @@ func (r *ConfluenceParagraphRenderer) renderParagraph(w util.BufWriter, source [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if n.FirstChild().Kind() != ast.KindRawHTML {
|
if firstChild == nil || firstChild.Kind() != ast.KindRawHTML {
|
||||||
_, _ = w.WriteString("</p>")
|
_, _ = w.WriteString("</p>")
|
||||||
}
|
}
|
||||||
_, _ = w.WriteString("\n")
|
_, _ = w.WriteString("\n")
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package stdlib
|
package stdlib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"html"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
@ -25,10 +26,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +64,9 @@ 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 }}</ac:parameter>`,
|
/**/ `<ac:parameter ac:name="language">{{ .Language | xmlesc }}</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 }}</ac:parameter>{{ end }}`,
|
/**/ `{{ if .Theme }}<ac:parameter ac:name="theme">{{ .Theme | xmlesc }}</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 }}</ac:parameter>{{ end }}`,
|
/**/ `{{ if .Title }}<ac:parameter ac:name="title">{{ .Title | xmlesc }}</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" }}</ac:parameter>`,
|
`<ac:parameter ac:name="colour">{{ or .Color "Grey" | xmlesc }}</ac:parameter>`,
|
||||||
`<ac:parameter ac:name="title">{{ or .Title .Color }}</ac:parameter>`,
|
`<ac:parameter ac:name="title">{{ or .Title .Color | xmlesc }}</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 }}</ac:parameter>{{ end }}`,
|
`{{ if .Title }}<ac:parameter ac:name="title">{{ .Title | xmlesc }}</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>`,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -46,7 +46,7 @@ func GetCredentials(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
password = string(stdin)
|
password = strings.TrimSpace(string(stdin))
|
||||||
}
|
}
|
||||||
|
|
||||||
if compileOnly && targetURL == "" {
|
if compileOnly && targetURL == "" {
|
||||||
|
|||||||
514
util/cli.go
514
util/cli.go
@ -1,31 +1,14 @@
|
|||||||
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"
|
||||||
"github.com/kovetskiy/mark/attachment"
|
mark "github.com/kovetskiy/mark"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
@ -44,26 +27,17 @@ func RunMark(ctx context.Context, cmd *cli.Command) error {
|
|||||||
log.GetLogger().SetOutput(os.Stderr)
|
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 {
|
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()
|
||||||
@ -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"))
|
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"))
|
config := mark.Config{
|
||||||
if err != nil {
|
BaseURL: creds.BaseURL,
|
||||||
fatalErrorHandler.Handle(err, "unable to extract metadata from file %q", file)
|
Username: creds.Username,
|
||||||
return nil
|
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 {
|
return mark.Run(config)
|
||||||
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 {
|
||||||
@ -535,13 +116,6 @@ 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))
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,6 +4,8 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -6,13 +6,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Opener interface {
|
type Opener interface {
|
||||||
Open(name string) (io.ReadWriteCloser, error)
|
Open(name string) (io.ReadCloser, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalOSOpener struct {
|
type LocalOSOpener struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o LocalOSOpener) Open(name string) (io.ReadWriteCloser, error) {
|
func (o LocalOSOpener) Open(name string) (io.ReadCloser, error) {
|
||||||
return os.Open(name)
|
return os.Open(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user