feat: address TODOs and write README.md
This commit is contained in:
@@ -1,3 +1,77 @@
|
|||||||
# gator
|
# 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://<user>:<password>@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://<user>:<password>@localhost:5432/gator?sslmode=disable",
|
||||||
|
"current_user_name": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run gator with `gator <command> <args>`. 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 <name>` | Register a new user and log in as them |
|
||||||
|
| `gator login <name>` | Log in as an existing user |
|
||||||
|
| `gator users` | List all registered users |
|
||||||
|
| `gator addfeed <name> <url>` | Add an RSS feed and follow it |
|
||||||
|
| `gator feeds` | List all feeds |
|
||||||
|
| `gator follow <name-or-url>` | Follow a feed by name or url |
|
||||||
|
| `gator following` | List the feeds you're following |
|
||||||
|
| `gator unfollow <name-or-url>` | Unfollow a feed by name or url |
|
||||||
|
| `gator browse [limit]` | Browse posts from your followed feeds |
|
||||||
|
| `gator agg <duration>` | Continuously fetch posts every `<duration>` (e.g. `60s`); press `q` or `Ctrl-C` to stop |
|
||||||
|
| `gator reset` | Delete all users and their data |
|
||||||
|
|||||||
+18
-31
@@ -4,7 +4,6 @@ import (
|
|||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -17,7 +16,6 @@ import (
|
|||||||
"github.com/StevanFreeborn/gator/internal/rss"
|
"github.com/StevanFreeborn/gator/internal/rss"
|
||||||
"github.com/StevanFreeborn/gator/internal/state"
|
"github.com/StevanFreeborn/gator/internal/state"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Command struct {
|
type Command struct {
|
||||||
@@ -192,30 +190,13 @@ func aggCommand() *Command {
|
|||||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
restore, err := enableKeypressExit(cancel)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to enable raw mode: %v", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer term.Restore(int(os.Stdin.Fd()), oldState)
|
defer restore()
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
ticker := time.NewTicker(validDuration)
|
ticker := time.NewTicker(validDuration)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -246,21 +227,27 @@ func addFeedCommand() *Command {
|
|||||||
feedName := s.Arguments[0]
|
feedName := s.Arguments[0]
|
||||||
feedUrl := s.Arguments[1]
|
feedUrl := s.Arguments[1]
|
||||||
|
|
||||||
validatedFeedUrl, err := url.Parse(feedUrl)
|
|
||||||
|
|
||||||
if strings.TrimSpace(feedName) == "" {
|
if strings.TrimSpace(feedName) == "" {
|
||||||
return fmt.Errorf("Feed name cannot be empty")
|
return fmt.Errorf("Feed name cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canonicalFeedUrl, err := normalizeFeedURL(feedUrl)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Feed url '%s' is not a valid url", feedUrl)
|
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 <name>' to follow it", canonicalFeedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
createFeedParams := database.CreateFeedParams{
|
createFeedParams := database.CreateFeedParams{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
UserID: currentUser.ID,
|
UserID: currentUser.ID,
|
||||||
Name: feedName,
|
Name: feedName,
|
||||||
Url: validatedFeedUrl.String(),
|
Url: canonicalFeedUrl,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
@@ -325,12 +312,12 @@ func followCommand() *Command {
|
|||||||
return fmt.Errorf("Did not receive expected url argument")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("Unable to find feed with url '%s'", urlForFeedToFollow)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
createFollowParams := database.CreateFollowParams{
|
createFollowParams := database.CreateFollowParams{
|
||||||
@@ -375,12 +362,12 @@ func unfollowCommand() *Command {
|
|||||||
return fmt.Errorf("Did not receive expected feed url argument")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("Unable to find feed with url '%s'", feedUrl)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteFollowParams := database.DeleteFollowForUserParams{
|
deleteFollowParams := database.DeleteFollowForUserParams{
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -12,14 +12,6 @@ import (
|
|||||||
_ "github.com/lib/pq"
|
_ "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() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
fmt.Println("Not enough arguments provided. Usage: <command> <args>")
|
fmt.Println("Not enough arguments provided. Usage: <command> <args>")
|
||||||
|
|||||||
Reference in New Issue
Block a user