[mirror] yet another tui rss reader github.com/olexsmir/smutok

a lil bit of refactoring

olexsmir.xyz 99b3ff90 58b9b495

verified
+77
app.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "log/slog" 7 + 8 + "olexsmir.xyz/smutok/internal/config" 9 + "olexsmir.xyz/smutok/internal/freshrss" 10 + "olexsmir.xyz/smutok/internal/store" 11 + ) 12 + 13 + type app struct { 14 + cfg *config.Config 15 + store *store.Sqlite 16 + freshrss *freshrss.Client 17 + freshrssSyncer *freshrss.Syncer 18 + freshrssWorker *freshrss.Worker 19 + } 20 + 21 + func bootstrap(ctx context.Context) (*app, error) { 22 + cfg, err := config.New() 23 + if err != nil { 24 + return nil, err 25 + } 26 + 27 + store, err := store.NewSQLite(cfg.DBPath) 28 + if err != nil { 29 + return nil, err 30 + } 31 + 32 + if merr := store.Migrate(ctx); merr != nil { 33 + return nil, merr 34 + } 35 + 36 + fr := freshrss.NewClient(cfg.FreshRSS.Host) 37 + token, err := getAuthToken(ctx, fr, store, cfg) 38 + if err != nil { 39 + return nil, err 40 + } 41 + fr.SetAuthToken(token) 42 + 43 + fs := freshrss.NewSyncer(fr, store) 44 + fw := freshrss.NewWorker() 45 + 46 + return &app{ 47 + cfg: cfg, 48 + store: store, 49 + freshrss: fr, 50 + freshrssSyncer: fs, 51 + freshrssWorker: fw, 52 + }, nil 53 + } 54 + 55 + func getAuthToken(ctx context.Context, fr *freshrss.Client, db *store.Sqlite, cfg *config.Config) (string, error) { 56 + token, err := db.GetToken(ctx) 57 + if err == nil { 58 + return token, nil 59 + } 60 + 61 + if !errors.Is(err, store.ErrNotFound) { 62 + return "", err 63 + } 64 + 65 + slog.Info("requesting auth key") 66 + 67 + token, err = fr.Login(ctx, cfg.FreshRSS.Username, cfg.FreshRSS.Password) 68 + if err != nil { 69 + return "", err 70 + } 71 + 72 + if serr := db.SetToken(ctx, token); serr != nil { 73 + return "", serr 74 + } 75 + 76 + return token, nil 77 + }
-24
cmd_init.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "log/slog" 7 - 8 - "github.com/urfave/cli/v3" 9 - "olexsmir.xyz/smutok/internal/config" 10 - ) 11 - 12 - var initConfigCmd = &cli.Command{ 13 - Name: "init", 14 - Usage: "Initialize smutok's config", 15 - Action: initConfig, 16 - } 17 - 18 - func initConfig(ctx context.Context, c *cli.Command) error { 19 - if err := config.Init(); err != nil { 20 - return fmt.Errorf("failed to init config: %w", err) 21 - } 22 - slog.Info("Config was initialized, enter your credentials", "file", config.MustGetConfigFilePath()) 23 - return nil 24 - }
-53
cmd_main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "log/slog" 7 - 8 - "github.com/urfave/cli/v3" 9 - 10 - "olexsmir.xyz/smutok/internal/config" 11 - "olexsmir.xyz/smutok/internal/provider" 12 - "olexsmir.xyz/smutok/internal/store" 13 - "olexsmir.xyz/smutok/internal/sync" 14 - ) 15 - 16 - func runTui(ctx context.Context, c *cli.Command) error { 17 - cfg, err := config.New() 18 - if err != nil { 19 - return err 20 - } 21 - 22 - db, err := store.NewSQLite(cfg.DBPath) 23 - if err != nil { 24 - return err 25 - } 26 - 27 - if merr := db.Migrate(ctx); merr != nil { 28 - return merr 29 - } 30 - 31 - gr := provider.NewFreshRSS(cfg.FreshRSS.Host) 32 - 33 - token, err := db.GetToken(ctx) 34 - if errors.Is(err, store.ErrNotFound) { 35 - slog.Info("authorizing") 36 - token, err = gr.Login(ctx, cfg.FreshRSS.Username, cfg.FreshRSS.Password) 37 - if err != nil { 38 - return err 39 - } 40 - 41 - if serr := db.SetToken(ctx, token); serr != nil { 42 - return serr 43 - } 44 - } 45 - if err != nil { 46 - return err 47 - } 48 - 49 - gr.SetAuthToken(token) 50 - 51 - gs := sync.NewFreshRSS(db, gr) 52 - return gs.Sync(ctx) 53 - }
-19
cmd_sync.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - 7 - "github.com/urfave/cli/v3" 8 - ) 9 - 10 - var syncFeedsCmd = &cli.Command{ 11 - Name: "sync", 12 - Usage: "Sync RSS feeds without opening the tui.", 13 - Aliases: []string{"s"}, 14 - Action: syncFeeds, 15 - } 16 - 17 - func syncFeeds(ctx context.Context, c *cli.Command) error { 18 - return errors.New("implement me") 19 - }
+1
internal/freshrss/freshrss.go
··· 1 + package freshrss
+7
internal/freshrss/worker.go
··· 1 + package freshrss 2 + 3 + type Worker struct{} 4 + 5 + func NewWorker() *Worker { 6 + return &Worker{} 7 + }
+55 -30
internal/provider/freshrss.go internal/freshrss/client.go
··· 1 - package provider 1 + package freshrss 2 2 3 3 import ( 4 4 "context" ··· 15 15 "github.com/tidwall/gjson" 16 16 ) 17 17 18 + const ( 19 + StateRead = "user/-/state/com.google/read" 20 + StateReadingList = "user/-/state/com.google/reading-list" 21 + StateStarred = "user/-/state/com.google/starred" 22 + ) 23 + 18 24 var ( 19 25 ErrInvalidRequest = errors.New("invalid invalid request") 20 26 ErrUnauthorized = errors.New("unauthorized") 21 27 ) 22 28 23 - type FreshRSS struct { 29 + type Client struct { 24 30 host string 25 31 authToken string 26 32 client *http.Client 27 33 } 28 34 29 - func NewFreshRSS(host string) *FreshRSS { 30 - return &FreshRSS{ 35 + func NewClient(host string) *Client { 36 + return &Client{ 31 37 host: host, 32 38 client: &http.Client{ 33 39 Timeout: 20 * time.Second, ··· 35 41 } 36 42 } 37 43 38 - func (g FreshRSS) Login(ctx context.Context, email, password string) (string, error) { 44 + func (g Client) Login(ctx context.Context, email, password string) (string, error) { 39 45 body := url.Values{} 40 46 body.Set("Email", email) 41 47 body.Set("Passwd", password) ··· 54 60 return "", ErrUnauthorized 55 61 } 56 62 57 - func (g *FreshRSS) SetAuthToken(token string) { 63 + func (g *Client) SetAuthToken(token string) { 58 64 // todo: validate token 59 65 g.authToken = token 60 66 } 61 67 62 - func (g FreshRSS) GetWriteToken(ctx context.Context) (string, error) { 68 + func (g Client) GetWriteToken(ctx context.Context) (string, error) { 63 69 var resp string 64 70 err := g.request(ctx, "/reader/api/0/token", nil, &resp) 65 71 return resp, err ··· 84 90 Label string `json:"label"` 85 91 } 86 92 87 - func (g FreshRSS) SubscriptionList(ctx context.Context) ([]Subscriptions, error) { 93 + func (g Client) SubscriptionList(ctx context.Context) ([]Subscriptions, error) { 88 94 params := url.Values{} 89 95 params.Set("output", "json") 90 96 ··· 102 108 Type string `json:"type,omitempty"` 103 109 } 104 110 105 - func (g FreshRSS) TagList(ctx context.Context) ([]Tag, error) { 111 + func (g Client) TagList(ctx context.Context) ([]Tag, error) { 106 112 params := url.Values{} 107 113 params.Set("output", "json") 108 114 ··· 125 131 StreamID string 126 132 Title string 127 133 } 134 + } 128 135 129 - // CrawlTimeMsec string `json:"crawlTimeMsec"` 136 + type StreamContents struct { 137 + StreamID string 138 + ExcludeTarget string 139 + LastModified int64 140 + N int 130 141 } 131 142 132 - func (g FreshRSS) StreamContents(ctx context.Context, steamID, excludeTarget string, lastModified int64, n int) ([]ContentItem, error) { 143 + func (g Client) StreamContents(ctx context.Context, opts StreamContents) ([]ContentItem, error) { 133 144 params := url.Values{} 134 - setOption(&params, "xt", excludeTarget) 135 - setOptionInt64(&params, "ot", lastModified) 136 - setOptionInt(&params, "n", n) 145 + setOption(&params, "xt", opts.ExcludeTarget) 146 + setOptionInt64(&params, "ot", opts.LastModified) 147 + setOptionInt(&params, "n", opts.N) 137 148 params.Set("r", "n") 138 149 139 150 var jsonResp string 140 - if err := g.request(ctx, "/reader/api/0/stream/contents/"+steamID, params, &jsonResp); err != nil { 151 + if err := g.request(ctx, "/reader/api/0/stream/contents/"+opts.StreamID, params, &jsonResp); err != nil { 141 152 return nil, err 142 153 } 143 154 ··· 174 185 return res, nil 175 186 } 176 187 177 - func (g FreshRSS) StreamIDs(ctx context.Context, includeTarget, excludeTarget string, n int) ([]string, error) { 188 + type StreamID struct { 189 + IncludeTarget string 190 + ExcludeTarget string 191 + N int 192 + } 193 + 194 + func (g Client) StreamIDs(ctx context.Context, opts StreamID) ([]string, error) { 178 195 params := url.Values{} 179 - setOption(&params, "xt", excludeTarget) 180 - setOption(&params, "s", includeTarget) 181 - setOptionInt(&params, "n", n) 196 + setOption(&params, "s", opts.IncludeTarget) 197 + setOption(&params, "xt", opts.ExcludeTarget) 198 + setOptionInt(&params, "n", opts.N) 182 199 params.Set("r", "n") 183 200 184 201 var jsonResp string ··· 195 212 return resp, nil 196 213 } 197 214 198 - func (g FreshRSS) SetItemsState(ctx context.Context, token, itemID string, addAction, removeAction string) error { 199 - params := url.Values{} 200 - params.Set("T", token) 201 - params.Set("i", itemID) 202 - setOption(&params, "a", addAction) 203 - setOption(&params, "r", removeAction) 215 + type EditTag struct { 216 + ItemID []string 217 + TagToAdd string 218 + TagToRemove string 219 + } 220 + 221 + func (g Client) EditTag(ctx context.Context, writeToken string, opts EditTag) error { 222 + body := url.Values{} 223 + body.Set("T", writeToken) 224 + setOption(&body, "a", opts.TagToAdd) 225 + setOption(&body, "r", opts.TagToRemove) 226 + for _, tag := range opts.ItemID { 227 + body.Add("i", tag) 228 + } 204 229 205 - err := g.postRequest(ctx, "/reader/api/0/edit-tag", params, nil) 230 + err := g.postRequest(ctx, "/reader/api/0/edit-tag", body, nil) 206 231 return err 207 232 } 208 233 ··· 226 251 Remove string 227 252 } 228 253 229 - func (g FreshRSS) SubscriptionEdit(ctx context.Context, token string, opts EditSubscription) (string, error) { 254 + func (g Client) SubscriptionEdit(ctx context.Context, token string, opts EditSubscription) (string, error) { 230 255 // todo: action is required 231 256 232 257 body := url.Values{} ··· 261 286 } 262 287 263 288 // request, makes GET request with params passed as url params 264 - func (g *FreshRSS) request(ctx context.Context, endpoint string, params url.Values, resp any) error { 289 + func (g *Client) request(ctx context.Context, endpoint string, params url.Values, resp any) error { 265 290 u, err := url.Parse(g.host + endpoint) 266 291 if err != nil { 267 292 return err ··· 279 304 } 280 305 281 306 // postRequest makes POST requests with parameters passed as form. 282 - func (g *FreshRSS) postRequest(ctx context.Context, endpoint string, body url.Values, resp any) error { 307 + func (g *Client) postRequest(ctx context.Context, endpoint string, body url.Values, resp any) error { 283 308 var reqBody io.Reader 284 309 if body != nil { 285 310 reqBody = strings.NewReader(body.Encode()) ··· 299 324 Error string `json:"error,omitempty"` 300 325 } 301 326 302 - func (g *FreshRSS) handleResponse(req *http.Request, out any) error { 327 + func (g *Client) handleResponse(req *http.Request, out any) error { 303 328 if g.authToken != "" { 304 329 req.Header.Set("Authorization", "GoogleLogin auth="+g.authToken) 305 330 }
+34 -33
internal/sync/freshrss.go internal/freshrss/sync.go
··· 1 - package sync 1 + package freshrss 2 2 3 3 import ( 4 4 "context" ··· 7 7 "strings" 8 8 "time" 9 9 10 - "olexsmir.xyz/smutok/internal/provider" 11 10 "olexsmir.xyz/smutok/internal/store" 12 11 ) 13 12 14 - type FreshRSS struct { 13 + type Syncer struct { 15 14 store *store.Sqlite 16 - api *provider.FreshRSS 15 + api *Client 17 16 18 17 ot int64 19 18 } 20 19 21 - func NewFreshRSS(store *store.Sqlite, api *provider.FreshRSS) *FreshRSS { 22 - return &FreshRSS{ 20 + func NewSyncer(api *Client, store *store.Sqlite) *Syncer { 21 + return &Syncer{ 23 22 store: store, 24 23 api: api, 25 24 } 26 25 } 27 26 28 - func (f *FreshRSS) Sync(ctx context.Context) error { 27 + func (f *Syncer) Sync(ctx context.Context) error { 29 28 ot, err := f.getLastSyncTime(ctx) 30 29 if err != nil { 31 30 return err ··· 68 67 return f.store.SetLastSyncTime(ctx, newOt) 69 68 } 70 69 71 - func (f *FreshRSS) getLastSyncTime(ctx context.Context) (int64, error) { 70 + func (f *Syncer) getLastSyncTime(ctx context.Context) (int64, error) { 72 71 ot, err := f.store.GetLastSyncTime(ctx) 73 72 if err != nil { 74 73 if errors.Is(err, store.ErrNotFound) { ··· 83 82 return ot, nil 84 83 } 85 84 86 - func (f *FreshRSS) syncTags(ctx context.Context) error { 85 + func (f *Syncer) syncTags(ctx context.Context) error { 87 86 slog.Info("syncing tags") 88 87 89 88 tags, err := f.api.TagList(ctx) ··· 94 93 var errs []error 95 94 for _, tag := range tags { 96 95 if strings.HasPrefix(tag.ID, "user/-/state/com.google/") && 97 - !strings.HasSuffix(tag.ID, "/com.google/starred") { 96 + !strings.HasSuffix(tag.ID, StateStarred) { 98 97 continue 99 98 } 100 99 ··· 107 106 return errors.Join(errs...) 108 107 } 109 108 110 - func (f *FreshRSS) syncSubscriptions(ctx context.Context) error { 109 + func (f *Syncer) syncSubscriptions(ctx context.Context) error { 111 110 slog.Info("syncing subscriptions") 112 111 113 112 subs, err := f.api.SubscriptionList(ctx) ··· 151 150 return errors.Join(errs...) 152 151 } 153 152 154 - func (f *FreshRSS) syncUnreadItems(ctx context.Context) error { 153 + func (f *Syncer) syncUnreadItems(ctx context.Context) error { 155 154 slog.Info("syncing unread items") 156 155 157 - items, err := f.api.StreamContents(ctx, 158 - "user/-/state/com.google/reading-list", 159 - "user/-/state/com.google/read", 160 - f.ot, 161 - 1000) 156 + items, err := f.api.StreamContents(ctx, StreamContents{ 157 + StreamID: StateReadingList, 158 + ExcludeTarget: StateRead, 159 + LastModified: f.ot, 160 + N: 1000, 161 + }) 162 162 if err != nil { 163 163 return err 164 164 } ··· 176 176 return errors.Join(errs...) 177 177 } 178 178 179 - func (f *FreshRSS) syncUnreadItemsStatuses(ctx context.Context) error { 179 + func (f *Syncer) syncUnreadItemsStatuses(ctx context.Context) error { 180 180 slog.Info("syncing unread items ids") 181 181 182 - ids, err := f.api.StreamIDs(ctx, 183 - "user/-/state/com.google/reading-list", 184 - "user/-/state/com.google/read", 185 - 1000) 182 + ids, err := f.api.StreamIDs(ctx, StreamID{ 183 + IncludeTarget: StateReadingList, 184 + ExcludeTarget: StateRead, 185 + N: 1000, 186 + }) 186 187 if err != nil { 187 188 return err 188 189 } ··· 194 195 return merr 195 196 } 196 197 197 - func (f *FreshRSS) syncStarredItems(ctx context.Context) error { 198 + func (f *Syncer) syncStarredItems(ctx context.Context) error { 198 199 slog.Info("sync stared items") 199 200 200 - items, err := f.api.StreamContents(ctx, 201 - "user/-/state/com.google/starred", 202 - "", 203 - f.ot, 204 - 1000) 201 + items, err := f.api.StreamContents(ctx, StreamContents{ 202 + StreamID: StateStarred, 203 + LastModified: f.ot, 204 + N: 1000, 205 + }) 205 206 if err != nil { 206 207 return err 207 208 } ··· 219 220 return errors.Join(errs...) 220 221 } 221 222 222 - func (f *FreshRSS) syncStarredItemStatuses(ctx context.Context) error { 223 + func (f *Syncer) syncStarredItemStatuses(ctx context.Context) error { 223 224 slog.Info("syncing starred items ids") 224 225 225 - ids, err := f.api.StreamIDs(ctx, 226 - "user/-/state/com.google/starred", 227 - "", 228 - 1000) 226 + ids, err := f.api.StreamIDs(ctx, StreamID{ 227 + IncludeTarget: StateStarred, 228 + N: 1000, 229 + }) 229 230 if err != nil { 230 231 return err 231 232 }
-7
internal/sync/sync.go
··· 1 - package sync 2 - 3 - import "context" 4 - 5 - type Strategy interface { 6 - Sync(ctx context.Context, initial bool) error 7 - }
+1 -7
internal/tui/tui.go
··· 1 1 package tui 2 2 3 - import ( 4 - tea "github.com/charmbracelet/bubbletea" 5 - 6 - "olexsmir.xyz/smutok/internal/sync" 7 - ) 3 + import tea "github.com/charmbracelet/bubbletea" 8 4 9 5 type Model struct { 10 6 isQutting bool 11 7 showErr bool 12 8 err error 13 - 14 - sync sync.Strategy 15 9 } 16 10 17 11 func NewModel() *Model {
+40
main.go
··· 3 3 import ( 4 4 "context" 5 5 _ "embed" 6 + "errors" 6 7 "fmt" 8 + "log/slog" 7 9 "os" 8 10 "strings" 9 11 10 12 "github.com/urfave/cli/v3" 13 + "olexsmir.xyz/smutok/internal/config" 11 14 ) 12 15 13 16 //go:embed version ··· 32 35 os.Exit(1) 33 36 } 34 37 } 38 + 39 + func runTui(ctx context.Context, c *cli.Command) error { 40 + return errors.New("there's no tui, i lied") 41 + } 42 + 43 + // sync 44 + 45 + var syncFeedsCmd = &cli.Command{ 46 + Name: "sync", 47 + Usage: "Sync RSS feeds without opening the tui.", 48 + Aliases: []string{"s"}, 49 + Action: syncFeeds, 50 + } 51 + 52 + func syncFeeds(ctx context.Context, c *cli.Command) error { 53 + app, err := bootstrap(ctx) 54 + if err != nil { 55 + return err 56 + } 57 + return app.freshrssSyncer.Sync(ctx) 58 + } 59 + 60 + // init 61 + 62 + var initConfigCmd = &cli.Command{ 63 + Name: "init", 64 + Usage: "Initialize smutok's config", 65 + Action: initConfig, 66 + } 67 + 68 + func initConfig(ctx context.Context, c *cli.Command) error { 69 + if err := config.Init(); err != nil { 70 + return fmt.Errorf("failed to init config: %w", err) 71 + } 72 + slog.Info("Config was initialized, enter your credentials", "file", config.MustGetConfigFilePath()) 73 + return nil 74 + }