From b06e92f26bd74e991fdafc796702c94b163b9ede Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Thu, 13 Jun 2024 19:02:31 +0100 Subject: [PATCH] feat: view polls and add votes to them Add support for viewing and voting in polls. --- internal/client/poll.go | 55 +++++++++++++++ internal/executor/add.go | 57 +++++++++++++++- internal/executor/errors.go | 12 ++++ internal/executor/flags.go | 35 +++++++++- internal/executor/resources.go | 4 +- internal/executor/show.go | 18 +++++ internal/model/poll.go | 119 +++++++++++++++++++++++++++++++++ internal/model/status.go | 102 +++++++++++++--------------- 8 files changed, 342 insertions(+), 60 deletions(-) create mode 100644 internal/client/poll.go create mode 100644 internal/model/poll.go diff --git a/internal/client/poll.go b/internal/client/poll.go new file mode 100644 index 0000000..7e55253 --- /dev/null +++ b/internal/client/poll.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2024 Dan Anglin +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "codeflow.dananglin.me.uk/apollo/enbas/internal/model" +) + +const ( + pollPath string = "/api/v1/polls" +) + +func (g *Client) GetPoll(pollID string) (model.Poll, error) { + url := g.Authentication.Instance + pollPath + "/" + pollID + + var poll model.Poll + + if err := g.sendRequest(http.MethodGet, url, nil, &poll); err != nil { + return model.Poll{}, fmt.Errorf( + "received an error after sending the request to get the poll: %w", + err, + ) + } + + return poll, nil +} + +func (g *Client) VoteInPoll(pollID string, choices []int) error { + form := struct { + Choices []int `json:"choices"` + }{ + Choices: choices, + } + + data, err := json.Marshal(form) + if err != nil { + return fmt.Errorf("unable to encode the JSON form: %w", err) + } + + requestBody := bytes.NewBuffer(data) + url := g.Authentication.Instance + pollPath + "/" + pollID + "/votes" + + if err := g.sendRequest(http.MethodPost, url, requestBody, nil); err != nil { + return fmt.Errorf("received an error after sending the request to vote in the poll: %w", err) + } + + return nil +} diff --git a/internal/executor/add.go b/internal/executor/add.go index 99946d7..9955017 100644 --- a/internal/executor/add.go +++ b/internal/executor/add.go @@ -5,6 +5,7 @@ package executor import ( + "errors" "flag" "fmt" @@ -19,6 +20,8 @@ type AddExecutor struct { toResourceType string listID string statusID string + pollID string + choices PollChoices accountNames AccountNames content string } @@ -34,10 +37,12 @@ func NewAddExecutor(tlf TopLevelFlags, name, summary string) *AddExecutor { addExe.StringVar(&addExe.resourceType, flagType, "", "Specify the resource type to add (e.g. account, note)") addExe.StringVar(&addExe.toResourceType, flagTo, "", "Specify the target resource type to add to (e.g. list, account, etc)") - addExe.StringVar(&addExe.listID, flagListID, "", "The ID of the list to add to") + addExe.StringVar(&addExe.listID, flagListID, "", "The ID of the list") addExe.StringVar(&addExe.statusID, flagStatusID, "", "The ID of the status") - addExe.Var(&addExe.accountNames, flagAccountName, "The name of the account") addExe.StringVar(&addExe.content, flagContent, "", "The content of the resource") + addExe.StringVar(&addExe.pollID, flagPollID, "", "The ID of the poll") + addExe.Var(&addExe.accountNames, flagAccountName, "The name of the account") + addExe.Var(&addExe.choices, flagChoose, "Specify your choice ") addExe.Usage = commandUsageFunc(name, summary, addExe.FlagSet) @@ -54,6 +59,7 @@ func (a *AddExecutor) Execute() error { resourceAccount: a.addToAccount, resourceBookmarks: a.addToBookmarks, resourceStatus: a.addToStatus, + resourcePoll: a.addToPoll, } doFunc, ok := funcMap[a.toResourceType] @@ -227,3 +233,50 @@ func (a *AddExecutor) addBoostToStatus(gtsClient *client.Client) error { return nil } + +func (a *AddExecutor) addToPoll(gtsClient *client.Client) error { + if a.pollID == "" { + return FlagNotSetError{flagText: flagPollID} + } + + funcMap := map[string]func(*client.Client) error{ + resourceVote: a.addVoteToPoll, + } + + doFunc, ok := funcMap[a.resourceType] + if !ok { + return UnsupportedAddOperationError{ + ResourceType: a.resourceType, + AddToResourceType: a.toResourceType, + } + } + + return doFunc(gtsClient) +} + +func (a *AddExecutor) addVoteToPoll(gtsClient *client.Client) error { + if len(a.choices) == 0 { + return errors.New("please use --" + flagChoose + " to make a choice in this poll") + } + + poll, err := gtsClient.GetPoll(a.pollID) + if err != nil { + return fmt.Errorf("unable to retrieve the poll: %w", err) + } + + if poll.Expired { + return ExpiredPollError{} + } + + if !poll.Multiple && len(a.choices) > 1 { + return MultipleChoiceError{} + } + + if err := gtsClient.VoteInPoll(a.pollID, []int(a.choices)); err != nil { + return fmt.Errorf("unable to add your vote(s) to the poll: %w", err) + } + + fmt.Println("Successfully added your vote(s) to the poll.") + + return nil +} diff --git a/internal/executor/errors.go b/internal/executor/errors.go index c7dd944..9fa9fef 100644 --- a/internal/executor/errors.go +++ b/internal/executor/errors.go @@ -66,3 +66,15 @@ type UnknownCommandError struct { func (e UnknownCommandError) Error() string { return "unknown command '" + e.Command + "'" } + +type ExpiredPollError struct{} + +func (e ExpiredPollError) Error() string { + return "this poll has expired" +} + +type MultipleChoiceError struct{} + +func (e MultipleChoiceError) Error() string { + return "this poll does not allow multiple choices" +} diff --git a/internal/executor/flags.go b/internal/executor/flags.go index 90ebab3..42e2052 100644 --- a/internal/executor/flags.go +++ b/internal/executor/flags.go @@ -4,11 +4,16 @@ package executor -import "strings" +import ( + "fmt" + "strconv" + "strings" +) const ( flagAccountName = "account-name" flagBrowser = "browser" + flagChoose = "choose" flagContentType = "content-type" flagContent = "content" flagEnableFederation = "enable-federation" @@ -26,6 +31,7 @@ const ( flagListRepliesPolicy = "list-replies-policy" flagMyAccount = "my-account" flagNotify = "notify" + flagPollID = "poll-id" flagSensitive = "sensitive" flagSkipRelationship = "skip-relationship" flagShowPreferences = "show-preferences" @@ -58,3 +64,30 @@ type TopLevelFlags struct { NoColor *bool Pager string } + +type PollChoices []int + +func (p *PollChoices) String() string { + value := "Choices: " + + for ind, vote := range *p { + if ind == len(*p)-1 { + value += strconv.Itoa(vote) + } else { + value += strconv.Itoa(vote) + ", " + } + } + + return value +} + +func (p *PollChoices) Set(text string) error { + value, err := strconv.Atoi(text) + if err != nil { + return fmt.Errorf("unable to parse the value to an integer: %w", err) + } + + *p = append(*p, value) + + return nil +} diff --git a/internal/executor/resources.go b/internal/executor/resources.go index 10fce42..a634113 100644 --- a/internal/executor/resources.go +++ b/internal/executor/resources.go @@ -4,7 +4,7 @@ package executor -const( +const ( resourceAccount = "account" resourceBlocked = "blocked" resourceBookmarks = "bookmarks" @@ -17,8 +17,10 @@ const( resourceLiked = "liked" resourceList = "list" resourceNote = "note" + resourcePoll = "poll" resourceStatus = "status" resourceStar = "star" resourceStarred = "starred" resourceTimeline = "timeline" + resourceVote = "vote" ) diff --git a/internal/executor/show.go b/internal/executor/show.go index b4a5660..e9b90d3 100644 --- a/internal/executor/show.go +++ b/internal/executor/show.go @@ -26,6 +26,7 @@ type ShowExecutor struct { timelineCategory string listID string tag string + pollID string limit int } @@ -45,6 +46,7 @@ func NewShowExecutor(tlf TopLevelFlags, name, summary string) *ShowExecutor { showExe.StringVar(&showExe.timelineCategory, flagTimelineCategory, model.TimelineCategoryHome, "Specify the timeline category to view") showExe.StringVar(&showExe.listID, flagListID, "", "Specify the ID of the list to display") showExe.StringVar(&showExe.tag, flagTag, "", "Specify the name of the tag to use") + showExe.StringVar(&showExe.pollID, flagPollID, "", "Specify the ID of the poll to display") showExe.IntVar(&showExe.limit, flagLimit, 20, "Specify the limit of items to display") showExe.Usage = commandUsageFunc(name, summary, showExe.FlagSet) @@ -70,6 +72,7 @@ func (s *ShowExecutor) Execute() error { resourceLiked: s.showLiked, resourceStarred: s.showLiked, resourceFollowRequest: s.showFollowRequests, + resourcePoll: s.showPoll, } doFunc, ok := funcMap[s.resourceType] @@ -362,3 +365,18 @@ func (s *ShowExecutor) showFollowRequests(gtsClient *client.Client) error { return nil } + +func (s *ShowExecutor) showPoll(gtsClient *client.Client) error { + if s.pollID == "" { + return FlagNotSetError{flagText: flagPollID} + } + + poll, err := gtsClient.GetPoll(s.pollID) + if err != nil { + return fmt.Errorf("unable to retrieve the poll: %w", err) + } + + utilities.Display(poll, *s.topLevelFlags.NoColor, s.topLevelFlags.Pager) + + return nil +} diff --git a/internal/model/poll.go b/internal/model/poll.go new file mode 100644 index 0000000..b1c9bec --- /dev/null +++ b/internal/model/poll.go @@ -0,0 +1,119 @@ +package model + +import ( + "io" + "math" + "strconv" + "strings" + "time" + + "codeflow.dananglin.me.uk/apollo/enbas/internal/utilities" +) + +type Poll struct { + Emojis []Emoji `json:"emojis"` + Expired bool `json:"expired"` + Voted bool `json:"voted"` + Multiple bool `json:"multiple"` + ExpiredAt time.Time `json:"expires_at"` + ID string `json:"id"` + OwnVotes []int `json:"own_votes"` + VotersCount int `json:"voters_count"` + VotesCount int `json:"votes_count"` + Options []PollOption `json:"options"` +} + +type PollOption struct { + Title string `json:"title"` + VotesCount int `json:"votes_count"` +} + +func (p Poll) Display(noColor bool) string { + var builder strings.Builder + + indent := " " + + builder.WriteString( + utilities.HeaderFormat(noColor, "POLL ID:") + + "\n" + indent + p.ID + + "\n\n" + utilities.HeaderFormat(noColor, "OPTIONS:"), + ) + + displayPollContent(&builder, p, noColor, indent) + + builder.WriteString( + "\n\n" + + utilities.HeaderFormat(noColor, "MULTIPLE CHOICES ALLOWED:") + + "\n" + indent + strconv.FormatBool(p.Multiple) + + "\n\n" + + utilities.HeaderFormat(noColor, "YOU VOTED:") + + "\n" + indent + strconv.FormatBool(p.Voted), + ) + + if len(p.OwnVotes) > 0 { + builder.WriteString("\n\n" + utilities.HeaderFormat(noColor, "YOUR VOTES:")) + + for _, vote := range p.OwnVotes { + builder.WriteString("\n" + indent + "[" + strconv.Itoa(vote) + "] " + p.Options[vote].Title) + } + } + + builder.WriteString( + "\n\n" + + utilities.HeaderFormat(noColor, "EXPIRED:") + + "\n" + indent + strconv.FormatBool(p.Expired), + ) + + return builder.String() +} + +func displayPollContent(writer io.StringWriter, poll Poll, noColor bool, indent string) { + for ind, option := range poll.Options { + var percentage int + var calculate float64 + + if poll.VotesCount == 0 { + percentage = 0 + } else { + calculate = float64(option.VotesCount) / float64(poll.VotesCount) + percentage = int(math.Floor(100 * calculate)) + } + + writer.WriteString("\n\n" + indent + "[" + strconv.Itoa(ind) + "] " + option.Title) + drawPollMeter(writer, noColor, calculate, 80, indent) + + writer.WriteString( + "\n" + indent + strconv.Itoa(option.VotesCount) + " votes " + + "(" + strconv.Itoa(percentage) + "%)", + ) + } + + writer.WriteString( + "\n\n" + + indent + utilities.FieldFormat(noColor, "Total votes:") + " " + strconv.Itoa(poll.VotesCount) + + "\n" + indent + utilities.FieldFormat(noColor, "Poll ID:") + " " + poll.ID + + "\n" + indent + utilities.FieldFormat(noColor, "Poll is open until:") + " " + utilities.FormatTime(poll.ExpiredAt), + ) +} + +func drawPollMeter(writer io.StringWriter, noColor bool, calculated float64, limit int, indent string) { + numVoteBlocks := int(math.Floor(float64(limit) * calculated)) + numBackgroundBlocks := limit - numVoteBlocks + blockChar := "\u2501" + voteBlockColor := "\033[32;1m" + backgroundBlockColor := "\033[90m" + + if noColor { + voteBlockColor = "\033[0m" + + if numVoteBlocks == 0 { + numVoteBlocks = 1 + } + } + + writer.WriteString("\n" + indent + voteBlockColor + strings.Repeat(blockChar, numVoteBlocks) + "\033[0m") + + if !noColor { + writer.WriteString(backgroundBlockColor + strings.Repeat(blockChar, numBackgroundBlocks) + "\033[0m") + } +} diff --git a/internal/model/status.go b/internal/model/status.go index 09befcd..d150765 100644 --- a/internal/model/status.go +++ b/internal/model/status.go @@ -5,7 +5,7 @@ package model import ( - "fmt" + "strconv" "strings" "time" @@ -30,7 +30,7 @@ type Status struct { Mentions []Mention `json:"mentions"` Muted bool `json:"muted"` Pinned bool `json:"pinned"` - Poll Poll `json:"poll"` + Poll *Poll `json:"poll"` Reblog *StatusReblogged `json:"reblog"` Reblogged bool `json:"reblogged"` ReblogsCount int `json:"reblogs_count"` @@ -68,24 +68,6 @@ type Mention struct { Username string `json:"username"` } -type Poll struct { - Emojis []Emoji `json:"emojis"` - Expired bool `json:"expired"` - Voted bool `json:"voted"` - Multiple bool `json:"multiple"` - ExpiredAt time.Time `json:"expires_at"` - ID string `json:"id"` - OwnVotes []int `json:"own_votes"` - VotersCount int `json:"voters_count"` - VotesCount int `json:"votes_count"` - Options []PollOption `json:"options"` -} - -type PollOption struct { - Title string `json:"title"` - VotesCount int `json:"votes_count"` -} - type StatusReblogged struct { Account Account `json:"account"` Application Application `json:"application"` @@ -158,47 +140,44 @@ type MediaDimensions struct { } func (s Status) Display(noColor bool) string { - format := ` -%s + indent := " " -%s - %s -%s - %s + var builder strings.Builder -%s - %s + // The account information + builder.WriteString(utilities.FullDisplayNameFormat(noColor, s.Account.DisplayName, s.Account.Acct) + "\n\n") -%s - Boosts: %d - Likes: %d - Replies: %d + // The content of the status. + builder.WriteString(utilities.HeaderFormat(noColor, "CONTENT:")) + builder.WriteString(utilities.WrapLines(utilities.ConvertHTMLToText(s.Content), "\n ", 80)) -%s - %s + // If a poll exists in a status, write the contents to the builder. + if s.Poll != nil { + displayPollContent(&builder, *s.Poll, noColor, indent) + } -%s - %s -` + // The ID of the status + builder.WriteString("\n\n" + utilities.HeaderFormat(noColor, "STATUS ID:") + "\n" + indent + s.ID) - return fmt.Sprintf( - format, - utilities.FullDisplayNameFormat(noColor, s.Account.DisplayName, s.Account.Acct), - utilities.HeaderFormat(noColor, "CONTENT:"), - utilities.WrapLines(utilities.ConvertHTMLToText(s.Content), "\n ", 80), - utilities.HeaderFormat(noColor, "STATUS ID:"), - s.ID, - utilities.HeaderFormat(noColor, "CREATED AT:"), - utilities.FormatTime(s.CreatedAt), - utilities.HeaderFormat(noColor, "STATS:"), - s.ReblogsCount, - s.FavouritesCount, - s.RepliesCount, - utilities.HeaderFormat(noColor, "VISIBILITY:"), - s.Visibility, - utilities.HeaderFormat(noColor, "URL:"), - s.URL, + // Status creation time + builder.WriteString("\n\n" + utilities.HeaderFormat(noColor, "CREATED AT:") + "\n" + indent + utilities.FormatTime(s.CreatedAt)) + + // Status stats + builder.WriteString( + "\n\n" + + utilities.HeaderFormat(noColor, "STATS:") + + "\n" + indent + utilities.FieldFormat(noColor, "Boosts: ") + strconv.Itoa(s.ReblogsCount) + + "\n" + indent + utilities.FieldFormat(noColor, "Likes: ") + strconv.Itoa(s.FavouritesCount) + + "\n" + indent + utilities.FieldFormat(noColor, "Replies: ") + strconv.Itoa(s.RepliesCount), ) + + // Status visibility + builder.WriteString("\n\n" + utilities.HeaderFormat(noColor, "VISIBILITY:") + "\n" + indent + s.Visibility.String()) + + // Status URL + builder.WriteString("\n\n" + utilities.HeaderFormat(noColor, "URL:") + "\n" + indent + s.URL) + + return builder.String() } type StatusList struct { @@ -225,8 +204,19 @@ func (s StatusList) Display(noColor bool) string { createdAt = status.Reblog.CreatedAt } - builder.WriteString(utilities.WrapLines(utilities.ConvertHTMLToText(status.Content), "\n", 80) + "\n\n") - builder.WriteString(utilities.FieldFormat(noColor, "ID:") + " " + statusID + "\t" + utilities.FieldFormat(noColor, "Created at:") + " " + utilities.FormatTime(createdAt) + "\n") + builder.WriteString(utilities.WrapLines(utilities.ConvertHTMLToText(status.Content), "\n", 80)) + + if status.Poll != nil { + displayPollContent(&builder, *status.Poll, noColor, "") + } + + builder.WriteString( + "\n\n" + + utilities.FieldFormat(noColor, "Status ID:") + " " + statusID + "\t" + + utilities.FieldFormat(noColor, "Created at:") + " " + utilities.FormatTime(createdAt) + + "\n", + ) + builder.WriteString(separator + "\n") }