From a83aaa17f6f6a90bd2fe720a2baba4a03b11d715 Mon Sep 17 00:00:00 2001 From: Dan Anglin Date: Sat, 24 Feb 2024 15:53:29 +0000 Subject: [PATCH] feat: add timeline support Add support for viewing all timeline types (home, public, lists and tags). --- cmd/enbas/show.go | 66 ++++++++++++++++++++++++++++----- cmd/enbas/usage.go | 7 ++-- internal/client/client.go | 64 ++++++++++++++++++++++++++++++-- internal/model/account.go | 28 +++++++------- internal/model/instance_v2.go | 19 ++++++---- internal/model/status.go | 16 ++++---- internal/model/timeline.go | 29 +++++++++++++++ internal/utilities/utilities.go | 16 +++++++- 8 files changed, 198 insertions(+), 47 deletions(-) create mode 100644 internal/model/timeline.go diff --git a/cmd/enbas/show.go b/cmd/enbas/show.go index 4aa90d1..5fb63ee 100644 --- a/cmd/enbas/show.go +++ b/cmd/enbas/show.go @@ -7,29 +7,34 @@ import ( "codeflow.dananglin.me.uk/apollo/enbas/internal/client" "codeflow.dananglin.me.uk/apollo/enbas/internal/config" + "codeflow.dananglin.me.uk/apollo/enbas/internal/model" ) type showCommand struct { *flag.FlagSet - myAccount bool - targetType string - account string - statusID string + myAccount bool + targetType string + account string + statusID string + timelineType string + timelineListID string + timelineTagName string + timelineLimit int } func newShowCommand(name, summary string) *showCommand { command := showCommand{ - FlagSet: flag.NewFlagSet(name, flag.ExitOnError), - myAccount: false, - targetType: "", - account: "", - statusID: "", + FlagSet: flag.NewFlagSet(name, flag.ExitOnError), } command.BoolVar(&command.myAccount, "my-account", false, "set to true to lookup your account") command.StringVar(&command.targetType, "type", "", "specify the type of resource to display") command.StringVar(&command.account, "account", "", "specify the account URI to lookup") command.StringVar(&command.statusID, "status-id", "", "specify the ID of the status to display") + command.StringVar(&command.timelineType, "timeline-type", "home", "specify the type of timeline to display (valid values are home, public, list and tag)") + command.StringVar(&command.timelineListID, "timeline-list-id", "", "specify the ID of the list timeline to display") + command.StringVar(&command.timelineTagName, "timeline-tag-name", "", "specify the name of the tag timeline to display") + command.IntVar(&command.timelineLimit, "timeline-limit", 5, "specify the number of statuses to display") command.Usage = commandUsageFunc(name, summary, command.FlagSet) return &command @@ -45,6 +50,7 @@ func (c *showCommand) Execute() error { "instance": c.showInstance, "account": c.showAccount, "status": c.showStatus, + "timeline": c.showTimeline, } doFunc, ok := funcMap[c.targetType] @@ -108,3 +114,45 @@ func (c *showCommand) showStatus(gts *client.Client) error { return nil } + +func (c *showCommand) showTimeline(gts *client.Client) error { + var ( + timeline model.Timeline + err error + ) + + switch c.timelineType { + case "home": + timeline, err = gts.GetHomeTimeline(c.timelineLimit) + case "public": + timeline, err = gts.GetPublicTimeline(c.timelineLimit) + case "list": + if c.timelineListID == "" { + return errors.New("the timeline-list-id flag is not set") + } + + timeline, err = gts.GetListTimeline(c.timelineListID, c.timelineLimit) + case "tag": + if c.timelineTagName == "" { + return errors.New("the timeline-tag-name flag is not set") + } + + timeline, err = gts.GetTagTimeline(c.timelineTagName, c.timelineLimit) + default: + return fmt.Errorf("%q is not a valid type of timeline", c.timelineType) + } + + if err != nil { + return fmt.Errorf("unable to retrieve the %s timeline; %w", c.timelineType, err) + } + + if len(timeline.Statuses) == 0 { + fmt.Println("There are no statuses in this timeline.") + + return nil + } + + fmt.Println(timeline) + + return nil +} diff --git a/cmd/enbas/usage.go b/cmd/enbas/usage.go index 660139b..4536ed3 100644 --- a/cmd/enbas/usage.go +++ b/cmd/enbas/usage.go @@ -22,8 +22,7 @@ func commandUsageFunc(name, summary string, flagset *flag.FlagSet) func() { flagset.VisitAll(func(f *flag.Flag) { fmt.Fprintf( &builder, - "\n -%s, --%s\n %s", - f.Name, + "\n --%s\n %s", f.Name, f.Usage, ) @@ -63,9 +62,9 @@ func enbasUsageFunc(summaries map[string]string) func() { fmt.Fprintf(&builder, "\n %s\t%s", cmd, summaries[cmd]) } - builder.WriteString("\n\nFLAGS:\n -help, --help\n print the help message\n") + builder.WriteString("\n\nFLAGS:\n --help\n print the help message\n") flag.VisitAll(func(f *flag.Flag) { - fmt.Fprintf(&builder, "\n -%s, --%s\n %s\n", f.Name, f.Name, f.Usage) + fmt.Fprintf(&builder, "\n --%s\n %s\n", f.Name, f.Usage) }) builder.WriteString("\nUse \"enbas [command] --help\" for more information about a command.\n") diff --git a/internal/client/client.go b/internal/client/client.go index 6113653..94d929f 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -34,14 +34,14 @@ func NewClientFromConfig() (*Client, error) { func NewClient(authentication config.Authentication) *Client { httpClient := http.Client{} - client := Client{ + gtsClient := Client{ Authentication: authentication, HTTPClient: httpClient, UserAgent: internal.UserAgent, Timeout: 5 * time.Second, } - return &client + return >sClient } func (g *Client) VerifyCredentials() (model.Account, error) { @@ -71,7 +71,7 @@ func (g *Client) GetInstance() (model.InstanceV2, error) { } func (g *Client) GetAccount(accountURI string) (model.Account, error) { - path := "/api/v1/accounts/lookup" + "?acct=" + accountURI + path := "/api/v1/accounts/lookup?acct=" + accountURI url := g.Authentication.Instance + path var account model.Account @@ -96,6 +96,64 @@ func (g *Client) GetStatus(statusID string) (model.Status, error) { return status, nil } +func (g *Client) GetHomeTimeline(limit int) (model.Timeline, error) { + path := fmt.Sprintf("/api/v1/timelines/home?limit=%d", limit) + + timeline := model.Timeline{ + Name: "HOME TIMELINE", + Statuses: nil, + } + + return g.getTimeline(path, timeline) +} + +func (g *Client) GetPublicTimeline(limit int) (model.Timeline, error) { + path := fmt.Sprintf("/api/v1/timelines/public?limit=%d", limit) + + timeline := model.Timeline{ + Name: "PUBLIC TIMELINE", + Statuses: nil, + } + + return g.getTimeline(path, timeline) +} + +func (g *Client) GetListTimeline(listID string, limit int) (model.Timeline, error) { + path := fmt.Sprintf("/api/v1/timelines/list/%s?limit=%d", listID, limit) + + timeline := model.Timeline{ + Name: "LIST: " + listID, + Statuses: nil, + } + + return g.getTimeline(path, timeline) +} + +func (g *Client) GetTagTimeline(tag string, limit int) (model.Timeline, error) { + path := fmt.Sprintf("/api/v1/timelines/tag/%s?limit=%d", tag, limit) + + timeline := model.Timeline{ + Name: "TAG: " + tag, + Statuses: nil, + } + + return g.getTimeline(path, timeline) +} + +func (g *Client) getTimeline(path string, timeline model.Timeline) (model.Timeline, error) { + url := g.Authentication.Instance + path + + var statuses []model.Status + + if err := g.sendRequest(http.MethodGet, url, nil, &statuses); err != nil { + return timeline, fmt.Errorf("received an error after sending the request to get the timeline; %w", err) + } + + timeline.Statuses = statuses + + return timeline, nil +} + func (g *Client) sendRequest(method string, url string, requestBody io.Reader, object any) error { ctx, cancel := context.WithTimeout(context.Background(), g.Timeout) defer cancel() diff --git a/internal/model/account.go b/internal/model/account.go index 377e430..4313263 100644 --- a/internal/model/account.go +++ b/internal/model/account.go @@ -67,9 +67,9 @@ func (a Account) String() string { %s %s - Followers: %d - Following: %d - Statuses: %d + %s %d + %s %d + %s %d %s %s @@ -84,28 +84,28 @@ func (a Account) String() string { for _, field := range a.Fields { metadata += fmt.Sprintf( "\n %s: %s", - field.Name, + utilities.FieldFormat(field.Name), utilities.StripHTMLTags(field.Value), ) } return fmt.Sprintf( format, - a.DisplayName, + utilities.DisplayNameFormat(a.DisplayName), a.Username, - utilities.Header("ACCOUNT ID:"), + utilities.HeaderFormat("ACCOUNT ID:"), a.ID, - utilities.Header("JOINED ON:"), + utilities.HeaderFormat("JOINED ON:"), utilities.FormatDate(a.CreatedAt), - utilities.Header("STATS:"), - a.FollowersCount, - a.FollowingCount, - a.StatusCount, - utilities.Header("BIOGRAPHY:"), + utilities.HeaderFormat("STATS:"), + utilities.FieldFormat("Followers:"), a.FollowersCount, + utilities.FieldFormat("Followeing:"), a.FollowingCount, + utilities.FieldFormat("Statuses:"), a.StatusCount, + utilities.HeaderFormat("BIOGRAPHY:"), utilities.WrapLine(utilities.StripHTMLTags(a.Note), "\n ", 80), - utilities.Header("METADATA:"), + utilities.HeaderFormat("METADATA:"), metadata, - utilities.Header("ACCOUNT URL:"), + utilities.HeaderFormat("ACCOUNT URL:"), a.URL, ) } diff --git a/internal/model/instance_v2.go b/internal/model/instance_v2.go index a7dba28..78b35d5 100644 --- a/internal/model/instance_v2.go +++ b/internal/model/instance_v2.go @@ -123,23 +123,26 @@ func (i InstanceV2) String() string { Running GoToSocial %s %s - name: %s - username: %s - email: %s + %s %s + %s %s + %s %s ` return fmt.Sprintf( format, - utilities.Header("INSTANCE:"), + utilities.HeaderFormat("INSTANCE:"), i.Title, i.Description, - utilities.Header("DOMAIN:"), + utilities.HeaderFormat("DOMAIN:"), i.Domain, - utilities.Header("VERSION:"), + utilities.HeaderFormat("VERSION:"), i.Version, - utilities.Header("CONTACT:"), - i.Contact.Account.DisplayName, + utilities.HeaderFormat("CONTACT:"), + utilities.FieldFormat("Name:"), + utilities.DisplayNameFormat(i.Contact.Account.DisplayName), + utilities.FieldFormat("Username:"), i.Contact.Account.Username, + utilities.FieldFormat("Email:"), i.Contact.Email, ) } diff --git a/internal/model/status.go b/internal/model/status.go index 2c8be69..15a1364 100644 --- a/internal/model/status.go +++ b/internal/model/status.go @@ -179,21 +179,21 @@ func (s Status) String() string { return fmt.Sprintf( format, - s.Account.DisplayName, + utilities.DisplayNameFormat(s.Account.DisplayName), s.Account.Username, - utilities.Header("CONTENT:"), - utilities.StripHTMLTags(s.Content), - utilities.Header("STATUS ID:"), + utilities.HeaderFormat("CONTENT:"), + s.Text, + utilities.HeaderFormat("STATUS ID:"), s.ID, - utilities.Header("CREATED AT:"), + utilities.HeaderFormat("CREATED AT:"), utilities.FormatTime(s.CreatedAt), - utilities.Header("STATS:"), + utilities.HeaderFormat("STATS:"), s.RebloggsCount, s.FavouritesCount, s.RepliesCount, - utilities.Header("VISIBILITY:"), + utilities.HeaderFormat("VISIBILITY:"), s.Visibility, - utilities.Header("URL:"), + utilities.HeaderFormat("URL:"), s.URL, ) } diff --git a/internal/model/timeline.go b/internal/model/timeline.go new file mode 100644 index 0000000..9d136dc --- /dev/null +++ b/internal/model/timeline.go @@ -0,0 +1,29 @@ +package model + +import ( + "strings" + + "codeflow.dananglin.me.uk/apollo/enbas/internal/utilities" +) + +type Timeline struct { + Name string + Statuses []Status +} + +func (t Timeline) String() string { + var builder strings.Builder + + separator := "────────────────────────────────────────────────────────────────────────────────" + + builder.WriteString(t.Name + "\n" + separator + "\n") + + for _, status := range t.Statuses { + builder.WriteString(utilities.DisplayNameFormat(status.Account.DisplayName) + " (@" + status.Account.Username + ")\n\n") + builder.WriteString(utilities.WrapLine(status.Text, "\n", 80) + "\n\n") + builder.WriteString(utilities.FieldFormat("ID:") + " " + status.ID + "\t" + utilities.FieldFormat("Created at:") + " " + utilities.FormatTime(status.CreatedAt) + "\n") + builder.WriteString(separator + "\n") + } + + return builder.String() +} diff --git a/internal/utilities/utilities.go b/internal/utilities/utilities.go index cb5f2be..69f5e56 100644 --- a/internal/utilities/utilities.go +++ b/internal/utilities/utilities.go @@ -1,6 +1,7 @@ package utilities import ( + "regexp" "strings" "time" "unicode" @@ -11,6 +12,8 @@ import ( const ( reset = "\033[0m" boldblue = "\033[34;1m" + boldmagenta = "\033[35;1m" + green = "\033[32m" ) func StripHTMLTags(text string) string { @@ -52,10 +55,21 @@ func WrapLine(line, separator string, charLimit int) string { return builder.String() } -func Header(text string) string { +func HeaderFormat(text string) string { return boldblue + text + reset } +func FieldFormat(text string) string { + return green + text + reset +} + +func DisplayNameFormat(text string) string { + // use this pattern to remove all emoji strings + pattern := regexp.MustCompile(`\s:[A-Za-z0-9]*:`) + + return boldmagenta + pattern.ReplaceAllString(text, "") + reset +} + func FormatDate(date time.Time) string { return date.Local().Format("02 Jan 2006") }