Compare commits

...

35 Commits

Author SHA1 Message Date
Manuel Rüger
17436efd17 renderer: HTML-escape admonition title and guard against empty attachments
mkDocsAdmonition: escape the admonition title with html.EscapeString
before inserting it into the Confluence storage format XML. An unescaped
title containing '<', '>', '&', or '"' would break the XML structure.

image: add a len(attachments)==0 guard before accessing attachments[0]
in the local-attachment code path. ResolveLocalAttachments always returns
either an error or the requested attachments, so this is currently
unreachable, but the explicit check prevents a future silent panic if the
function's behaviour changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
2b62ffd822 confluence: fix NewAPI double-slash and DeletePageLabel missing status check
NewAPI: normalize baseURL by trimming the trailing slash before building
rest and json-rpc endpoints. Previously the TrimSuffix only applied to
api.BaseURL but rest/json URLs were already constructed with the raw
(potentially trailing-slash) baseURL, producing double slashes like
'http://example.com//rest/api'.

DeletePageLabel: add a non-200/non-204 status check before the type
assertion. Without this guard any error response (400, 403, 500) would
fall through to request.Response.(*LabelInfo) and either panic or return
garbage data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
807d057f7b stdlib: remove duplicate err check in New() and add XML escaping to user-controlled template values
Remove the dead second 'if err != nil' block after the already-checked
lib.Templates assignment.

Add html.EscapeString as 'xmlesc' template function and apply it to
user-controlled string parameters in ac:code, ac:status, and ac:box
templates. Values like .Title, .Color, .Language, and .Theme can contain
XML special characters (<, >, &, ") when supplied by users, which would
break Confluence storage format XML structure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
0d7caab5d8 fix: close response body on all paths in newErrorStatusNotOK
The 401 and 404 early-return paths returned without closing the HTTP
response body, leaking the underlying connection. Move the
defer body.Close() to the top of the function so it runs regardless
of which code path is taken.

fix: add HTTP status check to GetCurrentUser

GetCurrentUser did not validate the HTTP response status code. A
401/403/500 response was silently ignored and returned a zero-value
User pointer, causing callers (e.g. RestrictPageUpdatesCloud fallback)
to use an empty accountId.

fix: return nil on HTTP 204 from DeletePageLabel instead of panicking

DeletePageLabel accepted both 200 OK and 204 No Content as success, but
then unconditionally did request.Response.(*LabelInfo). On a 204 the
response body is empty so request.Response is nil; the type assertion
panics. Return (nil, nil) for 204 responses.

fix: paginate GetPageLabels to handle pages with >50 labels

A single request with the default page size silently truncated label
lists longer than the API default (~50). Add a pagination loop
matching the pattern used by GetAttachments.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
14219aea59 fix: return error from Run() when ContinueOnError files fail
When --continue-on-error was set and one or more files failed to
process, Run() logged each failure but returned nil, making it
impossible for callers or CI systems to detect partial failures.

Track whether any file failed with a hasErrors flag and return a
descriptive error after all files have been attempted.

Update TestContinueOnError to reflect the corrected behaviour: the
test now asserts that an error IS returned (partial failure is
surfaced) while still verifying that all files in the batch are
attempted (not just the first one).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
7d4d0458ca fix: narrow vfs.Opener interface from ReadWriteCloser to ReadCloser
All callers only read from the opened file (io.ReadAll + Close). Using
io.ReadWriteCloser in the interface was misleading and violated the
principle of interface segregation. os.Open returns a read-only file
that satisfies io.ReadCloser but not the write contract implied by
io.ReadWriteCloser. Narrow both the interface and the implementation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
d6b37affd3 fix: use index-based loop in ResolveLocalAttachments to persist checksums
The range loop 'for _, attachment := range attachments' copied each
element by value. Assigning attachment.Checksum inside the loop only
modified the local copy; the original slice element was never updated.
All returned attachments had empty Checksum fields, causing every
attachment to be treated as changed on every run (the checksum
comparison would never match).

Switch to an index-based loop so the checksum is written directly to
the slice element.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
ed6ae15500 fix: add HTTP status checks to GetUserByName; remove redundant FindHomePage check
GetUserByName made two REST requests without checking the HTTP status
codes. A 401/403/500 response would silently be treated as an empty
result set and return 'user not found' instead of the real error.
Add a status check after each request.

FindHomePage had 'StatusNotFound || != StatusOK' — the first clause
is always a subset of the second, making it dead code. Simplified to
just '!= StatusOK'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
0d735203dd fix: trim whitespace from ParseTitle result
ParseTitle returned lang[start:] without trimming, so inputs like
'python title  My Title' returned '  My Title' with leading spaces.
The extra whitespace propagated into the rendered Confluence title
element. Add strings.TrimSpace to remove leading/trailing whitespace.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
d714bc9d2b fix: move scanner.Err() check after loop and fix matches bounds check
Two bugs in ExtractMeta:

1. scanner.Err() was checked inside the Scan() loop. According to the
   bufio.Scanner docs, Err() returns nil during a successful scan; it
   only reflects an error after Scan() returns false. Moving the check
   after the loop ensures real I/O errors are surfaced instead of
   silently ignored.

2. The len(matches) guard was 'len(matches) > 1' but the code
   accessed matches[2] (requires len >= 3). reHeaderPatternV2 always
   produces a 3-element slice when it matches, but the condition was
   misleading and could panic if the regex were ever changed to make
   the second capture group optional. Changed to 'len(matches) > 2'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
cf0699c088 fix: remove dead level-map infrastructure in mkDocsAdmonitionRenderer
GenerateMkDocsAdmonitionLevel walked the AST looking for
ast.KindBlockquote nodes to build a nesting-level map, but the
renderer is registered for parser.KindAdmonition nodes. Because
admonition nodes were never added to the map, LevelMap.Level()
always returned 0 for every admonition, making the level check
in renderMkDocsAdmonition a no-op.

The intended behaviour (all admonitions rendered as Confluence
structured macros regardless of nesting) was accidentally working
because of this bug. Remove the dead MkDocsAdmonitionLevelMap type,
GenerateMkDocsAdmonitionLevel function, and LevelMap field, and
simplify renderMkDocsAdmonition to directly render the Confluence
macro for all known admonition types.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
b7c9229da4 fix: RestrictPageUpdatesCloud now resolves allowedUser by name
The allowedUser parameter was completely ignored; the function always
restricted edits to the currently authenticated API user via
GetCurrentUser(). Resolve the specified user via GetUserByName first
and fall back to the current user only if that lookup fails, matching
the behaviour of RestrictPageUpdatesServer which uses the parameter
directly.

fix: paginate GetAttachments to handle pages with >100 attachments

The previous implementation fetched a single page of up to 1000
attachments. Pages with more than 1000 attachments would silently
miss some, causing attachment sync to skip or re-upload them.
Replace with a pagination loop (100 per page) that follows the
_links.next cursor until all attachments are retrieved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
fef39dc1e0 fix: preserve include directive in output on error in ProcessIncludes
All error paths in the ReplaceAllFunc callback returned nil, which
ReplaceAllFunc treats as an empty replacement, silently erasing the
guard (if err != nil) fired, every subsequent include in the file
was also erased. Return spec (the original directive bytes) instead
so failed includes are preserved and subsequent ones are not lost.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
4c81c81fb3 fix: return original match on error in macro Apply()
Two bugs in the ReplaceAllFunc callback:
1. After yaml.Unmarshal failure, execution continued into Execute(),
   which could succeed and overwrite err with nil, silently swallowing
   the unmarshal error and producing output with default (empty) config.
2. After any error, the callback returned buffer.Bytes() (empty or
   partial) instead of the original match, corrupting the document.

Return the original match bytes unchanged on either error so the
directive is preserved in output and the error is not lost.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
3e71d65f61 fix: remove unused newLabels parameter from UpdatePage
The newLabels parameter was accepted but never used in the function
body; labels are synced through the separate updateLabels/AddPageLabels
/DeletePageLabel calls. The dead parameter misled callers into thinking
labels were being set during the page update.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
5b87945f23 fix: use ancestry slice for warning log, not meta.Parents
ancestry grows to len(meta.Parents)+1. The subsequent warning log
indexed meta.Parents[len(ancestry)-1], which is one past the end of
meta.Parents, causing an out-of-bounds panic. Use ancestry[len(ancestry)-1]
to always reference the last element of the actual slice being validated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
566fd74efe fix: validate emoji rune from utf8.DecodeRuneInString
DecodeRuneInString returns utf8.RuneError for invalid UTF-8, which was
silently converted to the hex string "fffd" and sent to Confluence.
Return an error instead so the caller gets a clear diagnostic rather
than storing a replacement character as the page emoji.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
2a505a24a8 fix: parseLinks regex misses links at position 0 of string
The previous pattern [^\!]\[...\] required exactly one non-! character
before the opening bracket, so a markdown link at the very start of
anchors to start-of-string or a non-! character without consuming
a required preceding character.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
594c1e4fa0 fix: guard FirstChild() nil in paragraph renderer
A paragraph node with no children causes FirstChild() to return nil,
making both the entering and leaving Kind() checks panic. Cache the
result once and treat nil the same as a non-RawHTML child (emit <p>).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
9184e91268 fix: defer body.Close() before ReadAll to ensure it runs on read error
The defer was placed after io.ReadAll, so if ReadAll returned an
error the body would not be closed. Move the defer before the read.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
a4ef890247 fix: trim whitespace from stdin password
When password is read from stdin (e.g. echo password | mark ...),
the trailing newline was included in the password string, causing
authentication to fail. Use strings.TrimSpace to strip it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
a481424f7b fix: return error instead of panic from CompileMarkdown
Markdown conversion failures called panic(err), crashing the process
rather than allowing graceful error handling. Change the return type
to (string, []attachment.Attachment, error) and propagate the error.
Update all callers (mark.go, markdown_test.go) accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
32577c91f4 fix: return error instead of panic in ResolveAttachments; fix wrong slice index in log
Two issues in attachment handling:
1. GetAttachments failure called panic(err) instead of returning an
   error, crashing the process on any API failure.
2. The 'keeping unmodified' log loop indexed into the original
   attachments slice using the range of existing, causing wrong names
   to be logged and a potential out-of-bounds panic when existing is
   longer than attachments.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00
Manuel Rüger
9e7f2cf9d5 fix: check os.ReadFile error before using file contents
http.DetectContentType was called with potentially nil/empty contents
when ReadFile failed; the error was only checked afterward. Move the
error check immediately after ReadFile so bad data is never used.

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

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

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

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

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

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

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 01:05:32 +01:00
Manuel Rüger
06c3afef25 fix: skip label sync when metadata is absent
When PageID mode is used (meta == nil), labels is nil and calling
updateLabels unconditionally treats that as an empty desired set,
silently removing all existing global labels from the page. Guard
the call so label syncing only runs when metadata is present.

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

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

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

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

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

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

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ type virtualOpener struct {
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 {
return buf, nil
}

View File

@ -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
if insecureSkipVerify {
httpClient = &http.Client{
@ -137,7 +140,7 @@ func NewAPI(baseURL string, username string, password string, insecureSkipVerify
return &API{
rest: rest,
json: json,
BaseURL: strings.TrimSuffix(baseURL, "/"),
BaseURL: baseURL,
}
}
@ -180,7 +183,7 @@ func (api *API) FindHomePage(space string) (*PageInfo, error) {
return nil, err
}
if request.Raw.StatusCode == http.StatusNotFound || request.Raw.StatusCode != http.StatusOK {
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
@ -437,16 +440,25 @@ func getAttachmentPayload(name, comment string, reader io.Reader) (*form, error)
}
func (api *API) GetAttachments(pageID string) ([]AttachmentInfo, error) {
result := struct {
type page struct {
Links struct {
Context string `json:"context"`
Next string `json:"next"`
} `json:"_links"`
Results []AttachmentInfo `json:"results"`
}{}
}
const pageSize = 100
var all []AttachmentInfo
start := 0
for {
var result page
payload := map[string]string{
"expand": "version,container",
"limit": "1000",
"limit": fmt.Sprintf("%d", pageSize),
"start": fmt.Sprintf("%d", start),
}
request, err := api.rest.Res(
@ -464,11 +476,19 @@ func (api *API) GetAttachments(pageID string) ([]AttachmentInfo, error) {
if info.Links.Context == "" {
info.Links.Context = result.Links.Context
}
result.Results[i] = info
}
return result.Results, nil
all = append(all, result.Results...)
if len(result.Results) < pageSize || result.Links.Next == "" {
break
}
start += len(result.Results)
}
return all, nil
}
func (api *API) GetPageByID(pageID string) (*PageInfo, error) {
@ -534,7 +554,7 @@ func (api *API) CreatePage(
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
oldAncestors := []map[string]interface{}{}
@ -557,7 +577,10 @@ func (api *API) UpdatePage(page *PageInfo, newContent string, minorEdit bool, ve
}
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)
properties["emoji-title-draft"] = map[string]interface{}{
@ -641,7 +664,11 @@ func (api *API) DeletePageLabel(page *PageInfo, label string) (*LabelInfo, error
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)
}
@ -649,10 +676,28 @@ func (api *API) DeletePageLabel(page *PageInfo, label string) (*LabelInfo, error
}
func (api *API) GetPageLabels(page *PageInfo, prefix string) (*LabelInfo, error) {
type labelPage struct {
Links struct {
Next string `json:"next"`
} `json:"_links"`
Labels []Label `json:"results"`
Size int `json:"number"`
}
const pageSize = 50
var all []Label
start := 0
for {
var result labelPage
request, err := api.rest.Res(
"content/"+page.ID+"/label", &LabelInfo{},
).Get(map[string]string{"prefix": prefix})
"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
}
@ -660,7 +705,17 @@ func (api *API) GetPageLabels(page *PageInfo, prefix string) (*LabelInfo, error)
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
return request.Response.(*LabelInfo), nil
all = append(all, result.Labels...)
if len(result.Labels) < pageSize || result.Links.Next == "" {
break
}
start += len(result.Labels)
}
return &LabelInfo{Labels: all, Size: len(all)}, nil
}
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
_, err := api.rest.
request, err := api.rest.
Res("search").
Res("user", &response).
Get(map[string]string{
@ -680,10 +735,13 @@ func (api *API) GetUserByName(name string) (*User, error) {
if err != nil {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
// Try old path
if len(response.Results) == 0 {
_, err := api.rest.
request, err := api.rest.
Res("search", &response).
Get(map[string]string{
"cql": fmt.Sprintf("user.fullname~%q", name),
@ -691,6 +749,9 @@ func (api *API) GetUserByName(name string) (*User, error) {
if err != nil {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
}
if len(response.Results) == 0 {
@ -708,7 +769,7 @@ func (api *API) GetUserByName(name string) (*User, error) {
func (api *API) GetCurrentUser() (*User, error) {
var user User
_, err := api.rest.
request, err := api.rest.
Res("user").
Res("current", &user).
Get()
@ -716,6 +777,10 @@ func (api *API) GetCurrentUser() (*User, error) {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
return &user, nil
}
@ -723,9 +788,16 @@ func (api *API) RestrictPageUpdatesCloud(
page *PageInfo,
allowedUser string,
) error {
user, err := api.GetCurrentUser()
user, err := api.GetUserByName(allowedUser)
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{}
@ -812,6 +884,10 @@ func (api *API) RestrictPageUpdates(
}
func newErrorStatusNotOK(request *gopencils.Resource) error {
defer func() {
_ = request.Raw.Body.Close()
}()
if request.Raw.StatusCode == http.StatusUnauthorized {
return errors.New(
"the Confluence API returned unexpected status: 401 (Unauthorized)",
@ -825,9 +901,6 @@ func newErrorStatusNotOK(request *gopencils.Resource) error {
}
output, _ := io.ReadAll(request.Raw.Body)
defer func() {
_ = request.Raw.Body.Close()
}()
return fmt.Errorf(
"the Confluence API returned unexpected status: %v, "+

View File

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

View File

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

View File

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

534
mark.go Normal file
View 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))
}

View File

@ -90,7 +90,7 @@ func (c *ConfluenceExtension) Extend(m goldmark.Markdown) {
))
}
func CompileMarkdown(markdown []byte, stdlib *stdlib.Lib, path string, cfg types.MarkConfig) (string, []attachment.Attachment) {
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))
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))
if err != nil {
panic(err)
return "", nil, err
}
html := buf.Bytes()
log.Tracef(nil, "rendered markdown to html:\n%s", string(html))
return string(html), confluenceExtension.Attachments
return string(html), confluenceExtension.Attachments, nil
}

View File

@ -67,7 +67,7 @@ func TestCompileMarkdown(t *testing.T) {
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)
}
}
@ -109,7 +109,7 @@ func TestCompileMarkdownDropH1(t *testing.T) {
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)
}
@ -153,7 +153,7 @@ func TestCompileMarkdownStripNewlines(t *testing.T) {
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)
}
@ -181,5 +181,8 @@ func TestContinueOnError(t *testing.T) {
}
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")
}

View File

@ -63,10 +63,6 @@ func ExtractMeta(data []byte, spaceFromCli string, titleFromH1 bool, titleFromFi
for scanner.Scan() {
line := scanner.Text()
if err := scanner.Err(); err != nil {
return nil, nil, err
}
offset += len(line) + 1
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])
var value string
if len(matches) > 1 {
if len(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 meta == nil {
meta = &Meta{}

View File

@ -102,6 +102,9 @@ func resolveLink(
}
linkContents, err := os.ReadFile(filepath)
if err != nil {
return "", karma.Format(err, "read file: %s", filepath)
}
contentType := http.DetectContentType(linkContents)
// Check if the MIME type starts with "text/"
@ -110,10 +113,6 @@ func resolveLink(
return "", nil
}
if err != nil {
return "", karma.Format(err, "read file: %s", filepath)
}
linkContents = bytes.ReplaceAll(
linkContents,
[]byte("\r\n"),
@ -186,8 +185,13 @@ func SubstituteLinks(markdown []byte, links []LinkSubstitution) []byte {
}
func parseLinks(markdown string) []markdownLink {
// Matches links but not inline images
re := regexp.MustCompile(`[^\!]\[.+\]\((([^\)#]+)?#?([^\)]+)?)\)`)
// Matches markdown links but not inline images (![ ... ]).
// 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)
links := make([]markdownLink, len(matches))

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ package renderer
import (
"fmt"
stdhtml "html"
"strconv"
parser "github.com/stefanfritsch/goldmark-admonitions"
@ -18,14 +19,12 @@ var MkDocsAdmonitionAttributeFilter = html.GlobalAttributeFilter
// nodes as (X)HTML.
type ConfluenceMkDocsAdmonitionRenderer struct {
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 {
return &ConfluenceMkDocsAdmonitionRenderer{
Config: html.NewConfig(),
LevelMap: nil,
}
}
@ -49,12 +48,6 @@ func (t MkDocsAdmonitionType) String() string {
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 {
n, ok := node.(*parser.Admonition)
if !ok {
@ -75,42 +68,13 @@ func ParseMkDocsAdmonitionType(node ast.Node) MkDocsAdmonitionType {
}
}
// GenerateMkDocsAdmonitionLevel walks a given node and returns a map of blockquote levels
func GenerateMkDocsAdmonitionLevel(someNode ast.Node) MkDocsAdmonitionLevelMap {
// 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
// renderMkDocsAdmonition renders an admonition node as a Confluence structured macro.
// All admonitions (including nested ones) are rendered as Confluence macros.
func (r *ConfluenceMkDocsAdmonitionRenderer) renderMkDocsAdmonition(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
// Initialize BlockQuote level map
n := node.(*parser.Admonition)
if r.LevelMap == nil {
r.LevelMap = GenerateMkDocsAdmonitionLevel(node)
}
admonitionType := ParseMkDocsAdmonitionType(node)
admonitionLevel := r.LevelMap.Level(node)
if admonitionLevel == 0 && entering && admonitionType != ANone {
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)
if _, err := writer.Write([]byte(prefix)); err != nil {
return ast.WalkStop, err
@ -118,7 +82,7 @@ func (r *ConfluenceMkDocsAdmonitionRenderer) renderMkDocsAdmonition(writer util.
title, _ := strconv.Unquote(string(n.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 {
return ast.WalkStop, err
}
@ -126,7 +90,7 @@ func (r *ConfluenceMkDocsAdmonitionRenderer) renderMkDocsAdmonition(writer util.
return ast.WalkContinue, nil
}
if admonitionLevel == 0 && !entering && admonitionType != ANone {
if !entering && admonitionType != ANone {
suffix := "</ac:rich-text-body></ac:structured-macro>\n"
if _, err := writer.Write([]byte(suffix)); err != nil {
return ast.WalkStop, err

View File

@ -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) {
firstChild := n.FirstChild()
if entering {
if n.FirstChild().Kind() != ast.KindRawHTML {
if firstChild == nil || firstChild.Kind() != ast.KindRawHTML {
if n.Attributes() != nil {
_, _ = w.WriteString("<p")
html.RenderAttributes(w, n, html.ParagraphAttributeFilter)
@ -35,7 +36,7 @@ func (r *ConfluenceParagraphRenderer) renderParagraph(w util.BufWriter, source [
}
}
} else {
if n.FirstChild().Kind() != ast.KindRawHTML {
if firstChild == nil || firstChild.Kind() != ast.KindRawHTML {
_, _ = w.WriteString("</p>")
}
_, _ = w.WriteString("\n")

View File

@ -1,6 +1,7 @@
package stdlib
import (
"html"
"strings"
"text/template"
@ -25,10 +26,6 @@ func New(api *confluence.API) (*Lib, error) {
return nil, err
}
if err != nil {
return nil, err
}
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 ```
`ac:code`: text(
`<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>`,
/**/ `{{ 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 .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:structured-macro>`,
),
`ac:status`: text(
`<ac:structured-macro ac:name="status">`,
`<ac:parameter ac:name="colour">{{ or .Color "Grey" }}</ac:parameter>`,
`<ac:parameter ac:name="title">{{ or .Title .Color }}</ac:parameter>`,
`<ac:parameter ac:name="colour">{{ or .Color "Grey" | xmlesc }}</ac:parameter>`,
`<ac:parameter ac:name="title">{{ or .Title .Color | xmlesc }}</ac:parameter>`,
`<ac:parameter ac:name="subtle">{{ or .Subtle false }}</ac:parameter>`,
`</ac:structured-macro>`,
),
@ -161,7 +161,7 @@ func templates(api *confluence.API) (*template.Template, error) {
`ac:box`: text(
`<ac:structured-macro ac:name="{{ .Name }}">`,
`<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:structured-macro>`,
),

View File

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

View File

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

View File

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

View File

@ -6,13 +6,13 @@ import (
)
type Opener interface {
Open(name string) (io.ReadWriteCloser, error)
Open(name string) (io.ReadCloser, error)
}
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)
}