From ecaa816f1caed6cb1a51d5495ecba90be1cc57f1 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Tue, 4 Aug 2026 18:00:54 -0500 Subject: [PATCH] feat: address TODOs and write README.md --- README.md | 76 ++++++++++++++++++++++++- internal/command/command.go | 49 ++++++----------- internal/command/helpers.go | 107 ++++++++++++++++++++++++++++++++++++ main.go | 8 --- 4 files changed, 200 insertions(+), 40 deletions(-) create mode 100644 internal/command/helpers.go diff --git a/README.md b/README.md index 0ab778f..bae56d2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,77 @@ # gator -This is a blog aggregator that was built as part of a [boot.dev](https://boot.dev) course. +A blog aggregator CLI built as part of the [boot.dev](https://boot.dev) course. Follow RSS feeds and browse their latest posts right from your terminal. + +## Requirements + +Before you can run gator you'll need to have the following installed: + +- **Go** (1.26 or newer) +- **PostgreSQL** (a running server, local or remote) + +## Installation + +Install the gator CLI with `go install`: + +```sh +go install github.com/StevanFreeborn/gator@latest +``` + +The binary is placed in `$(go env GOPATH)/bin`. Make sure that directory is on your `PATH`: + +```sh +export PATH="$(go env GOPATH)/bin:$PATH" +``` + +## Setup + +### 1. Create a database + +Create a database for gator in your Postgres server: + +```sh +createdb gator +``` + +The schema is managed with [goose](https://github.com/pressly/goose) migrations in `sql/schema`. Install goose and apply the migrations: + +```sh +go install github.com/pressly/goose/v3/cmd/goose@latest +goose -dir sql/schema postgres "postgres://:@localhost:5432/gator?sslmode=disable" up +``` + +### 2. Create the config file + +gator reads its configuration from `~/.gatorconfig.json`. Create that file with the connection string for your database: + +```json +{ + "db_url": "postgres://:@localhost:5432/gator?sslmode=disable", + "current_user_name": "" +} +``` + +## Usage + +Run gator with `gator `. Start by registering a user, then log in: + +```sh +gator register myusername +gator login myusername +``` + +A few commands you can run: + +| Command | Description | +| ------------------------------ | --------------------------------------------------------------------------------------- | +| `gator register ` | Register a new user and log in as them | +| `gator login ` | Log in as an existing user | +| `gator users` | List all registered users | +| `gator addfeed ` | Add an RSS feed and follow it | +| `gator feeds` | List all feeds | +| `gator follow ` | Follow a feed by name or url | +| `gator following` | List the feeds you're following | +| `gator unfollow ` | Unfollow a feed by name or url | +| `gator browse [limit]` | Browse posts from your followed feeds | +| `gator agg ` | Continuously fetch posts every `` (e.g. `60s`); press `q` or `Ctrl-C` to stop | +| `gator reset` | Delete all users and their data | diff --git a/internal/command/command.go b/internal/command/command.go index cbf8e50..d7b1843 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -4,7 +4,6 @@ import ( "cmp" "context" "fmt" - "net/url" "os" "os/signal" "slices" @@ -17,7 +16,6 @@ import ( "github.com/StevanFreeborn/gator/internal/rss" "github.com/StevanFreeborn/gator/internal/state" "github.com/google/uuid" - "golang.org/x/term" ) type Command struct { @@ -192,30 +190,13 @@ func aggCommand() *Command { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + restore, err := enableKeypressExit(cancel) if err != nil { - return fmt.Errorf("Failed to enable raw mode: %v", err) + return err } - defer term.Restore(int(os.Stdin.Fd()), oldState) - - go func() { - buf := make([]byte, 1) - - for { - n, err := os.Stdin.Read(buf) - - if err != nil || n == 0 { - return - } - - if buf[0] == 'q' || buf[0] == 'Q' || buf[0] == 3 { - cancel() - return - } - } - }() + defer restore() ticker := time.NewTicker(validDuration) defer ticker.Stop() @@ -246,21 +227,27 @@ func addFeedCommand() *Command { feedName := s.Arguments[0] feedUrl := s.Arguments[1] - validatedFeedUrl, err := url.Parse(feedUrl) - if strings.TrimSpace(feedName) == "" { return fmt.Errorf("Feed name cannot be empty") } + canonicalFeedUrl, err := normalizeFeedURL(feedUrl) + if err != nil { return fmt.Errorf("Feed url '%s' is not a valid url", feedUrl) } + _, err = s.Database.GetFeedByUrl(context.Background(), canonicalFeedUrl) + + if err == nil { + return fmt.Errorf("Feed with url '%s' already exists; use 'follow ' to follow it", canonicalFeedUrl) + } + createFeedParams := database.CreateFeedParams{ ID: uuid.New(), UserID: currentUser.ID, Name: feedName, - Url: validatedFeedUrl.String(), + Url: canonicalFeedUrl, CreatedAt: time.Now(), UpdatedAt: time.Now(), } @@ -325,12 +312,12 @@ func followCommand() *Command { return fmt.Errorf("Did not receive expected url argument") } - urlForFeedToFollow := s.Arguments[0] + feedIdentifier := s.Arguments[0] - feed, err := s.Database.GetFeedByUrl(context.Background(), urlForFeedToFollow) + feed, err := resolveFeedByNameOrURL(s, feedIdentifier) if err != nil { - return fmt.Errorf("Unable to find feed with url '%s'", urlForFeedToFollow) + return err } createFollowParams := database.CreateFollowParams{ @@ -375,12 +362,12 @@ func unfollowCommand() *Command { return fmt.Errorf("Did not receive expected feed url argument") } - feedUrl := s.Arguments[0] + feedIdentifier := s.Arguments[0] - feed, err := s.Database.GetFeedByUrl(context.Background(), feedUrl) + feed, err := resolveFeedByNameOrURL(s, feedIdentifier) if err != nil { - return fmt.Errorf("Unable to find feed with url '%s'", feedUrl) + return err } deleteFollowParams := database.DeleteFollowForUserParams{ diff --git a/internal/command/helpers.go b/internal/command/helpers.go new file mode 100644 index 0000000..51e774d --- /dev/null +++ b/internal/command/helpers.go @@ -0,0 +1,107 @@ +package command + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + + "github.com/StevanFreeborn/gator/internal/database" + "github.com/StevanFreeborn/gator/internal/state" + "golang.org/x/term" +) + +func normalizeFeedURL(raw string) (string, error) { + u, err := url.Parse(raw) + + if err != nil { + return "", err + } + + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + + if (u.Scheme == "http" && u.Port() == "80") || (u.Scheme == "https" && u.Port() == "443") { + u.Host = u.Hostname() + } + + if len(u.Path) > 1 { + u.Path = strings.TrimSuffix(u.Path, "/") + } + + u.Fragment = "" + + return u.String(), nil +} + +func resolveFeedByNameOrURL(s *state.State, identifier string) (database.Feed, error) { + if strings.TrimSpace(identifier) == "" { + return database.Feed{}, fmt.Errorf("Feed identifier cannot be empty") + } + + feeds, err := s.Database.GetAllFeeds(context.Background()) + + if err != nil { + return database.Feed{}, fmt.Errorf("Failed to load feeds") + } + + var matches []database.Feed + + for _, feed := range feeds { + if strings.EqualFold(feed.Name, identifier) { + matches = append(matches, feed) + } + } + + if len(matches) == 1 { + return matches[0], nil + } + + if len(matches) > 1 { + return database.Feed{}, fmt.Errorf("Multiple feeds match name '%s'; use a url instead", identifier) + } + + normalizedURL, err := normalizeFeedURL(identifier) + + if err != nil { + return database.Feed{}, fmt.Errorf("No feed found matching name or url '%s'", identifier) + } + + feed, err := s.Database.GetFeedByUrl(context.Background(), normalizedURL) + + if err != nil { + return database.Feed{}, fmt.Errorf("No feed found matching name or url '%s'", identifier) + } + + return feed, nil +} + +func enableKeypressExit(cancel context.CancelFunc) (func(), error) { + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + + if err != nil { + return nil, fmt.Errorf("Failed to enable raw mode: %v", err) + } + + go func() { + buf := make([]byte, 1) + + for { + n, err := os.Stdin.Read(buf) + + if err != nil || n == 0 { + return + } + + if buf[0] == 'q' || buf[0] == 'Q' || buf[0] == 3 { + cancel() + return + } + } + }() + + return func() { + term.Restore(int(os.Stdin.Fd()), oldState) + }, nil +} diff --git a/main.go b/main.go index 2280fda..1de04b6 100644 --- a/main.go +++ b/main.go @@ -12,14 +12,6 @@ import ( _ "github.com/lib/pq" ) -// TODO: Reconcile identifiers for commands. Should prefer referring to things by Name -// not URL - -// TODO: Address what happens when user provides URL that resolves to same set of posts -// but URL slight difference than existing one. i.e. https://test.com/rss vs. https://test.com/rss/ - -// TODO: Extract exit handling in agg command to separate function - func main() { if len(os.Args) < 2 { fmt.Println("Not enough arguments provided. Usage: ")