From 26651547e104492b38ce11094eae1ba7d61820cf Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Mon, 6 Jul 2026 01:43:56 -0400 Subject: [PATCH] feat(nadezhda): M5 bubbletea TUI browser (cybercore) + open-in-browser Interactive terminal UI over the ranked story clusters. Rave Maximal cybercore theming: cyan-to-magenta spectrum score bars, severity-reactive rows, hot KEV chips, cool-ramp outlet dots, and a scrollable detail dossier with per-outlet links and full CVE cards (CVSS meter, KEV / ransomware, EPSS, CWE, vector, description). Reuses store.DigestClusters and rank.Rank verbatim and preloads full store.CVE records for detail. - internal/tui: model/update/view state machine with an injected Loader and opener, so Update is fully unit-testable without a terminal or DB (nav / clamp / open / back / quit / resize / empty-store / too-small). - o opens the selected story in the default browser, keyboard-only, cross-platform, and refuses non-http urls. - tui command replaces the M5 stub; supports --since. - deps: charmbracelet bubbletea v1.3.10 + bubbles v1.0.0 + lipgloss v1.1.0. --- .../cmd/nadezhda/stubs.go | 1 - .../security-news-scraper/cmd/nadezhda/tui.go | 75 ++++ .../intermediate/security-news-scraper/go.mod | 20 ++ .../intermediate/security-news-scraper/go.sum | 43 +++ .../internal/tui/browser.go | 31 ++ .../internal/tui/detail.go | 202 +++++++++++ .../internal/tui/format.go | 197 +++++++++++ .../internal/tui/format_test.go | 173 ++++++++++ .../internal/tui/keys.go | 54 +++ .../internal/tui/list.go | 222 ++++++++++++ .../internal/tui/model.go | 253 ++++++++++++++ .../internal/tui/model_test.go | 322 ++++++++++++++++++ .../internal/tui/theme.go | 151 ++++++++ .../internal/tui/view.go | 159 +++++++++ 14 files changed, 1902 insertions(+), 1 deletion(-) create mode 100644 PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/tui.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/tui/browser.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/tui/detail.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/tui/format.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/tui/format_test.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/tui/keys.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/tui/list.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/tui/model.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/tui/model_test.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/tui/theme.go create mode 100644 PROJECTS/intermediate/security-news-scraper/internal/tui/view.go diff --git a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/stubs.go b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/stubs.go index e12b52fe..5dbe4177 100644 --- a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/stubs.go +++ b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/stubs.go @@ -21,7 +21,6 @@ func init() { short string milestone string }{ - {"tui", "Browse aggregated news in an interactive terminal UI", "milestone M5"}, {"ideate", "Generate content angles from ranked clusters via an AI provider", "milestone M6"}, {"watch", "Run as a daemon, re-ingesting on an interval", "milestone M7"}, } diff --git a/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/tui.go b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/tui.go new file mode 100644 index 00000000..093b87ac --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/cmd/nadezhda/tui.go @@ -0,0 +1,75 @@ +// ©AngelaMos | 2026 +// tui.go + +package main + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/nadezhda/internal/rank" + "github.com/CarterPerez-dev/nadezhda/internal/store" + "github.com/CarterPerez-dev/nadezhda/internal/tui" +) + +var tuiSince string + +var tuiCmd = &cobra.Command{ + Use: "tui", + Short: "Browse aggregated news in an interactive terminal UI", + RunE: runTUI, +} + +func init() { + tuiCmd.Flags().StringVar(&tuiSince, "since", "", "only clusters active within this window (e.g. 24h, 168h)") + rootCmd.AddCommand(tuiCmd) +} + +func runTUI(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + now := time.Now() + var since int64 + if tuiSince != "" { + d, err := time.ParseDuration(tuiSince) + if err != nil { + return fmt.Errorf("invalid --since %q: %w", tuiSince, err) + } + since = now.Add(-d).Unix() + } + + st, err := store.Open(cfg.DBPath) + if err != nil { + return err + } + defer st.Close() + + loader := func() (tui.Data, error) { + clusters, err := st.DigestClusters(since) + if err != nil { + return tui.Data{}, err + } + scored := rank.Rank(clusters, cfg.Rank, cfg.Watchlist, now) + detail := make(map[string]store.CVE) + for _, s := range scored { + for _, v := range s.Cluster.CVEs { + if _, ok := detail[v.ID]; ok { + continue + } + full, err := st.GetCVE(v.ID) + if err != nil { + continue + } + detail[v.ID] = full + } + } + return tui.Data{Scored: scored, CVEDetail: detail}, nil + } + + return tui.Run(loader) +} diff --git a/PROJECTS/intermediate/security-news-scraper/go.mod b/PROJECTS/intermediate/security-news-scraper/go.mod index 7a74c19b..382c7a78 100644 --- a/PROJECTS/intermediate/security-news-scraper/go.mod +++ b/PROJECTS/intermediate/security-news-scraper/go.mod @@ -4,6 +4,9 @@ go 1.25.0 require ( github.com/PuerkitoBio/goquery v1.12.0 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/mmcdole/gofeed v1.3.0 github.com/spf13/cobra v1.10.2 github.com/temoto/robotstxt v1.1.2 @@ -15,17 +18,34 @@ require ( require ( github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/lucasb-eyer/go-colorful v1.3.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.19 // indirect github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // 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 v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/PROJECTS/intermediate/security-news-scraper/go.sum b/PROJECTS/intermediate/security-news-scraper/go.sum index d61e7395..7cf2a882 100644 --- a/PROJECTS/intermediate/security-news-scraper/go.sum +++ b/PROJECTS/intermediate/security-news-scraper/go.sum @@ -2,12 +2,36 @@ github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +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/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +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.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +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.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -20,8 +44,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= @@ -31,12 +61,20 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= @@ -48,6 +86,8 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= +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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -56,6 +96,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -86,6 +128,7 @@ golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/browser.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/browser.go new file mode 100644 index 00000000..5de543cf --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/browser.go @@ -0,0 +1,31 @@ +// ©AngelaMos | 2026 +// browser.go + +package tui + +import ( + "fmt" + "net/url" + "os/exec" + "runtime" +) + +func openURL(target string) error { + u, err := url.Parse(target) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("refusing to open non-http url: %q", target) + } + name, args := openerCommand(target) + return exec.Command(name, args...).Start() +} + +func openerCommand(target string) (string, []string) { + switch runtime.GOOS { + case "windows": + return "rundll32", []string{"url.dll,FileProtocolHandler", target} + case "darwin": + return "open", []string{target} + default: + return "xdg-open", []string{target} + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/detail.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/detail.go new file mode 100644 index 00000000..854bd490 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/detail.go @@ -0,0 +1,202 @@ +// ©AngelaMos | 2026 +// detail.go + +package tui + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/CarterPerez-dev/nadezhda/internal/rank" + "github.com/CarterPerez-dev/nadezhda/internal/store" +) + +const ( + cvssMaxScore = 10.0 + nvdBaseURL = "https://nvd.nist.gov/vuln/detail/" + detailIndent = 3 + metaDivider = " · " +) + +func (m Model) renderDetailBody() string { + s := m.selected() + c := s.Cluster + w := m.bodyWidth() + t := m.theme + indent := strings.Repeat(" ", detailIndent) + var b strings.Builder + + b.WriteString(t.HeadlineSel.Width(w).Render(headlineOf(c))) + b.WriteString("\n") + b.WriteString(m.detailMeta(s)) + b.WriteString("\n\n") + + b.WriteString(m.sectionHeader("OUTLETS", w)) + b.WriteString("\n") + for _, a := range sortedArticles(c) { + b.WriteString(m.renderArticle(a, w, indent)) + } + + cves := m.sortedDetailCVEs(c) + if len(cves) > 0 { + b.WriteString(m.sectionHeader("VULNERABILITIES", w)) + b.WriteString("\n") + for _, v := range cves { + b.WriteString(m.renderCVE(v, w, indent)) + } + } + return strings.TrimRight(b.String(), "\n") +} + +func (m Model) detailMeta(s rank.Scored) string { + t := m.theme + c := s.Cluster + parts := []string{ + t.spectrumBar(s.Score, colScoreBarW) + " " + t.Meta.Bold(true).Render(fmt.Sprintf("%.2f", s.Score)), + t.Muted.Render(fmt.Sprintf("%d outlets", outletCount(c))), + } + if mx := clusterMaxCVSS(c); mx != nil { + b := cvssBand(mx) + parts = append(parts, t.Muted.Render("CVSS ")+t.bandFG(b).Bold(true).Render(cvssString(mx))) + } + if me := clusterMaxEPSS(c); me != nil { + b := epssBand(me) + parts = append(parts, t.Muted.Render("EPSS ")+t.bandFG(b).Bold(true).Render(epssString(me))) + } + parts = append(parts, + t.Muted.Render("first "+relativeAge(c.FirstSeen, m.now)), + t.Muted.Render("last "+relativeAge(c.LastSeen, m.now)), + ) + return strings.Join(parts, t.Dim.Render(metaDivider)) +} + +func (m Model) sectionHeader(title string, w int) string { + t := m.theme + head := t.fg(colorViolet).Bold(true).Render(glyphSelected + " " + title + " ") + n := w - lipgloss.Width(head) + if n < 0 { + n = 0 + } + return head + t.Dim.Render(strings.Repeat(ruleBody, n)) +} + +func (m Model) renderArticle(a store.DigestArticle, w int, indent string) string { + t := m.theme + head := t.fg(colorCyan).Bold(true).Render(a.SourceName) + + t.Dim.Render(metaDivider) + t.Muted.Render(relativeAge(a.PublishedAt, m.now)) + title := m.wrapIndent(a.Title, w, t.Text) + url := indent + t.Link.Render(truncate(a.CanonicalURL, w-detailIndent)) + return head + "\n" + title + "\n" + url + "\n\n" +} + +func (m Model) renderCVE(v store.CVE, w int, indent string) string { + t := m.theme + bnd := cvssBand(v.CVSSScore) + var b strings.Builder + + id := t.fg(colorCyan).Bold(true).Render(v.ID) + if v.IsKEV { + id += " " + t.chip("KEV", colorMagenta) + } + if v.KEVRansomware { + id += " " + t.chip("RANSOMWARE", colorMagenta) + } + id += " " + t.bandFG(bnd).Bold(true).Render(bandLabel(bnd)) + b.WriteString(id + "\n") + + if v.CVSSScore != nil { + line := indent + t.Label.Render("CVSS ") + + t.bandBar(bnd, *v.CVSSScore/cvssMaxScore, colScoreBarW) + " " + + t.bandFG(bnd).Bold(true).Render(cvssString(v.CVSSScore)) + if v.CVSSSeverity != "" { + line += " " + t.Muted.Render(v.CVSSSeverity) + } + if v.CVSSVersion != "" { + line += t.Dim.Render(" (v" + v.CVSSVersion + ")") + } + b.WriteString(line + "\n") + } + + if v.EPSS != nil { + eb := epssBand(v.EPSS) + line := indent + t.Label.Render("EPSS ") + t.bandFG(eb).Bold(true).Render(epssString(v.EPSS)) + if v.EPSSPercentile != nil { + line += t.Dim.Render(" (percentile ") + t.Muted.Render(epssString(v.EPSSPercentile)) + t.Dim.Render(")") + } + b.WriteString(line + "\n") + } + + var meta []string + if v.CWE != "" { + meta = append(meta, t.Label.Render("CWE ")+t.Muted.Render(v.CWE)) + } + if v.CVSSVector != "" { + meta = append(meta, t.Dim.Render(v.CVSSVector)) + } + if len(meta) > 0 { + b.WriteString(indent + strings.Join(meta, t.Dim.Render(metaDivider)) + "\n") + } + + if strings.TrimSpace(v.Description) != "" { + b.WriteString(m.wrapIndent(v.Description, w, t.Muted) + "\n") + } + b.WriteString(indent + t.Link.Render(nvdBaseURL+v.ID) + "\n\n") + return b.String() +} + +func (m Model) wrapIndent(s string, w int, style lipgloss.Style) string { + width := w - detailIndent + if width < 1 { + width = 1 + } + wrapped := style.Width(width).Render(strings.TrimSpace(s)) + pad := strings.Repeat(" ", detailIndent) + lines := strings.Split(wrapped, "\n") + for i := range lines { + lines[i] = pad + lines[i] + } + return strings.Join(lines, "\n") +} + +func sortedArticles(c store.DigestCluster) []store.DigestArticle { + out := make([]store.DigestArticle, len(c.Articles)) + copy(out, c.Articles) + sort.SliceStable(out, func(i, j int) bool { + if out[i].PublishedAt != out[j].PublishedAt { + return out[i].PublishedAt > out[j].PublishedAt + } + return out[i].ID < out[j].ID + }) + return out +} + +func (m Model) sortedDetailCVEs(c store.DigestCluster) []store.CVE { + out := make([]store.CVE, 0, len(c.CVEs)) + for _, dc := range c.CVEs { + if full, ok := m.cveDetail[dc.ID]; ok { + out = append(out, full) + continue + } + out = append(out, store.CVE{ID: dc.ID, CVSSScore: dc.CVSSScore, EPSS: dc.EPSS, IsKEV: dc.IsKEV}) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].IsKEV != out[j].IsKEV { + return out[i].IsKEV + } + if ci, cj := scoreOf(out[i].CVSSScore), scoreOf(out[j].CVSSScore); ci != cj { + return ci > cj + } + return out[i].ID < out[j].ID + }) + return out +} + +func scoreOf(p *float64) float64 { + if p == nil { + return -1 + } + return *p +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/format.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/format.go new file mode 100644 index 00000000..790147a3 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/format.go @@ -0,0 +1,197 @@ +// ©AngelaMos | 2026 +// format.go + +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/CarterPerez-dev/nadezhda/internal/store" +) + +type band int + +const ( + bandNone band = iota + bandLow + bandMedium + bandHigh + bandCritical +) + +const ( + cvssCritical = 9.0 + cvssHigh = 7.0 + cvssMedium = 4.0 + + epssHot = 0.5 + epssWarm = 0.1 + + secsPerMinute = 60 + secsPerHour = 3600 + secsPerDay = 86400 + + ellipsis = "…" + emptyMarker = "───" + naMarker = "—" +) + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func cvssBand(score *float64) band { + if score == nil { + return bandNone + } + switch v := *score; { + case v >= cvssCritical: + return bandCritical + case v >= cvssHigh: + return bandHigh + case v >= cvssMedium: + return bandMedium + case v > 0: + return bandLow + default: + return bandNone + } +} + +func bandLabel(b band) string { + switch b { + case bandCritical: + return "CRITICAL" + case bandHigh: + return "HIGH" + case bandMedium: + return "MEDIUM" + case bandLow: + return "LOW" + default: + return naMarker + } +} + +func clusterHasKEV(c store.DigestCluster) bool { + for _, v := range c.CVEs { + if v.IsKEV { + return true + } + } + return false +} + +func clusterMaxCVSS(c store.DigestCluster) *float64 { + var best *float64 + for _, v := range c.CVEs { + if v.CVSSScore == nil { + continue + } + if best == nil || *v.CVSSScore > *best { + best = v.CVSSScore + } + } + return best +} + +func clusterMaxEPSS(c store.DigestCluster) *float64 { + var best *float64 + for _, v := range c.CVEs { + if v.EPSS == nil { + continue + } + if best == nil || *v.EPSS > *best { + best = v.EPSS + } + } + return best +} + +func clusterBand(c store.DigestCluster) band { + if clusterHasKEV(c) { + return bandCritical + } + return cvssBand(clusterMaxCVSS(c)) +} + +func epssBand(p *float64) band { + if p == nil { + return bandNone + } + switch v := *p; { + case v >= epssHot: + return bandCritical + case v >= epssWarm: + return bandHigh + case v > 0: + return bandMedium + default: + return bandNone + } +} + +func cvssString(score *float64) string { + if score == nil { + return naMarker + } + return fmt.Sprintf("%.1f", *score) +} + +func epssString(p *float64) string { + if p == nil { + return naMarker + } + return fmt.Sprintf("%.1f%%", clamp01(*p)*100) +} + +func relativeAge(unix int64, now time.Time) string { + if unix <= 0 { + return naMarker + } + secs := now.Unix() - unix + if secs < 0 { + secs = 0 + } + switch { + case secs < secsPerMinute: + return "just now" + case secs < secsPerHour: + return fmt.Sprintf("%dm ago", secs/secsPerMinute) + case secs < secsPerDay: + return fmt.Sprintf("%dh ago", secs/secsPerHour) + default: + return fmt.Sprintf("%dd ago", secs/secsPerDay) + } +} + +func truncate(s string, max int) string { + s = strings.Join(strings.Fields(s), " ") + r := []rune(s) + if max <= 0 { + return "" + } + if len(r) <= max { + return s + } + if max == 1 { + return ellipsis + } + return string(r[:max-1]) + ellipsis +} + +func padRight(s string, width int) string { + w := len([]rune(s)) + if w >= width { + return s + } + return s + strings.Repeat(" ", width-w) +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/format_test.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/format_test.go new file mode 100644 index 00000000..c96d4ca0 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/format_test.go @@ -0,0 +1,173 @@ +// ©AngelaMos | 2026 +// format_test.go + +package tui + +import ( + "testing" + "time" + + "github.com/CarterPerez-dev/nadezhda/internal/store" +) + +func ptr(v float64) *float64 { return &v } + +func TestCVSSBand(t *testing.T) { + cases := []struct { + in *float64 + want band + }{ + {nil, bandNone}, + {ptr(0), bandNone}, + {ptr(3.9), bandLow}, + {ptr(4.0), bandMedium}, + {ptr(6.9), bandMedium}, + {ptr(7.0), bandHigh}, + {ptr(8.9), bandHigh}, + {ptr(9.0), bandCritical}, + {ptr(10.0), bandCritical}, + } + for _, c := range cases { + if got := cvssBand(c.in); got != c.want { + t.Errorf("cvssBand(%v) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestEPSSBand(t *testing.T) { + cases := []struct { + in *float64 + want band + }{ + {nil, bandNone}, + {ptr(0), bandNone}, + {ptr(0.05), bandMedium}, + {ptr(0.1), bandHigh}, + {ptr(0.49), bandHigh}, + {ptr(0.5), bandCritical}, + {ptr(0.97), bandCritical}, + } + for _, c := range cases { + if got := epssBand(c.in); got != c.want { + t.Errorf("epssBand(%v) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestBandLabel(t *testing.T) { + cases := map[band]string{ + bandCritical: "CRITICAL", + bandHigh: "HIGH", + bandMedium: "MEDIUM", + bandLow: "LOW", + bandNone: naMarker, + } + for b, want := range cases { + if got := bandLabel(b); got != want { + t.Errorf("bandLabel(%v) = %q, want %q", b, got, want) + } + } +} + +func TestCVSSString(t *testing.T) { + if got := cvssString(nil); got != naMarker { + t.Errorf("cvssString(nil) = %q, want %q", got, naMarker) + } + if got := cvssString(ptr(9.8)); got != "9.8" { + t.Errorf("cvssString(9.8) = %q, want 9.8", got) + } + if got := cvssString(ptr(10)); got != "10.0" { + t.Errorf("cvssString(10) = %q, want 10.0", got) + } +} + +func TestEPSSString(t *testing.T) { + if got := epssString(nil); got != naMarker { + t.Errorf("epssString(nil) = %q, want %q", got, naMarker) + } + if got := epssString(ptr(0.5)); got != "50.0%" { + t.Errorf("epssString(0.5) = %q, want 50.0%%", got) + } + if got := epssString(ptr(0.001)); got != "0.1%" { + t.Errorf("epssString(0.001) = %q, want 0.1%%", got) + } +} + +func TestRelativeAge(t *testing.T) { + now := time.Unix(1_000_000, 0) + cases := []struct { + unix int64 + want string + }{ + {0, naMarker}, + {now.Unix() + 100, "just now"}, + {now.Unix() - 30, "just now"}, + {now.Unix() - 120, "2m ago"}, + {now.Unix() - 7200, "2h ago"}, + {now.Unix() - 172800, "2d ago"}, + } + for _, c := range cases { + if got := relativeAge(c.unix, now); got != c.want { + t.Errorf("relativeAge(%d) = %q, want %q", c.unix, got, c.want) + } + } +} + +func TestTruncate(t *testing.T) { + cases := []struct { + in string + max int + want string + }{ + {"hello world", 20, "hello world"}, + {"hello world", 5, "hell" + ellipsis}, + {" multi space ", 20, "multi space"}, + {"abc", 1, ellipsis}, + {"abc", 0, ""}, + } + for _, c := range cases { + if got := truncate(c.in, c.max); got != c.want { + t.Errorf("truncate(%q, %d) = %q, want %q", c.in, c.max, got, c.want) + } + } +} + +func TestClusterSignals(t *testing.T) { + c := store.DigestCluster{ + CVEs: []store.DigestCVE{ + {ID: "CVE-A", CVSSScore: ptr(7.5), EPSS: ptr(0.2)}, + {ID: "CVE-B", CVSSScore: ptr(9.8), EPSS: ptr(0.9), IsKEV: true}, + {ID: "CVE-C"}, + }, + } + if !clusterHasKEV(c) { + t.Error("clusterHasKEV = false, want true") + } + if got := clusterMaxCVSS(c); got == nil || *got != 9.8 { + t.Errorf("clusterMaxCVSS = %v, want 9.8", got) + } + if got := clusterMaxEPSS(c); got == nil || *got != 0.9 { + t.Errorf("clusterMaxEPSS = %v, want 0.9", got) + } + if got := clusterBand(c); got != bandCritical { + t.Errorf("clusterBand = %v, want bandCritical (KEV)", got) + } + + noKev := store.DigestCluster{CVEs: []store.DigestCVE{{ID: "CVE-D", CVSSScore: ptr(7.5)}}} + if got := clusterBand(noKev); got != bandHigh { + t.Errorf("clusterBand(no kev, 7.5) = %v, want bandHigh", got) + } + empty := store.DigestCluster{} + if got := clusterBand(empty); got != bandNone { + t.Errorf("clusterBand(empty) = %v, want bandNone", got) + } +} + +func TestOutletColor(t *testing.T) { + cases := map[int]string{1: colorDim, 2: colorBlue, 3: colorCyan, 4: colorViolet, 9: colorViolet} + for n, want := range cases { + if got := outletColor(n); got != want { + t.Errorf("outletColor(%d) = %q, want %q", n, got, want) + } + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/keys.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/keys.go new file mode 100644 index 00000000..11995599 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/keys.go @@ -0,0 +1,54 @@ +// ©AngelaMos | 2026 +// keys.go + +package tui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Up key.Binding + Down key.Binding + Top key.Binding + Bottom key.Binding + Open key.Binding + Browser key.Binding + Back key.Binding + Quit key.Binding +} + +func defaultKeyMap() keyMap { + return keyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Top: key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g", "top"), + ), + Bottom: key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G", "bottom"), + ), + Open: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("⏎", "detail"), + ), + Browser: key.NewBinding( + key.WithKeys("o"), + key.WithHelp("o", "open"), + ), + Back: key.NewBinding( + key.WithKeys("esc", "backspace"), + key.WithHelp("esc", "back"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/list.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/list.go new file mode 100644 index 00000000..56474138 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/list.go @@ -0,0 +1,222 @@ +// ©AngelaMos | 2026 +// list.go + +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/CarterPerez-dev/nadezhda/internal/rank" + "github.com/CarterPerez-dev/nadezhda/internal/store" +) + +const ( + colSelBarW = 1 + colRankW = 2 + colScoreBarW = 10 + colScoreNumW = 4 + colOutletW = 6 + colCVEW = 5 + colGaps = 6 + minHeadlineW = 12 + + glyphSelected = "◆" + glyphHot = "▐" + glyphOutlet = "◉" +) + +func (m Model) listView() string { + header := m.listChrome() + footer := m.listFooter() + capacity := m.height - lipgloss.Height(header) - lipgloss.Height(footer) + if capacity < 1 { + capacity = 1 + } + body := m.listBody(capacity) + return lipgloss.JoinVertical(lipgloss.Left, header, body, footer) +} + +func (m Model) listChrome() string { + right := m.theme.Meta.Render(fmt.Sprintf("%d stories", len(m.scored))) + return lipgloss.JoinVertical(lipgloss.Left, + m.spread(m.wordmark(), right), + m.theme.rule(m.width), + m.listColHeader(), + ) +} + +func (m Model) listColHeader() string { + t := m.theme.ColHead + left := strings.Join([]string{ + " ", + fmt.Sprintf("%*s", colRankW, "#"), + padRight("STORY", m.headlineWidth()), + padRight("SCORE", colScoreBarW), + fmt.Sprintf("%*s", colScoreNumW, ""), + padRight("OUT", colOutletW), + }, " ") + return m.spread(t.Render(left), t.Render("CVE")) +} + +func (m Model) listFooter() string { + keys := m.keyHints(m.keys.Up, m.keys.Down, m.keys.Open, m.keys.Browser, m.keys.Quit) + right := m.severityLegend() + if s := m.statusText(); s != "" { + right = s + } + return lipgloss.JoinVertical(lipgloss.Left, + m.theme.rule(m.width), + m.spread(keys, right), + ) +} + +func (m Model) listBody(capacity int) string { + if len(m.scored) == 0 { + return m.emptyBody(capacity) + } + first, last := windowRange(m.cursor, capacity, len(m.scored)) + lines := make([]string, 0, capacity) + for i := first; i < last; i++ { + lines = append(lines, m.renderRow(i, m.scored[i], i == m.cursor)) + } + for len(lines) < capacity { + lines = append(lines, "") + } + return strings.Join(lines, "\n") +} + +func (m Model) emptyBody(capacity int) string { + t := m.theme + msg := t.Muted.Render("no stories in the store yet — run ") + + t.KeyGlyph.Render("nadezhda scrape") + + t.Muted.Render(" then ") + + t.KeyGlyph.Render("nadezhda enrich") + return lipgloss.Place(m.width, capacity, lipgloss.Center, lipgloss.Center, msg) +} + +func (m Model) renderRow(i int, s rank.Scored, selected bool) string { + t := m.theme + c := s.Cluster + b := clusterBand(c) + kev := clusterHasKEV(c) + + barStyle := t.Dim + barGlyph := " " + switch { + case selected: + barGlyph, barStyle = glyphSelected, t.SelBar + case kev || b == bandCritical: + barGlyph, barStyle = glyphHot, t.bandFG(bandCritical) + case b != bandNone: + barGlyph, barStyle = glyphHot, t.bandFG(b) + } + + rankStyle := t.Rank + headStyle := t.Headline + if selected { + rankStyle, headStyle = t.RankSel, t.HeadlineSel + } + + hlW := m.headlineWidth() + left := strings.Join([]string{ + barStyle.Render(barGlyph), + rankStyle.Render(fmt.Sprintf("%*d", colRankW, i+1)), + headStyle.Render(padRight(truncate(headlineOf(c), hlW), hlW)), + t.spectrumBar(s.Score, colScoreBarW), + t.Meta.Render(fmt.Sprintf("%*s", colScoreNumW, fmt.Sprintf("%.2f", s.Score))), + m.renderOutlets(c), + }, " ") + return m.spread(left, m.renderCVEChip(c, b, kev)) +} + +func windowRange(cursor, capacity, total int) (int, int) { + if capacity < 1 { + capacity = 1 + } + first := 0 + if cursor >= capacity { + first = cursor - capacity + 1 + } + last := first + capacity + if last > total { + last = total + } + return first, last +} + +func (m Model) renderOutlets(c store.DigestCluster) string { + n := outletCount(c) + dots := n + if dots > colOutletW { + dots = colOutletW + } + styled := m.theme.fg(outletColor(n)).Render(strings.Repeat(glyphOutlet, dots)) + if pad := colOutletW - dots; pad > 0 { + styled += strings.Repeat(" ", pad) + } + return styled +} + +func (m Model) renderCVEChip(c store.DigestCluster, b band, kev bool) string { + t := m.theme + if kev { + return t.chip("KEV", colorMagenta) + } + if max := clusterMaxCVSS(c); max != nil { + return t.bandFG(b).Bold(true).Render(cvssString(max)) + } + if len(c.CVEs) > 0 { + return t.Dim.Render("cve") + } + return t.Dim.Render(emptyMarker) +} + +func (m Model) headlineWidth() int { + fixed := colSelBarW + colRankW + colScoreBarW + colScoreNumW + colOutletW + colCVEW + colGaps + if w := m.width - fixed; w > minHeadlineW { + return w + } + return minHeadlineW +} + +func headlineArticle(c store.DigestCluster) store.DigestArticle { + var best store.DigestArticle + for i, a := range c.Articles { + if i == 0 || a.PublishedAt > best.PublishedAt || + (a.PublishedAt == best.PublishedAt && a.ID < best.ID) { + best = a + } + } + return best +} + +func headlineOf(c store.DigestCluster) string { + if a := headlineArticle(c); strings.TrimSpace(a.Title) != "" { + return a.Title + } + return "(untitled cluster)" +} + +func outletCount(c store.DigestCluster) int { + seen := make(map[string]struct{}, len(c.Articles)) + for _, a := range c.Articles { + seen[a.SourceName] = struct{}{} + } + return len(seen) +} + +func outletColor(n int) string { + switch { + case n >= 4: + return colorViolet + case n == 3: + return colorCyan + case n == 2: + return colorBlue + default: + return colorDim + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/model.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/model.go new file mode 100644 index 00000000..0f1884ec --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/model.go @@ -0,0 +1,253 @@ +// ©AngelaMos | 2026 +// model.go + +package tui + +import ( + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + "github.com/CarterPerez-dev/nadezhda/internal/rank" + "github.com/CarterPerez-dev/nadezhda/internal/store" +) + +type viewState int + +const ( + stateLoading viewState = iota + stateList + stateDetail + stateError +) + +const ( + defaultWidth = 100 + defaultHeight = 32 + detailChrome = 4 + spinnerFPS = time.Second / 10 +) + +var raveSpinner = spinner.Spinner{ + Frames: []string{"◇", "◈", "◆", "◈", "◇", "·"}, + FPS: spinnerFPS, +} + +type Data struct { + Scored []rank.Scored + CVEDetail map[string]store.CVE +} + +type Loader func() (Data, error) + +type dataMsg struct{ data Data } + +type errMsg struct{ err error } + +type openedMsg struct { + url string + err error +} + +type Model struct { + state viewState + loader Loader + now time.Time + theme Theme + keys keyMap + spinner spinner.Model + viewport viewport.Model + + width int + height int + + scored []rank.Scored + cveDetail map[string]store.CVE + + cursor int + err error + + opener func(string) error + status string + statusErr bool +} + +func New(loader Loader, now time.Time) Model { + th := NewTheme() + sp := spinner.New(spinner.WithSpinner(raveSpinner), spinner.WithStyle(th.Spinner)) + m := Model{ + state: stateLoading, + loader: loader, + now: now, + theme: th, + keys: defaultKeyMap(), + spinner: sp, + viewport: viewport.New(defaultWidth, defaultHeight-detailChrome), + width: defaultWidth, + height: defaultHeight, + cveDetail: map[string]store.CVE{}, + opener: openURL, + } + return m +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, m.load()) +} + +func (m Model) load() tea.Cmd { + loader := m.loader + return func() tea.Msg { + data, err := loader() + if err != nil { + return errMsg{err} + } + return dataMsg{data} + } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.applySize(msg.Width, msg.Height) + return m, nil + case dataMsg: + m.scored = msg.data.Scored + m.cveDetail = msg.data.CVEDetail + m.state = stateList + return m, nil + case errMsg: + m.state = stateError + m.err = msg.err + return m, nil + case openedMsg: + if msg.err != nil { + m.status, m.statusErr = "open failed: "+msg.err.Error(), true + } else { + m.status, m.statusErr = "opened in browser", false + } + return m, nil + case spinner.TickMsg: + if m.state != stateLoading { + return m, nil + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + case tea.KeyMsg: + return m.handleKey(msg) + } + if m.state == stateDetail { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + m.status = "" + if key.Matches(msg, m.keys.Quit) { + return m, tea.Quit + } + if key.Matches(msg, m.keys.Browser) && (m.state == stateList || m.state == stateDetail) { + return m, m.openSelected() + } + switch m.state { + case stateList: + return m.handleListKey(msg) + case stateDetail: + return m.handleDetailKey(msg) + default: + return m, nil + } +} + +func (m Model) openSelected() tea.Cmd { + target := headlineArticle(m.selected().Cluster).CanonicalURL + if target == "" { + return nil + } + open := m.opener + return func() tea.Msg { + return openedMsg{url: target, err: open(target)} + } +} + +func (m Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Up): + if m.cursor > 0 { + m.cursor-- + } + case key.Matches(msg, m.keys.Down): + if m.cursor < len(m.scored)-1 { + m.cursor++ + } + case key.Matches(msg, m.keys.Top): + m.cursor = 0 + case key.Matches(msg, m.keys.Bottom): + if len(m.scored) > 0 { + m.cursor = len(m.scored) - 1 + } + case key.Matches(msg, m.keys.Open): + if len(m.scored) > 0 { + m.state = stateDetail + m.viewport.SetContent(m.renderDetailBody()) + m.viewport.GotoTop() + } + } + return m, nil +} + +func (m Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, m.keys.Back) { + m.state = stateList + return m, nil + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m *Model) applySize(w, h int) { + m.width = w + m.height = h + m.viewport.Width = m.bodyWidth() + m.viewport.Height = m.detailBodyHeight() + if m.state == stateDetail { + m.viewport.SetContent(m.renderDetailBody()) + } +} + +func (m Model) bodyWidth() int { + if m.width > 1 { + return m.width + } + return 1 +} + +func (m Model) detailBodyHeight() int { + if h := m.height - detailChrome; h > 1 { + return h + } + return 1 +} + +func (m Model) selected() rank.Scored { + if m.cursor < 0 || m.cursor >= len(m.scored) { + return rank.Scored{} + } + return m.scored[m.cursor] +} + +func Run(loader Loader) error { + m := New(loader, time.Now()) + _, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + return err +} + +var _ tea.Model = Model{} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/model_test.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/model_test.go new file mode 100644 index 00000000..e8c8fd27 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/model_test.go @@ -0,0 +1,322 @@ +// ©AngelaMos | 2026 +// model_test.go + +package tui + +import ( + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + + "github.com/CarterPerez-dev/nadezhda/internal/rank" + "github.com/CarterPerez-dev/nadezhda/internal/store" +) + +func testNow() time.Time { return time.Unix(1_720_000_000, 0) } + +func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} } + +func sampleData() Data { + now := testNow().Unix() + c1 := store.DigestCluster{ + ClusterID: 1, + Key: "log4shell", + Size: 2, + FirstSeen: now - 3600, + LastSeen: now - 600, + Articles: []store.DigestArticle{ + {ID: 1, SourceName: "krebs", SourceWeight: 1.0, Title: "Log4Shell RCE exploited in the wild", CanonicalURL: "https://krebsonsecurity.com/log4shell", PublishedAt: now - 600}, + {ID: 2, SourceName: "theregister", SourceWeight: 0.9, Title: "Log4j flaw under active attack", CanonicalURL: "https://theregister.com/log4j", PublishedAt: now - 3600}, + }, + CVEs: []store.DigestCVE{ + {ID: "CVE-2021-44228", CVSSScore: ptr(10.0), EPSS: ptr(0.97), IsKEV: true}, + }, + } + c2 := store.DigestCluster{ + ClusterID: 2, + Key: "policy", + Size: 1, + FirstSeen: now - 7200, + LastSeen: now - 7200, + Articles: []store.DigestArticle{ + {ID: 3, SourceName: "darkreading", SourceWeight: 0.7, Title: "New disclosure guidelines published", CanonicalURL: "https://darkreading.com/policy", PublishedAt: now - 7200}, + }, + } + scored := []rank.Scored{{Cluster: c1, Score: 0.94}, {Cluster: c2, Score: 0.42}} + detail := map[string]store.CVE{ + "CVE-2021-44228": { + ID: "CVE-2021-44228", + Description: "Apache Log4j2 JNDI features do not protect against attacker controlled LDAP endpoints.", + CVSSScore: ptr(10.0), + CVSSVersion: "3.1", + CVSSSeverity: "CRITICAL", + CVSSVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H", + CWE: "CWE-502", + IsKEV: true, + EPSS: ptr(0.97), + }, + } + return Data{Scored: scored, CVEDetail: detail} +} + +func toModel(t *testing.T, tm tea.Model) Model { + t.Helper() + m, ok := tm.(Model) + if !ok { + t.Fatalf("Update returned %T, want tui.Model", tm) + } + return m +} + +func step(t *testing.T, m Model, msg tea.Msg) Model { + t.Helper() + tm, _ := m.Update(msg) + return toModel(t, tm) +} + +func loadedModel(t *testing.T) Model { + t.Helper() + return step(t, New(nil, testNow()), dataMsg{sampleData()}) +} + +func TestInitialStateIsLoading(t *testing.T) { + if m := New(nil, testNow()); m.state != stateLoading { + t.Fatalf("initial state = %v, want stateLoading", m.state) + } +} + +func TestDataMsgTransitionsToList(t *testing.T) { + m := loadedModel(t) + if m.state != stateList { + t.Fatalf("state = %v, want stateList", m.state) + } + if len(m.scored) != 2 { + t.Fatalf("len(scored) = %d, want 2", len(m.scored)) + } +} + +func TestErrMsgTransitionsToError(t *testing.T) { + m := step(t, New(nil, testNow()), errMsg{errors.New("wire down")}) + if m.state != stateError { + t.Fatalf("state = %v, want stateError", m.state) + } + if m.err == nil { + t.Fatal("err = nil, want non-nil") + } +} + +func TestListNavigationClamps(t *testing.T) { + m := loadedModel(t) + m = step(t, m, runeKey('j')) + if m.cursor != 1 { + t.Fatalf("after down, cursor = %d, want 1", m.cursor) + } + m = step(t, m, runeKey('j')) + if m.cursor != 1 { + t.Fatalf("after down past end, cursor = %d, want 1", m.cursor) + } + m = step(t, m, runeKey('k')) + if m.cursor != 0 { + t.Fatalf("after up, cursor = %d, want 0", m.cursor) + } + m = step(t, m, runeKey('k')) + if m.cursor != 0 { + t.Fatalf("after up past start, cursor = %d, want 0", m.cursor) + } +} + +func TestListTopBottom(t *testing.T) { + m := loadedModel(t) + m = step(t, m, runeKey('G')) + if m.cursor != 1 { + t.Fatalf("after G, cursor = %d, want 1", m.cursor) + } + m = step(t, m, runeKey('g')) + if m.cursor != 0 { + t.Fatalf("after g, cursor = %d, want 0", m.cursor) + } +} + +func TestOpenAndBack(t *testing.T) { + m := loadedModel(t) + m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if m.state != stateDetail { + t.Fatalf("after enter, state = %v, want stateDetail", m.state) + } + m = step(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.state != stateList { + t.Fatalf("after esc, state = %v, want stateList", m.state) + } +} + +func TestQuitReturnsQuitCmd(t *testing.T) { + m := loadedModel(t) + _, cmd := m.Update(runeKey('q')) + if cmd == nil { + t.Fatal("quit returned nil cmd") + } + if reflect.TypeOf(cmd()) != reflect.TypeOf(tea.Quit()) { + t.Fatalf("quit cmd yielded %T, want tea.QuitMsg", cmd()) + } +} + +func TestWindowSizeSizesViewport(t *testing.T) { + m := loadedModel(t) + m = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 40}) + if m.width != 120 || m.height != 40 { + t.Fatalf("size = %dx%d, want 120x40", m.width, m.height) + } + if m.viewport.Width != 120 { + t.Fatalf("viewport.Width = %d, want 120", m.viewport.Width) + } + if m.viewport.Height != 40-detailChrome { + t.Fatalf("viewport.Height = %d, want %d", m.viewport.Height, 40-detailChrome) + } +} + +func TestSpinnerTickIgnoredOutsideLoading(t *testing.T) { + m := loadedModel(t) + m = step(t, m, spinner.TickMsg{}) + if m.state != stateList { + t.Fatalf("spinner tick changed state to %v, want stateList", m.state) + } +} + +func TestViewsRenderNonEmpty(t *testing.T) { + loading := New(nil, testNow()) + if strings.TrimSpace(loading.View()) == "" { + t.Error("loading view is empty") + } + + list := loadedModel(t) + lv := list.View() + if !strings.Contains(lv, "NADEZHDA") { + t.Error("list view missing brand") + } + if !strings.Contains(lv, "Log4Shell") { + t.Error("list view missing headline") + } + + detail := step(t, list, tea.KeyMsg{Type: tea.KeyEnter}) + dv := detail.View() + if !strings.Contains(dv, "CVE-2021-44228") { + t.Error("detail view missing CVE id") + } + if !strings.Contains(dv, "CRITICAL") { + t.Error("detail view missing severity label") + } + + errv := step(t, New(nil, testNow()), errMsg{errors.New("boom")}) + if strings.TrimSpace(errv.View()) == "" { + t.Error("error view is empty") + } +} + +func TestEmptyStoreRendersHint(t *testing.T) { + m := step(t, New(nil, testNow()), dataMsg{Data{}}) + if m.state != stateList { + t.Fatalf("state = %v, want stateList", m.state) + } + if !strings.Contains(m.View(), "scrape") { + t.Error("empty list view missing scrape hint") + } +} + +func TestTooSmallTerminal(t *testing.T) { + m := step(t, loadedModel(t), tea.WindowSizeMsg{Width: 20, Height: 5}) + if !strings.Contains(m.View(), "too small") { + t.Error("tiny terminal did not render the too-small notice") + } +} + +func TestWindowRange(t *testing.T) { + cases := []struct { + cursor, capacity, total int + first, last int + }{ + {0, 5, 10, 0, 5}, + {4, 5, 10, 0, 5}, + {5, 5, 10, 1, 6}, + {9, 5, 10, 5, 10}, + {0, 5, 3, 0, 3}, + {2, 5, 3, 0, 3}, + {0, 0, 10, 0, 1}, + {7, 3, 10, 5, 8}, + } + for _, c := range cases { + first, last := windowRange(c.cursor, c.capacity, c.total) + if first != c.first || last != c.last { + t.Errorf("windowRange(%d, %d, %d) = (%d, %d), want (%d, %d)", + c.cursor, c.capacity, c.total, first, last, c.first, c.last) + } + if c.cursor < c.total && (c.cursor < first || c.cursor >= last) { + t.Errorf("windowRange(%d, %d, %d) window [%d,%d) excludes cursor", + c.cursor, c.capacity, c.total, first, last) + } + } +} + +func TestHeadlineFreshest(t *testing.T) { + fresh := store.DigestCluster{Articles: []store.DigestArticle{ + {ID: 1, Title: "older", PublishedAt: 100}, + {ID: 2, Title: "newest", PublishedAt: 300}, + {ID: 3, Title: "mid", PublishedAt: 200}, + }} + if got := headlineOf(fresh); got != "newest" { + t.Errorf("headlineOf(fresh) = %q, want newest", got) + } + tie := store.DigestCluster{Articles: []store.DigestArticle{ + {ID: 5, Title: "high-id", PublishedAt: 300}, + {ID: 2, Title: "low-id", PublishedAt: 300}, + }} + if got := headlineOf(tie); got != "low-id" { + t.Errorf("headlineOf(tie) = %q, want low-id (lowest id on equal time)", got) + } + if got := headlineOf(store.DigestCluster{}); got != "(untitled cluster)" { + t.Errorf("headlineOf(empty) = %q, want (untitled cluster)", got) + } +} + +func TestOpenInBrowser(t *testing.T) { + m := loadedModel(t) + var opened string + m.opener = func(u string) error { opened = u; return nil } + + tm, cmd := m.Update(runeKey('o')) + m = toModel(t, tm) + if cmd == nil { + t.Fatal("pressing o produced no command") + } + msg := cmd() + if opened != "https://krebsonsecurity.com/log4shell" { + t.Fatalf("opened %q, want the selected cluster's headline url", opened) + } + om, ok := msg.(openedMsg) + if !ok { + t.Fatalf("open cmd returned %T, want openedMsg", msg) + } + m = step(t, m, om) + if m.status == "" || m.statusErr { + t.Errorf("after open: status=%q err=%v, want a success status", m.status, m.statusErr) + } + if !strings.Contains(m.View(), m.status) { + t.Error("open status not shown in the view") + } + m = step(t, m, runeKey('j')) + if m.status != "" { + t.Error("status should clear on the next keypress") + } +} + +func TestOpenURLRejectsNonHTTP(t *testing.T) { + for _, bad := range []string{"file:///etc/passwd", "javascript:alert(1)", "", "ftp://x/y"} { + if err := openURL(bad); err == nil { + t.Errorf("openURL(%q) = nil, want refusal", bad) + } + } +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/theme.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/theme.go new file mode 100644 index 00000000..3e3fd795 --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/theme.go @@ -0,0 +1,151 @@ +// ©AngelaMos | 2026 +// theme.go + +package tui + +import ( + "math" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +const ( + colorVoid = "#0B0620" + colorText = "#E7E0FF" + colorMuted = "#9A8AD0" + colorDim = "#574B7D" + + colorMagenta = "#FF2E97" + colorAmber = "#FF8A3D" + colorYellow = "#FFE04D" + colorCyan = "#00E5FF" + colorViolet = "#A65CFF" + colorBlue = "#2D6BFF" +) + +const ( + barFull = "█" + barEmpty = "░" + ruleLead = "▓▒░" + ruleBody = "─" +) + +func bandColor(b band) string { + switch b { + case bandCritical: + return colorMagenta + case bandHigh: + return colorAmber + case bandMedium: + return colorYellow + case bandLow: + return colorCyan + default: + return colorDim + } +} + +type Theme struct { + Brand lipgloss.Style + BrandMark lipgloss.Style + Meta lipgloss.Style + ColHead lipgloss.Style + Text lipgloss.Style + Muted lipgloss.Style + Dim lipgloss.Style + Rank lipgloss.Style + RankSel lipgloss.Style + Headline lipgloss.Style + HeadlineSel lipgloss.Style + SelBar lipgloss.Style + KeyGlyph lipgloss.Style + KeyDesc lipgloss.Style + Spinner lipgloss.Style + PanelTitle lipgloss.Style + Label lipgloss.Style + Link lipgloss.Style +} + +func NewTheme() Theme { + base := lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + return Theme{ + Brand: lipgloss.NewStyle().Foreground(lipgloss.Color(colorViolet)).Bold(true), + BrandMark: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true), + Meta: lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)), + ColHead: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true), + Text: base, + Muted: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)), + Dim: lipgloss.NewStyle().Foreground(lipgloss.Color(colorDim)), + Rank: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)), + RankSel: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true), + Headline: base, + HeadlineSel: lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)).Bold(true), + SelBar: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true), + KeyGlyph: lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)).Bold(true), + KeyDesc: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)), + Spinner: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true), + PanelTitle: lipgloss.NewStyle().Foreground(lipgloss.Color(colorViolet)).Bold(true), + Label: lipgloss.NewStyle().Foreground(lipgloss.Color(colorBlue)).Bold(true), + Link: lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)).Underline(true), + } +} + +func (t Theme) fg(color string) lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(color)) +} + +func (t Theme) bandFG(b band) lipgloss.Style { + return t.fg(bandColor(b)) +} + +func (t Theme) chip(text, color string) string { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorVoid)). + Background(lipgloss.Color(color)). + Bold(true). + Render(" " + text + " ") +} + +func (t Theme) spectrumBar(frac float64, width int) string { + if width <= 0 { + return "" + } + filled := int(math.Round(clamp01(frac) * float64(width))) + stops := []string{colorCyan, colorBlue, colorViolet, colorMagenta} + var b strings.Builder + for i := 0; i < width; i++ { + if i < filled { + b.WriteString(t.fg(stops[i*len(stops)/width]).Render(barFull)) + } else { + b.WriteString(t.Dim.Render(barEmpty)) + } + } + return b.String() +} + +func (t Theme) bandBar(b band, frac float64, width int) string { + if width <= 0 { + return "" + } + filled := int(math.Round(clamp01(frac) * float64(width))) + color := bandColor(b) + var sb strings.Builder + for i := 0; i < width; i++ { + if i < filled { + sb.WriteString(t.fg(color).Render(barFull)) + } else { + sb.WriteString(t.Dim.Render(barEmpty)) + } + } + return sb.String() +} + +func (t Theme) rule(width int) string { + lead := len([]rune(ruleLead)) + 1 + n := width - lead + if n < 0 { + return t.fg(colorViolet).Render(ruleLead) + } + return t.fg(colorViolet).Render(ruleLead) + " " + t.Dim.Render(strings.Repeat(ruleBody, n)) +} diff --git a/PROJECTS/intermediate/security-news-scraper/internal/tui/view.go b/PROJECTS/intermediate/security-news-scraper/internal/tui/view.go new file mode 100644 index 00000000..15a3278f --- /dev/null +++ b/PROJECTS/intermediate/security-news-scraper/internal/tui/view.go @@ -0,0 +1,159 @@ +// ©AngelaMos | 2026 +// view.go + +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/lipgloss" +) + +const ( + brandName = "NADEZHDA" + tagline = "THREAT.WIRE" + + markOpen = "◤ " + markClose = " ◢" + markMid = "═◆═" + + errWrapMax = 80 + errWrapMin = 20 + errWrapMargin = 6 + + minRenderWidth = 44 + minRenderHeight = 10 +) + +func (m Model) View() string { + if m.width < minRenderWidth || m.height < minRenderHeight { + return m.tooSmallView() + } + switch m.state { + case stateLoading: + return m.loadingView() + case stateError: + return m.errorView() + case stateDetail: + return m.detailView() + default: + return m.listView() + } +} + +func (m Model) tooSmallView() string { + w, h := m.width, m.height + if w < 1 { + w = 1 + } + if h < 1 { + h = 1 + } + msg := m.theme.Muted.Render(fmt.Sprintf("terminal too small — need at least %dx%d", minRenderWidth, minRenderHeight)) + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, msg) +} + +func (m Model) wordmark() string { + t := m.theme + return t.BrandMark.Render(markOpen) + t.Brand.Render(brandName) + t.BrandMark.Render(markClose) + + " " + t.fg(colorCyan).Render(markMid) + " " + t.fg(colorViolet).Render(tagline) + " " + t.fg(colorCyan).Render(markMid) +} + +func (m Model) spread(left, right string) string { + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + gap = 1 + } + return left + strings.Repeat(" ", gap) + right +} + +func (m Model) keyHints(bindings ...key.Binding) string { + t := m.theme + parts := make([]string, 0, len(bindings)) + for _, b := range bindings { + h := b.Help() + parts = append(parts, t.KeyGlyph.Render(h.Key)+" "+t.KeyDesc.Render(h.Desc)) + } + return strings.Join(parts, t.Dim.Render(" · ")) +} + +func (m Model) severityLegend() string { + t := m.theme + item := func(color, label string) string { + return t.fg(color).Render("■") + " " + t.KeyDesc.Render(label) + } + return strings.Join([]string{ + item(colorMagenta, "KEV/CRIT"), + item(colorAmber, "HIGH"), + item(colorYellow, "MED"), + item(colorCyan, "LOW"), + }, " ") +} + +func (m Model) loadingView() string { + t := m.theme + brand := t.BrandMark.Render(markOpen) + t.Brand.Render(brandName) + t.BrandMark.Render(markClose) + tag := t.fg(colorViolet).Render(tagline) + line := m.spinner.View() + " " + t.Meta.Render("assembling threat wire") + " " + m.spinner.View() + block := lipgloss.JoinVertical(lipgloss.Center, brand, tag, "", line) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, block) +} + +func (m Model) errorView() string { + t := m.theme + title := t.fg(colorMagenta).Bold(true).Render("▚ WIRE FAULT ▞") + block := lipgloss.JoinVertical(lipgloss.Center, + title, + "", + m.errString(), + "", + t.KeyDesc.Render("press ")+t.KeyGlyph.Render("q")+t.KeyDesc.Render(" to quit"), + ) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, block) +} + +func (m Model) errString() string { + if m.err == nil { + return m.theme.Text.Render("unknown fault") + } + w := m.width - errWrapMargin + if w > errWrapMax { + w = errWrapMax + } + if w < errWrapMin { + w = errWrapMin + } + return m.theme.Text.Width(w).Align(lipgloss.Center).Render(m.err.Error()) +} + +func (m Model) detailView() string { + t := m.theme + scroll := fmt.Sprintf("%3.0f%%", m.viewport.ScrollPercent()*100) + left := t.PanelTitle.Render(markOpen+"DOSSIER"+markClose) + " " + + t.Muted.Render(fmt.Sprintf("story %d of %d", m.cursor+1, len(m.scored))) + head := m.spread(left, t.Meta.Render(scroll)) + foot := m.spread( + m.keyHints(m.keys.Back, m.keys.Browser, m.keys.Down, m.keys.Up, m.keys.Quit), + m.statusText(), + ) + return lipgloss.JoinVertical(lipgloss.Left, + head, + t.rule(m.width), + m.viewport.View(), + t.rule(m.width), + foot, + ) +} + +func (m Model) statusText() string { + if m.status == "" { + return "" + } + color := colorCyan + if m.statusErr { + color = colorMagenta + } + return m.theme.fg(color).Render(m.status) +}