mirror of
https://github.com/kovetskiy/mark.git
synced 2026-04-27 09:42:33 +00:00
This is based on guoweis-work PR https://github.com/kovetskiy/mark/pull/145 * feat(confluence): add support for fetching page body and inline comments * feat(cmd): add --preserve-comments flag to preserve inline comments * feat(mark): implement context-aware inline comment preservation * test(mark): add tests for context-aware MergeComments logic * fix: remove empty else branch in MergeComments to fix SA9003 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * perf: compile markerRegex once as package-level variable Avoids recompiling the inline comment marker regex on every call to MergeComments, which matters for pages with many comment markers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: guard against nil comments pointer in MergeComments Prevents a panic when GetInlineComments returns nil (e.g. on pages where the inline comments feature is not enabled). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add edge-case tests for MergeComments; fix overlapping replacement Four new test cases: - SelectionMissing: comment dropped gracefully when text is gone from new body - OverlappingSelections: overlapping comments no longer corrupt the body; the later match (by position) wins and the earlier overlapping one is dropped - NilComments: nil pointer returns new body unchanged - HTMLEntities: <, >, ' selections match correctly Also fixes the overlapping replacement bug: apply back-to-front and skip any replacement whose end exceeds the start of an already-applied one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: escape ref attribute value in inline comment marker XML Use html.EscapeString on r.ref before interpolating it into the ac:ref attribute to prevent malformed XML if the value ever contains quotes or other special characters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use first occurrence when no context is available in MergeComments Without context the old code left distance=0 for every match and updated bestStart on each iteration, so the final result depended on whichever occurrence was visited last (non-deterministic with respect to the search order). Restructure the loop to break immediately on the first match when hasCtx is false, making the behaviour explicit and deterministic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: log warning when overlapping inline comment marker is dropped Previously the overlap was silently skipped. Now a zerolog Warn message is emitted with the ref, the conflicting byte offsets, and the ref of the already-placed marker, so users can see which comment was lost rather than silently getting incomplete output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: warn when inline comments are silently dropped in MergeComments Three cases now emit a zerolog Warn instead of silently discarding: 1. Comment location != "inline": logs ref and actual location. 2. Selected text not found in new body: logs ref and selection text. 3. Overlapping replacement (existing): adds selection text to the already-present overlap warning for easier diagnosis. Also adds a selection field to the replacement struct so the overlap warning can report the dropped text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: update markerRegex to match markers with nested tags Replace ([^<]*) with (?s)(.*?) so the pattern: - Matches marker content that contains nested inline tags (e.g. <strong>) - Matches across newlines ((?s) / DOTALL mode) The old character class [^<]* stopped at the first < inside the marker body, causing the context-extraction step to miss any comment whose original selection spanned formatted text. Add TestMergeComments_NestedTags to cover this path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: guard against empty OriginalSelection in MergeComments strings.Index(s, "") always returns 0, so an empty escapedSelection would spin the search loop indefinitely (or panic when currentPos advances past len(newBody)). Skip comments with an empty selection early, emit a Warn log, and add TestMergeComments_EmptySelection to cover the path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: paginate GetInlineComments to avoid silently truncating results The Confluence child/comment endpoint is paginated. The previous single-request implementation silently dropped any comments beyond the server's default page size. Changes: - Add Links (context, next) to InlineComments struct so the _links field from each page response is decoded. - Rewrite GetInlineComments to loop with limit/start parameters (pageSize=100), accumulating all results, following the same pattern used by GetAttachments and label fetching. - Add TestMergeComments_DuplicateMarkerRef to cover the deduplication guard added in the previous commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix UTF-8 safety, API compat, log verbosity - levenshteinDistance: convert to []rune before empty-string checks so rune counts (not byte counts) are returned for strings with multi-byte characters - Add contextBefore/contextAfter helpers that use utf8.RuneStart to avoid slicing in the middle of a multi-byte UTF-8 sequence when extracting 100-char context windows from oldBody and newBody - Add truncateSelection helper (50 runes + ellipsis) and apply it in all Warn log messages that include the selected text, preventing large or sensitive page content from appearing in logs - Downgrade non-inline comment log from Warn to Debug with message 'comment ignored during inline marker merge: not an inline comment'; page-level comments are not inline markers and are not 'lost' - Restore original one-argument GetPageByID (expand='ancestors,version') and add GetPageByIDExpanded for the one caller that needs a custom expand value, preserving backward compatibility for API consumers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address new PR review comments - Remove custom min() function: shadows the Go 1.21+ built-in min for the entire package; the built-in handles the 3-arg call in levenshteinDistance identically - Validate rune boundaries on strings.Index candidates: skip any match where start or end falls in the middle of a multi-byte UTF-8 rune to prevent corrupt UTF-8 output - Defer preserve-comments API calls until after shouldUpdatePage is determined: avoids unnecessary GetPageByIDExpanded + GetInlineComments round-trips on no-op --changes-only runs - Capitalize Usage string for --preserve-comments flag (util/flags.go) and matching README.md entry to match sentence case of surrounding flags - Run gofmt on util/cli.go to fix struct literal field alignment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document --preserve-comments feature in README Add a dedicated 'Preserving Inline Comments' section under Tricks with: - Usage examples (CLI flag and env var) - Step-by-step explanation of the Levenshtein-based relocation algorithm - Limitations (deleted text, overlapping selections, new pages, changes-only interaction) Also add a cross-reference NOTE near the --preserve-comments flag entry in the Usage section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: fix markdownlint errors in README - Change unordered list markers from dashes to asterisks (MD004) - Remove extra blank line before Issues section (MD012) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Extract named types for InlineComments; optimize Levenshtein search - Introduce InlineCommentProperties, InlineCommentExtensions, and InlineCommentResult named types in confluence/api.go, replacing the anonymous nested struct in InlineComments.Results. Callers and tests can now construct/inspect comment objects without repeating the JSON shape. - Simplify makeComments helper in mark_test.go to use the new named types directly, eliminating the verbose anonymous struct literal. - Add two Levenshtein candidate-search optimisations in MergeComments: * Exact-context fast path: if both the before and after windows match exactly, take that occurrence immediately without computing distance. * Lower-bound pruning: skip the full O(m*n) Levenshtein computation for a candidate when the absolute difference in window lengths alone already meets or exceeds the current best distance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use stable sort with ref tie-breaker; fix README overlap description - Replace slices.SortFunc with slices.SortStableFunc for the replacements slice, adding ref as a lexicographic tie-breaker when two markers resolve to the same start offset. This makes overlap resolution fully deterministic across runs. - Correct the README limitation note: the *earlier* overlapping match (lower byte offset) is what gets dropped; the later one (higher byte offset, applied first in the back-to-front pass) is kept. The previous wording said 'the second one is dropped' which was ambiguous and inaccurate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix rune-based lower-bound pruning; clarify test comment - Use utf8.RuneCountInString instead of len() for the Levenshtein lower-bound pruning computation. The levenshteinDistance function operates on rune slices, so byte-length differences can exceed the true rune-length difference for multibyte UTF-8 content, causing valid candidates to be incorrectly skipped. - Update TestMergeComments_SelectionMissing comment to say the comment is 'dropped with a warning' rather than 'silently dropped', matching the actual behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add missing unit tests for helpers and MergeComments scenarios Helper function tests: - TestTruncateSelection: short/exact/long strings and multibyte runes - TestLevenshteinDistance: empty strings, identical, insertions, deletions, substitutions, 'kitten/sitting', and a multibyte UTF-8 case to exercise rune-based counting - TestContextBefore / TestContextAfter: basic windowing, window larger than string, and a case where the raw byte offset lands mid-rune (é) to verify the rune-boundary correction logic MergeComments scenario tests: - TestMergeComments_MultipleComments: two non-overlapping comments both correctly applied via back-to-front replacement - TestMergeComments_EmptyResults: non-nil InlineComments with zero results returns body unchanged - TestMergeComments_NonInlineLocation: page-level comments (location != 'inline') are skipped; body unchanged - TestMergeComments_NoContext: when a ref has no marker in oldBody the first occurrence of the selection in newBody is used - TestMergeComments_UTF8: multibyte (Japanese) characters in both body and selection are handled correctly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix three correctness issues in MergeComments - Fix html import shadowing: alias the 'html' import as 'stdhtml' to avoid shadowing by the local 'html' variable used throughout ProcessFile. Both callers updated: stdhtml.EscapeString for the ref attribute, htmlEscapeText for the selection search. - Fix selection search with quotes/apostrophes: replace html.EscapeString for the selection with a new htmlEscapeText helper that only escapes &, <, > — not ' or ". Confluence storage HTML often leaves quotes and apostrophes unescaped in text nodes, so fully-escaped selections would fail to match and inline comments would be silently dropped. Add TestMergeComments_SelectionWithQuotes. - Fix duplicate-ref warnings: move seenRefs[ref]=true to immediately after the duplicate-check, before the search loop. Previously seenRefs was only set on a successful match, so multiple results for the same MarkerRef with no match in the new body would each emit a 'dropped' warning. Add TestMergeComments_DuplicateMarkerRefDropped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Optimize levenshteinDistance to use two rolling rows instead of full matrix Reduces memory allocation from O(m×n) to O(n) by keeping only the previous and current rows. Also swaps r1/r2 so the shorter string is used for column width, minimizing row allocation size. This matters in MergeComments where levenshteinDistance is called for every candidate match of every comment's selection in newBody — on pages with many comments or short/common selections the number of calls can be high. Addresses thread [40] from PR review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix test description and README algorithm doc mark_test.go (thread [43]): - TestMergeComments_HTMLEntities: the description incorrectly claimed ' (apostrophe) was tested; the selection '<world>' contains no apostrophe. Updated comment to accurately describe what is covered (</> entity matching) and note the ' limitation. - Add TestMergeComments_ApostropheSelection: verifies a selection with a literal apostrophe is found when the new body also has a literal apostrophe (the common case from mark's renderer). This exercises the htmlEscapeText path which intentionally does not encode ' or ". README.md (thread [42]): - Step 2 of the algorithm description said context was recorded 'immediately before and after the commented selection' which is ambiguous. Clarified that context windows are taken around the <ac:inline-comment-marker> tag boundaries in the old body (not around the raw selection text), so the context is stable even when the marker wraps additional inline markup such as <strong>. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Unexport mergeComments and cap candidate evaluation Thread [44]: MergeComments was exported but is internal-only — only called within the mark package and tested from the same package. Unexport it to mergeComments to avoid expanding the public API surface unnecessarily. Add a Go doc comment describing the function contract, HTML expectations, and the candidate cap. Thread [45]: The candidate-scoring loop had no upper bound. For short or common selections (e.g. 'a', 'the') on large pages the loop could invoke levenshteinDistance thousands of times, each allocating rune and int slices. Add a maxCandidates=100 constant and break once that many on-rune-boundary occurrences have been evaluated. The exact-context fast-path and lower-bound pruning already skip many candidates before Levenshtein is called, so in practice the cap is only reached for very common selections where the 100th candidate is unlikely to be meaningfully better than an earlier one anyway. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: fix HTMLEntities description and add ApostropheEncoded limitation test Thread #43: TestMergeComments_HTMLEntities had a misleading note claiming it covered the ' apostrophe case, but the selection under test ('<world>') did not include an apostrophe. Remove that note and add a dedicated TestMergeComments_ApostropheEncoded test that explicitly documents the known limitation: when a Confluence body stores an apostrophe as the numeric entity ', mergeComments cannot locate the selection (htmlEscapeText does not encode ' to '), so the comment is dropped with a warning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CDATA selection fallback and extract contextWindowBytes constant Thread #46: mergeComments only searched for htmlEscapeText(selection) and would fail for selections inside CDATA-backed macro bodies (e.g. ac:code), where < and > are stored as raw characters rather than HTML entities. Restructure the search loop to build a searchForms slice: the escaped form is tried first (covers normal XML text nodes), and the raw unescaped form is appended as a fallback when they differ. A stopSearch flag exits early on an exact context match or when maxCandidates is reached, preserving the same performance guarantees as before. Add TestMergeComments_CDATASelection to cover this path. Thread #47: The context-window size 100 was repeated in four places across mergeComments (two in the context-extraction loop and two in the scoring loop). Extract it to const contextWindowBytes = 100 so it is easy to tune and stays consistent everywhere. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
947 lines
20 KiB
Go
947 lines
20 KiB
Go
package confluence
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/kovetskiy/gopencils"
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type User struct {
|
|
AccountID string `json:"accountId,omitempty"`
|
|
UserKey string `json:"userKey,omitempty"`
|
|
}
|
|
|
|
type API struct {
|
|
rest *gopencils.Resource
|
|
|
|
// it's deprecated accordingly to Atlassian documentation,
|
|
// but it's only way to set permissions
|
|
json *gopencils.Resource
|
|
BaseURL string
|
|
}
|
|
|
|
type SpaceInfo struct {
|
|
ID int `json:"id"`
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
|
|
Homepage PageInfo `json:"homepage"`
|
|
|
|
Links struct {
|
|
Full string `json:"webui"`
|
|
} `json:"_links"`
|
|
}
|
|
|
|
type PageInfo struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Type string `json:"type"`
|
|
|
|
Version struct {
|
|
Number int64 `json:"number"`
|
|
Message string `json:"message"`
|
|
} `json:"version"`
|
|
|
|
Ancestors []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"ancestors"`
|
|
|
|
Body struct {
|
|
Storage struct {
|
|
Value string `json:"value"`
|
|
} `json:"storage"`
|
|
} `json:"body"`
|
|
|
|
Links struct {
|
|
Full string `json:"webui"`
|
|
Base string `json:"-"` // Not from JSON; populated from response _links.base
|
|
} `json:"_links"`
|
|
}
|
|
|
|
type AttachmentInfo struct {
|
|
Filename string `json:"title"`
|
|
ID string `json:"id"`
|
|
Metadata struct {
|
|
Comment string `json:"comment"`
|
|
} `json:"metadata"`
|
|
Links struct {
|
|
Context string `json:"context"`
|
|
Download string `json:"download"`
|
|
} `json:"_links"`
|
|
}
|
|
|
|
type Label struct {
|
|
ID string `json:"id"`
|
|
Prefix string `json:"prefix"`
|
|
Name string `json:"name"`
|
|
}
|
|
type LabelInfo struct {
|
|
Labels []Label `json:"results"`
|
|
Size int `json:"number"`
|
|
}
|
|
|
|
type InlineCommentProperties struct {
|
|
OriginalSelection string `json:"originalSelection"`
|
|
MarkerRef string `json:"markerRef"`
|
|
}
|
|
|
|
type InlineCommentExtensions struct {
|
|
Location string `json:"location"`
|
|
InlineProperties InlineCommentProperties `json:"inlineProperties"`
|
|
}
|
|
|
|
type InlineCommentResult struct {
|
|
Extensions InlineCommentExtensions `json:"extensions"`
|
|
}
|
|
|
|
type InlineComments struct {
|
|
Links struct {
|
|
Context string `json:"context"`
|
|
Next string `json:"next"`
|
|
} `json:"_links"`
|
|
Results []InlineCommentResult `json:"results"`
|
|
}
|
|
|
|
type form struct {
|
|
buffer io.Reader
|
|
writer *multipart.Writer
|
|
}
|
|
|
|
type tracer struct {
|
|
prefix string
|
|
}
|
|
|
|
func (tracer *tracer) Printf(format string, args ...any) {
|
|
log.Trace().Msgf(tracer.prefix+" "+format, args...)
|
|
}
|
|
|
|
func NewAPI(baseURL string, username string, password string, insecureSkipVerify bool) *API {
|
|
var auth *gopencils.BasicAuth
|
|
if username != "" {
|
|
auth = &gopencils.BasicAuth{
|
|
Username: username,
|
|
Password: password,
|
|
}
|
|
}
|
|
|
|
// Normalize baseURL once before building all derived endpoints.
|
|
baseURL = strings.TrimSuffix(baseURL, "/")
|
|
|
|
var httpClient *http.Client
|
|
if insecureSkipVerify {
|
|
httpClient = &http.Client{
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
rest := gopencils.Api(baseURL+"/rest/api", auth, httpClient, 3) // set option for 3 retries on failure
|
|
if username == "" {
|
|
if rest.Headers == nil {
|
|
rest.Headers = http.Header{}
|
|
}
|
|
rest.SetHeader("Authorization", fmt.Sprintf("Bearer %s", password))
|
|
}
|
|
|
|
json := gopencils.Api(baseURL+"/rpc/json-rpc/confluenceservice-v2", auth, httpClient, 3)
|
|
|
|
if zerolog.GlobalLevel() == zerolog.TraceLevel {
|
|
rest.Logger = &tracer{"rest:"}
|
|
json.Logger = &tracer{"json-rpc:"}
|
|
}
|
|
|
|
return &API{
|
|
rest: rest,
|
|
json: json,
|
|
BaseURL: baseURL,
|
|
}
|
|
}
|
|
|
|
func (api *API) FindRootPage(space string) (*PageInfo, error) {
|
|
page, err := api.FindPage(space, ``, "page")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("can't obtain first page from space %q: %w", space, err)
|
|
}
|
|
|
|
if page == nil {
|
|
return nil, errors.New("no such space")
|
|
}
|
|
|
|
if len(page.Ancestors) == 0 {
|
|
return &PageInfo{
|
|
ID: page.ID,
|
|
Title: page.Title,
|
|
}, nil
|
|
}
|
|
|
|
return &PageInfo{
|
|
ID: page.Ancestors[0].ID,
|
|
Title: page.Ancestors[0].Title,
|
|
}, nil
|
|
}
|
|
|
|
func (api *API) FindHomePage(space string) (*PageInfo, error) {
|
|
payload := map[string]string{
|
|
"expand": "homepage",
|
|
}
|
|
|
|
request, err := api.rest.Res(
|
|
"space/"+space, &SpaceInfo{},
|
|
).Get(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return nil, newErrorStatusNotOK(request)
|
|
}
|
|
|
|
return &request.Response.(*SpaceInfo).Homepage, nil
|
|
}
|
|
|
|
func (api *API) FindPage(
|
|
space string,
|
|
title string,
|
|
pageType string,
|
|
) (*PageInfo, error) {
|
|
result := struct {
|
|
Results []PageInfo `json:"results"`
|
|
Links struct {
|
|
Base string `json:"base"`
|
|
} `json:"_links"`
|
|
}{}
|
|
|
|
payload := map[string]string{
|
|
"spaceKey": space,
|
|
"expand": "ancestors,version",
|
|
"type": pageType,
|
|
}
|
|
|
|
if title != "" {
|
|
payload["title"] = title
|
|
}
|
|
|
|
request, err := api.rest.Res(
|
|
"content/", &result,
|
|
).Get(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// allow 404 because it's fine if page is not found,
|
|
// the function will return nil, nil
|
|
if request.Raw.StatusCode != http.StatusNotFound && request.Raw.StatusCode != http.StatusOK {
|
|
return nil, newErrorStatusNotOK(request)
|
|
}
|
|
|
|
if len(result.Results) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
page := &result.Results[0]
|
|
// Populate the base URL from the response _links.base
|
|
if result.Links.Base != "" {
|
|
page.Links.Base = result.Links.Base
|
|
}
|
|
|
|
return page, nil
|
|
}
|
|
|
|
func (api *API) CreateAttachment(
|
|
pageID string,
|
|
name string,
|
|
comment string,
|
|
reader io.Reader,
|
|
) (AttachmentInfo, error) {
|
|
var info AttachmentInfo
|
|
|
|
form, err := getAttachmentPayload(name, comment, reader)
|
|
if err != nil {
|
|
return AttachmentInfo{}, err
|
|
}
|
|
|
|
var result struct {
|
|
Links struct {
|
|
Context string `json:"context"`
|
|
} `json:"_links"`
|
|
Results []AttachmentInfo `json:"results"`
|
|
}
|
|
|
|
resource := api.rest.Res(
|
|
"content/"+pageID+"/child/attachment", &result,
|
|
)
|
|
|
|
resource.Payload = form.buffer
|
|
oldHeaders := resource.Headers.Clone()
|
|
resource.Headers = http.Header{}
|
|
if resource.Api.BasicAuth == nil {
|
|
resource.Headers.Set("Authorization", oldHeaders.Get("Authorization"))
|
|
}
|
|
|
|
resource.SetHeader("Content-Type", form.writer.FormDataContentType())
|
|
resource.SetHeader("X-Atlassian-Token", "no-check")
|
|
|
|
request, err := resource.Post()
|
|
if err != nil {
|
|
return info, err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return info, newErrorStatusNotOK(request)
|
|
}
|
|
|
|
if len(result.Results) == 0 {
|
|
return info, errors.New(
|
|
"the Confluence REST API for creating attachments returned " +
|
|
"0 json objects, expected at least 1",
|
|
)
|
|
}
|
|
|
|
for i, info := range result.Results {
|
|
if info.Links.Context == "" {
|
|
info.Links.Context = result.Links.Context
|
|
}
|
|
|
|
result.Results[i] = info
|
|
}
|
|
|
|
info = result.Results[0]
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// UpdateAttachment uploads a new version of the same attachment if the
|
|
// checksums differs from the previous one.
|
|
// It also handles a case where Confluence returns sort of "short" variant of
|
|
// the response instead of an extended one.
|
|
func (api *API) UpdateAttachment(
|
|
pageID string,
|
|
attachID string,
|
|
name string,
|
|
comment string,
|
|
reader io.Reader,
|
|
) (AttachmentInfo, error) {
|
|
var info AttachmentInfo
|
|
|
|
form, err := getAttachmentPayload(name, comment, reader)
|
|
if err != nil {
|
|
return AttachmentInfo{}, err
|
|
}
|
|
|
|
var extendedResponse struct {
|
|
Links struct {
|
|
Context string `json:"context"`
|
|
} `json:"_links"`
|
|
Results []AttachmentInfo `json:"results"`
|
|
}
|
|
|
|
var result json.RawMessage
|
|
|
|
resource := api.rest.Res(
|
|
"content/"+pageID+"/child/attachment/"+attachID+"/data", &result,
|
|
)
|
|
|
|
resource.Payload = form.buffer
|
|
oldHeaders := resource.Headers.Clone()
|
|
resource.Headers = http.Header{}
|
|
if resource.Api.BasicAuth == nil {
|
|
resource.Headers.Set("Authorization", oldHeaders.Get("Authorization"))
|
|
}
|
|
|
|
resource.SetHeader("Content-Type", form.writer.FormDataContentType())
|
|
resource.SetHeader("X-Atlassian-Token", "no-check")
|
|
|
|
request, err := resource.Post()
|
|
if err != nil {
|
|
return info, err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return info, newErrorStatusNotOK(request)
|
|
}
|
|
|
|
err = json.Unmarshal(result, &extendedResponse)
|
|
if err != nil {
|
|
return info, fmt.Errorf("unable to unmarshal JSON response as full response format (JSON=%s): %w", string(result), err)
|
|
}
|
|
|
|
if len(extendedResponse.Results) > 0 {
|
|
for i, info := range extendedResponse.Results {
|
|
if info.Links.Context == "" {
|
|
info.Links.Context = extendedResponse.Links.Context
|
|
}
|
|
|
|
extendedResponse.Results[i] = info
|
|
}
|
|
|
|
info = extendedResponse.Results[0]
|
|
|
|
return info, nil
|
|
}
|
|
|
|
var shortResponse AttachmentInfo
|
|
err = json.Unmarshal(result, &shortResponse)
|
|
if err != nil {
|
|
return info, fmt.Errorf("unable to unmarshal JSON response as short response format (JSON=%s): %w", string(result), err)
|
|
}
|
|
|
|
return shortResponse, nil
|
|
}
|
|
|
|
func getAttachmentPayload(name, comment string, reader io.Reader) (*form, error) {
|
|
var (
|
|
payload = bytes.NewBuffer(nil)
|
|
writer = multipart.NewWriter(payload)
|
|
)
|
|
|
|
content, err := writer.CreateFormFile("file", name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create form file: %w", err)
|
|
}
|
|
|
|
_, err = io.Copy(content, reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to copy i/o between form-file and file: %w", err)
|
|
}
|
|
|
|
commentWriter, err := writer.CreateFormField("comment")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to create form field for comment: %w", err)
|
|
}
|
|
|
|
_, err = commentWriter.Write([]byte(comment))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to write comment in form-field: %w", err)
|
|
}
|
|
|
|
err = writer.Close()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to close form-writer: %w", err)
|
|
}
|
|
|
|
return &form{
|
|
buffer: payload,
|
|
writer: writer,
|
|
}, nil
|
|
}
|
|
|
|
func (api *API) GetAttachments(pageID string) ([]AttachmentInfo, error) {
|
|
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": fmt.Sprintf("%d", pageSize),
|
|
"start": fmt.Sprintf("%d", start),
|
|
}
|
|
|
|
request, err := api.rest.Res(
|
|
"content/"+pageID+"/child/attachment", &result,
|
|
).Get(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return nil, newErrorStatusNotOK(request)
|
|
}
|
|
|
|
for i, info := range result.Results {
|
|
if info.Links.Context == "" {
|
|
info.Links.Context = result.Links.Context
|
|
}
|
|
result.Results[i] = info
|
|
}
|
|
|
|
all = append(all, result.Results...)
|
|
|
|
if len(result.Results) < pageSize || result.Links.Next == "" {
|
|
break
|
|
}
|
|
|
|
start += len(result.Results)
|
|
}
|
|
|
|
return all, nil
|
|
}
|
|
|
|
func (api *API) GetPageByID(pageID string) (*PageInfo, error) {
|
|
return api.GetPageByIDExpanded(pageID, "ancestors,version")
|
|
}
|
|
|
|
func (api *API) GetPageByIDExpanded(pageID string, expand string) (*PageInfo, error) {
|
|
request, err := api.rest.Res(
|
|
"content/"+pageID, &PageInfo{},
|
|
).Get(map[string]string{"expand": expand})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return nil, newErrorStatusNotOK(request)
|
|
}
|
|
|
|
return request.Response.(*PageInfo), nil
|
|
}
|
|
|
|
func (api *API) GetInlineComments(pageID string) (*InlineComments, error) {
|
|
const pageSize = 100
|
|
all := &InlineComments{}
|
|
start := 0
|
|
|
|
for {
|
|
result := &InlineComments{}
|
|
request, err := api.rest.Res(
|
|
"content/"+pageID+"/child/comment", result,
|
|
).Get(map[string]string{
|
|
"expand": "extensions.inlineProperties",
|
|
"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)
|
|
}
|
|
|
|
if all.Links.Context == "" {
|
|
all.Links = result.Links
|
|
}
|
|
|
|
all.Results = append(all.Results, result.Results...)
|
|
|
|
if len(result.Results) < pageSize || result.Links.Next == "" {
|
|
break
|
|
}
|
|
|
|
start += len(result.Results)
|
|
}
|
|
|
|
return all, nil
|
|
}
|
|
|
|
func (api *API) CreatePage(
|
|
space string,
|
|
pageType string,
|
|
parent *PageInfo,
|
|
title string,
|
|
body string,
|
|
) (*PageInfo, error) {
|
|
payload := map[string]any{
|
|
"type": pageType,
|
|
"title": title,
|
|
"space": map[string]any{
|
|
"key": space,
|
|
},
|
|
"body": map[string]any{
|
|
"storage": map[string]any{
|
|
"representation": "storage",
|
|
"value": body,
|
|
},
|
|
},
|
|
"metadata": map[string]any{
|
|
"properties": map[string]any{
|
|
"editor": map[string]any{
|
|
"value": "v2",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
if parent != nil {
|
|
payload["ancestors"] = []map[string]any{
|
|
{"id": parent.ID},
|
|
}
|
|
}
|
|
|
|
request, err := api.rest.Res(
|
|
"content/", &PageInfo{},
|
|
).Post(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return nil, newErrorStatusNotOK(request)
|
|
}
|
|
|
|
return request.Response.(*PageInfo), nil
|
|
}
|
|
|
|
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]any{}
|
|
|
|
if page.Type != "blogpost" && len(page.Ancestors) > 0 {
|
|
// picking only the last one, which is required by confluence
|
|
oldAncestors = []map[string]any{
|
|
{"id": page.Ancestors[len(page.Ancestors)-1].ID},
|
|
}
|
|
}
|
|
|
|
properties := map[string]any{
|
|
// Fix to set full-width as has changed on Confluence APIs again.
|
|
// https://jira.atlassian.com/browse/CONFCLOUD-65447
|
|
//
|
|
"content-appearance-published": map[string]any{
|
|
"value": appearance,
|
|
},
|
|
// content-appearance-draft should not be set as this is impacted by
|
|
// the user editor default configurations - which caused the sporadic published widths.
|
|
}
|
|
|
|
if 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]any{
|
|
"value": unicodeHex,
|
|
}
|
|
properties["emoji-title-published"] = map[string]any{
|
|
"value": unicodeHex,
|
|
}
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"id": page.ID,
|
|
"type": page.Type,
|
|
"title": page.Title,
|
|
"version": map[string]any{
|
|
"number": nextPageVersion,
|
|
"minorEdit": minorEdit,
|
|
"message": versionMessage,
|
|
},
|
|
"ancestors": oldAncestors,
|
|
"body": map[string]any{
|
|
"storage": map[string]any{
|
|
"value": newContent,
|
|
"representation": "storage",
|
|
},
|
|
},
|
|
"metadata": map[string]any{
|
|
"properties": properties,
|
|
},
|
|
}
|
|
|
|
request, err := api.rest.Res(
|
|
"content/"+page.ID, &map[string]any{},
|
|
).Put(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return newErrorStatusNotOK(request)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *API) AddPageLabels(page *PageInfo, newLabels []string) (*LabelInfo, error) {
|
|
|
|
labels := []map[string]any{}
|
|
for _, label := range newLabels {
|
|
if label != "" {
|
|
item := map[string]any{
|
|
"prefix": "global",
|
|
"name": label,
|
|
}
|
|
labels = append(labels, item)
|
|
}
|
|
}
|
|
|
|
payload := labels
|
|
|
|
request, err := api.rest.Res(
|
|
"content/"+page.ID+"/label", &LabelInfo{},
|
|
).Post(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return nil, newErrorStatusNotOK(request)
|
|
}
|
|
|
|
return request.Response.(*LabelInfo), nil
|
|
}
|
|
|
|
func (api *API) DeletePageLabel(page *PageInfo, label string) (*LabelInfo, error) {
|
|
|
|
request, err := api.rest.Res(
|
|
"content/"+page.ID+"/label", &LabelInfo{},
|
|
).SetQuery(map[string]string{"name": label}).Delete()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if request.Raw.StatusCode == http.StatusNoContent {
|
|
return nil, nil
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return nil, newErrorStatusNotOK(request)
|
|
}
|
|
|
|
return request.Response.(*LabelInfo), nil
|
|
}
|
|
|
|
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", &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 &LabelInfo{Labels: all, Size: len(all)}, nil
|
|
}
|
|
|
|
func (api *API) GetUserByName(name string) (*User, error) {
|
|
var response struct {
|
|
Results []struct {
|
|
User User
|
|
}
|
|
}
|
|
|
|
// Try the new path first
|
|
request, err := api.rest.
|
|
Res("search").
|
|
Res("user", &response).
|
|
Get(map[string]string{
|
|
"cql": fmt.Sprintf("user.fullname~%q", name),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Try old path
|
|
if request.Raw.StatusCode != http.StatusOK || len(response.Results) == 0 {
|
|
request, err = api.rest.
|
|
Res("search", &response).
|
|
Get(map[string]string{
|
|
"cql": fmt.Sprintf("user.fullname~%q", name),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return nil, newErrorStatusNotOK(request)
|
|
}
|
|
}
|
|
|
|
if len(response.Results) == 0 {
|
|
|
|
return nil, fmt.Errorf("user with name %q is not found", name)
|
|
}
|
|
|
|
return &response.Results[0].User, nil
|
|
}
|
|
|
|
func (api *API) GetCurrentUser() (*User, error) {
|
|
var user User
|
|
|
|
request, err := api.rest.
|
|
Res("user").
|
|
Res("current", &user).
|
|
Get()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return nil, newErrorStatusNotOK(request)
|
|
}
|
|
|
|
return &user, nil
|
|
}
|
|
|
|
func (api *API) RestrictPageUpdatesCloud(
|
|
page *PageInfo,
|
|
allowedUser string,
|
|
) error {
|
|
user, err := api.GetUserByName(allowedUser)
|
|
if err != nil {
|
|
// 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 any
|
|
|
|
request, err := api.rest.
|
|
Res("content").
|
|
Id(page.ID).
|
|
Res("restriction", &result).
|
|
Post([]map[string]any{
|
|
{
|
|
"operation": "update",
|
|
"restrictions": map[string]any{
|
|
"user": []map[string]any{
|
|
{
|
|
"type": "known",
|
|
"accountId": user.AccountID,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return newErrorStatusNotOK(request)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *API) RestrictPageUpdatesServer(
|
|
page *PageInfo,
|
|
allowedUser string,
|
|
) error {
|
|
var (
|
|
err error
|
|
result any
|
|
)
|
|
|
|
request, err := api.json.Res(
|
|
"setContentPermissions", &result,
|
|
).Post([]any{
|
|
page.ID,
|
|
"Edit",
|
|
[]map[string]any{
|
|
{
|
|
"userName": allowedUser,
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if request.Raw.StatusCode != http.StatusOK {
|
|
return newErrorStatusNotOK(request)
|
|
}
|
|
|
|
if success, ok := result.(bool); !ok || !success {
|
|
return fmt.Errorf(
|
|
"'true' response expected, but '%v' encountered",
|
|
result,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (api *API) RestrictPageUpdates(
|
|
page *PageInfo,
|
|
allowedUser string,
|
|
) error {
|
|
var err error
|
|
|
|
if strings.HasSuffix(api.rest.Api.BaseUrl.Host, "jira.com") || strings.HasSuffix(api.rest.Api.BaseUrl.Host, "atlassian.net") {
|
|
err = api.RestrictPageUpdatesCloud(page, allowedUser)
|
|
} else {
|
|
err = api.RestrictPageUpdatesServer(page, allowedUser)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
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)",
|
|
)
|
|
}
|
|
|
|
if request.Raw.StatusCode == http.StatusNotFound {
|
|
return errors.New(
|
|
"the Confluence API returned unexpected status: 404 (Not Found)",
|
|
)
|
|
}
|
|
|
|
output, _ := io.ReadAll(request.Raw.Body)
|
|
|
|
return fmt.Errorf(
|
|
"the Confluence API returned unexpected status: %v, "+
|
|
"output: %q",
|
|
request.Raw.Status, output,
|
|
)
|
|
}
|