all repos

smutok @ 99b3ff9

yet another tui rss reader (not abandoned, just paused development)
11 files changed, 215 insertions(+), 173 deletions(-)
a lil bit of refactoring
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2025-12-25 00:20:50 +0200
Change ID: twvntpwrxnxlmmnysuzyqzskqwvnpyxo
Parent: 58b9b49
A app.go

@@ -0,0 +1,77 @@

+package main + +import ( + "context" + "errors" + "log/slog" + + "olexsmir.xyz/smutok/internal/config" + "olexsmir.xyz/smutok/internal/freshrss" + "olexsmir.xyz/smutok/internal/store" +) + +type app struct { + cfg *config.Config + store *store.Sqlite + freshrss *freshrss.Client + freshrssSyncer *freshrss.Syncer + freshrssWorker *freshrss.Worker +} + +func bootstrap(ctx context.Context) (*app, error) { + cfg, err := config.New() + if err != nil { + return nil, err + } + + store, err := store.NewSQLite(cfg.DBPath) + if err != nil { + return nil, err + } + + if merr := store.Migrate(ctx); merr != nil { + return nil, merr + } + + fr := freshrss.NewClient(cfg.FreshRSS.Host) + token, err := getAuthToken(ctx, fr, store, cfg) + if err != nil { + return nil, err + } + fr.SetAuthToken(token) + + fs := freshrss.NewSyncer(fr, store) + fw := freshrss.NewWorker() + + return &app{ + cfg: cfg, + store: store, + freshrss: fr, + freshrssSyncer: fs, + freshrssWorker: fw, + }, nil +} + +func getAuthToken(ctx context.Context, fr *freshrss.Client, db *store.Sqlite, cfg *config.Config) (string, error) { + token, err := db.GetToken(ctx) + if err == nil { + return token, nil + } + + if !errors.Is(err, store.ErrNotFound) { + return "", err + } + + slog.Info("requesting auth key") + + token, err = fr.Login(ctx, cfg.FreshRSS.Username, cfg.FreshRSS.Password) + if err != nil { + return "", err + } + + if serr := db.SetToken(ctx, token); serr != nil { + return "", serr + } + + return token, nil +}
D

@@ -1,24 +0,0 @@

-package main - -import ( - "context" - "fmt" - "log/slog" - - "github.com/urfave/cli/v3" - "olexsmir.xyz/smutok/internal/config" -) - -var initConfigCmd = &cli.Command{ - Name: "init", - Usage: "Initialize smutok's config", - Action: initConfig, -} - -func initConfig(ctx context.Context, c *cli.Command) error { - if err := config.Init(); err != nil { - return fmt.Errorf("failed to init config: %w", err) - } - slog.Info("Config was initialized, enter your credentials", "file", config.MustGetConfigFilePath()) - return nil -}
D

@@ -1,53 +0,0 @@

-package main - -import ( - "context" - "errors" - "log/slog" - - "github.com/urfave/cli/v3" - - "olexsmir.xyz/smutok/internal/config" - "olexsmir.xyz/smutok/internal/provider" - "olexsmir.xyz/smutok/internal/store" - "olexsmir.xyz/smutok/internal/sync" -) - -func runTui(ctx context.Context, c *cli.Command) error { - cfg, err := config.New() - if err != nil { - return err - } - - db, err := store.NewSQLite(cfg.DBPath) - if err != nil { - return err - } - - if merr := db.Migrate(ctx); merr != nil { - return merr - } - - gr := provider.NewFreshRSS(cfg.FreshRSS.Host) - - token, err := db.GetToken(ctx) - if errors.Is(err, store.ErrNotFound) { - slog.Info("authorizing") - token, err = gr.Login(ctx, cfg.FreshRSS.Username, cfg.FreshRSS.Password) - if err != nil { - return err - } - - if serr := db.SetToken(ctx, token); serr != nil { - return serr - } - } - if err != nil { - return err - } - - gr.SetAuthToken(token) - - gs := sync.NewFreshRSS(db, gr) - return gs.Sync(ctx) -}
D

@@ -1,19 +0,0 @@

-package main - -import ( - "context" - "errors" - - "github.com/urfave/cli/v3" -) - -var syncFeedsCmd = &cli.Command{ - Name: "sync", - Usage: "Sync RSS feeds without opening the tui.", - Aliases: []string{"s"}, - Action: syncFeeds, -} - -func syncFeeds(ctx context.Context, c *cli.Command) error { - return errors.New("implement me") -}
A internal/freshrss/freshrss.go

@@ -0,0 +1,1 @@

+package freshrss
A internal/freshrss/worker.go

@@ -0,0 +1,7 @@

+package freshrss + +type Worker struct{} + +func NewWorker() *Worker { + return &Worker{} +}
M internal/freshrss/client.gointernal/freshrss/client.go

@@ -1,4 +1,4 @@

-package provider +package freshrss import ( "context"

@@ -15,19 +15,25 @@

"github.com/tidwall/gjson" ) +const ( + StateRead = "user/-/state/com.google/read" + StateReadingList = "user/-/state/com.google/reading-list" + StateStarred = "user/-/state/com.google/starred" +) + var ( ErrInvalidRequest = errors.New("invalid invalid request") ErrUnauthorized = errors.New("unauthorized") ) -type FreshRSS struct { +type Client struct { host string authToken string client *http.Client } -func NewFreshRSS(host string) *FreshRSS { - return &FreshRSS{ +func NewClient(host string) *Client { + return &Client{ host: host, client: &http.Client{ Timeout: 20 * time.Second,

@@ -35,7 +41,7 @@ },

} } -func (g FreshRSS) Login(ctx context.Context, email, password string) (string, error) { +func (g Client) Login(ctx context.Context, email, password string) (string, error) { body := url.Values{} body.Set("Email", email) body.Set("Passwd", password)

@@ -54,12 +60,12 @@

return "", ErrUnauthorized } -func (g *FreshRSS) SetAuthToken(token string) { +func (g *Client) SetAuthToken(token string) { // todo: validate token g.authToken = token } -func (g FreshRSS) GetWriteToken(ctx context.Context) (string, error) { +func (g Client) GetWriteToken(ctx context.Context) (string, error) { var resp string err := g.request(ctx, "/reader/api/0/token", nil, &resp) return resp, err

@@ -84,7 +90,7 @@ ID string `json:"id"`

Label string `json:"label"` } -func (g FreshRSS) SubscriptionList(ctx context.Context) ([]Subscriptions, error) { +func (g Client) SubscriptionList(ctx context.Context) ([]Subscriptions, error) { params := url.Values{} params.Set("output", "json")

@@ -102,7 +108,7 @@ ID string `json:"id"`

Type string `json:"type,omitempty"` } -func (g FreshRSS) TagList(ctx context.Context) ([]Tag, error) { +func (g Client) TagList(ctx context.Context) ([]Tag, error) { params := url.Values{} params.Set("output", "json")

@@ -125,19 +131,24 @@ HTMLURL string

StreamID string Title string } +} - // CrawlTimeMsec string `json:"crawlTimeMsec"` +type StreamContents struct { + StreamID string + ExcludeTarget string + LastModified int64 + N int } -func (g FreshRSS) StreamContents(ctx context.Context, steamID, excludeTarget string, lastModified int64, n int) ([]ContentItem, error) { +func (g Client) StreamContents(ctx context.Context, opts StreamContents) ([]ContentItem, error) { params := url.Values{} - setOption(&params, "xt", excludeTarget) - setOptionInt64(&params, "ot", lastModified) - setOptionInt(&params, "n", n) + setOption(&params, "xt", opts.ExcludeTarget) + setOptionInt64(&params, "ot", opts.LastModified) + setOptionInt(&params, "n", opts.N) params.Set("r", "n") var jsonResp string - if err := g.request(ctx, "/reader/api/0/stream/contents/"+steamID, params, &jsonResp); err != nil { + if err := g.request(ctx, "/reader/api/0/stream/contents/"+opts.StreamID, params, &jsonResp); err != nil { return nil, err }

@@ -174,11 +185,17 @@

return res, nil } -func (g FreshRSS) StreamIDs(ctx context.Context, includeTarget, excludeTarget string, n int) ([]string, error) { +type StreamID struct { + IncludeTarget string + ExcludeTarget string + N int +} + +func (g Client) StreamIDs(ctx context.Context, opts StreamID) ([]string, error) { params := url.Values{} - setOption(&params, "xt", excludeTarget) - setOption(&params, "s", includeTarget) - setOptionInt(&params, "n", n) + setOption(&params, "s", opts.IncludeTarget) + setOption(&params, "xt", opts.ExcludeTarget) + setOptionInt(&params, "n", opts.N) params.Set("r", "n") var jsonResp string

@@ -195,14 +212,22 @@

return resp, nil } -func (g FreshRSS) SetItemsState(ctx context.Context, token, itemID string, addAction, removeAction string) error { - params := url.Values{} - params.Set("T", token) - params.Set("i", itemID) - setOption(&params, "a", addAction) - setOption(&params, "r", removeAction) +type EditTag struct { + ItemID []string + TagToAdd string + TagToRemove string +} + +func (g Client) EditTag(ctx context.Context, writeToken string, opts EditTag) error { + body := url.Values{} + body.Set("T", writeToken) + setOption(&body, "a", opts.TagToAdd) + setOption(&body, "r", opts.TagToRemove) + for _, tag := range opts.ItemID { + body.Add("i", tag) + } - err := g.postRequest(ctx, "/reader/api/0/edit-tag", params, nil) + err := g.postRequest(ctx, "/reader/api/0/edit-tag", body, nil) return err }

@@ -226,7 +251,7 @@ // Remove, StreamId to remove the subscription(s) from (generally a category)

Remove string } -func (g FreshRSS) SubscriptionEdit(ctx context.Context, token string, opts EditSubscription) (string, error) { +func (g Client) SubscriptionEdit(ctx context.Context, token string, opts EditSubscription) (string, error) { // todo: action is required body := url.Values{}

@@ -261,7 +286,7 @@ }

} // request, makes GET request with params passed as url params -func (g *FreshRSS) request(ctx context.Context, endpoint string, params url.Values, resp any) error { +func (g *Client) request(ctx context.Context, endpoint string, params url.Values, resp any) error { u, err := url.Parse(g.host + endpoint) if err != nil { return err

@@ -279,7 +304,7 @@ return g.handleResponse(req, resp)

} // postRequest makes POST requests with parameters passed as form. -func (g *FreshRSS) postRequest(ctx context.Context, endpoint string, body url.Values, resp any) error { +func (g *Client) postRequest(ctx context.Context, endpoint string, body url.Values, resp any) error { var reqBody io.Reader if body != nil { reqBody = strings.NewReader(body.Encode())

@@ -299,7 +324,7 @@ type apiResponse struct {

Error string `json:"error,omitempty"` } -func (g *FreshRSS) handleResponse(req *http.Request, out any) error { +func (g *Client) handleResponse(req *http.Request, out any) error { if g.authToken != "" { req.Header.Set("Authorization", "GoogleLogin auth="+g.authToken) }
M internal/freshrss/sync.gointernal/freshrss/sync.go

@@ -1,4 +1,4 @@

-package sync +package freshrss import ( "context"

@@ -7,25 +7,24 @@ "log/slog"

"strings" "time" - "olexsmir.xyz/smutok/internal/provider" "olexsmir.xyz/smutok/internal/store" ) -type FreshRSS struct { +type Syncer struct { store *store.Sqlite - api *provider.FreshRSS + api *Client ot int64 } -func NewFreshRSS(store *store.Sqlite, api *provider.FreshRSS) *FreshRSS { - return &FreshRSS{ +func NewSyncer(api *Client, store *store.Sqlite) *Syncer { + return &Syncer{ store: store, api: api, } } -func (f *FreshRSS) Sync(ctx context.Context) error { +func (f *Syncer) Sync(ctx context.Context) error { ot, err := f.getLastSyncTime(ctx) if err != nil { return err

@@ -68,7 +67,7 @@

return f.store.SetLastSyncTime(ctx, newOt) } -func (f *FreshRSS) getLastSyncTime(ctx context.Context) (int64, error) { +func (f *Syncer) getLastSyncTime(ctx context.Context) (int64, error) { ot, err := f.store.GetLastSyncTime(ctx) if err != nil { if errors.Is(err, store.ErrNotFound) {

@@ -83,7 +82,7 @@ slog.Info("got last sync time", "ot", ot)

return ot, nil } -func (f *FreshRSS) syncTags(ctx context.Context) error { +func (f *Syncer) syncTags(ctx context.Context) error { slog.Info("syncing tags") tags, err := f.api.TagList(ctx)

@@ -94,7 +93,7 @@

var errs []error for _, tag := range tags { if strings.HasPrefix(tag.ID, "user/-/state/com.google/") && - !strings.HasSuffix(tag.ID, "/com.google/starred") { + !strings.HasSuffix(tag.ID, StateStarred) { continue }

@@ -107,7 +106,7 @@ slog.Info("finished tag sync", "errs", errs)

return errors.Join(errs...) } -func (f *FreshRSS) syncSubscriptions(ctx context.Context) error { +func (f *Syncer) syncSubscriptions(ctx context.Context) error { slog.Info("syncing subscriptions") subs, err := f.api.SubscriptionList(ctx)

@@ -151,14 +150,15 @@ slog.Info("finished subscriptions sync", "errs", errs)

return errors.Join(errs...) } -func (f *FreshRSS) syncUnreadItems(ctx context.Context) error { +func (f *Syncer) syncUnreadItems(ctx context.Context) error { slog.Info("syncing unread items") - items, err := f.api.StreamContents(ctx, - "user/-/state/com.google/reading-list", - "user/-/state/com.google/read", - f.ot, - 1000) + items, err := f.api.StreamContents(ctx, StreamContents{ + StreamID: StateReadingList, + ExcludeTarget: StateRead, + LastModified: f.ot, + N: 1000, + }) if err != nil { return err }

@@ -176,13 +176,14 @@ slog.Info("finished syncing unread items", "errs", errs)

return errors.Join(errs...) } -func (f *FreshRSS) syncUnreadItemsStatuses(ctx context.Context) error { +func (f *Syncer) syncUnreadItemsStatuses(ctx context.Context) error { slog.Info("syncing unread items ids") - ids, err := f.api.StreamIDs(ctx, - "user/-/state/com.google/reading-list", - "user/-/state/com.google/read", - 1000) + ids, err := f.api.StreamIDs(ctx, StreamID{ + IncludeTarget: StateReadingList, + ExcludeTarget: StateRead, + N: 1000, + }) if err != nil { return err }

@@ -194,14 +195,14 @@ slog.Info("finished syncing unread items", "err", merr)

return merr } -func (f *FreshRSS) syncStarredItems(ctx context.Context) error { +func (f *Syncer) syncStarredItems(ctx context.Context) error { slog.Info("sync stared items") - items, err := f.api.StreamContents(ctx, - "user/-/state/com.google/starred", - "", - f.ot, - 1000) + items, err := f.api.StreamContents(ctx, StreamContents{ + StreamID: StateStarred, + LastModified: f.ot, + N: 1000, + }) if err != nil { return err }

@@ -219,13 +220,13 @@ slog.Info("finished syncing unstarred items", "errs", errs)

return errors.Join(errs...) } -func (f *FreshRSS) syncStarredItemStatuses(ctx context.Context) error { +func (f *Syncer) syncStarredItemStatuses(ctx context.Context) error { slog.Info("syncing starred items ids") - ids, err := f.api.StreamIDs(ctx, - "user/-/state/com.google/starred", - "", - 1000) + ids, err := f.api.StreamIDs(ctx, StreamID{ + IncludeTarget: StateStarred, + N: 1000, + }) if err != nil { return err }
D

@@ -1,7 +0,0 @@

-package sync - -import "context" - -type Strategy interface { - Sync(ctx context.Context, initial bool) error -}
M internal/tui/tui.go

@@ -1,17 +1,11 @@

package tui -import ( - tea "github.com/charmbracelet/bubbletea" - - "olexsmir.xyz/smutok/internal/sync" -) +import tea "github.com/charmbracelet/bubbletea" type Model struct { isQutting bool showErr bool err error - - sync sync.Strategy } func NewModel() *Model {
M main.go

@@ -3,11 +3,14 @@

import ( "context" _ "embed" + "errors" "fmt" + "log/slog" "os" "strings" "github.com/urfave/cli/v3" + "olexsmir.xyz/smutok/internal/config" ) //go:embed version

@@ -32,3 +35,40 @@ fmt.Fprintf(os.Stderr, "%v\n", err)

os.Exit(1) } } + +func runTui(ctx context.Context, c *cli.Command) error { + return errors.New("there's no tui, i lied") +} + +// sync + +var syncFeedsCmd = &cli.Command{ + Name: "sync", + Usage: "Sync RSS feeds without opening the tui.", + Aliases: []string{"s"}, + Action: syncFeeds, +} + +func syncFeeds(ctx context.Context, c *cli.Command) error { + app, err := bootstrap(ctx) + if err != nil { + return err + } + return app.freshrssSyncer.Sync(ctx) +} + +// init + +var initConfigCmd = &cli.Command{ + Name: "init", + Usage: "Initialize smutok's config", + Action: initConfig, +} + +func initConfig(ctx context.Context, c *cli.Command) error { + if err := config.Init(); err != nil { + return fmt.Errorf("failed to init config: %w", err) + } + slog.Info("Config was initialized, enter your credentials", "file", config.MustGetConfigFilePath()) + return nil +}