From dfbc6066c99c597d6bf6c1c4d9e94c68590eb792 Mon Sep 17 00:00:00 2001 From: Arindy Date: Tue, 30 Dec 2025 00:59:56 +0100 Subject: [PATCH] Add Calendar Events widget, integrate ICS calendar parsing, and refine related UI components --- apis/calendar/main.go | 89 +++++++++++++++++++++++++++++++++++++ apis/networking/main.go | 1 + apis/newsapi/main.go | 10 ++++- apis/openweathermap/main.go | 5 +++ apis/pihole/pihole.go | 3 ++ config/config.go | 2 + config_template.toml | 8 ++++ go.mod | 1 + go.sum | 2 + main.go | 59 ++++++++++++++++++------ widgets/calendar.go | 4 +- widgets/calendarEvents.go | 89 +++++++++++++++++++++++++++++++++++++ widgets/dockerlist.go | 18 +++++--- widgets/piholestats.go | 11 ++--- 14 files changed, 275 insertions(+), 27 deletions(-) create mode 100644 apis/calendar/main.go create mode 100644 widgets/calendarEvents.go diff --git a/apis/calendar/main.go b/apis/calendar/main.go new file mode 100644 index 0000000..007c14e --- /dev/null +++ b/apis/calendar/main.go @@ -0,0 +1,89 @@ +package calendar + +import ( + "ArinDash/config" + "sort" + "time" + + "github.com/arran4/golang-ical" +) + +type configFile struct { + Calendar calendarConfig +} + +type calendarConfig struct { + ICS []ICS + Icon string +} + +type ICS struct { + Icon string + URL string +} + +type Event struct { + UID string + Summary string + Description string + Start time.Time + End time.Time + Location string + Icon string +} + +func FetchEvents() []Event { + cfg := &configFile{} + config.LoadConfig(cfg) + + events := make([]Event, 0) + for _, ic := range cfg.Calendar.ICS { + cal, err := ics.ParseCalendarFromUrl(ic.URL) + if err != nil { + panic(err) + } + for _, event := range cal.Events() { + startAt, err := event.GetStartAt() + if err != nil { + startAt = time.Now() + } + endAt, err := event.GetEndAt() + if err != nil { + endAt = startAt + } + events = append(events, Event{ + UID: getValue(event, ics.ComponentPropertyUniqueId), + Icon: ic.Icon, + Summary: getValue(event, ics.ComponentPropertySummary), + Description: getValue(event, ics.ComponentPropertyDescription), + Start: startAt, + End: endAt, + Location: getValue(event, ics.ComponentPropertyLocation), + }) + } + } + result := filter(events, func(i Event) bool { + return i.End.After(time.Now()) + }) + sort.Slice(result, func(i, j int) bool { + return result[i].Start.Before(result[j].Start) + }) + return result +} + +func getValue(event *ics.VEvent, property ics.ComponentProperty) string { + ianaProperty := event.GetProperty(property) + if ianaProperty == nil { + return "" + } + return ianaProperty.Value +} + +func filter[T any](ss []T, test func(T) bool) (ret []T) { + for _, s := range ss { + if test(s) { + ret = append(ret, s) + } + } + return +} diff --git a/apis/networking/main.go b/apis/networking/main.go index f0585b8..0e9c937 100644 --- a/apis/networking/main.go +++ b/apis/networking/main.go @@ -84,6 +84,7 @@ func knownDevices() map[string]Device { } func arpDevices() map[string]Device { + exec.Command("ip", "n", "show") arp := exec.Command("arp", "-a") var out bytes.Buffer arp.Stdout = &out diff --git a/apis/newsapi/main.go b/apis/newsapi/main.go index 91caebd..5673369 100644 --- a/apis/newsapi/main.go +++ b/apis/newsapi/main.go @@ -47,9 +47,17 @@ func FetchNews() News { config.LoadConfig(cfg) client := &http.Client{} + if cfg.News.ApiKey == "" { + return News{ + Status: "No API key provided", + } + } + req, err := http.NewRequest("GET", apiBaseURL+"?sources="+cfg.News.Sources+"&pageSize=100&apiKey="+cfg.News.ApiKey, nil) if err != nil { - panic(err) + return News{ + Status: err.Error(), + } } resp, err := client.Do(req) if err != nil { diff --git a/apis/openweathermap/main.go b/apis/openweathermap/main.go index c0c9342..b56398e 100644 --- a/apis/openweathermap/main.go +++ b/apis/openweathermap/main.go @@ -23,6 +23,11 @@ func FetchCurrentWeather() CurrentWeather { config.LoadConfig(cfg) client := &http.Client{} currentWeather := &CurrentWeather{} + if cfg.OpenWeatherMap.ApiKey == "" { + return CurrentWeather{ + ErrorMessage: "No API key provided", + } + } req, err := http.NewRequest("GET", apiBaseURL+"?id="+cfg.OpenWeatherMap.LocationId+"&units=metric&lang=en&APPID="+cfg.OpenWeatherMap.ApiKey, nil) if err != nil { diff --git a/apis/pihole/pihole.go b/apis/pihole/pihole.go index 9bd90e2..29c3ac3 100644 --- a/apis/pihole/pihole.go +++ b/apis/pihole/pihole.go @@ -78,6 +78,9 @@ func Connect() PiHConnector { cfg := &configFile{} config.LoadConfig(cfg) client := &http.Client{} + if cfg.Pihole.Host == "" { + return PiHConnector{} + } req, err := http.NewRequest("POST", "http://"+cfg.Pihole.Host+"/api/auth", strings.NewReader("{\"password\": \""+cfg.Pihole.Password+"\"}")) if err != nil { diff --git a/config/config.go b/config/config.go index f41a6f3..3ac4f02 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "github.com/pelletier/go-toml/v2" @@ -8,6 +9,7 @@ import ( func LoadConfig(config interface{}) { if err := toml.Unmarshal(readFile("config.toml"), config); err != nil { + fmt.Println(err) config = nil } } diff --git a/config_template.toml b/config_template.toml index 053d470..bff544f 100644 --- a/config_template.toml +++ b/config_template.toml @@ -22,3 +22,11 @@ gebietsCode = "000000000000" #apiKey from newsapi.org ApiKey = "ApiKey" Sources = "comma,sepparated,sources,from,newsapi" + +[[calendar.ics]] +Icon = "" +Url = "https://url.to.ics" + +[[calendar.ics]] +Icon = "" +Url = "https://url.to.second.ics" diff --git a/go.mod b/go.mod index 025ebac..e233e1b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module ArinDash go 1.25.5 require ( + github.com/arran4/golang-ical v0.3.2 github.com/docker/docker v28.5.2+incompatible github.com/mum4k/termdash v0.20.0 github.com/pelletier/go-toml/v2 v2.2.4 diff --git a/go.sum b/go.sum index 3aa0729..7da854e 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/arran4/golang-ical v0.3.2 h1:MGNjcXJFSuCXmYX/RpZhR2HDCYoFuK8vTPFLEdFC3JY= +github.com/arran4/golang-ical v0.3.2/go.mod h1:xblDGxxIUMWwFZk9dlECUlc1iXNV65LJZOTHLVwu8bo= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/main.go b/main.go index 944c754..6e512ea 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,7 @@ func initWidgets(ctx context.Context, term *tcell.Terminal) { widgets.New("Clock", widgets.Clock()), widgets.New("Date", widgets.Date()), widgets.New("Calendar", widgets.Calendar()), + widgets.New("CalendarEvents", widgets.CalendarEvents()), widgets.New("Weather", widgets.Weather()), widgets.New("ChangeTitle", widgets.TextInput(titleUpdate, textinput.Label("Update Title: "), textinput.PlaceHolder("New Title"))), widgets.New("Wifi", widgets.WifiQRCode()), @@ -63,10 +64,10 @@ func layout() []container.Option { grid.Widget(widgets.Get["NetworkDevices"], container.BorderTitle("Network Devices"), container.Border(linestyle.Light), - container.BorderColor(cell.ColorWhite)), - ), - grid.RowHeightFixed(1, - grid.Widget(widgets.Get["empty"]), + container.BorderColor(cell.ColorWhite), + container.PaddingLeft(1), + container.PaddingTop(1), + ), ), ), @@ -86,6 +87,8 @@ func layout() []container.Option { container.BorderTitle("pi-hole"), container.Border(linestyle.Light), container.BorderColor(cell.ColorWhite), + container.PaddingLeft(1), + container.PaddingTop(1), ), ), grid.RowHeightFixed(40, @@ -95,9 +98,6 @@ func layout() []container.Option { container.BorderColor(cell.ColorWhite), ), ), - grid.RowHeightFixed(85, - grid.Widget(widgets.Get["empty"]), - ), ), grid.ColWidthFixed(26, @@ -105,23 +105,42 @@ func layout() []container.Option { grid.Widget(widgets.Get["Calendar"], container.BorderTitle("Calendar"), container.Border(linestyle.Light), - container.BorderColor(cell.ColorWhite)), + container.BorderColor(cell.ColorWhite), + container.PaddingLeft(1), + container.PaddingTop(1), + ), ), grid.RowHeightFixed(25, - grid.Widget(widgets.Get["empty"]), + grid.Widget(widgets.Get["empty"], + container.Border(linestyle.Light), + container.BorderColor(cell.ColorGreen), + ), ), ), grid.ColWidthFixed(25, grid.RowHeightFixed(20, + grid.Widget(widgets.Get["CalendarEvents"], + container.BorderTitle("Events"), + container.Border(linestyle.Light), + container.BorderColor(cell.ColorWhite), + container.PaddingLeft(1), + container.PaddingTop(1), + ), + ), + grid.RowHeightFixed(13, grid.Widget(widgets.Get["Weather"], container.BorderTitle("Weather"), container.Border(linestyle.Light), container.BorderColor(cell.ColorWhite), + container.PaddingLeft(1), ), ), grid.RowHeightFixed(85, - grid.Widget(widgets.Get["empty"]), + grid.Widget(widgets.Get["empty"], + container.Border(linestyle.Light), + container.BorderColor(cell.ColorWhite), + ), ), ), ), @@ -131,6 +150,8 @@ func layout() []container.Option { container.BorderTitle("News"), container.Border(linestyle.Light), container.BorderColor(cell.ColorWhite), + container.PaddingLeft(1), + container.PaddingTop(1), ), ), grid.RowHeightFixed(20, @@ -138,6 +159,8 @@ func layout() []container.Option { container.BorderTitle("BBK Warnings"), container.Border(linestyle.Light), container.BorderColor(cell.ColorWhite), + container.PaddingLeft(1), + container.PaddingTop(1), ), ), ), @@ -145,17 +168,27 @@ func layout() []container.Option { grid.ColWidthPerc(35, grid.RowHeightFixed(20, - grid.Widget(widgets.Get["HTTPProber"], - container.BorderTitle("Website Status"), + grid.Widget(widgets.Get["empty"], container.Border(linestyle.Light), container.BorderColor(cell.ColorWhite), ), ), - grid.RowHeightPerc(25, + grid.RowHeightFixed(24, + grid.Widget(widgets.Get["HTTPProber"], + container.BorderTitle("Website Status"), + container.Border(linestyle.Light), + container.BorderColor(cell.ColorWhite), + container.PaddingLeft(1), + container.PaddingTop(1), + ), + ), + grid.RowHeightFixed(40, grid.Widget(widgets.Get["Docker"], container.BorderTitle("Docker"), container.Border(linestyle.Light), container.BorderColor(cell.ColorWhite), + container.PaddingLeft(1), + container.PaddingTop(1), ), ), ), diff --git a/widgets/calendar.go b/widgets/calendar.go index 8418eda..460867d 100644 --- a/widgets/calendar.go +++ b/widgets/calendar.go @@ -78,7 +78,7 @@ func createTableForMonth(widget *text.Text) error { } } var str string - if err := widget.Write("│"); err != nil { + if err := widget.Write("│", text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } if field == 0 { @@ -96,7 +96,7 @@ func createTableForMonth(widget *text.Text) error { return err } if index%7 == 6 { - if err := widget.Write("│\n"); err != nil { + if err := widget.Write("│\n", text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } } diff --git a/widgets/calendarEvents.go b/widgets/calendarEvents.go new file mode 100644 index 0000000..516f99e --- /dev/null +++ b/widgets/calendarEvents.go @@ -0,0 +1,89 @@ +package widgets + +import ( + "ArinDash/apis/calendar" + "ArinDash/util" + "context" + "fmt" + "time" + + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/terminal/terminalapi" + "github.com/mum4k/termdash/widgetapi" + "github.com/mum4k/termdash/widgets/text" +) + +type CalendarEventsOptions struct { +} + +func CalendarEvents() CalendarEventsOptions { + widgetOptions["CalendarEventsOptions"] = createCalendarEventsOptions + return CalendarEventsOptions{} +} + +func createCalendarEventsOptions(ctx context.Context, _ terminalapi.Terminal, _ interface{}) widgetapi.Widget { + widget := util.PanicOnErrorWithResult(text.New(text.WrapAtWords())) + go util.Periodic(ctx, 1*time.Hour, func() error { + widget.Reset() + calendarEvents := calendar.FetchEvents() + for _, event := range calendarEvents { + var color cell.Color + onlySummary := false + if isToday(event.Start) { + color = cell.ColorGreen + } else if event.Start.After(time.Now().AddDate(0, 0, 7)) { + color = cell.ColorGray + onlySummary = true + } else { + color = cell.ColorWhite + } + summary := event.Summary + if onlySummary { + summary = fmt.Sprintf("%s %s", event.Start.Format(time.DateOnly), event.Summary) + } + if err := widget.Write(fmt.Sprintf("%s %s\n", event.Icon, summary), text.WriteCellOpts(cell.FgColor(color), cell.Bold())); err != nil { + return err + } + if !onlySummary { + var date string + if event.Start == event.End { + date = formatDate(event.Start) + } else { + date = fmt.Sprintf("%s - %s", formatDate(event.Start), formatDate(event.End)) + } + if err := widget.Write(fmt.Sprintf("%s\n", date), text.WriteCellOpts(cell.FgColor(color), cell.Dim())); err != nil { + return err + } + if err := widget.Write(fmt.Sprintf("%s\n", event.Description), text.WriteCellOpts(cell.FgColor(color))); err != nil { + return err + } + if err := widget.Write(fmt.Sprintf("%s\n\n", event.Location), text.WriteCellOpts(cell.FgColor(color))); err != nil { + return err + } + } + + } + return nil + }) + + return widget +} + +func isToday(date time.Time) bool { + year, month, day := date.Date() + return year == time.Now().Year() && month == time.Now().Month() && day == time.Now().Day() +} + +func isTomorrow(date time.Time) bool { + year, month, day := date.Date() + return year == time.Now().Year() && month == time.Now().Month() && day == time.Now().AddDate(0, 0, 1).Day() +} + +func formatDate(date time.Time) string { + if isToday(date) { + return fmt.Sprintf("Today at %s", date.Format(time.TimeOnly)) + } else if isTomorrow(date) { + return fmt.Sprintf("Tomorrow at %s", date.Format(time.TimeOnly)) + } + return date.Format(time.DateTime) +} diff --git a/widgets/dockerlist.go b/widgets/dockerlist.go index aefe278..79e1b23 100644 --- a/widgets/dockerlist.go +++ b/widgets/dockerlist.go @@ -33,10 +33,10 @@ func createDockerList(ctx context.Context, _ terminalapi.Terminal, _ interface{} } sort.Slice(ms, func(i, j int) bool { return ms[i].CPUPercent > ms[j].CPUPercent }) - if err := list.Write(fmt.Sprintf("%-40s | %-6s | %-6s | %s\n", "Name", "CPU", "MEM", "Status"), text.WriteReplace()); err != nil { + if err := list.Write(fmt.Sprintf("%-30s | %-6s | %-6s | %s\n", "Name", "CPU", "MEM", "Status"), text.WriteReplace(), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } - if err := list.Write(fmt.Sprintf("─────────────────────────────────────────┼────────┼────────┼─────────────────────\n")); err != nil { + if err := list.Write(fmt.Sprintf("───────────────────────────────┼────────┼────────┼─────────────────────\n"), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } for _, m := range ms { @@ -53,22 +53,28 @@ func createDockerList(ctx context.Context, _ terminalapi.Terminal, _ interface{} if strings.Contains(m.Status, "Paused") { status = cell.ColorBlue } - if err := list.Write(fmt.Sprintf("%-40s", m.Name), text.WriteCellOpts(cell.FgColor(status))); err != nil { + var name string + if len(m.Name) > 30 { + name = m.Name[:27] + "..." + } else { + name = m.Name + } + if err := list.Write(fmt.Sprintf("%-30s", name), text.WriteCellOpts(cell.FgColor(status))); err != nil { return err } - if err := list.Write(fmt.Sprint(" | ")); err != nil { + if err := list.Write(fmt.Sprint(" │ "), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } if err := writePercent(m.CPUPercent, list); err != nil { return err } - if err := list.Write(fmt.Sprint(" | ")); err != nil { + if err := list.Write(fmt.Sprint(" │ "), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } if err := writePercent(m.MemPercent, list); err != nil { return err } - if err := list.Write(fmt.Sprint(" | ")); err != nil { + if err := list.Write(fmt.Sprint(" │ "), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } if err := list.Write(fmt.Sprintf("%s\n", m.Status), text.WriteCellOpts(cell.FgColor(status))); err != nil { diff --git a/widgets/piholestats.go b/widgets/piholestats.go index 23a6411..2b1a377 100644 --- a/widgets/piholestats.go +++ b/widgets/piholestats.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/terminal/terminalapi" "github.com/mum4k/termdash/widgetapi" "github.com/mum4k/termdash/widgets/text" @@ -26,19 +27,19 @@ func createPiholeStats(ctx context.Context, _ terminalapi.Terminal, _ interface{ ph := pihole.Connect() go util.Periodic(ctx, 1*time.Minute, func() error { summary := ph.Summary() - if err := list.Write(fmt.Sprintf("Blocked Domains: %d\n", summary.Gravity.DomainsBeingBlocked), text.WriteReplace()); err != nil { + if err := list.Write(fmt.Sprintf("Blocked Domains: %d\n", summary.Gravity.DomainsBeingBlocked), text.WriteReplace(), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } - if err := list.Write(fmt.Sprint("---------------------------\n")); err != nil { + if err := list.Write(fmt.Sprint("───────────────────────────\n"), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } - if err := list.Write(fmt.Sprintf("Total Queries: %d\n", summary.Queries.Total)); err != nil { + if err := list.Write(fmt.Sprintf("Total Queries: %d\n", summary.Queries.Total), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } - if err := list.Write(fmt.Sprintf("Blocked Queries: %d\n", summary.Queries.Blocked)); err != nil { + if err := list.Write(fmt.Sprintf("Blocked Queries: %d\n", summary.Queries.Blocked), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } - if err := list.Write(fmt.Sprintf("Unique Domains: %d\n", summary.Queries.UniqueDomains)); err != nil { + if err := list.Write(fmt.Sprintf("Unique Domains: %d\n", summary.Queries.UniqueDomains), text.WriteCellOpts(cell.FgColor(cell.ColorWhite))); err != nil { return err } return nil