diff --git a/main.go b/main.go index b183e18..f6f098c 100644 --- a/main.go +++ b/main.go @@ -187,7 +187,10 @@ func main() { target = page } - mark.ResolveAttachments(api, target, ".", meta.Attachments) + err = mark.ResolveAttachments(api, target, ".", meta.Attachments) + if err != nil { + log.Fatalf(err, "unable to create/update attachments") + } err = api.UpdatePage( target, diff --git a/pkg/confluence/api.go b/pkg/confluence/api.go index d3c8e81..3338611 100644 --- a/pkg/confluence/api.go +++ b/pkg/confluence/api.go @@ -1,10 +1,14 @@ package confluence import ( - "encoding/json" + "bytes" "errors" "fmt" + "io" "io/ioutil" + "mime/multipart" + "net/http" + "os" "github.com/bndr/gopencils" "github.com/kovetskiy/lorg" @@ -12,20 +16,6 @@ import ( "github.com/reconquest/karma-go" ) -func discarder() *lorg.Log { - stderr := lorg.NewLog() - stderr.SetOutput(ioutil.Discard) - return stderr -} - -var ( - log = cog.NewLogger(discarder()) -) - -func SetLogger(logger *cog.Logger) { - log = logger -} - type RestrictionOperation string const ( @@ -64,6 +54,36 @@ type PageInfo struct { } `json:"_links"` } +type AttachmentInfo struct { + Filename string `json:"title"` + ID string `json:"id"` + Metadata struct { + Comment string `json:"comment"` + } `json:"metadata"` + Links struct { + Download string `json:"download"` + } `json:"_links"` +} + +func discarder() *lorg.Log { + stderr := lorg.NewLog() + stderr.SetOutput(ioutil.Discard) + return stderr +} + +var ( + log = cog.NewLogger(discarder()) +) + +func SetLogger(logger *cog.Logger) { + log = logger +} + +type form struct { + buffer io.Reader + writer *multipart.Writer +} + func NewAPI(baseURL string, username string, password string) *API { auth := &gopencils.BasicAuth{username, password} @@ -92,11 +112,10 @@ func (api *API) FindRootPage(space string) (*PageInfo, error) { } if len(page.Ancestors) == 0 { - return nil, fmt.Errorf( - "page %q from space %q has no parents", - page.Title, - space, - ) + return &PageInfo{ + ID: page.ID, + Title: page.Title, + }, nil } return &PageInfo{ @@ -139,8 +158,170 @@ func (api *API) FindPage(space string, title string) (*PageInfo, error) { return &result.Results[0], nil } -func (api *API) GetAttachments(pageID string) error { - result := map[string]interface{}{} +func (api *API) CreateAttachment( + pageID string, + name string, + comment string, + path string, +) (AttachmentInfo, error) { + var info AttachmentInfo + + form, err := getAttachmentPayload(name, comment, path) + if err != nil { + return AttachmentInfo{}, err + } + + var result struct { + Results []AttachmentInfo `json:"results"` + } + + resource := api.rest.Res( + "content/"+pageID+"/child/attachment", &result, + ) + + resource.Payload = form.buffer + resource.Headers = http.Header{} + + 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 != 200 { + return info, newErrorStatusNotOK(request) + } + + if len(result.Results) == 0 { + return info, errors.New( + "Confluence REST API for creating attachments returned " + + "0 json objects, expected at least 1", + ) + } + + info = result.Results[0] + + return info, nil +} + +func (api *API) UpdateAttachment( + pageID string, + attachID string, + name string, + comment string, + path string, +) (AttachmentInfo, error) { + var info AttachmentInfo + + form, err := getAttachmentPayload(name, comment, path) + if err != nil { + return AttachmentInfo{}, err + } + + var result struct { + Results []AttachmentInfo `json:"results"` + } + + resource := api.rest.Res( + "content/"+pageID+"/child/attachment/"+attachID+"/data", &result, + ) + + resource.Payload = form.buffer + resource.Headers = http.Header{} + + 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 != 200 { + return info, newErrorStatusNotOK(request) + //} + + if len(result.Results) == 0 { + return info, errors.New( + "Confluence REST API for creating attachments returned " + + "0 json objects, expected at least 1", + ) + } + + info = result.Results[0] + + return info, nil +} + +func getAttachmentPayload(name, comment, path string) (*form, error) { + var ( + payload = bytes.NewBuffer(nil) + writer = multipart.NewWriter(payload) + ) + + file, err := os.Open(path) + if err != nil { + return nil, karma.Format( + err, + "unable to open file: %q", + path, + ) + } + + defer file.Close() + + content, err := writer.CreateFormFile("file", name) + if err != nil { + return nil, karma.Format( + err, + "unable to create form file", + ) + } + + _, err = io.Copy(content, file) + if err != nil { + return nil, karma.Format( + err, + "unable to copy i/o between form-file and file", + ) + } + + commentWriter, err := writer.CreateFormField("comment") + if err != nil { + return nil, karma.Format( + err, + "unable to create form field for comment", + ) + } + + _, err = commentWriter.Write([]byte(comment)) + if err != nil { + return nil, karma.Format( + err, + "unable to write comment in form-field", + ) + } + + err = writer.Close() + if err != nil { + return nil, karma.Format( + err, + "unable to close form-writer", + ) + } + + return &form{ + buffer: payload, + writer: writer, + }, nil +} + +func (api *API) GetAttachments(pageID string) ([]AttachmentInfo, error) { + result := struct { + Results []AttachmentInfo `json:"results"` + }{} payload := map[string]string{ "expand": "version,container", @@ -150,19 +331,14 @@ func (api *API) GetAttachments(pageID string) error { "content/"+pageID+"/child/attachment", &result, ).Get(payload) if err != nil { - return err + return nil, err } if request.Raw.StatusCode != 200 { - return newErrorStatusNotOK(request) + return nil, newErrorStatusNotOK(request) } - { - marshaledXXX, _ := json.MarshalIndent(result, "", " ") - fmt.Printf("result: %s\n", string(marshaledXXX)) - } - - return nil + return result.Results, nil } func (api *API) GetPageByID(pageID string) (*PageInfo, error) { diff --git a/pkg/mark/attachment.go b/pkg/mark/attachment.go index 514cd97..f57682d 100644 --- a/pkg/mark/attachment.go +++ b/pkg/mark/attachment.go @@ -1,9 +1,30 @@ package mark -import "github.com/kovetskiy/mark/pkg/confluence" +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/kovetskiy/mark/pkg/confluence" + "github.com/reconquest/karma-go" +) + +const ( + AttachmentChecksumPrefix = `mark:checksum: ` +) type Attachment struct { - Name string + ID string + Name string + Filename string + Path string + Checksum string + Link string } func ResolveAttachments( @@ -11,9 +32,144 @@ func ResolveAttachments( page *confluence.PageInfo, base string, names []string, -) { - err := api.GetAttachments(page.ID) +) error { + attachs := []Attachment{} + for _, name := range names { + attach := Attachment{ + Name: name, + Filename: strings.ReplaceAll(name, "/", "_"), + Path: filepath.Join(base, name), + } + + checksum, err := getChecksum(attach.Path) + if err != nil { + return karma.Format( + err, + "unable to get checksum for attachment: %q", attach.Name, + ) + } + + attach.Checksum = checksum + + attachs = append(attachs, attach) + } + + remotes, err := api.GetAttachments(page.ID) if err != nil { panic(err) } + + existing := []Attachment{} + creating := []Attachment{} + updating := []Attachment{} + for _, attach := range attachs { + var found bool + var same bool + for _, remote := range remotes { + if remote.Filename == attach.Filename { + same = attach.Checksum == strings.TrimPrefix( + remote.Metadata.Comment, + AttachmentChecksumPrefix, + ) + + attach.ID = remote.ID + attach.Link = remote.Links.Download + + found = true + + break + } + } + + if found { + if same { + existing = append(existing, attach) + } else { + updating = append(updating, attach) + } + } else { + creating = append(creating, attach) + } + } + + { + marshaledXXX, _ := json.MarshalIndent(existing, "", " ") + fmt.Printf("existing: %s\n", string(marshaledXXX)) + } + + { + marshaledXXX, _ := json.MarshalIndent(creating, "", " ") + fmt.Printf("creating: %s\n", string(marshaledXXX)) + } + + { + marshaledXXX, _ := json.MarshalIndent(updating, "", " ") + fmt.Printf("updating: %s\n", string(marshaledXXX)) + } + + for i, attach := range creating { + log.Infof(nil, "creating attachment: %q", attach.Name) + + info, err := api.CreateAttachment( + page.ID, + attach.Filename, + AttachmentChecksumPrefix+attach.Checksum, + attach.Path, + ) + if err != nil { + return karma.Format( + err, + "unable to create attachment %q", + attach.Name, + ) + } + + attach.ID = info.ID + attach.Link = info.Links.Download + + creating[i] = attach + } + + for i, attach := range updating { + log.Infof(nil, "updating attachment: %q", attach.Name) + + info, err := api.UpdateAttachment( + page.ID, + attach.ID, + attach.Name, + AttachmentChecksumPrefix+attach.Checksum, + attach.Path, + ) + if err != nil { + return karma.Format( + err, + "unable to update attachment %q", + attach.Name, + ) + } + + attach.Link = info.Links.Download + + updating[i] = attach + } + + return nil +} + +func getChecksum(filename string) (string, error) { + file, err := os.Open(filename) + if err != nil { + return "", karma.Format( + err, + "unable to open file", + ) + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil }