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

init

+2
.gitignore
··· 1 + /__debug* 2 + /smutok
+1
README.md
··· 1 + # Smutok
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + [freshrss] 2 + host = "https://example.com/api/greader.php" 3 + username = "username" 4 + password = "password" 5 + 6 + # you can set set the password form the env 7 + # password = "$env:ENV_VAR_NAME" 8 + # or read it from file 9 + # password = "file:/path/to/file"
+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

This is a binary file and will not be displayed.

+1
internal/config/testdata/password
··· 1 + qwerty123
+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(&params, "xt", excludeTarget) 135 + setOptionInt(&params, "ot", lastModified) 136 + setOptionInt(&params, "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(&params, "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(&params, "xt", excludeTarget) 162 + setOption(&params, "s", includeTarget) 163 + setOptionInt(&params, "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(&params, "a", addAction) 175 + setOption(&params, "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
··· 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
··· 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
··· 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
··· 1 + package store 2 + 3 + import "errors" 4 + 5 + var ErrNotFound = errors.New("not found")
+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
··· 1 + package sync 2 + 3 + import "context" 4 + 5 + type Strategy interface { 6 + Sync(ctx context.Context, initial bool) error 7 + }
+15
internal/tui/error.go
··· 1 + package tui 2 + 3 + import tea "github.com/charmbracelet/bubbletea" 4 + 5 + type errMsg struct{ err error } 6 + 7 + func (e errMsg) Error() string { 8 + return e.err.Error() 9 + } 10 + 11 + func sendErr(err error) tea.Cmd { 12 + return func() tea.Msg { 13 + return errMsg{err} 14 + } 15 + }
+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
··· 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 + very-pre-alpha