+77
app.go
+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
-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
-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
-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
internal/freshrss/freshrss.go
···
1
+
package freshrss
+7
internal/freshrss/worker.go
+7
internal/freshrss/worker.go
+55
-30
internal/provider/freshrss.go
internal/freshrss/client.go
+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(¶ms, "xt", excludeTarget)
135
-
setOptionInt64(¶ms, "ot", lastModified)
136
-
setOptionInt(¶ms, "n", n)
145
+
setOption(¶ms, "xt", opts.ExcludeTarget)
146
+
setOptionInt64(¶ms, "ot", opts.LastModified)
147
+
setOptionInt(¶ms, "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(¶ms, "xt", excludeTarget)
180
-
setOption(¶ms, "s", includeTarget)
181
-
setOptionInt(¶ms, "n", n)
196
+
setOption(¶ms, "s", opts.IncludeTarget)
197
+
setOption(¶ms, "xt", opts.ExcludeTarget)
198
+
setOptionInt(¶ms, "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(¶ms, "a", addAction)
203
-
setOption(¶ms, "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
+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
-7
internal/sync/sync.go
+1
-7
internal/tui/tui.go
+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
+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
+
}