From 9b5afc3d7c6357abecc7659429dc302a2564e0a6 Mon Sep 17 00:00:00 2001 From: Arindy Date: Mon, 29 Dec 2025 21:19:05 +0100 Subject: [PATCH] Add News widget and integrate NewsAPI for live updates --- apis/newsapi/main.go | 77 +++++++++++++++++ apis/nina/main.go | 26 ++++-- apis/openweathermap/currentWeather.go | 31 +++---- apis/openweathermap/main.go | 13 ++- apis/pihole/pihole.go | 15 ++-- config_template.toml | 5 ++ main.go | 13 ++- widgets/news.go | 114 ++++++++++++++++++++++++++ widgets/weather.go | 4 + 9 files changed, 260 insertions(+), 38 deletions(-) create mode 100644 apis/newsapi/main.go create mode 100644 widgets/news.go diff --git a/apis/newsapi/main.go b/apis/newsapi/main.go new file mode 100644 index 0000000..91caebd --- /dev/null +++ b/apis/newsapi/main.go @@ -0,0 +1,77 @@ +package news + +import ( + "ArinDash/config" + "encoding/json" + "io" + "log" + "net/http" + "strings" +) + +const apiBaseURL = "https://newsapi.org/v2/top-headlines" + +type configFile struct { + News newsConfig +} + +type newsConfig struct { + ApiKey string + Sources string +} + +type News struct { + Status string `json:"status"` + TotalResults int `json:"totalResults"` + Articles []Article `json:"articles"` +} + +type Article struct { + Source Source `json:"source"` + Author string `json:"author"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + URLToImage string `json:"urlToImage"` + PublishedAt string `json:"publishedAt"` + Content string `json:"content"` +} + +type Source struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func FetchNews() News { + cfg := &configFile{} + config.LoadConfig(cfg) + client := &http.Client{} + + req, err := http.NewRequest("GET", apiBaseURL+"?sources="+cfg.News.Sources+"&pageSize=100&apiKey="+cfg.News.ApiKey, nil) + if err != nil { + panic(err) + } + resp, err := client.Do(req) + if err != nil { + return News{ + Status: err.Error(), + } + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return News{ + Status: err.Error(), + } + } + + news := News{} + + err = json.Unmarshal([]byte(strings.ReplaceAll(strings.ReplaceAll(string(respBody), "\r", ""), "\u00A0", "")), &news) + + if err != nil { + log.Fatal(err) + } + + return news +} diff --git a/apis/nina/main.go b/apis/nina/main.go index 9b02686..b83c03b 100644 --- a/apis/nina/main.go +++ b/apis/nina/main.go @@ -4,7 +4,6 @@ import ( "ArinDash/config" "encoding/json" "io" - "log" "net/http" ) @@ -29,6 +28,7 @@ type Warning struct { Code []string `json:"code"` Reference string `json:"reference"` Info []Info `json:"info"` + Errormsg string `json:"errormsg"` } type Info struct { @@ -53,33 +53,45 @@ func FetchWarnings() []Warning { } resp, err := client.Do(req) if err != nil { - log.Fatal(err) + return append(make([]Warning, 0), Warning{ + Errormsg: err.Error(), + }) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - log.Fatal(err) + return append(make([]Warning, 0), Warning{ + Errormsg: err.Error(), + }) } warnings := make([]Warning, 0) err = json.Unmarshal(respBody, &warnings) if err != nil { - log.Fatal(err) + return append(make([]Warning, 0), Warning{ + Errormsg: err.Error(), + }) } result := make([]Warning, 0) for _, warning := range warnings { req, err := http.NewRequest("GET", apiBaseURL+"/warnings/"+warning.Id+".json", nil) if err != nil { - panic(err) + return append(make([]Warning, 0), Warning{ + Errormsg: err.Error(), + }) } resp, err := client.Do(req) if err != nil { - log.Fatal(err) + return append(make([]Warning, 0), Warning{ + Errormsg: err.Error(), + }) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - log.Fatal(err) + return append(make([]Warning, 0), Warning{ + Errormsg: err.Error(), + }) } warning := Warning{} err = json.Unmarshal(respBody, &warning) diff --git a/apis/openweathermap/currentWeather.go b/apis/openweathermap/currentWeather.go index b9e8f1c..4c6c4f0 100644 --- a/apis/openweathermap/currentWeather.go +++ b/apis/openweathermap/currentWeather.go @@ -3,21 +3,22 @@ package openWeatherMap import "time" type CurrentWeather struct { - Base string `json:"base"` - Visibility int `json:"visibility"` - Dt int `json:"dt"` - Timezone int `json:"timezone"` - Id int `json:"id"` - Name string `json:"name"` - Cod int `json:"cod"` - Coordinates Coordinates `json:"coord"` - Weather []Weather `json:"weather"` - Wind Wind `json:"wind"` - Clouds Clouds `json:"clouds"` - Rain Rain `json:"rain"` - Snow Snow `json:"snow"` - Sys Sys `json:"sys"` - Main Main `json:"main"` + Base string `json:"base"` + Visibility int `json:"visibility"` + Dt int `json:"dt"` + Timezone int `json:"timezone"` + Id int `json:"id"` + Name string `json:"name"` + Cod int `json:"cod"` + Coordinates Coordinates `json:"coord"` + Weather []Weather `json:"weather"` + Wind Wind `json:"wind"` + Clouds Clouds `json:"clouds"` + Rain Rain `json:"rain"` + Snow Snow `json:"snow"` + Sys Sys `json:"sys"` + Main Main `json:"main"` + ErrorMessage string } type Coordinates struct { diff --git a/apis/openweathermap/main.go b/apis/openweathermap/main.go index 567df9d..c0c9342 100644 --- a/apis/openweathermap/main.go +++ b/apis/openweathermap/main.go @@ -4,7 +4,6 @@ import ( "ArinDash/config" "encoding/json" "io" - "log" "net/http" ) @@ -27,16 +26,22 @@ func FetchCurrentWeather() CurrentWeather { req, err := http.NewRequest("GET", apiBaseURL+"?id="+cfg.OpenWeatherMap.LocationId+"&units=metric&lang=en&APPID="+cfg.OpenWeatherMap.ApiKey, nil) if err != nil { - panic(err) + return CurrentWeather{ + ErrorMessage: err.Error(), + } } resp, err := client.Do(req) if err != nil { - log.Fatal(err) + return CurrentWeather{ + ErrorMessage: err.Error(), + } } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - log.Fatal(err) + return CurrentWeather{ + ErrorMessage: err.Error(), + } } err = json.Unmarshal(respBody, currentWeather) diff --git a/apis/pihole/pihole.go b/apis/pihole/pihole.go index 0256e9e..9bd90e2 100644 --- a/apis/pihole/pihole.go +++ b/apis/pihole/pihole.go @@ -4,7 +4,6 @@ import ( "ArinDash/config" "encoding/json" "io" - "log" "net/http" "strings" ) @@ -50,7 +49,7 @@ func (ph *PiHConnector) do(method string, endpoint string, body io.Reader) []byt req, err := http.NewRequest(method, requestString, body) if err != nil { - log.Fatal(err) + return make([]byte, 0) } req.Header.Add("X-FTL-SID", ph.Session.SID) @@ -58,13 +57,13 @@ func (ph *PiHConnector) do(method string, endpoint string, body io.Reader) []byt resp, err := client.Do(req) if err != nil { - log.Fatal(err) + return make([]byte, 0) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - log.Fatal(err) + return make([]byte, 0) } return respBody @@ -82,24 +81,24 @@ func Connect() PiHConnector { req, err := http.NewRequest("POST", "http://"+cfg.Pihole.Host+"/api/auth", strings.NewReader("{\"password\": \""+cfg.Pihole.Password+"\"}")) if err != nil { - log.Fatal(err) + panic(err) } resp, err := client.Do(req) if err != nil { - log.Fatal(err) + panic(err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - log.Fatal(err) + panic(err) } s := &PiHAuth{} err = json.Unmarshal(respBody, s) if err != nil { - log.Fatal(err) + panic(err) } connector = &PiHConnector{ Host: cfg.Pihole.Host, diff --git a/config_template.toml b/config_template.toml index 9460894..053d470 100644 --- a/config_template.toml +++ b/config_template.toml @@ -17,3 +17,8 @@ ApiKey = "YourApiKey" [nina] #Code from https://www.xrepository.de/api/xrepository/urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs_2021-07-31/download/Regionalschl_ssel_2021-07-31.json gebietsCode = "000000000000" + +[news] +#apiKey from newsapi.org +ApiKey = "ApiKey" +Sources = "comma,sepparated,sources,from,newsapi" diff --git a/main.go b/main.go index 0a2a105..944c754 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ func initWidgets(ctx context.Context, term *tcell.Terminal) { widgets.New("PiHole", widgets.PiholeStats()), widgets.New("PiHoleBlocked", widgets.PiholeBlocked()), widgets.New("NinaWarnings", widgets.NinaWarnings()), + widgets.New("News", widgets.News()), ) } @@ -69,7 +70,7 @@ func layout() []container.Option { ), ), - grid.ColWidthPerc(58, + grid.ColWidthPerc(62, grid.RowHeightFixed(44, grid.ColWidthFixed(40, grid.RowHeightFixed(8, @@ -125,8 +126,12 @@ func layout() []container.Option { ), ), grid.RowHeightFixed(20, - grid.RowHeightFixed(25, - grid.Widget(widgets.Get["empty"]), + grid.RowHeightFixed(20, + grid.Widget(widgets.Get["News"], + container.BorderTitle("News"), + container.Border(linestyle.Light), + container.BorderColor(cell.ColorWhite), + ), ), grid.RowHeightFixed(20, grid.Widget(widgets.Get["NinaWarnings"], @@ -139,7 +144,7 @@ func layout() []container.Option { ), grid.ColWidthPerc(35, - grid.RowHeightPerc(20, + grid.RowHeightFixed(20, grid.Widget(widgets.Get["HTTPProber"], container.BorderTitle("Website Status"), container.Border(linestyle.Light), diff --git a/widgets/news.go b/widgets/news.go new file mode 100644 index 0000000..2d8d068 --- /dev/null +++ b/widgets/news.go @@ -0,0 +1,114 @@ +package widgets + +import ( + news "ArinDash/apis/newsapi" + "ArinDash/util" + "context" + "fmt" + "strings" + "time" + + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/keyboard" + "github.com/mum4k/termdash/terminal/terminalapi" + "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/widgets/text" +) + +type NewsOptions struct { +} + +func News() NewsOptions { + widgetOptions["NewsOptions"] = createNewsOptions + return NewsOptions{} +} + +func createNewsOptions(ctx context.Context, _ terminalapi.Terminal, _ interface{}) widgetapi.Widget { + widget := util.PanicOnErrorWithResult(NewNewsText(text.WrapAtWords())) + go util.Periodic(ctx, 1*time.Hour, func() error { + widget.newsArticles = news.FetchNews() + if widget.newsArticles.Status != "ok" { + if err := widget.Write(widget.newsArticles.Status, text.WriteCellOpts(cell.FgColor(cell.ColorRed))); err != nil { + return err + } + } + return nil + }) + go util.Periodic(ctx, 30*time.Second, func() error { + err := widget.drawNews() + if err != nil { + return err + } + if len(widget.newsArticles.Articles) > 0 { + widget.Selected = (widget.Selected + 1) % (len(widget.newsArticles.Articles) + 1) + } + return nil + }) + + return widget +} + +func (widget *NewsText) drawNews() error { + selected := widget.Selected + widget.Reset() + if len(widget.newsArticles.Articles) == 0 { + return nil + } + if err := widget.Write(fmt.Sprintf("%d/%d\n", selected+1, len(widget.newsArticles.Articles)), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { + widget.Write(fmt.Sprintf("Error: %s", err.Error()), text.WriteCellOpts(cell.FgColor(cell.ColorRed))) + return err + } + article := widget.newsArticles.Articles[selected] + if err := widget.Write(fmt.Sprintf("%s\n", article.Author), text.WriteCellOpts(cell.FgColor(cell.ColorGray))); err != nil { + widget.Write(fmt.Sprintf("Error: %s", err.Error()), text.WriteCellOpts(cell.FgColor(cell.ColorRed))) + return err + } + if err := widget.Write(fmt.Sprintf("%s\n\n", article.Title), text.WriteCellOpts(cell.FgColor(cell.ColorWhite), cell.Bold())); err != nil { + widget.Write(fmt.Sprintf("Error: %s", err.Error()), text.WriteCellOpts(cell.FgColor(cell.ColorRed))) + return err + } + if err := widget.Write(fmt.Sprintf("%s\n\n", article.Description), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { + widget.Write(fmt.Sprintf("Error: %s", err.Error()), text.WriteCellOpts(cell.FgColor(cell.ColorRed))) + return err + } + if err := widget.Write(fmt.Sprintf("%s\n", strings.ReplaceAll(article.Content, "\r", "")), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { + widget.Write(fmt.Sprintf("Error: %s", err.Error()), text.WriteCellOpts(cell.FgColor(cell.ColorRed))) + return err + } + if err := widget.Write(fmt.Sprintf("%s\n\n", article.PublishedAt), text.WriteCellOpts(cell.FgColor(cell.ColorGray))); err != nil { + widget.Write(fmt.Sprintf("Error: %s", err.Error()), text.WriteCellOpts(cell.FgColor(cell.ColorRed))) + return err + } + if err := widget.Write(fmt.Sprintf("%s\n", article.URL)); err != nil { + widget.Write(fmt.Sprintf("Error: %s", err.Error()), text.WriteCellOpts(cell.FgColor(cell.ColorRed))) + return err + } + return nil +} + +type NewsText struct { + *text.Text + Selected int + newsArticles news.News +} + +func NewNewsText(opts ...text.Option) (*NewsText, error) { + t, err := text.New(opts...) + if err != nil { + return nil, err + } + return &NewsText{Text: t}, nil +} + +func (t *NewsText) Keyboard(k *terminalapi.Keyboard, _ *widgetapi.EventMeta) error { + if k.Key == keyboard.KeyArrowLeft { + if t.Selected == 0 { + t.Selected = len(t.newsArticles.Articles) - 1 + } else { + t.Selected = t.Selected - 1 + } + } else if k.Key == keyboard.KeyArrowRight { + t.Selected = (t.Selected + 1) % len(t.newsArticles.Articles) + } + return t.drawNews() +} diff --git a/widgets/weather.go b/widgets/weather.go index dab5384..7db859f 100644 --- a/widgets/weather.go +++ b/widgets/weather.go @@ -27,6 +27,10 @@ func createWeather(ctx context.Context, _ terminalapi.Terminal, _ interface{}) w go util.Periodic(ctx, 1*time.Hour, func() error { weather := openWeatherMap.FetchCurrentWeather() widget.Reset() + if weather.ErrorMessage != "" { + widget.Write(weather.ErrorMessage, text.WriteCellOpts(cell.FgColor(cell.ColorRed))) + return nil + } weatherIcon := getIcon(weather.Weather[0].Icon)