diff --git a/internal/client/media.go b/internal/client/media.go index a855071..182c7d8 100644 --- a/internal/client/media.go +++ b/internal/client/media.go @@ -1,8 +1,14 @@ package client import ( + "bytes" + "encoding/json" "fmt" + "io" + "mime/multipart" "net/http" + "os" + "path/filepath" "codeflow.dananglin.me.uk/apollo/enbas/internal/model" ) @@ -31,12 +37,123 @@ func (g *Client) GetMediaAttachment(mediaAttachmentID string) (model.Attachment, return attachment, nil } -//type CreateMediaAttachmentForm struct { -// Description string -// Focus string -// Filepath string -//} -// -//func (g *Client) CreateMediaAttachment(form CreateMediaAttachmentForm) (model.Attachment, error) { -// return model.Attachment{}, nil -//} +func (g *Client) CreateMediaAttachment(path, description, focus string) (model.Attachment, error) { + file, err := os.Open(path) + if err != nil { + return model.Attachment{}, fmt.Errorf("unable to open the file: %w", err) + } + defer file.Close() + + // create the request body using a writer from the multipart package + requestBody := bytes.Buffer{} + requestBodyWriter := multipart.NewWriter(&requestBody) + + filename := filepath.Base(path) + + part, err := requestBodyWriter.CreateFormFile("file", filename) + if err != nil { + return model.Attachment{}, fmt.Errorf("unable to create the new part: %w", err) + } + + if _, err := io.Copy(part, file); err != nil { + return model.Attachment{}, fmt.Errorf("unable to copy the file contents to the form: %w", err) + } + + // add the description + if description != "" { + descriptionFormFieldWriter, err := requestBodyWriter.CreateFormField("description") + if err != nil { + return model.Attachment{}, fmt.Errorf( + "unable to create the writer for the 'description' form field: %w", + err, + ) + } + + if _, err := io.WriteString(descriptionFormFieldWriter, description); err != nil { + return model.Attachment{}, fmt.Errorf( + "unable to write the description to the form: %w", + err, + ) + } + } + + // add the focus values + if focus != "" { + focusFormFieldWriter, err := requestBodyWriter.CreateFormField("focus") + if err != nil { + return model.Attachment{}, fmt.Errorf( + "unable to create the writer for the 'focus' form field: %w", + err, + ) + } + + if _, err := io.WriteString(focusFormFieldWriter, focus); err != nil { + return model.Attachment{}, fmt.Errorf( + "unable to write the focus values to the form: %w", + err, + ) + } + } + + if err := requestBodyWriter.Close(); err != nil { + return model.Attachment{}, fmt.Errorf("unable to close the writer: %w", err) + } + + url := g.Authentication.Instance + baseMediaPath + + var attachment model.Attachment + + params := requestParameters{ + httpMethod: http.MethodPost, + url: url, + requestBody: &requestBody, + contentType: requestBodyWriter.FormDataContentType(), + output: &attachment, + } + + if err := g.sendRequest(params); err != nil { + return model.Attachment{}, fmt.Errorf( + "received an error after sending the request to create the media attachment: %w", + err, + ) + } + + return attachment, nil +} + +func (g *Client) UpdateMediaAttachment(mediaAttachmentID, description, focus string) (model.Attachment, error) { + form := struct { + Description string `json:"description"` + Focus string `json:"focus"` + }{ + Description: description, + Focus: focus, + } + + data, err := json.Marshal(form) + if err != nil { + return model.Attachment{}, fmt.Errorf("unable to marshal the form: %w", err) + } + + requestBody := bytes.NewBuffer(data) + url := g.Authentication.Instance + baseMediaPath + "/" + mediaAttachmentID + + var updatedMediaAttachment model.Attachment + + params := requestParameters{ + httpMethod: http.MethodPut, + url: url, + requestBody: requestBody, + contentType: applicationJSON, + output: &updatedMediaAttachment, + } + + if err := g.sendRequest(params); err != nil { + return model.Attachment{}, fmt.Errorf( + "received an error after sending the request to update the media attachment: %w", + err, + ) + } + + return updatedMediaAttachment, nil +} diff --git a/internal/executor/create.go b/internal/executor/create.go index 194400a..8c68306 100644 --- a/internal/executor/create.go +++ b/internal/executor/create.go @@ -19,8 +19,9 @@ func (c *CreateExecutor) Execute() error { } funcMap := map[string]func(*client.Client) error{ - resourceList: c.createList, - resourceStatus: c.createStatus, + resourceList: c.createList, + resourceStatus: c.createStatus, + resourceMediaAttachment: c.createMediaAttachment, } doFunc, ok := funcMap[c.resourceType] @@ -153,3 +154,18 @@ func (c *CreateExecutor) createStatus(gtsClient *client.Client) error { return nil } + +func (c *CreateExecutor) createMediaAttachment(gtsClient *client.Client) error { + if c.fromFile == "" { + return FlagNotSetError{flagText: flagFromFile} + } + + attachment, err := gtsClient.CreateMediaAttachment(c.fromFile, c.description, c.focus) + if err != nil { + return fmt.Errorf("unable to create the media attachment: %w", err) + } + + c.printer.PrintSuccess("Successfully created the media attachment with ID: " + attachment.ID) + + return nil +} diff --git a/internal/executor/edit.go b/internal/executor/edit.go index 50c7247..1dc96b0 100644 --- a/internal/executor/edit.go +++ b/internal/executor/edit.go @@ -13,7 +13,8 @@ func (e *EditExecutor) Execute() error { } funcMap := map[string]func(*client.Client) error{ - resourceList: e.editList, + resourceList: e.editList, + resourceMediaAttachment: e.editMediaAttachment, } doFunc, ok := funcMap[e.resourceType] @@ -57,8 +58,41 @@ func (e *EditExecutor) editList(gtsClient *client.Client) error { return fmt.Errorf("unable to update the list: %w", err) } - e.printer.PrintSuccess("Successfully updated the list.") + e.printer.PrintSuccess("Successfully edited the list.") e.printer.PrintList(updatedList) return nil } + +func (e *EditExecutor) editMediaAttachment(gtsClient *client.Client) error { + expectedNumMediaAttachmentIDs := 1 + if !e.attachmentIDs.ExpectedLength(expectedNumMediaAttachmentIDs) { + return fmt.Errorf( + "received an unexpected number of media attachment IDs: want %d", + expectedNumMediaAttachmentIDs, + ) + } + + attachment, err := gtsClient.GetMediaAttachment(e.attachmentIDs[0]) + if err != nil { + return fmt.Errorf("unable to get the media attachment: %w", err) + } + + description := e.description + if description == "" { + description = attachment.Description + } + + focus := e.focus + if focus == "" { + focus = fmt.Sprintf("%f,%f", attachment.Meta.Focus.X, attachment.Meta.Focus.Y) + } + + if _, err = gtsClient.UpdateMediaAttachment(e.attachmentIDs[0], description, focus); err != nil { + return fmt.Errorf("unable to update the media attachment: %w", err) + } + + e.printer.PrintSuccess("Successfully edited the media attachment.") + + return nil +} diff --git a/internal/executor/executors.go b/internal/executor/executors.go index 4ce5cfc..a72b751 100644 --- a/internal/executor/executors.go +++ b/internal/executor/executors.go @@ -121,12 +121,15 @@ type CreateExecutor struct { printer *printer.Printer config *config.Config addPoll bool + attachmentIDs internalFlag.StringSliceValue content string contentType string + description string federated bool likeable bool replyable bool boostable bool + focus string fromFile string inReplyTo string language string @@ -150,6 +153,7 @@ func NewCreateExecutor( FlagSet: flag.NewFlagSet("create", flag.ExitOnError), printer: printer, config: config, + attachmentIDs: internalFlag.NewStringSliceValue(), pollExpiresIn: internalFlag.NewTimeDurationValue(), pollOptions: internalFlag.NewStringSliceValue(), sensitive: internalFlag.NewBoolPtrValue(), @@ -158,12 +162,15 @@ func NewCreateExecutor( exe.Usage = usage.ExecutorUsageFunc("create", "Creates a specific resource", exe.FlagSet) exe.BoolVar(&exe.addPoll, "add-poll", false, "Set to true to add a poll when creating a status") + exe.Var(&exe.attachmentIDs, "attachment-id", "The ID of the media attachment") exe.StringVar(&exe.content, "content", "", "The content of the created resource") exe.StringVar(&exe.contentType, "content-type", "plain", "The type that the contents should be parsed from (valid values are plain and markdown)") + exe.StringVar(&exe.description, "description", "", "The description of the media attachment that will be used as the alt-text") exe.BoolVar(&exe.federated, "enable-federation", true, "Set to true to federate the status beyond the local timelines") exe.BoolVar(&exe.likeable, "enable-likes", true, "Set to true to allow the status to be liked (favourited)") exe.BoolVar(&exe.replyable, "enable-replies", true, "Set to true to allow viewers to reply to the status") exe.BoolVar(&exe.boostable, "enable-reposts", true, "Set to true to allow the status to be reposted (boosted) by others") + exe.StringVar(&exe.focus, "focus", "", "The focus of the media file") exe.StringVar(&exe.fromFile, "from-file", "", "The file path where to read the contents from") exe.StringVar(&exe.inReplyTo, "in-reply-to", "", "The ID of the status that you want to reply to") exe.StringVar(&exe.language, "language", "", "The ISO 639 language code for this status") @@ -213,6 +220,9 @@ type EditExecutor struct { *flag.FlagSet printer *printer.Printer config *config.Config + attachmentIDs internalFlag.StringSliceValue + description string + focus string listID string listTitle string listRepliesPolicy string @@ -224,13 +234,17 @@ func NewEditExecutor( config *config.Config, ) *EditExecutor { exe := EditExecutor{ - FlagSet: flag.NewFlagSet("edit", flag.ExitOnError), - printer: printer, - config: config, + FlagSet: flag.NewFlagSet("edit", flag.ExitOnError), + printer: printer, + config: config, + attachmentIDs: internalFlag.NewStringSliceValue(), } exe.Usage = usage.ExecutorUsageFunc("edit", "Edit a specific resource", exe.FlagSet) + exe.Var(&exe.attachmentIDs, "attachment-id", "The ID of the media attachment") + exe.StringVar(&exe.description, "description", "", "The description of the media attachment that will be used as the alt-text") + exe.StringVar(&exe.focus, "focus", "", "The focus of the media file") exe.StringVar(&exe.listID, "list-id", "", "The ID of the list in question") exe.StringVar(&exe.listTitle, "list-title", "", "The title of the list") exe.StringVar(&exe.listRepliesPolicy, "list-replies-policy", "", "The replies policy of the list") diff --git a/schema/enbas_cli_schema.json b/schema/enbas_cli_schema.json index bbed96d..9c96abb 100644 --- a/schema/enbas_cli_schema.json +++ b/schema/enbas_cli_schema.json @@ -32,6 +32,10 @@ "type": "string", "description": "The type that the contents should be parsed from (valid values are plain and markdown)" }, + "description": { + "type": "string", + "description": "The description of the media attachment that will be used as the alt-text" + }, "enable-federation": { "type": "bool", "description": "Set to true to federate the status beyond the local timelines" @@ -56,6 +60,10 @@ "type": "bool", "description": "Set to true to exclude statuses that are a reply to another status" }, + "focus": { + "type": "string", + "description": "The focus of the media file" + }, "from": { "type": "string", "description": "The resource type to action the target resource from (e.g. status)" @@ -234,12 +242,15 @@ "additionalFields": [], "flags": [ { "flag": "add-poll", "default": "false" }, + { "flag": "attachment-id", "fieldName": "attachmentIDs" }, { "flag": "content", "default": "" }, { "flag": "content-type", "default": "plain" }, + { "flag": "description", "default": "" }, { "flag": "enable-federation", "fieldName": "federated", "default": "true" }, { "flag": "enable-likes", "fieldName": "likeable", "default": "true" }, { "flag": "enable-replies", "fieldName": "replyable", "default": "true" }, { "flag": "enable-reposts", "fieldName": "boostable", "default": "true" }, + { "flag": "focus", "default": "" }, { "flag": "from-file", "default": "" }, { "flag": "in-reply-to", "default": "" }, { "flag": "language", "default": "" }, @@ -271,6 +282,9 @@ "edit": { "additionalFields": [], "flags": [ + { "flag": "attachment-id", "fieldName": "attachmentIDs" }, + { "flag": "description", "default": "" }, + { "flag": "focus", "default": "" }, { "flag": "list-id", "fieldName": "listID", "default": ""}, { "flag": "list-title", "default": "" }, { "flag": "list-replies-policy", "default": "" },