all repos

smutok @ 0ff9ce851c4eada2ffd4d564de030a347b1a59c5

yet another tui rss reader (not abandoned, just paused development)
23 files changed, 1077 insertions(+), 0 deletions(-)
init
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2025-12-24 01:05:17 +0200
Change ID: wqptzrswmxrvwtrslrnqypvyysnrkpwx
A .gitignore

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

+/__debug* +/smutok
A README.md

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

+# Smutok
A cmd_init.go

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

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

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

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

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

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

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

+module olexsmir.xyz/smutok + +go 1.25.3 + +require ( + ariga.io/atlas v0.38.0 + github.com/adrg/xdg v0.5.3 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/pelletier/go-toml/v2 v2.2.4 + github.com/urfave/cli/v3 v3.6.1 + modernc.org/sqlite v1.40.1 + olexsmir.xyz/x v0.1.1 +) + +require ( + github.com/agext/levenshtein v1.2.1 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bmatcuk/doublestar v1.3.4 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-openapi/inflect v0.19.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/hcl/v2 v2.13.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/zclconf/go-cty v1.14.4 // indirect + github.com/zclconf/go-cty-yaml v1.1.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +)
A go.sum

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

+ariga.io/atlas v0.38.0 h1:MwbtwVtDWJFq+ECyeTAz2ArvewDnpeiw/t/sgNdDsdo= +ariga.io/atlas v0.38.0/go.mod h1:D7XMK6ei3GvfDqvzk+2VId78j77LdqHrqPOWamn51/s= +github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= +github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= +github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= +github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgCNc= +github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0zIUqPc2jr0= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= +github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= +github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= +github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +olexsmir.xyz/x v0.1.1 h1:7kHziqd8zqdR/jfdF33hu1GycFRM6Phit/oNjS9dXmo= +olexsmir.xyz/x v0.1.1/go.mod h1:5bTe00ESSr/m6iHEiTla+tkKr5x1rFwdoNEgJHnNeNY=
A internal/config/config.go

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

+package config + +import ( + _ "embed" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/adrg/xdg" + "github.com/pelletier/go-toml/v2" +) + +//go:embed config.toml +var defaultConfig []byte + +var ( + ErrUnsetPasswordEnv = errors.New("password env is unset") + ErrNotInitializedConfig = errors.New("config is not initialized") + ErrConfigAlreadyExists = errors.New("config already exists") + ErrPasswordFileNotFound = errors.New("password file not found") + ErrEmptyPasswordFile = errors.New("password file is empty") +) + +type Config struct { + DBPath string + LogFilePath string + FreshRSS struct { + Host string `toml:"host"` + Username string `toml:"username"` + Password string `toml:"password"` + } `toml:"freshrss"` +} + +func New() (*Config, error) { + configPath := MustGetConfigFilePath() + if !isFileExists(configPath) { + return nil, ErrNotInitializedConfig + } + + configRaw, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var config *Config + if cerr := toml.Unmarshal(configRaw, &config); cerr != nil { + return nil, cerr + } + + passwd, err := parsePassword( + config.FreshRSS.Password, + filepath.Dir(configPath)) + if err != nil { + return nil, err + } + + config.FreshRSS.Password = passwd + config.DBPath = mustGetStateFile("smutok.sqlite") + config.LogFilePath = mustGetStateFile("smutok.log") + + return config, nil +} + +func Init() error { + configPath := MustGetConfigFilePath() + if isFileExists(configPath) { + return ErrConfigAlreadyExists + } + + err := os.WriteFile(configPath, defaultConfig, 0o644) + return err +} + +func MustGetConfigFilePath() string { return mustGetConfigFile("config.toml") } + +func mustGetStateFile(file string) string { + stateFile, err := xdg.StateFile("smutok/" + file) + if err != nil { + panic(err) + } + return stateFile +} + +func mustGetConfigFile(file string) string { + configFile, err := xdg.ConfigFile("smutok/" + file) + if err != nil { + panic(err) + } + return configFile +} + +func parsePassword(passwd string, baseDir string) (string, error) { + envPrefix := "$env:" + filePrefix := "file:" + + switch { + case strings.HasPrefix(passwd, envPrefix): + env := os.Getenv(passwd[len(envPrefix):]) + if env == "" { + return "", ErrUnsetPasswordEnv + } + return env, nil + + case strings.HasPrefix(passwd, filePrefix): + fpath := os.ExpandEnv(passwd[len(filePrefix):]) + + if strings.HasPrefix(fpath, "./") { + fpath = filepath.Join(baseDir, fpath) + } + + if !isFileExists(fpath) { + return "", ErrPasswordFileNotFound + } + + data, err := os.ReadFile(fpath) + if err != nil { + return "", err + } + + password := strings.TrimSpace(string(data)) + if password == "" { + return "", ErrEmptyPasswordFile + } + return password, nil + + default: + return passwd, nil + } +} + +func isFileExists(fpath string) bool { + _, err := os.Stat(fpath) + return err == nil +}
A internal/config/config.toml

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

+[freshrss] +host = "https://example.com/api/greader.php" +username = "username" +password = "password" + +# you can set set the password form the env +# password = "$env:ENV_VAR_NAME" +# or read it from file +# password = "file:/path/to/file"
A internal/config/config_test.go

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

+package config + +import ( + "os" + "path/filepath" + "testing" + + "olexsmir.xyz/x/is" +) + +func TestNewConfig(t *testing.T) { +} + +func TestParsePassword(t *testing.T) { + passwd := "qwerty123" + + t.Run("string", func(t *testing.T) { + r, err := parsePassword(passwd, ".") + is.Err(t, err, nil) + is.Equal(t, r, passwd) + }) + + t.Run("env var", func(t *testing.T) { + t.Setenv("secret_password", passwd) + r, err := parsePassword("$env:secret_password", ".") + is.Err(t, err, nil) + is.Equal(t, r, passwd) + }) + + t.Run("unset env var", func(t *testing.T) { + _, err := parsePassword("$env:secret_password", ".") + is.Err(t, err, ErrUnsetPasswordEnv) + }) + + t.Run("file", func(t *testing.T) { + r, err := parsePassword("file:./testdata/password", ".") + is.Err(t, err, nil) + is.Equal(t, r, passwd) + }) + + t.Run("empty file", func(t *testing.T) { + _, err := parsePassword("file:./testdata/empty_password", ".") + is.Err(t, err, ErrEmptyPasswordFile) + }) + + t.Run("non existing file", func(t *testing.T) { + _, err := parsePassword("file:/not/exists", ".") + is.Err(t, err, ErrPasswordFileNotFound) + }) + + t.Run("file, not set path", func(t *testing.T) { + _, err := parsePassword("file:", ".") + is.Err(t, err, ErrPasswordFileNotFound) + }) + + t.Run("file, path with env", func(t *testing.T) { + tmpdir := t.TempDir() + t.Setenv("TMP_DIR", tmpdir) + + data, _ := os.ReadFile("./testdata/password") + os.WriteFile(filepath.Join(tmpdir, "password"), data, 0o644) + + r, err := parsePassword("file:$TMP_DIR/password", ".") + is.Err(t, err, nil) + is.Equal(t, r, passwd) + }) +}
A internal/config/testdata/empty_password

Not showing binary file.

A internal/config/testdata/password

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

+qwerty123
A internal/provider/freshrss.go

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

+package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +var ( + ErrInvalidRequest = errors.New("invalid invalid request") + ErrUnauthorized = errors.New("unauthorized") +) + +type FreshRSS struct { + host string + authToken string + client *http.Client +} + +func NewFreshRSS(host string) *FreshRSS { + return &FreshRSS{ + host: host, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (g FreshRSS) Login(ctx context.Context, email, password string) (string, error) { + body := url.Values{} + body.Set("Email", email) + body.Set("Passwd", password) + + var resp string + if err := g.postRequest(ctx, "/accounts/ClientLogin", body, &resp); err != nil { + return "", err + } + + for line := range strings.SplitSeq(resp, "\n") { + if after, ok := strings.CutPrefix(line, "Auth="); ok { + return after, nil + } + } + + return "", ErrUnauthorized +} + +func (g *FreshRSS) SetAuthToken(token string) { + // todo: validate token + g.authToken = token +} + +func (g FreshRSS) GetWriteToken(ctx context.Context) (string, error) { + var resp string + err := g.request(ctx, "/reader/api/0/token", nil, &resp) + return resp, err +} + +type subscriptionList struct { + Subscriptions []Subscriptions `json:"subscriptions"` +} +type Subscriptions struct { + Categories struct { + ID string `json:"id"` + Label string `json:"label"` + } `json:"categories"` + ID string `json:"id"` + HTMLURL string `json:"htmlUrl"` + IconURL string `json:"iconUrl"` + Title string `json:"title"` + URL string `json:"url"` +} + +func (g FreshRSS) SubscriptionList(ctx context.Context) ([]Subscriptions, error) { + var resp subscriptionList + err := g.request(ctx, "/reader/api/0/subscription/list?output=json", nil, &resp) + return resp.Subscriptions, err +} + +type tagList struct { + Tags []Tag `json:"tags"` +} + +type Tag struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` +} + +func (g FreshRSS) TagList(ctx context.Context) ([]Tag, error) { + var resp tagList + err := g.request(ctx, "/reader/api/0/tag/list?output=json", nil, &resp) + return resp.Tags, err +} + +type StreamContents struct { + Continuation string `json:"continuation"` + ID string `json:"id"` + Items []struct { + Alternate []struct { + Href string `json:"href"` + } `json:"alternate"` + Author string `json:"author"` + Canonical []struct { + Href string `json:"href"` + } `json:"canonical"` + Categories []string `json:"categories"` + CrawlTimeMsec string `json:"crawlTimeMsec"` + ID string `json:"id"` + Origin struct { + HTMLURL string `json:"htmlUrl"` + StreamID string `json:"streamId"` + Title string `json:"title"` + } `json:"origin"` + Published int `json:"published"` + Summary struct { + Content string `json:"content"` + } `json:"summary"` + TimestampUsec string `json:"timestampUsec"` + Title string `json:"title"` + } `json:"items"` + Updated int `json:"updated"` +} + +func (g FreshRSS) GetItems(ctx context.Context, excludeTarget string, lastModified, n int) (StreamContents, error) { + params := url.Values{} + setOption(&params, "xt", excludeTarget) + setOptionInt(&params, "ot", lastModified) + setOptionInt(&params, "n", n) + + var resp StreamContents + err := g.request(ctx, "/reader/api/0/stream/contents/user/-/state/com.google/reading-list", params, &resp) + return resp, err +} + +func (g FreshRSS) GetStaredItems(ctx context.Context, n int) (StreamContents, error) { + params := url.Values{} + setOptionInt(&params, "n", n) + + var resp StreamContents + err := g.request(ctx, "/reader/api/0/stream/contents/user/-/state/com.google/starred", params, &resp) + return resp, err +} + +type StreamItemsIDs struct { + Continuation string `json:"continuation"` + ItemRefs []struct { + ID string `json:"id"` + } `json:"itemRefs"` +} + +func (g FreshRSS) GetItemsIDs(ctx context.Context, excludeTarget, includeTarget string, n int) (StreamItemsIDs, error) { + params := url.Values{} + setOption(&params, "xt", excludeTarget) + setOption(&params, "s", includeTarget) + setOptionInt(&params, "n", n) + + var resp StreamItemsIDs + err := g.request(ctx, "/reader/api/0/stream/items/ids", params, &resp) + return resp, err +} + +func (g FreshRSS) SetItemsState(ctx context.Context, token, itemID string, addAction, removeAction string) error { + params := url.Values{} + params.Set("T", token) + params.Set("i", itemID) + setOption(&params, "a", addAction) + setOption(&params, "r", removeAction) + + err := g.postRequest(ctx, "/reader/api/0/edit-tag", params, nil) + return err +} + +type EditSubscription struct { + // StreamID to operate on (required) + // `feed/1` - the id + // `feed/https:...` - or the url + // it seems like 'feed' is required in the id + StreamID string + + // Action can be one of those: subscribe OR unsubscribe OR edit + Action string + + // Title, or for edit, or title for adding + Title string + + // Add, StreamID to add the sub (generally a category) + AddCategoryID string + + // Remove, StreamId to remove the subscription(s) from (generally a category) + Remove string +} + +func (g FreshRSS) SubscriptionEdit(ctx context.Context, token string, opts EditSubscription) (string, error) { + // todo: action is required + + body := url.Values{} + body.Set("T", token) + body.Set("s", opts.StreamID) + body.Set("ac", opts.Action) + setOption(&body, "t", opts.Title) + setOption(&body, "a", opts.AddCategoryID) + setOption(&body, "r", opts.Remove) + + var resp string + err := g.postRequest(ctx, "/reader/api/0/subscription/edit", body, &resp) + return resp, err +} + +func setOption(b *url.Values, k, v string) { + if v != "" { + b.Set(k, v) + } +} + +func setOptionInt(b *url.Values, k string, v int) { + if v != 0 { + b.Set(k, strconv.Itoa(v)) + } +} + +// request, makes GET request with params passed as url params +func (g *FreshRSS) request(ctx context.Context, endpoint string, params url.Values, resp any) error { + u, err := url.Parse(g.host + endpoint) + if err != nil { + return err + } + u.RawQuery = params.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + return g.handleResponse(req, resp) +} + +// postRequest makes POST requests with parameters passed as form. +func (g *FreshRSS) postRequest(ctx context.Context, endpoint string, body url.Values, resp any) error { + var reqBody io.Reader + if body != nil { + reqBody = strings.NewReader(body.Encode()) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.host+endpoint, reqBody) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return g.handleResponse(req, resp) +} + +type apiResponse struct { + Error string `json:"error,omitempty"` +} + +func (g *FreshRSS) handleResponse(req *http.Request, out any) error { + if g.authToken != "" { + req.Header.Set("Authorization", "GoogleLogin auth="+g.authToken) + } + + resp, err := g.client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusUnauthorized { + return ErrUnauthorized + } + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API error: status %d: %s", resp.StatusCode, string(body)) + } + + if strPtr, ok := out.(*string); ok { + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + *strPtr = string(body) + + slog.Debug("string response", "content", string(body)) + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + if apiResp, ok := out.(*apiResponse); ok && apiResp.Error != "" { + return fmt.Errorf("%s", apiResp.Error) + } + + return nil +}
A internal/store/schema.hcl

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

+schema "main" {} + +table "reader" { + schema = schema.main + column "id" { + null = true + type = integer + auto_increment = true + } + column "token" { + null = true + type = text + } + column "last_sync" { + null = true + type = date + } + primary_key { + columns = [column.id] + } +}
A internal/store/sqlite.go

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

+package store + +import ( + "context" + "database/sql" + _ "embed" + "log/slog" + + amigrate "ariga.io/atlas/sql/migrate" + aschema "ariga.io/atlas/sql/schema" + asqlite "ariga.io/atlas/sql/sqlite" + + _ "modernc.org/sqlite" +) + +//go:embed schema.hcl +var schema []byte + +type Sqlite struct { + db *sql.DB +} + +func NewSQLite(path string) (*Sqlite, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + return &Sqlite{ + db: db, + }, nil +} + +func (s *Sqlite) Close() error { return s.db.Close() } + +func (s *Sqlite) Migrate(ctx context.Context) error { + driver, err := asqlite.Open(s.db) + if err != nil { + return err + } + + want := &aschema.Schema{} + if serr := asqlite.EvalHCLBytes(schema, want, nil); serr != nil { + return err + } + + got, err := driver.InspectSchema(ctx, "", nil) + if err != nil { + return err + } + + changes, err := driver.SchemaDiff(got, want) + if err != nil { + return err + } + + slog.Info("running migration") + if merr := driver.ApplyChanges(ctx, changes, []amigrate.PlanOption{}...); merr != nil { + return merr + } + + _, err = driver.ExecContext(ctx, `--sql + PRAGMA foreign_keys = ON`) + return err +}
A internal/store/sqlite_reader.go

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

+package store + +import ( + "context" + "database/sql" + "errors" +) + +func (s *Sqlite) GetLastSyncTime(ctx context.Context) (int, error) { + var lut int + err := s.db.QueryRowContext(ctx, "select last_sync from reader where id = 1 and last_sync is not null").Scan(&lut) + if errors.Is(err, sql.ErrNoRows) { + return 0, ErrNotFound + } + return lut, err +} + +func (s *Sqlite) SetLastSyncTime(ctx context.Context, lastSync int) error { + _, err := s.db.ExecContext(ctx, + `insert into reader (id, last_sync) values (1, ?) + on conflict(id) do update set last_sync = excluded.last_sync`, + lastSync) + return err +} + +func (s *Sqlite) GetToken(ctx context.Context) (string, error) { + var tok string + err := s.db.QueryRowContext(ctx, "select token from reader where id = 1 and token is not null").Scan(&tok) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return tok, err +} + +func (s *Sqlite) SetToken(ctx context.Context, token string) error { + _, err := s.db.ExecContext(ctx, + `insert into reader (id, token) values (1, ?) + on conflict(id) do update set token = excluded.token`, + token) + return err +}
A internal/store/store.go

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

+package store + +import "errors" + +var ErrNotFound = errors.New("not found")
A internal/sync/freshrss.go

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

+package sync + +import ( + "context" + + "olexsmir.xyz/smutok/internal/provider" + "olexsmir.xyz/smutok/internal/store" +) + +type FreshRSS struct { + store store.Store + api *provider.FreshRSS +} + +func NewFreshRSS(store store.Store, api *provider.FreshRSS) *FreshRSS { + return &FreshRSS{ + store: store, + api: api, + } +} + +func (g *FreshRSS) Sync(ctx context.Context, initial bool) error { + writeToken, err := g.api.GetWriteToken(ctx) + if err != nil { + return err + } + + _ = writeToken + + return nil +}
A internal/sync/sync.go

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

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

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

+package tui + +import tea "github.com/charmbracelet/bubbletea" + +type errMsg struct{ err error } + +func (e errMsg) Error() string { + return e.err.Error() +} + +func sendErr(err error) tea.Cmd { + return func() tea.Msg { + return errMsg{err} + } +}
A internal/tui/tui.go

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

+package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "olexsmir.xyz/smutok/internal/sync" +) + +type Model struct { + isQutting bool + showErr bool + err error + + sync sync.Strategy +} + +func NewModel() *Model { + return &Model{} +} + +func (m *Model) Init() tea.Cmd { + return nil +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case errMsg: + m.err = msg + m.showErr = true + return m, nil + + case tea.WindowSizeMsg: + + case tea.KeyMsg: + switch msg.String() { + case "q": + m.isQutting = true + return m, tea.Quit + } + } + return m, nil +} + +func (m *Model) View() string { + if m.isQutting { + return "" + } + return "are you feeling smutok?" +}
A main.go

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

+package main + +import ( + "context" + _ "embed" + "fmt" + "os" + "strings" + + "github.com/urfave/cli/v3" +) + +//go:embed version +var _version string + +var version = strings.Trim(_version, "\n") + +func main() { + cmd := &cli.Command{ + Name: "smutok", + Version: version, + Usage: "An RSS feed reader.", + EnableShellCompletion: true, + Action: runTui, + Commands: []*cli.Command{ + initConfigCmd, + syncFeedsCmd, + }, + } + if err := cmd.Run(context.Background(), os.Args); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +}
A version

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

+very-pre-alpha