Skip to content

Commit 09d93fb

Browse files
committed
Add command to work with Confluence attachments
1 parent 4790d9c commit 09d93fb

File tree

3 files changed

+229
-1
lines changed

3 files changed

+229
-1
lines changed

plugin/confluence/attachment.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright 2025 The Heimdall authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//go:build !no_atlassian && !no_confluence
16+
17+
package confluence
18+
19+
import (
20+
"cmp"
21+
"net/http"
22+
"os"
23+
"path/filepath"
24+
"slices"
25+
"strconv"
26+
"strings"
27+
28+
"github.com/MakeNowJust/heredoc/v2"
29+
"github.com/abc-inc/heimdall/cli"
30+
"github.com/abc-inc/heimdall/internal"
31+
"github.com/abc-inc/heimdall/res"
32+
"github.com/spf13/cobra"
33+
goconfluence "github.com/virtomize/confluence-go-api"
34+
)
35+
36+
type confluenceAttCfg struct {
37+
confluenceCfg
38+
pageID string
39+
attId string
40+
attName string
41+
file string
42+
dir string
43+
}
44+
45+
func NewAttachmentCmd() *cobra.Command {
46+
cmd := &cobra.Command{
47+
Use: "attachment",
48+
Short: "Work with Confluence attachments",
49+
Args: cobra.ExactArgs(0),
50+
}
51+
52+
cmd.AddCommand(
53+
NewAttachmentDownloadCmd(),
54+
NewAttachmentsDownloadCmd(),
55+
NewAttachmentsListCmd(),
56+
NewAttachmentUploadCmd(),
57+
)
58+
59+
return cmd
60+
}
61+
62+
func NewAttachmentDownloadCmd() *cobra.Command {
63+
cfg := confluenceAttCfg{confluenceCfg: *newConfluenceCfg()}
64+
cmd := &cobra.Command{
65+
Use: "download",
66+
Short: "Download a single attachment from a Confluence page",
67+
Example: heredoc.Doc(`
68+
heimdall confluence attachment download --page 12345 --name users.csv
69+
# or write to standard output (-) directly
70+
heimdall confluence attachment download --file - --page 12345
71+
`),
72+
Args: cobra.ExactArgs(0),
73+
Run: func(cmd *cobra.Command, args []string) {
74+
if cfg.dir != "" {
75+
cfg.dir = internal.Must(filepath.Abs(cfg.dir))
76+
}
77+
client := internal.Must(newClient(cfg.apiURL, cfg.token))
78+
download(client, cfg)
79+
},
80+
}
81+
82+
cmd.Flags().StringVarP(&cfg.dir, "directory", "C", cfg.dir, "Change to the directory before downloading attachments")
83+
cmd.Flags().StringVarP(&cfg.file, "file", "O", cfg.file, "File to save the attachment to (use '-' for standard output, default is the name of the attachment)")
84+
cmd.Flags().StringVar(&cfg.attId, "id", cfg.attId, "Attachment ID to download")
85+
cmd.Flags().StringVar(&cfg.pageID, "page", cfg.pageID, "Page ID")
86+
cmd.Flags().StringVar(&cfg.attName, "name", cfg.attName, "Attachment name to download")
87+
88+
cmd.MarkFlagsMutuallyExclusive("id", "name")
89+
cmd.MarkFlagsOneRequired("id", "name")
90+
cmd.MarkFlagsRequiredTogether("page", "name")
91+
return cmd
92+
}
93+
94+
func NewAttachmentsDownloadCmd() *cobra.Command {
95+
cfg := confluenceAttCfg{confluenceCfg: *newConfluenceCfg()}
96+
cmd := &cobra.Command{
97+
Use: "downloads",
98+
Short: "Download all matching attachments from a Confluence page",
99+
Example: heredoc.Doc(`
100+
# wildcard match with a glob pattern
101+
heimdall confluence attachment downloads --page 12345 --name '*.csv'
102+
# regular expressions must start with ^
103+
heimdall confluence attachment downloads --page 12345 --name '^.*2025\.png'
104+
`),
105+
Args: cobra.ExactArgs(0),
106+
Run: func(cmd *cobra.Command, args []string) {
107+
if cfg.dir != "" {
108+
cfg.dir = internal.Must(filepath.Abs(cfg.dir))
109+
}
110+
client := internal.Must(newClient(cfg.apiURL, cfg.token))
111+
downloadMatching(client, cfg)
112+
},
113+
}
114+
115+
cmd.Flags().StringVarP(&cfg.dir, "directory", "C", cfg.dir, "Change to the directory before downloading attachments")
116+
cmd.Flags().StringVar(&cfg.pageID, "page", cfg.pageID, "Page ID")
117+
cmd.Flags().StringVar(&cfg.attName, "name", "*", "Attachment name(s) to download (can be a wildcard or regular expression)")
118+
internal.MustNoErr(cmd.MarkFlagRequired("page"))
119+
return cmd
120+
}
121+
122+
func NewAttachmentsListCmd() *cobra.Command {
123+
cfg := confluenceAttCfg{confluenceCfg: *newConfluenceCfg(), attName: "*"}
124+
cmd := &cobra.Command{
125+
Use: "list",
126+
Short: "List attachments from a Confluence page",
127+
Args: cobra.ExactArgs(0),
128+
Run: func(cmd *cobra.Command, args []string) {
129+
client := internal.Must(newClient(cfg.apiURL, cfg.token))
130+
cli.Fmtln(list(client, cfg))
131+
},
132+
}
133+
134+
cmd.Flags().StringVar(&cfg.pageID, "page", cfg.pageID, "Page ID")
135+
136+
cli.AddOutputFlags(cmd, &cfg.OutCfg)
137+
internal.MustNoErr(cmd.MarkFlagRequired("page"))
138+
return cmd
139+
}
140+
141+
func NewAttachmentUploadCmd() *cobra.Command {
142+
cfg := confluenceAttCfg{confluenceCfg: *newConfluenceCfg()}
143+
cmd := &cobra.Command{
144+
Use: "upload",
145+
Short: "Upload an attachment to a Confluence page",
146+
Args: cobra.ExactArgs(0),
147+
Run: func(cmd *cobra.Command, args []string) {
148+
client := internal.Must(newClient(cfg.apiURL, cfg.token))
149+
cli.Fmtln(upload(client, cfg))
150+
},
151+
}
152+
153+
cmd.Flags().StringVar(&cfg.pageID, "page", cfg.pageID, "Page ID")
154+
cmd.Flags().StringVar(&cfg.file, "file", cfg.file, "File to upload")
155+
156+
internal.MustNoErr(cmd.MarkFlagRequired("page"))
157+
internal.MustNoErr(cmd.MarkFlagRequired("file"))
158+
return cmd
159+
}
160+
161+
// download downloads an attachment from a Confluence page.
162+
func download(client *goconfluence.API, cfg confluenceAttCfg) {
163+
if cfg.dir != "" {
164+
internal.MustNoErr(os.Chdir(cfg.dir))
165+
}
166+
167+
if cfg.attId == "" {
168+
atts := list(client, cfg)
169+
filename := func(e goconfluence.Results) string { return e.Title }
170+
internal.MustOkMsgf(atts, len(atts) == 1, "expected exactly one attachment, found: %s", res.Strings(filename, atts...))
171+
cfg.attId, cfg.attName = atts[0].ID, atts[0].Title
172+
}
173+
174+
att := internal.Must(client.GetContentByID(cfg.attId, goconfluence.ContentQuery{Type: "attachment"}))
175+
req := internal.Must(http.NewRequest(http.MethodGet, baseURL(cfg.apiURL)+att.Links.Download, nil))
176+
body := internal.Must(client.Request(req))
177+
178+
if cfg.file == "-" {
179+
_ = internal.Must(os.Stdout.Write(body))
180+
return
181+
}
182+
if cfg.file == "" {
183+
cfg.file = cfg.attName
184+
}
185+
186+
f := internal.Must(os.OpenFile(cfg.file, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640))
187+
defer func() { _ = f.Close() }()
188+
_ = internal.Must(f.Write(body))
189+
}
190+
191+
// downloadMatching downloads all attachments matching a given name, glob, or regex from a Confluence page.
192+
func downloadMatching(client *goconfluence.API, cfg confluenceAttCfg) {
193+
if cfg.dir != "" {
194+
internal.MustNoErr(os.Chdir(cfg.dir))
195+
}
196+
197+
atts := list(client, cfg)
198+
internal.MustOkMsgf(atts, len(atts) > 0, "")
199+
for _, att := range atts {
200+
fileCfg := cfg
201+
fileCfg.attId, fileCfg.file = att.ID, att.Title
202+
download(client, fileCfg)
203+
}
204+
}
205+
206+
// list returns all files attached to a Confluence page.
207+
func list(client *goconfluence.API, cfg confluenceAttCfg) []goconfluence.Results {
208+
atts := internal.Must(client.GetAttachments(cfg.pageID))
209+
filename := func(e goconfluence.Results) string { return e.Title }
210+
211+
matches := slices.Collect(res.Seq(res.MatchAny[goconfluence.Results](filename, cfg.attName), atts.Results...))
212+
if len(matches) > 1 {
213+
slices.SortFunc(matches, func(a, b goconfluence.Results) int {
214+
return cmp.Or(strings.Compare(a.Title, b.Title),
215+
internal.Must(strconv.Atoi(b.ID))-internal.Must(strconv.Atoi(a.ID)))
216+
})
217+
matches = slices.CompactFunc(matches, func(a, b goconfluence.Results) bool { return a.Title == b.Title })
218+
}
219+
return matches
220+
}
221+
222+
// upload uploads an attachment to a Confluence page.
223+
func upload(client *goconfluence.API, cfg confluenceAttCfg) *[]goconfluence.Results {
224+
r := internal.Must(res.Open(cfg.file))
225+
defer func() { _ = r.Close() }()
226+
return &internal.Must(client.UploadAttachment(cfg.pageID, cfg.file, r)).Results
227+
}

plugin/confluence/confluence.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func NewConfluenceCmd() *cobra.Command {
6767
}
6868

6969
cmd.AddCommand(
70+
NewAttachmentCmd(),
7071
NewCreateCmd(),
7172
NewUpdateCmd(),
7273
NewSearchCmd(),

plugin/jira/attachment.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ func NewAttachmentsDownloadCmd() *cobra.Command {
9595
cfg := jiraAttCfg{jiraCfg: *newJiraCfg()}
9696
cmd := &cobra.Command{
9797
Use: "downloads",
98-
Short: "Downloads all matching attachments from a Jira issue",
98+
Short: "Download all matching attachments from a Jira issue",
9999
Example: heredoc.Doc(`
100100
# wildcard match with a glob pattern
101101
heimdall jira attachment downloads --key ABC-123 --name '*.csv'

0 commit comments

Comments
 (0)