enbas/internal/executor/show.go
Dan Anglin 42251f6df8
feat: add configuration support to enbas
SUMMARY

This commit adds configuration support to enbas. The configuration is
stored as a JSON file in the user specified configuration directory.

When using enbas for the first time, the user will first need to
execute the new init command in order to generate the configuration.
Once this has been generated the user can edit the settings to
personalise their experience, login to their account and use enbas as
normal.

For now the configurable settings included in the configuration
are as follows:

- The path to the credentials file (by default this is set to a file in
  the same directory as the configuration file).
- The path to the cache directory.
- The character limit used for line wrapping.
- The programs used for integrations such as paging, media viewing,
  opening URLs, etc.

CHANGES

- added the new config type.
- added the new init executor for generating a new configuration file.
- removed the following top level flags in favour of the new
  configration support.
    - cache-dir
    - pager
    - image-viewer
    - video-player
    - max-terminal-width
- added a new error type for use when an unknown media attachment ID
  is specified.
- updated the usage function for the executors to support a case
  where a flagsets has no flags.
- update .golangci.yaml to disable some linters
2024-06-25 12:39:39 +01:00

543 lines
14 KiB
Go

// SPDX-FileCopyrightText: 2024 Dan Anglin <d.n.i.anglin@gmail.com>
//
// SPDX-License-Identifier: GPL-3.0-or-later
package executor
import (
"flag"
"fmt"
"path/filepath"
"strings"
"codeflow.dananglin.me.uk/apollo/enbas/internal/client"
"codeflow.dananglin.me.uk/apollo/enbas/internal/config"
"codeflow.dananglin.me.uk/apollo/enbas/internal/model"
"codeflow.dananglin.me.uk/apollo/enbas/internal/printer"
"codeflow.dananglin.me.uk/apollo/enbas/internal/utilities"
)
type ShowExecutor struct {
*flag.FlagSet
printer *printer.Printer
config *config.Config
myAccount bool
skipAccountRelationship bool
showUserPreferences bool
showInBrowser bool
resourceType string
accountName string
statusID string
timelineCategory string
listID string
tag string
pollID string
fromResourceType string
limit int
attachmentIDs MultiStringFlagValue
}
func NewShowExecutor(printer *printer.Printer, config *config.Config, name, summary string) *ShowExecutor {
showExe := ShowExecutor{
FlagSet: flag.NewFlagSet(name, flag.ExitOnError),
printer: printer,
config: config,
}
showExe.BoolVar(&showExe.myAccount, flagMyAccount, false, "Set to true to lookup your account")
showExe.BoolVar(&showExe.skipAccountRelationship, flagSkipRelationship, false, "Set to true to skip showing your relationship to the specified account")
showExe.BoolVar(&showExe.showUserPreferences, flagShowPreferences, false, "Show your preferences")
showExe.BoolVar(&showExe.showInBrowser, flagBrowser, false, "Set to true to view in the browser")
showExe.StringVar(&showExe.resourceType, flagType, "", "Specify the type of resource to display")
showExe.StringVar(&showExe.accountName, flagAccountName, "", "Specify the account name in full (username@domain)")
showExe.StringVar(&showExe.statusID, flagStatusID, "", "Specify the ID of the status to display")
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.Var(&showExe.attachmentIDs, flagAttachmentID, "Specify the ID of the media attachment to display")
showExe.StringVar(&showExe.fromResourceType, flagFrom, "", "Specify the resource type to view the target resource from (e.g. status for viewing media from, etc)")
showExe.IntVar(&showExe.limit, flagLimit, 20, "Specify the limit of items to display")
showExe.Usage = commandUsageFunc(name, summary, showExe.FlagSet)
return &showExe
}
func (s *ShowExecutor) Execute() error {
if s.resourceType == "" {
return FlagNotSetError{flagText: flagType}
}
funcMap := map[string]func(*client.Client) error{
resourceInstance: s.showInstance,
resourceAccount: s.showAccount,
resourceStatus: s.showStatus,
resourceTimeline: s.showTimeline,
resourceList: s.showList,
resourceFollowers: s.showFollowers,
resourceFollowing: s.showFollowing,
resourceBlocked: s.showBlocked,
resourceBookmarks: s.showBookmarks,
resourceLiked: s.showLiked,
resourceStarred: s.showLiked,
resourceFollowRequest: s.showFollowRequests,
resourcePoll: s.showPoll,
resourceMutedAccounts: s.showMutedAccounts,
resourceMedia: s.showMedia,
resourceMediaAttachment: s.showMediaAttachment,
}
doFunc, ok := funcMap[s.resourceType]
if !ok {
return UnsupportedTypeError{resourceType: s.resourceType}
}
gtsClient, err := client.NewClientFromFile(s.config.CredentialsFile)
if err != nil {
return fmt.Errorf("unable to create the GoToSocial client: %w", err)
}
return doFunc(gtsClient)
}
func (s *ShowExecutor) showInstance(gtsClient *client.Client) error {
instance, err := gtsClient.GetInstance()
if err != nil {
return fmt.Errorf("unable to retrieve the instance details: %w", err)
}
s.printer.PrintInstance(instance)
return nil
}
func (s *ShowExecutor) showAccount(gtsClient *client.Client) error {
var (
account model.Account
err error
)
if s.myAccount {
account, err = getMyAccount(gtsClient, s.config.CredentialsFile)
if err != nil {
return fmt.Errorf("received an error while getting the account details: %w", err)
}
} else {
if s.accountName == "" {
return FlagNotSetError{flagText: flagAccountName}
}
account, err = getAccount(gtsClient, s.accountName)
if err != nil {
return fmt.Errorf("received an error while getting the account details: %w", err)
}
}
if s.showInBrowser {
utilities.OpenLink(account.URL)
return nil
}
var (
relationship *model.AccountRelationship
preferences *model.Preferences
)
if !s.myAccount && !s.skipAccountRelationship {
relationship, err = gtsClient.GetAccountRelationship(account.ID)
if err != nil {
return fmt.Errorf("unable to retrieve the relationship to this account: %w", err)
}
}
if s.myAccount && s.showUserPreferences {
preferences, err = gtsClient.GetUserPreferences()
if err != nil {
return fmt.Errorf("unable to retrieve the user preferences: %w", err)
}
}
s.printer.PrintAccount(account, relationship, preferences)
return nil
}
func (s *ShowExecutor) showStatus(gtsClient *client.Client) error {
if s.statusID == "" {
return FlagNotSetError{flagText: flagStatusID}
}
status, err := gtsClient.GetStatus(s.statusID)
if err != nil {
return fmt.Errorf("unable to retrieve the status: %w", err)
}
if s.showInBrowser {
utilities.OpenLink(status.URL)
return nil
}
s.printer.PrintStatus(status)
return nil
}
func (s *ShowExecutor) showTimeline(gtsClient *client.Client) error {
var (
timeline model.StatusList
err error
)
switch s.timelineCategory {
case model.TimelineCategoryHome:
timeline, err = gtsClient.GetHomeTimeline(s.limit)
case model.TimelineCategoryPublic:
timeline, err = gtsClient.GetPublicTimeline(s.limit)
case model.TimelineCategoryList:
if s.listID == "" {
return FlagNotSetError{flagText: flagListID}
}
var list model.List
list, err = gtsClient.GetList(s.listID)
if err != nil {
return fmt.Errorf("unable to retrieve the list: %w", err)
}
timeline, err = gtsClient.GetListTimeline(list.ID, list.Title, s.limit)
case model.TimelineCategoryTag:
if s.tag == "" {
return FlagNotSetError{flagText: flagTag}
}
timeline, err = gtsClient.GetTagTimeline(s.tag, s.limit)
default:
return model.InvalidTimelineCategoryError{Value: s.timelineCategory}
}
if err != nil {
return fmt.Errorf("unable to retrieve the %s timeline: %w", s.timelineCategory, err)
}
if len(timeline.Statuses) == 0 {
s.printer.PrintInfo("There are no statuses in this timeline.\n")
return nil
}
s.printer.PrintStatusList(timeline)
return nil
}
func (s *ShowExecutor) showList(gtsClient *client.Client) error {
if s.listID == "" {
return s.showLists(gtsClient)
}
list, err := gtsClient.GetList(s.listID)
if err != nil {
return fmt.Errorf("unable to retrieve the list: %w", err)
}
accounts, err := gtsClient.GetAccountsFromList(s.listID, 0)
if err != nil {
return fmt.Errorf("unable to retrieve the accounts from the list: %w", err)
}
if len(accounts) > 0 {
accountMap := make(map[string]string)
for i := range accounts {
accountMap[accounts[i].Acct] = accounts[i].Username
}
list.Accounts = accountMap
}
s.printer.PrintList(list)
return nil
}
func (s *ShowExecutor) showLists(gtsClient *client.Client) error {
lists, err := gtsClient.GetAllLists()
if err != nil {
return fmt.Errorf("unable to retrieve the lists: %w", err)
}
if len(lists) == 0 {
s.printer.PrintInfo("You have no lists.\n")
return nil
}
s.printer.PrintLists(lists)
return nil
}
func (s *ShowExecutor) showFollowers(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, s.myAccount, s.accountName, s.config.CredentialsFile)
if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
followers, err := gtsClient.GetFollowers(accountID, s.limit)
if err != nil {
return fmt.Errorf("unable to retrieve the list of followers: %w", err)
}
if len(followers.Accounts) > 0 {
s.printer.PrintAccountList(followers)
} else {
s.printer.PrintInfo("There are no followers for this account (or the list is hidden).\n")
}
return nil
}
func (s *ShowExecutor) showFollowing(gtsClient *client.Client) error {
accountID, err := getAccountID(gtsClient, s.myAccount, s.accountName, s.config.CredentialsFile)
if err != nil {
return fmt.Errorf("received an error while getting the account ID: %w", err)
}
following, err := gtsClient.GetFollowing(accountID, s.limit)
if err != nil {
return fmt.Errorf("unable to retrieve the list of followed accounts: %w", err)
}
if len(following.Accounts) > 0 {
s.printer.PrintAccountList(following)
} else {
s.printer.PrintInfo("This account is not following anyone or the list is hidden.\n")
}
return nil
}
func (s *ShowExecutor) showBlocked(gtsClient *client.Client) error {
blocked, err := gtsClient.GetBlockedAccounts(s.limit)
if err != nil {
return fmt.Errorf("unable to retrieve the list of blocked accounts: %w", err)
}
if len(blocked.Accounts) > 0 {
s.printer.PrintAccountList(blocked)
} else {
s.printer.PrintInfo("You have no blocked accounts.\n")
}
return nil
}
func (s *ShowExecutor) showBookmarks(gtsClient *client.Client) error {
bookmarks, err := gtsClient.GetBookmarks(s.limit)
if err != nil {
return fmt.Errorf("unable to retrieve the list of bookmarks: %w", err)
}
if len(bookmarks.Statuses) > 0 {
s.printer.PrintStatusList(bookmarks)
} else {
s.printer.PrintInfo("You have no bookmarks.\n")
}
return nil
}
func (s *ShowExecutor) showLiked(gtsClient *client.Client) error {
liked, err := gtsClient.GetLikedStatuses(s.limit, s.resourceType)
if err != nil {
return fmt.Errorf("unable to retrieve the list of your %s statuses: %w", s.resourceType, err)
}
if len(liked.Statuses) > 0 {
s.printer.PrintStatusList(liked)
} else {
s.printer.PrintInfo("You have no " + s.resourceType + " statuses.\n")
}
return nil
}
func (s *ShowExecutor) showFollowRequests(gtsClient *client.Client) error {
accounts, err := gtsClient.GetFollowRequests(s.limit)
if err != nil {
return fmt.Errorf("unable to retrieve the list of follow requests: %w", err)
}
if len(accounts.Accounts) > 0 {
s.printer.PrintAccountList(accounts)
} else {
s.printer.PrintInfo("You have no follow requests.\n")
}
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)
}
s.printer.PrintPoll(poll)
return nil
}
func (s *ShowExecutor) showMutedAccounts(gtsClient *client.Client) error {
muted, err := gtsClient.GetMutedAccounts(s.limit)
if err != nil {
return fmt.Errorf("unable to retrieve the list of muted accounts: %w", err)
}
if len(muted.Accounts) > 0 {
s.printer.PrintAccountList(muted)
} else {
s.printer.PrintInfo("You have not muted any accounts.\n")
}
return nil
}
func (s *ShowExecutor) showMediaAttachment(gtsClient *client.Client) error {
if len(s.attachmentIDs) == 0 {
return FlagNotSetError{flagText: flagAttachmentID}
}
if len(s.attachmentIDs) != 1 {
return fmt.Errorf(
"unexpected number of attachment IDs received: want 1, got %d",
len(s.attachmentIDs),
)
}
attachment, err := gtsClient.GetMediaAttachment(s.attachmentIDs[0])
if err != nil {
return fmt.Errorf("unable to retrieve the media attachment: %w", err)
}
s.printer.PrintMediaAttachment(attachment)
return nil
}
func (s *ShowExecutor) showMedia(gtsClient *client.Client) error {
if s.fromResourceType == "" {
return FlagNotSetError{flagText: flagFrom}
}
funcMap := map[string]func(*client.Client) error{
resourceStatus: s.showMediaFromStatus,
}
doFunc, ok := funcMap[s.fromResourceType]
if !ok {
return fmt.Errorf("do not support viewing media from %s", s.fromResourceType)
}
return doFunc(gtsClient)
}
func (s *ShowExecutor) showMediaFromStatus(gtsClient *client.Client) error {
if len(s.attachmentIDs) == 0 {
return FlagNotSetError{flagText: flagAttachmentID}
}
if s.statusID == "" {
return FlagNotSetError{flagText: flagStatusID}
}
status, err := gtsClient.GetStatus(s.statusID)
if err != nil {
return fmt.Errorf("unable to retrieve the status: %w", err)
}
cacheDir := filepath.Join(
utilities.CalculateCacheDir(s.config.CacheDirectory, utilities.GetFQDN(gtsClient.Authentication.Instance)),
"media",
)
if err := utilities.EnsureDirectory(cacheDir); err != nil {
return fmt.Errorf("unable to ensure the existence of the directory %q: %w", cacheDir, err)
}
type media struct {
url string
mediaType string
}
attachmentsHashMap := make(map[string]media)
imageFiles := make([]string, 0)
videoFiles := make([]string, 0)
for _, statusAttachment := range status.MediaAttachments {
attachmentsHashMap[statusAttachment.ID] = media{
url: statusAttachment.URL,
mediaType: statusAttachment.Type,
}
}
for _, attachmentID := range s.attachmentIDs {
mediaObj, ok := attachmentsHashMap[attachmentID]
if !ok {
return UnknownMediaAttachmentError{AttachmentID: attachmentID}
}
split := strings.Split(mediaObj.url, "/")
filename := split[len(split)-1]
filePath := filepath.Join(cacheDir, filename)
fileExists, err := utilities.FileExists(filePath)
if err != nil {
return fmt.Errorf(
"unable to check if the media file is already downloaded for %s: %w",
attachmentID,
err,
)
}
if !fileExists {
if err := gtsClient.DownloadMedia(mediaObj.url, filePath); err != nil {
return fmt.Errorf(
"unable to download the media attachment for %s: %w",
attachmentID,
err,
)
}
}
switch mediaObj.mediaType {
case "image":
imageFiles = append(imageFiles, filePath)
case "video":
videoFiles = append(videoFiles, filePath)
}
}
if len(imageFiles) > 0 {
if err := utilities.OpenMedia(s.config.Integrations.ImageViewer, imageFiles); err != nil {
return fmt.Errorf("unable to open the image viewer: %w", err)
}
}
if len(videoFiles) > 0 {
if err := utilities.OpenMedia(s.config.Integrations.VideoPlayer, videoFiles); err != nil {
return fmt.Errorf("unable to open the video player: %w", err)
}
}
return nil
}