+1
README.md
+1
README.md
···
1
+
# Smutok
+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
+
}
+56
cmd_main.go
+56
cmd_main.go
···
1
+
package main
2
+
3
+
import (
4
+
"context"
5
+
"errors"
6
+
"fmt"
7
+
"log/slog"
8
+
9
+
"github.com/urfave/cli/v3"
10
+
11
+
"olexsmir.xyz/smutok/internal/config"
12
+
"olexsmir.xyz/smutok/internal/provider"
13
+
"olexsmir.xyz/smutok/internal/store"
14
+
"olexsmir.xyz/smutok/internal/sync"
15
+
)
16
+
17
+
func runTui(ctx context.Context, c *cli.Command) error {
18
+
cfg, err := config.New()
19
+
if err != nil {
20
+
return err
21
+
}
22
+
23
+
db, err := store.NewSQLite(cfg.DBPath)
24
+
if err != nil {
25
+
return err
26
+
}
27
+
28
+
if merr := db.Migrate(ctx); merr != nil {
29
+
return merr
30
+
}
31
+
32
+
gr := provider.NewFreshRSS(cfg.FreshRSS.Host)
33
+
34
+
token, err := db.GetToken(ctx)
35
+
if errors.Is(err, store.ErrNotFound) {
36
+
slog.Info("authorizing")
37
+
token, err = gr.Login(ctx, cfg.FreshRSS.Username, cfg.FreshRSS.Password)
38
+
if err != nil {
39
+
return err
40
+
}
41
+
42
+
if serr := db.SetToken(ctx, token); serr != nil {
43
+
return serr
44
+
}
45
+
}
46
+
if err != nil {
47
+
return err
48
+
}
49
+
50
+
gr.SetAuthToken(token)
51
+
52
+
gs := sync.NewFreshRSS(db, gr)
53
+
fmt.Println(gs.Sync(ctx, true))
54
+
55
+
return nil
56
+
}
+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
+
}
+53
go.mod
+53
go.mod
···
1
+
module olexsmir.xyz/smutok
2
+
3
+
go 1.25.3
4
+
5
+
require (
6
+
ariga.io/atlas v0.38.0
7
+
github.com/adrg/xdg v0.5.3
8
+
github.com/charmbracelet/bubbletea v1.3.10
9
+
github.com/pelletier/go-toml/v2 v2.2.4
10
+
github.com/urfave/cli/v3 v3.6.1
11
+
modernc.org/sqlite v1.40.1
12
+
olexsmir.xyz/x v0.1.1
13
+
)
14
+
15
+
require (
16
+
github.com/agext/levenshtein v1.2.1 // indirect
17
+
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
18
+
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
19
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
20
+
github.com/bmatcuk/doublestar v1.3.4 // indirect
21
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
22
+
github.com/charmbracelet/lipgloss v1.1.0 // indirect
23
+
github.com/charmbracelet/x/ansi v0.10.1 // indirect
24
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
25
+
github.com/charmbracelet/x/term v0.2.1 // indirect
26
+
github.com/dustin/go-humanize v1.0.1 // indirect
27
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
28
+
github.com/go-openapi/inflect v0.19.0 // indirect
29
+
github.com/google/go-cmp v0.7.0 // indirect
30
+
github.com/google/uuid v1.6.0 // indirect
31
+
github.com/hashicorp/hcl/v2 v2.13.0 // indirect
32
+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
33
+
github.com/mattn/go-isatty v0.0.20 // indirect
34
+
github.com/mattn/go-localereader v0.0.1 // indirect
35
+
github.com/mattn/go-runewidth v0.0.16 // indirect
36
+
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
37
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
38
+
github.com/muesli/cancelreader v0.2.2 // indirect
39
+
github.com/muesli/termenv v0.16.0 // indirect
40
+
github.com/ncruces/go-strftime v0.1.9 // indirect
41
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
42
+
github.com/rivo/uniseg v0.4.7 // indirect
43
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
44
+
github.com/zclconf/go-cty v1.14.4 // indirect
45
+
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
46
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
47
+
golang.org/x/sys v0.36.0 // indirect
48
+
golang.org/x/text v0.28.0 // indirect
49
+
gopkg.in/yaml.v3 v3.0.1 // indirect
50
+
modernc.org/libc v1.66.10 // indirect
51
+
modernc.org/mathutil v1.7.1 // indirect
52
+
modernc.org/memory v1.11.0 // indirect
53
+
)
+137
go.sum
+137
go.sum
···
1
+
ariga.io/atlas v0.38.0 h1:MwbtwVtDWJFq+ECyeTAz2ArvewDnpeiw/t/sgNdDsdo=
2
+
ariga.io/atlas v0.38.0/go.mod h1:D7XMK6ei3GvfDqvzk+2VId78j77LdqHrqPOWamn51/s=
3
+
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
4
+
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
5
+
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
6
+
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
7
+
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
8
+
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
9
+
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
10
+
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
11
+
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
12
+
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
13
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
14
+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
15
+
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
16
+
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
17
+
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
18
+
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
19
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
20
+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
21
+
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
22
+
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
23
+
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
24
+
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
25
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
26
+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
27
+
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
28
+
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
29
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
30
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
31
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
32
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
33
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
34
+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
35
+
github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
36
+
github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
37
+
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
38
+
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
39
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
40
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
41
+
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
42
+
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
43
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
44
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
45
+
github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgCNc=
46
+
github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0zIUqPc2jr0=
47
+
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
48
+
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
49
+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
50
+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
51
+
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
52
+
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
53
+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
54
+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
55
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
56
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
57
+
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
58
+
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
59
+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
60
+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
61
+
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
62
+
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
63
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
64
+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
65
+
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
66
+
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
67
+
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
68
+
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
69
+
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
70
+
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
71
+
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
72
+
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
73
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
74
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
75
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
76
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
77
+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
78
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
79
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
80
+
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
81
+
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
82
+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
83
+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
84
+
github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo=
85
+
github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
86
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
87
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
88
+
github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8=
89
+
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
90
+
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
91
+
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
92
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
93
+
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
94
+
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
95
+
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
96
+
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
97
+
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
98
+
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
99
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
100
+
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
101
+
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
102
+
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
103
+
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
104
+
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
105
+
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
106
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
107
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
108
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
109
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
110
+
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
111
+
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
112
+
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
113
+
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
114
+
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
115
+
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
116
+
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
117
+
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
118
+
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
119
+
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
120
+
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
121
+
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
122
+
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
123
+
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
124
+
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
125
+
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
126
+
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
127
+
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
128
+
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
129
+
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
130
+
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
131
+
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
132
+
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
133
+
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
134
+
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
135
+
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
136
+
olexsmir.xyz/x v0.1.1 h1:7kHziqd8zqdR/jfdF33hu1GycFRM6Phit/oNjS9dXmo=
137
+
olexsmir.xyz/x v0.1.1/go.mod h1:5bTe00ESSr/m6iHEiTla+tkKr5x1rFwdoNEgJHnNeNY=
+135
internal/config/config.go
+135
internal/config/config.go
···
1
+
package config
2
+
3
+
import (
4
+
_ "embed"
5
+
"errors"
6
+
"os"
7
+
"path/filepath"
8
+
"strings"
9
+
10
+
"github.com/adrg/xdg"
11
+
"github.com/pelletier/go-toml/v2"
12
+
)
13
+
14
+
//go:embed config.toml
15
+
var defaultConfig []byte
16
+
17
+
var (
18
+
ErrUnsetPasswordEnv = errors.New("password env is unset")
19
+
ErrNotInitializedConfig = errors.New("config is not initialized")
20
+
ErrConfigAlreadyExists = errors.New("config already exists")
21
+
ErrPasswordFileNotFound = errors.New("password file not found")
22
+
ErrEmptyPasswordFile = errors.New("password file is empty")
23
+
)
24
+
25
+
type Config struct {
26
+
DBPath string
27
+
LogFilePath string
28
+
FreshRSS struct {
29
+
Host string `toml:"host"`
30
+
Username string `toml:"username"`
31
+
Password string `toml:"password"`
32
+
} `toml:"freshrss"`
33
+
}
34
+
35
+
func New() (*Config, error) {
36
+
configPath := MustGetConfigFilePath()
37
+
if !isFileExists(configPath) {
38
+
return nil, ErrNotInitializedConfig
39
+
}
40
+
41
+
configRaw, err := os.ReadFile(configPath)
42
+
if err != nil {
43
+
return nil, err
44
+
}
45
+
46
+
var config *Config
47
+
if cerr := toml.Unmarshal(configRaw, &config); cerr != nil {
48
+
return nil, cerr
49
+
}
50
+
51
+
passwd, err := parsePassword(
52
+
config.FreshRSS.Password,
53
+
filepath.Dir(configPath))
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
58
+
config.FreshRSS.Password = passwd
59
+
config.DBPath = mustGetStateFile("smutok.sqlite")
60
+
config.LogFilePath = mustGetStateFile("smutok.log")
61
+
62
+
return config, nil
63
+
}
64
+
65
+
func Init() error {
66
+
configPath := MustGetConfigFilePath()
67
+
if isFileExists(configPath) {
68
+
return ErrConfigAlreadyExists
69
+
}
70
+
71
+
err := os.WriteFile(configPath, defaultConfig, 0o644)
72
+
return err
73
+
}
74
+
75
+
func MustGetConfigFilePath() string { return mustGetConfigFile("config.toml") }
76
+
77
+
func mustGetStateFile(file string) string {
78
+
stateFile, err := xdg.StateFile("smutok/" + file)
79
+
if err != nil {
80
+
panic(err)
81
+
}
82
+
return stateFile
83
+
}
84
+
85
+
func mustGetConfigFile(file string) string {
86
+
configFile, err := xdg.ConfigFile("smutok/" + file)
87
+
if err != nil {
88
+
panic(err)
89
+
}
90
+
return configFile
91
+
}
92
+
93
+
func parsePassword(passwd string, baseDir string) (string, error) {
94
+
envPrefix := "$env:"
95
+
filePrefix := "file:"
96
+
97
+
switch {
98
+
case strings.HasPrefix(passwd, envPrefix):
99
+
env := os.Getenv(passwd[len(envPrefix):])
100
+
if env == "" {
101
+
return "", ErrUnsetPasswordEnv
102
+
}
103
+
return env, nil
104
+
105
+
case strings.HasPrefix(passwd, filePrefix):
106
+
fpath := os.ExpandEnv(passwd[len(filePrefix):])
107
+
108
+
if strings.HasPrefix(fpath, "./") {
109
+
fpath = filepath.Join(baseDir, fpath)
110
+
}
111
+
112
+
if !isFileExists(fpath) {
113
+
return "", ErrPasswordFileNotFound
114
+
}
115
+
116
+
data, err := os.ReadFile(fpath)
117
+
if err != nil {
118
+
return "", err
119
+
}
120
+
121
+
password := strings.TrimSpace(string(data))
122
+
if password == "" {
123
+
return "", ErrEmptyPasswordFile
124
+
}
125
+
return password, nil
126
+
127
+
default:
128
+
return passwd, nil
129
+
}
130
+
}
131
+
132
+
func isFileExists(fpath string) bool {
133
+
_, err := os.Stat(fpath)
134
+
return err == nil
135
+
}
+9
internal/config/config.toml
+9
internal/config/config.toml
+67
internal/config/config_test.go
+67
internal/config/config_test.go
···
1
+
package config
2
+
3
+
import (
4
+
"os"
5
+
"path/filepath"
6
+
"testing"
7
+
8
+
"olexsmir.xyz/x/is"
9
+
)
10
+
11
+
func TestNewConfig(t *testing.T) {
12
+
}
13
+
14
+
func TestParsePassword(t *testing.T) {
15
+
passwd := "qwerty123"
16
+
17
+
t.Run("string", func(t *testing.T) {
18
+
r, err := parsePassword(passwd, ".")
19
+
is.Err(t, err, nil)
20
+
is.Equal(t, r, passwd)
21
+
})
22
+
23
+
t.Run("env var", func(t *testing.T) {
24
+
t.Setenv("secret_password", passwd)
25
+
r, err := parsePassword("$env:secret_password", ".")
26
+
is.Err(t, err, nil)
27
+
is.Equal(t, r, passwd)
28
+
})
29
+
30
+
t.Run("unset env var", func(t *testing.T) {
31
+
_, err := parsePassword("$env:secret_password", ".")
32
+
is.Err(t, err, ErrUnsetPasswordEnv)
33
+
})
34
+
35
+
t.Run("file", func(t *testing.T) {
36
+
r, err := parsePassword("file:./testdata/password", ".")
37
+
is.Err(t, err, nil)
38
+
is.Equal(t, r, passwd)
39
+
})
40
+
41
+
t.Run("empty file", func(t *testing.T) {
42
+
_, err := parsePassword("file:./testdata/empty_password", ".")
43
+
is.Err(t, err, ErrEmptyPasswordFile)
44
+
})
45
+
46
+
t.Run("non existing file", func(t *testing.T) {
47
+
_, err := parsePassword("file:/not/exists", ".")
48
+
is.Err(t, err, ErrPasswordFileNotFound)
49
+
})
50
+
51
+
t.Run("file, not set path", func(t *testing.T) {
52
+
_, err := parsePassword("file:", ".")
53
+
is.Err(t, err, ErrPasswordFileNotFound)
54
+
})
55
+
56
+
t.Run("file, path with env", func(t *testing.T) {
57
+
tmpdir := t.TempDir()
58
+
t.Setenv("TMP_DIR", tmpdir)
59
+
60
+
data, _ := os.ReadFile("./testdata/password")
61
+
os.WriteFile(filepath.Join(tmpdir, "password"), data, 0o644)
62
+
63
+
r, err := parsePassword("file:$TMP_DIR/password", ".")
64
+
is.Err(t, err, nil)
65
+
is.Equal(t, r, passwd)
66
+
})
67
+
}
internal/config/testdata/empty_password
internal/config/testdata/empty_password
This is a binary file and will not be displayed.
+1
internal/config/testdata/password
+1
internal/config/testdata/password
···
1
+
qwerty123
+305
internal/provider/freshrss.go
+305
internal/provider/freshrss.go
···
1
+
package provider
2
+
3
+
import (
4
+
"context"
5
+
"encoding/json"
6
+
"errors"
7
+
"fmt"
8
+
"io"
9
+
"log/slog"
10
+
"net/http"
11
+
"net/url"
12
+
"strconv"
13
+
"strings"
14
+
"time"
15
+
)
16
+
17
+
var (
18
+
ErrInvalidRequest = errors.New("invalid invalid request")
19
+
ErrUnauthorized = errors.New("unauthorized")
20
+
)
21
+
22
+
type FreshRSS struct {
23
+
host string
24
+
authToken string
25
+
client *http.Client
26
+
}
27
+
28
+
func NewFreshRSS(host string) *FreshRSS {
29
+
return &FreshRSS{
30
+
host: host,
31
+
client: &http.Client{
32
+
Timeout: 10 * time.Second,
33
+
},
34
+
}
35
+
}
36
+
37
+
func (g FreshRSS) Login(ctx context.Context, email, password string) (string, error) {
38
+
body := url.Values{}
39
+
body.Set("Email", email)
40
+
body.Set("Passwd", password)
41
+
42
+
var resp string
43
+
if err := g.postRequest(ctx, "/accounts/ClientLogin", body, &resp); err != nil {
44
+
return "", err
45
+
}
46
+
47
+
for line := range strings.SplitSeq(resp, "\n") {
48
+
if after, ok := strings.CutPrefix(line, "Auth="); ok {
49
+
return after, nil
50
+
}
51
+
}
52
+
53
+
return "", ErrUnauthorized
54
+
}
55
+
56
+
func (g *FreshRSS) SetAuthToken(token string) {
57
+
// todo: validate token
58
+
g.authToken = token
59
+
}
60
+
61
+
func (g FreshRSS) GetWriteToken(ctx context.Context) (string, error) {
62
+
var resp string
63
+
err := g.request(ctx, "/reader/api/0/token", nil, &resp)
64
+
return resp, err
65
+
}
66
+
67
+
type subscriptionList struct {
68
+
Subscriptions []Subscriptions `json:"subscriptions"`
69
+
}
70
+
type Subscriptions struct {
71
+
Categories struct {
72
+
ID string `json:"id"`
73
+
Label string `json:"label"`
74
+
} `json:"categories"`
75
+
ID string `json:"id"`
76
+
HTMLURL string `json:"htmlUrl"`
77
+
IconURL string `json:"iconUrl"`
78
+
Title string `json:"title"`
79
+
URL string `json:"url"`
80
+
}
81
+
82
+
func (g FreshRSS) SubscriptionList(ctx context.Context) ([]Subscriptions, error) {
83
+
var resp subscriptionList
84
+
err := g.request(ctx, "/reader/api/0/subscription/list?output=json", nil, &resp)
85
+
return resp.Subscriptions, err
86
+
}
87
+
88
+
type tagList struct {
89
+
Tags []Tag `json:"tags"`
90
+
}
91
+
92
+
type Tag struct {
93
+
ID string `json:"id"`
94
+
Type string `json:"type,omitempty"`
95
+
}
96
+
97
+
func (g FreshRSS) TagList(ctx context.Context) ([]Tag, error) {
98
+
var resp tagList
99
+
err := g.request(ctx, "/reader/api/0/tag/list?output=json", nil, &resp)
100
+
return resp.Tags, err
101
+
}
102
+
103
+
type StreamContents struct {
104
+
Continuation string `json:"continuation"`
105
+
ID string `json:"id"`
106
+
Items []struct {
107
+
Alternate []struct {
108
+
Href string `json:"href"`
109
+
} `json:"alternate"`
110
+
Author string `json:"author"`
111
+
Canonical []struct {
112
+
Href string `json:"href"`
113
+
} `json:"canonical"`
114
+
Categories []string `json:"categories"`
115
+
CrawlTimeMsec string `json:"crawlTimeMsec"`
116
+
ID string `json:"id"`
117
+
Origin struct {
118
+
HTMLURL string `json:"htmlUrl"`
119
+
StreamID string `json:"streamId"`
120
+
Title string `json:"title"`
121
+
} `json:"origin"`
122
+
Published int `json:"published"`
123
+
Summary struct {
124
+
Content string `json:"content"`
125
+
} `json:"summary"`
126
+
TimestampUsec string `json:"timestampUsec"`
127
+
Title string `json:"title"`
128
+
} `json:"items"`
129
+
Updated int `json:"updated"`
130
+
}
131
+
132
+
func (g FreshRSS) GetItems(ctx context.Context, excludeTarget string, lastModified, n int) (StreamContents, error) {
133
+
params := url.Values{}
134
+
setOption(¶ms, "xt", excludeTarget)
135
+
setOptionInt(¶ms, "ot", lastModified)
136
+
setOptionInt(¶ms, "n", n)
137
+
138
+
var resp StreamContents
139
+
err := g.request(ctx, "/reader/api/0/stream/contents/user/-/state/com.google/reading-list", params, &resp)
140
+
return resp, err
141
+
}
142
+
143
+
func (g FreshRSS) GetStaredItems(ctx context.Context, n int) (StreamContents, error) {
144
+
params := url.Values{}
145
+
setOptionInt(¶ms, "n", n)
146
+
147
+
var resp StreamContents
148
+
err := g.request(ctx, "/reader/api/0/stream/contents/user/-/state/com.google/starred", params, &resp)
149
+
return resp, err
150
+
}
151
+
152
+
type StreamItemsIDs struct {
153
+
Continuation string `json:"continuation"`
154
+
ItemRefs []struct {
155
+
ID string `json:"id"`
156
+
} `json:"itemRefs"`
157
+
}
158
+
159
+
func (g FreshRSS) GetItemsIDs(ctx context.Context, excludeTarget, includeTarget string, n int) (StreamItemsIDs, error) {
160
+
params := url.Values{}
161
+
setOption(¶ms, "xt", excludeTarget)
162
+
setOption(¶ms, "s", includeTarget)
163
+
setOptionInt(¶ms, "n", n)
164
+
165
+
var resp StreamItemsIDs
166
+
err := g.request(ctx, "/reader/api/0/stream/items/ids", params, &resp)
167
+
return resp, err
168
+
}
169
+
170
+
func (g FreshRSS) SetItemsState(ctx context.Context, token, itemID string, addAction, removeAction string) error {
171
+
params := url.Values{}
172
+
params.Set("T", token)
173
+
params.Set("i", itemID)
174
+
setOption(¶ms, "a", addAction)
175
+
setOption(¶ms, "r", removeAction)
176
+
177
+
err := g.postRequest(ctx, "/reader/api/0/edit-tag", params, nil)
178
+
return err
179
+
}
180
+
181
+
type EditSubscription struct {
182
+
// StreamID to operate on (required)
183
+
// `feed/1` - the id
184
+
// `feed/https:...` - or the url
185
+
// it seems like 'feed' is required in the id
186
+
StreamID string
187
+
188
+
// Action can be one of those: subscribe OR unsubscribe OR edit
189
+
Action string
190
+
191
+
// Title, or for edit, or title for adding
192
+
Title string
193
+
194
+
// Add, StreamID to add the sub (generally a category)
195
+
AddCategoryID string
196
+
197
+
// Remove, StreamId to remove the subscription(s) from (generally a category)
198
+
Remove string
199
+
}
200
+
201
+
func (g FreshRSS) SubscriptionEdit(ctx context.Context, token string, opts EditSubscription) (string, error) {
202
+
// todo: action is required
203
+
204
+
body := url.Values{}
205
+
body.Set("T", token)
206
+
body.Set("s", opts.StreamID)
207
+
body.Set("ac", opts.Action)
208
+
setOption(&body, "t", opts.Title)
209
+
setOption(&body, "a", opts.AddCategoryID)
210
+
setOption(&body, "r", opts.Remove)
211
+
212
+
var resp string
213
+
err := g.postRequest(ctx, "/reader/api/0/subscription/edit", body, &resp)
214
+
return resp, err
215
+
}
216
+
217
+
func setOption(b *url.Values, k, v string) {
218
+
if v != "" {
219
+
b.Set(k, v)
220
+
}
221
+
}
222
+
223
+
func setOptionInt(b *url.Values, k string, v int) {
224
+
if v != 0 {
225
+
b.Set(k, strconv.Itoa(v))
226
+
}
227
+
}
228
+
229
+
// request, makes GET request with params passed as url params
230
+
func (g *FreshRSS) request(ctx context.Context, endpoint string, params url.Values, resp any) error {
231
+
u, err := url.Parse(g.host + endpoint)
232
+
if err != nil {
233
+
return err
234
+
}
235
+
u.RawQuery = params.Encode()
236
+
237
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
238
+
if err != nil {
239
+
return fmt.Errorf("failed to create request: %w", err)
240
+
}
241
+
242
+
return g.handleResponse(req, resp)
243
+
}
244
+
245
+
// postRequest makes POST requests with parameters passed as form.
246
+
func (g *FreshRSS) postRequest(ctx context.Context, endpoint string, body url.Values, resp any) error {
247
+
var reqBody io.Reader
248
+
if body != nil {
249
+
reqBody = strings.NewReader(body.Encode())
250
+
}
251
+
252
+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.host+endpoint, reqBody)
253
+
if err != nil {
254
+
return fmt.Errorf("failed to create request: %w", err)
255
+
}
256
+
257
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
258
+
259
+
return g.handleResponse(req, resp)
260
+
}
261
+
262
+
type apiResponse struct {
263
+
Error string `json:"error,omitempty"`
264
+
}
265
+
266
+
func (g *FreshRSS) handleResponse(req *http.Request, out any) error {
267
+
if g.authToken != "" {
268
+
req.Header.Set("Authorization", "GoogleLogin auth="+g.authToken)
269
+
}
270
+
271
+
resp, err := g.client.Do(req)
272
+
if err != nil {
273
+
return fmt.Errorf("request failed: %w", err)
274
+
}
275
+
defer resp.Body.Close()
276
+
277
+
if resp.StatusCode != http.StatusOK {
278
+
if resp.StatusCode == http.StatusUnauthorized {
279
+
return ErrUnauthorized
280
+
}
281
+
body, _ := io.ReadAll(resp.Body)
282
+
return fmt.Errorf("API error: status %d: %s", resp.StatusCode, string(body))
283
+
}
284
+
285
+
if strPtr, ok := out.(*string); ok {
286
+
body, err := io.ReadAll(resp.Body)
287
+
if err != nil {
288
+
return fmt.Errorf("failed to read response body: %w", err)
289
+
}
290
+
*strPtr = string(body)
291
+
292
+
slog.Debug("string response", "content", string(body))
293
+
return nil
294
+
}
295
+
296
+
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
297
+
return fmt.Errorf("failed to decode response: %w", err)
298
+
}
299
+
300
+
if apiResp, ok := out.(*apiResponse); ok && apiResp.Error != "" {
301
+
return fmt.Errorf("%s", apiResp.Error)
302
+
}
303
+
304
+
return nil
305
+
}
+21
internal/store/schema.hcl
+21
internal/store/schema.hcl
···
1
+
schema "main" {}
2
+
3
+
table "reader" {
4
+
schema = schema.main
5
+
column "id" {
6
+
null = true
7
+
type = integer
8
+
auto_increment = true
9
+
}
10
+
column "token" {
11
+
null = true
12
+
type = text
13
+
}
14
+
column "last_sync" {
15
+
null = true
16
+
type = date
17
+
}
18
+
primary_key {
19
+
columns = [column.id]
20
+
}
21
+
}
+64
internal/store/sqlite.go
+64
internal/store/sqlite.go
···
1
+
package store
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
_ "embed"
7
+
"log/slog"
8
+
9
+
amigrate "ariga.io/atlas/sql/migrate"
10
+
aschema "ariga.io/atlas/sql/schema"
11
+
asqlite "ariga.io/atlas/sql/sqlite"
12
+
13
+
_ "modernc.org/sqlite"
14
+
)
15
+
16
+
//go:embed schema.hcl
17
+
var schema []byte
18
+
19
+
type Sqlite struct {
20
+
db *sql.DB
21
+
}
22
+
23
+
func NewSQLite(path string) (*Sqlite, error) {
24
+
db, err := sql.Open("sqlite", path)
25
+
if err != nil {
26
+
return nil, err
27
+
}
28
+
return &Sqlite{
29
+
db: db,
30
+
}, nil
31
+
}
32
+
33
+
func (s *Sqlite) Close() error { return s.db.Close() }
34
+
35
+
func (s *Sqlite) Migrate(ctx context.Context) error {
36
+
driver, err := asqlite.Open(s.db)
37
+
if err != nil {
38
+
return err
39
+
}
40
+
41
+
want := &aschema.Schema{}
42
+
if serr := asqlite.EvalHCLBytes(schema, want, nil); serr != nil {
43
+
return err
44
+
}
45
+
46
+
got, err := driver.InspectSchema(ctx, "", nil)
47
+
if err != nil {
48
+
return err
49
+
}
50
+
51
+
changes, err := driver.SchemaDiff(got, want)
52
+
if err != nil {
53
+
return err
54
+
}
55
+
56
+
slog.Info("running migration")
57
+
if merr := driver.ApplyChanges(ctx, changes, []amigrate.PlanOption{}...); merr != nil {
58
+
return merr
59
+
}
60
+
61
+
_, err = driver.ExecContext(ctx, `--sql
62
+
PRAGMA foreign_keys = ON`)
63
+
return err
64
+
}
+41
internal/store/sqlite_reader.go
+41
internal/store/sqlite_reader.go
···
1
+
package store
2
+
3
+
import (
4
+
"context"
5
+
"database/sql"
6
+
"errors"
7
+
)
8
+
9
+
func (s *Sqlite) GetLastSyncTime(ctx context.Context) (int, error) {
10
+
var lut int
11
+
err := s.db.QueryRowContext(ctx, "select last_sync from reader where id = 1 and last_sync is not null").Scan(&lut)
12
+
if errors.Is(err, sql.ErrNoRows) {
13
+
return 0, ErrNotFound
14
+
}
15
+
return lut, err
16
+
}
17
+
18
+
func (s *Sqlite) SetLastSyncTime(ctx context.Context, lastSync int) error {
19
+
_, err := s.db.ExecContext(ctx,
20
+
`insert into reader (id, last_sync) values (1, ?)
21
+
on conflict(id) do update set last_sync = excluded.last_sync`,
22
+
lastSync)
23
+
return err
24
+
}
25
+
26
+
func (s *Sqlite) GetToken(ctx context.Context) (string, error) {
27
+
var tok string
28
+
err := s.db.QueryRowContext(ctx, "select token from reader where id = 1 and token is not null").Scan(&tok)
29
+
if errors.Is(err, sql.ErrNoRows) {
30
+
return "", ErrNotFound
31
+
}
32
+
return tok, err
33
+
}
34
+
35
+
func (s *Sqlite) SetToken(ctx context.Context, token string) error {
36
+
_, err := s.db.ExecContext(ctx,
37
+
`insert into reader (id, token) values (1, ?)
38
+
on conflict(id) do update set token = excluded.token`,
39
+
token)
40
+
return err
41
+
}
+5
internal/store/store.go
+5
internal/store/store.go
+31
internal/sync/freshrss.go
+31
internal/sync/freshrss.go
···
1
+
package sync
2
+
3
+
import (
4
+
"context"
5
+
6
+
"olexsmir.xyz/smutok/internal/provider"
7
+
"olexsmir.xyz/smutok/internal/store"
8
+
)
9
+
10
+
type FreshRSS struct {
11
+
store store.Store
12
+
api *provider.FreshRSS
13
+
}
14
+
15
+
func NewFreshRSS(store store.Store, api *provider.FreshRSS) *FreshRSS {
16
+
return &FreshRSS{
17
+
store: store,
18
+
api: api,
19
+
}
20
+
}
21
+
22
+
func (g *FreshRSS) Sync(ctx context.Context, initial bool) error {
23
+
writeToken, err := g.api.GetWriteToken(ctx)
24
+
if err != nil {
25
+
return err
26
+
}
27
+
28
+
_ = writeToken
29
+
30
+
return nil
31
+
}
+7
internal/sync/sync.go
+7
internal/sync/sync.go
+15
internal/tui/error.go
+15
internal/tui/error.go
+49
internal/tui/tui.go
+49
internal/tui/tui.go
···
1
+
package tui
2
+
3
+
import (
4
+
tea "github.com/charmbracelet/bubbletea"
5
+
6
+
"olexsmir.xyz/smutok/internal/sync"
7
+
)
8
+
9
+
type Model struct {
10
+
isQutting bool
11
+
showErr bool
12
+
err error
13
+
14
+
sync sync.Strategy
15
+
}
16
+
17
+
func NewModel() *Model {
18
+
return &Model{}
19
+
}
20
+
21
+
func (m *Model) Init() tea.Cmd {
22
+
return nil
23
+
}
24
+
25
+
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
26
+
switch msg := msg.(type) {
27
+
case errMsg:
28
+
m.err = msg
29
+
m.showErr = true
30
+
return m, nil
31
+
32
+
case tea.WindowSizeMsg:
33
+
34
+
case tea.KeyMsg:
35
+
switch msg.String() {
36
+
case "q":
37
+
m.isQutting = true
38
+
return m, tea.Quit
39
+
}
40
+
}
41
+
return m, nil
42
+
}
43
+
44
+
func (m *Model) View() string {
45
+
if m.isQutting {
46
+
return ""
47
+
}
48
+
return "are you feeling smutok?"
49
+
}
+34
main.go
+34
main.go
···
1
+
package main
2
+
3
+
import (
4
+
"context"
5
+
_ "embed"
6
+
"fmt"
7
+
"os"
8
+
"strings"
9
+
10
+
"github.com/urfave/cli/v3"
11
+
)
12
+
13
+
//go:embed version
14
+
var _version string
15
+
16
+
var version = strings.Trim(_version, "\n")
17
+
18
+
func main() {
19
+
cmd := &cli.Command{
20
+
Name: "smutok",
21
+
Version: version,
22
+
Usage: "An RSS feed reader.",
23
+
EnableShellCompletion: true,
24
+
Action: runTui,
25
+
Commands: []*cli.Command{
26
+
initConfigCmd,
27
+
syncFeedsCmd,
28
+
},
29
+
}
30
+
if err := cmd.Run(context.Background(), os.Args); err != nil {
31
+
fmt.Fprintf(os.Stderr, "%v\n", err)
32
+
os.Exit(1)
33
+
}
34
+
}
+1
version
+1
version
···
1
+
very-pre-alpha