Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Page version history: `page history` lists a page's versions, and
`page restore --version N` rolls a page back by republishing that version's
body as a new version (non-destructive — the history is kept). `restore`
supports `--dry-run`.
- Page watch commands: `page watch` / `page unwatch` subscribe or unsubscribe
the authenticated user, and `page watch-status` reports whether you watch a
page. `watch` / `unwatch` support `--dry-run`.
- Attachment write commands: `attachment upload` attaches a file to a page,
`attachment update` replaces an attachment's content with a new version, and
`attachment delete` removes one. Uploads use `multipart/form-data`; `--file -`
Expand Down
5 changes: 5 additions & 0 deletions docs/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,13 @@ is published at <https://angelmsger.github.io/confluence-cli/cli/>.
| [`confluence-cli page delete`](https://angelmsger.github.io/confluence-cli/cli/#confluence-cli-page-delete) | Delete a page (move it to the trash) |
| [`confluence-cli page descendants`](https://angelmsger.github.io/confluence-cli/cli/#confluence-cli-page-descendants) | List all descendant pages of a page |
| [`confluence-cli page get`](https://angelmsger.github.io/confluence-cli/cli/#confluence-cli-page-get) | Fetch a page and render its body |
| [`confluence-cli page history`](https://angelmsger.github.io/confluence-cli/cli/#confluence-cli-page-history) | List a page's version history |
| [`confluence-cli page move`](https://angelmsger.github.io/confluence-cli/cli/#confluence-cli-page-move) | Move a page under a new parent and/or space |
| [`confluence-cli page restore`](https://angelmsger.github.io/confluence-cli/cli/#confluence-cli-page-restore) | Restore a page to an earlier version |
| [`confluence-cli page unwatch`](https://angelmsger.github.io/confluence-cli/cli/#confluence-cli-page-unwatch) | Stop watching a page |
| [`confluence-cli page update`](https://angelmsger.github.io/confluence-cli/cli/#confluence-cli-page-update) | Update a page's title and/or body |
| [`confluence-cli page watch`](https://angelmsger.github.io/confluence-cli/cli/#confluence-cli-page-watch) | Watch a page (subscribe to its notifications) |
| [`confluence-cli page watch-status`](https://angelmsger.github.io/confluence-cli/cli/#confluence-cli-page-watch-status) | Report whether you watch a page |

## search

Expand Down
81 changes: 81 additions & 0 deletions docs/cli/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,13 @@
<a href="#confluence-cli-page-delete">confluence-cli page delete</a>
<a href="#confluence-cli-page-descendants">confluence-cli page descendants</a>
<a href="#confluence-cli-page-get">confluence-cli page get</a>
<a href="#confluence-cli-page-history">confluence-cli page history</a>
<a href="#confluence-cli-page-move">confluence-cli page move</a>
<a href="#confluence-cli-page-restore">confluence-cli page restore</a>
<a href="#confluence-cli-page-unwatch">confluence-cli page unwatch</a>
<a href="#confluence-cli-page-update">confluence-cli page update</a>
<a href="#confluence-cli-page-watch">confluence-cli page watch</a>
<a href="#confluence-cli-page-watch-status">confluence-cli page watch-status</a>
</div>
<div class="side-group">
<div class="side-title">search</div>
Expand Down Expand Up @@ -606,6 +611,23 @@ <h3>Examples</h3><pre><span class="c"> # render the whole page as Markdown</spa
confluence-cli page get https://wiki.example.com/pages/viewpage.action?pageId=123456</pre>
</section>

<section class="cmd" id="confluence-cli-page-history">
<h2>confluence-cli page history</h2>
<p class="short">List a page&#39;s version history</p>
<pre>confluence-cli page history &lt;id|url&gt; [flags]</pre>

<h3>Options</h3>
<table>
<thead><tr><th>Flag</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>--all</code></td><td><code>false</code></td><td>fetch every page of results</td></tr>
<tr><td><code>--limit</code></td><td><code>0</code></td><td>page size (default from config)</td></tr>
</tbody>
</table>
<h3>Examples</h3><pre> confluence-cli page history 123456
confluence-cli page history 123456 --all --format table</pre>
</section>

<section class="cmd" id="confluence-cli-page-move">
<h2>confluence-cli page move</h2>
<p class="short">Move a page under a new parent and/or space</p>
Expand All @@ -627,6 +649,41 @@ <h3>Examples</h3><pre><span class="c"> # reparent a page</span>
confluence-cli page move 123456 --target-space DOCS</pre>
</section>

<section class="cmd" id="confluence-cli-page-restore">
<h2>confluence-cli page restore</h2>
<p class="short">Restore a page to an earlier version</p>
<pre>confluence-cli page restore &lt;id|url&gt; --version &lt;N&gt; [flags]</pre>
<p class="long">Republish an earlier version&#39;s body as a new version. The restore is
non-destructive: the version history is left intact. Run `page history`
first to find the version number to restore.</p>
<h3>Options</h3>
<table>
<thead><tr><th>Flag</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>--dry-run</code></td><td><code>false</code></td><td>print the request without sending it</td></tr>
<tr><td><code>--message</code></td><td></td><td>version comment for the restore</td></tr>
<tr><td><code>--version</code></td><td><code>0</code></td><td>the version number to restore</td></tr>
</tbody>
</table>
<h3>Examples</h3><pre> confluence-cli page restore 123456 --version 3
confluence-cli page restore 123456 --version 3 --message &#34;roll back bad edit&#34;</pre>
</section>

<section class="cmd" id="confluence-cli-page-unwatch">
<h2>confluence-cli page unwatch</h2>
<p class="short">Stop watching a page</p>
<pre>confluence-cli page unwatch &lt;id|url&gt; [flags]</pre>

<h3>Options</h3>
<table>
<thead><tr><th>Flag</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>--dry-run</code></td><td><code>false</code></td><td>print the request without sending it</td></tr>
</tbody>
</table>
<h3>Examples</h3><pre> confluence-cli page unwatch 123456</pre>
</section>

<section class="cmd" id="confluence-cli-page-update">
<h2>confluence-cli page update</h2>
<p class="short">Update a page&#39;s title and/or body</p>
Expand Down Expand Up @@ -655,6 +712,30 @@ <h3>Examples</h3><pre><span class="c"> # retitle a page, keeping its body</span
confluence-cli page update 123456 --format markdown --body-file body.md --version 7</pre>
</section>

<section class="cmd" id="confluence-cli-page-watch">
<h2>confluence-cli page watch</h2>
<p class="short">Watch a page (subscribe to its notifications)</p>
<pre>confluence-cli page watch &lt;id|url&gt; [flags]</pre>

<h3>Options</h3>
<table>
<thead><tr><th>Flag</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>--dry-run</code></td><td><code>false</code></td><td>print the request without sending it</td></tr>
</tbody>
</table>
<h3>Examples</h3><pre> confluence-cli page watch 123456</pre>
</section>

<section class="cmd" id="confluence-cli-page-watch-status">
<h2>confluence-cli page watch-status</h2>
<p class="short">Report whether you watch a page</p>
<pre>confluence-cli page watch-status &lt;id|url&gt;</pre>


<h3>Examples</h3><pre> confluence-cli page watch-status 123456</pre>
</section>

<section class="cmd" id="confluence-cli-search">
<h2>confluence-cli search</h2>
<p class="short">Search pages with CQL or filter flags</p>
Expand Down
6 changes: 6 additions & 0 deletions internal/apiclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ type Client interface {
CopyPage(ctx context.Context, req CopyPageReq) (*Page, error)
DescribeWrite(ctx context.Context, op any) (WriteRequestPlan, error)

ListPageVersions(ctx context.Context, id string, opt ListOpts) (ListResult[PageVersion], error)
RestorePage(ctx context.Context, req RestorePageReq) (*Page, error)

WatchStatus(ctx context.Context, pageID string) (bool, error)
SetWatch(ctx context.Context, req WatchReq) error

Search(ctx context.Context, cql string, opt ListOpts) (ListResult[SearchHit], error)

ListSpaces(ctx context.Context, opt SpaceListOpts) (ListResult[Space], error)
Expand Down
37 changes: 34 additions & 3 deletions internal/apiclient/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,33 @@ type rawBody struct {
}

type rawVersion struct {
Number int `json:"number"`
When string `json:"when"`
By struct {
Number int `json:"number"`
When string `json:"when"`
Message string `json:"message"`
MinorEdit bool `json:"minorEdit"`
By struct {
DisplayName string `json:"displayName"`
} `json:"by"`
}

type rawVersionList struct {
Results []rawVersion `json:"results"`
Size int `json:"size"`
Limit int `json:"limit"`
}

// rawContentVersion is one entry of a content's version history with the
// version's content (and body) expanded.
type rawContentVersion struct {
Number int `json:"number"`
Content rawContent `json:"content"`
}

// rawWatch is the response of the user-watch status endpoint.
type rawWatch struct {
Watching bool `json:"watching"`
}

type rawSpace struct {
ID any `json:"id"` // server returns a number, Cloud a string
Key string `json:"key"`
Expand Down Expand Up @@ -140,6 +160,17 @@ func versionOf(v *rawVersion) *Version {
return &Version{Number: v.Number, When: v.When, By: v.By.DisplayName}
}

// pageVersionOf normalizes a raw version-history entry into a PageVersion.
func pageVersionOf(r rawVersion) PageVersion {
return PageVersion{
Number: r.Number,
When: r.When,
By: r.By.DisplayName,
Message: r.Message,
MinorEdit: r.MinorEdit,
}
}

// mapPage normalizes a v1 content object into a Page.
func (c *apiClient) mapPage(r rawContent) *Page {
p := &Page{
Expand Down
24 changes: 24 additions & 0 deletions internal/apiclient/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ type Attachment struct {
DownloadURL string `json:"download_url,omitempty"`
}

// PageVersion is one entry in a page's version history.
type PageVersion struct {
Number int `json:"number"`
When string `json:"when,omitempty"`
By string `json:"by,omitempty"`
Message string `json:"message,omitempty"`
MinorEdit bool `json:"minor_edit,omitempty"`
}

// Label is a normalized Confluence content label.
type Label struct {
ID string `json:"id,omitempty"`
Expand Down Expand Up @@ -221,6 +230,21 @@ type DeleteAttachmentReq struct {
AttachmentID string
}

// RestorePageReq is a request to restore a page to an earlier version. The
// restore is non-destructive: it republishes the version's body as a new
// version, leaving the history intact.
type RestorePageReq struct {
ID string
Version int // the historical version number to restore
Message string // optional version comment for the restore
}

// WatchReq toggles whether the authenticated user watches a page.
type WatchReq struct {
PageID string
Watching bool // true = watch, false = unwatch
}

// AddLabelsReq is a request to add one or more labels to a page.
type AddLabelsReq struct {
PageID string
Expand Down
4 changes: 4 additions & 0 deletions internal/apiclient/pages_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ func (c *apiClient) DescribeWrite(ctx context.Context, op any) (WriteRequestPlan
method, path, payload, err = c.buildAddLabels(v)
case RemoveLabelReq:
method, path, err = c.buildRemoveLabel(v)
case RestorePageReq:
method, path, payload, err = c.buildRestorePage(ctx, v)
case WatchReq:
method, path, err = c.buildSetWatch(v)
default:
return WriteRequestPlan{}, cerrors.New(cerrors.CategoryInternal, "DRYRUN_BAD_OP",
"unsupported write operation for dry-run")
Expand Down
85 changes: 85 additions & 0 deletions internal/apiclient/versions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package apiclient

import (
"context"
"fmt"
"net/url"
"strconv"

cerrors "github.com/angelmsger/confluence-cli/internal/errors"
)

// versions.go holds the page version-history operations: listing a page's
// versions and restoring it to an earlier one. Restore has no native v1
// endpoint, so it republishes a historical version's body as a new version,
// reusing buildUpdatePage — the history is left intact.

// ListPageVersions lists a page's version history, newest first.
func (c *apiClient) ListPageVersions(ctx context.Context, id string, opt ListOpts) (ListResult[PageVersion], error) {
limit := c.limitOf(opt)
q := offsetQuery(opt.Cursor, limit)
path := c.v1Base() + "/content/" + url.PathEscape(id) + "/version"
var raw rawVersionList
if err := c.getJSON(ctx, path, q, &raw); err != nil {
return ListResult[PageVersion]{}, err
}
res := ListResult[PageVersion]{Next: nextOffsetToken(opt.Cursor, limit, len(raw.Results))}
for _, r := range raw.Results {
res.Items = append(res.Items, pageVersionOf(r))
}
return res, nil
}

// getPageVersionBody fetches the storage-format body of a historical version.
func (c *apiClient) getPageVersionBody(ctx context.Context, id string, version int) (PageBody, error) {
q := url.Values{}
q.Set("expand", "content.body.storage")
path := c.v1Base() + "/content/" + url.PathEscape(id) + "/version/" + strconv.Itoa(version)
var raw rawContentVersion
if err := c.getJSON(ctx, path, q, &raw); err != nil {
return PageBody{}, err
}
b := bodyOf(raw.Content.Body)
if b == nil {
return PageBody{}, cerrors.Newf(cerrors.CategoryNotFound, "VERSION_NO_BODY",
"version %d of page %s has no retrievable body", version, id)
}
return PageBody{Value: b.Value, Format: "storage"}, nil
}

// buildRestorePage assembles the PUT request that restores a page to an earlier
// version: it fetches that version's body and delegates to buildUpdatePage,
// which carries over the current title and bumps the version number.
func (c *apiClient) buildRestorePage(ctx context.Context, req RestorePageReq) (method, path string, payload any, err error) {
if req.ID == "" {
return "", "", nil, cerrors.New(cerrors.CategoryUsage, "PAGE_NO_ID",
"a page ID is required to restore a page")
}
if req.Version <= 0 {
return "", "", nil, cerrors.New(cerrors.CategoryUsage, "RESTORE_NO_VERSION",
"a positive --version is required to restore a page")
}
body, err := c.getPageVersionBody(ctx, req.ID, req.Version)
if err != nil {
return "", "", nil, err
}
message := req.Message
if message == "" {
message = fmt.Sprintf("Restored to version %d", req.Version)
}
return c.buildUpdatePage(ctx, UpdatePageReq{ID: req.ID, Body: &body, Message: message})
}

// RestorePage restores a page to an earlier version by republishing that
// version's body as a new version.
func (c *apiClient) RestorePage(ctx context.Context, req RestorePageReq) (*Page, error) {
method, path, payload, err := c.buildRestorePage(ctx, req)
if err != nil {
return nil, err
}
var raw rawContent
if err := c.doJSON(ctx, method, path, nil, payload, &raw); err != nil {
return nil, err
}
return c.mapPage(raw), nil
}
Loading