From 90fbba600f5b29d9649ae2f748a9f745dc9160ba Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:34:15 +0000 Subject: [PATCH] Restructure & refactor codebase --- Dockerfile | 2 +- Dockerfile.goreleaser | 2 +- README.md | 2 + docs/configuration.md | 2 + go.mod | 6 +- go.sum | 12 +- internal/assets/templates.go | 112 ----- .../templates/page-style-overrides.gotmpl | 14 - internal/feed/adguard.go | 120 ------ internal/feed/codeberg.go | 39 -- internal/feed/custom-api.go | 148 ------- internal/feed/dockerhub.go | 102 ----- internal/feed/extension.go | 102 ----- internal/feed/gitlab.go | 48 --- internal/feed/lobsters.go | 91 ---- internal/feed/monitor.go | 77 ---- internal/feed/pihole.go | 136 ------ internal/feed/primitives.go | 247 ----------- internal/feed/releases.go | 72 ---- internal/feed/yahoo.go | 104 ----- internal/feed/youtube.go | 115 ----- .../fields.go => glance/config-fields.go} | 78 ++-- internal/glance/config.go | 94 ++-- internal/glance/diagnose.go | 14 - internal/{assets/files.go => glance/embed.go} | 19 +- internal/glance/glance.go | 243 ++++------- internal/glance/main.go | 73 +++- .../{assets => glance}/static/app-icon.png | Bin .../{assets => glance}/static/favicon.png | Bin .../static/fonts/JetBrainsMono-Regular.woff2 | Bin .../static/icons/codeberg.svg | 0 .../static/icons/dockerhub.svg | 0 .../static/icons/github.svg | 0 .../static/icons/gitlab.svg | 0 internal/{assets => glance}/static/js/main.js | 0 .../{assets => glance}/static/js/masonry.js | 0 .../{assets => glance}/static/js/popover.js | 0 .../{assets => glance}/static/js/utils.js | 0 internal/{assets => glance}/static/main.css | 5 +- .../{assets => glance}/static/manifest.json | 0 internal/glance/templates.go | 62 +++ .../templates/bookmarks.html | 0 .../templates/calendar.html | 0 .../templates/change-detection.html | 0 .../{assets => glance}/templates/clock.html | 0 .../templates/custom-api.html | 0 .../templates/dns-stats.html | 2 +- .../templates/document.html | 0 .../templates/extension.html | 0 .../templates/forum-posts.html | 4 +- .../{assets => glance}/templates/group.html | 0 .../{assets => glance}/templates/iframe.html | 0 .../{assets => glance}/templates/markets.html | 0 .../templates/monitor-compact.html | 0 .../{assets => glance}/templates/monitor.html | 0 .../templates/page-content.html} | 0 .../{assets => glance}/templates/page.html | 5 +- .../templates/reddit-horizontal-cards.html | 0 .../templates/reddit-vertical-cards.html | 0 .../templates/releases.html | 2 +- .../templates/repository.html | 36 +- .../templates/rss-detailed-list.html | 0 .../templates/rss-horizontal-cards-2.html | 0 .../templates/rss-horizontal-cards.html | 0 .../templates/rss-list.html | 0 .../{assets => glance}/templates/search.html | 0 .../templates/split-column.html | 0 internal/glance/templates/theme-style.gotmpl | 14 + .../templates/twitch-channels.html | 0 .../templates/twitch-games-list.html | 0 .../templates/v0.7-update-notice-page.html | 44 ++ .../templates/video-card-contents.html | 0 .../templates/videos-grid.html | 0 .../{assets => glance}/templates/videos.html | 0 .../{assets => glance}/templates/weather.html | 0 .../templates/widget-base.html | 0 internal/{feed => glance}/utils.go | 67 ++- internal/glance/widget-bookmarks.go | 34 ++ .../calendar.go => glance/widget-calendar.go} | 43 +- .../widget-changedetection.go} | 84 +++- .../clock.go => glance/widget-clock.go} | 20 +- internal/glance/widget-container.go | 58 +++ internal/glance/widget-custom-api.go | 209 +++++++++ internal/glance/widget-dns-stats.go | 342 +++++++++++++++ internal/glance/widget-extension.go | 156 +++++++ internal/glance/widget-group.go | 52 +++ .../widget-hacker-news.go} | 79 +++- .../{widget/html.go => glance/widget-html.go} | 8 +- .../iframe.go => glance/widget-iframe.go} | 18 +- internal/glance/widget-lobsters.go | 147 +++++++ internal/glance/widget-markets.go | 206 +++++++++ internal/glance/widget-monitor.go | 178 ++++++++ .../reddit.go => glance/widget-reddit.go} | 127 +++++- internal/glance/widget-releases.go | 401 ++++++++++++++++++ .../widget-repository-overview.go} | 160 +++---- .../{feed/rss.go => glance/widget-rss.go} | 113 ++++- .../search.go => glance/widget-search.go} | 18 +- internal/glance/widget-shared.go | 62 +++ internal/glance/widget-split-column.go | 45 ++ .../widget-twitch-channels.go} | 147 +++---- internal/glance/widget-twitch-top-games.go | 126 ++++++ .../requests.go => glance/widget-utils.go} | 38 +- internal/glance/widget-videos.go | 189 +++++++++ .../openmeteo.go => glance/widget-weather.go} | 155 ++++++- internal/{widget => glance}/widget.go | 142 +++---- internal/widget/bookmarks.go | 34 -- internal/widget/calendar.go | 31 -- internal/widget/changedetection.go | 66 --- internal/widget/container.go | 48 --- internal/widget/custom-api.go | 70 --- internal/widget/dns-stats.go | 77 ---- internal/widget/extension.go | 61 --- internal/widget/group.go | 52 --- internal/widget/hacker-news.go | 65 --- internal/widget/lobsters.go | 64 --- internal/widget/markets.go | 50 --- internal/widget/monitor.go | 105 ----- internal/widget/reddit.go | 121 ------ internal/widget/releases.go | 103 ----- internal/widget/repository-overview.go | 58 --- internal/widget/rss.go | 83 ---- internal/widget/split-column.go | 47 -- internal/widget/twitch-channels.go | 55 --- internal/widget/twitch-top-games.go | 49 --- internal/widget/videos.go | 57 --- internal/widget/weather.go | 74 ---- 126 files changed, 3492 insertions(+), 3550 deletions(-) delete mode 100644 internal/assets/templates.go delete mode 100644 internal/assets/templates/page-style-overrides.gotmpl delete mode 100644 internal/feed/adguard.go delete mode 100644 internal/feed/codeberg.go delete mode 100644 internal/feed/custom-api.go delete mode 100644 internal/feed/dockerhub.go delete mode 100644 internal/feed/extension.go delete mode 100644 internal/feed/gitlab.go delete mode 100644 internal/feed/lobsters.go delete mode 100644 internal/feed/monitor.go delete mode 100644 internal/feed/pihole.go delete mode 100644 internal/feed/primitives.go delete mode 100644 internal/feed/releases.go delete mode 100644 internal/feed/yahoo.go delete mode 100644 internal/feed/youtube.go rename internal/{widget/fields.go => glance/config-fields.go} (61%) rename internal/{assets/files.go => glance/embed.go} (68%) rename internal/{assets => glance}/static/app-icon.png (100%) rename internal/{assets => glance}/static/favicon.png (100%) rename internal/{assets => glance}/static/fonts/JetBrainsMono-Regular.woff2 (100%) rename internal/{assets => glance}/static/icons/codeberg.svg (100%) rename internal/{assets => glance}/static/icons/dockerhub.svg (100%) rename internal/{assets => glance}/static/icons/github.svg (100%) rename internal/{assets => glance}/static/icons/gitlab.svg (100%) rename internal/{assets => glance}/static/js/main.js (100%) rename internal/{assets => glance}/static/js/masonry.js (100%) rename internal/{assets => glance}/static/js/popover.js (100%) rename internal/{assets => glance}/static/js/utils.js (100%) rename internal/{assets => glance}/static/main.css (99%) rename internal/{assets => glance}/static/manifest.json (100%) create mode 100644 internal/glance/templates.go rename internal/{assets => glance}/templates/bookmarks.html (100%) rename internal/{assets => glance}/templates/calendar.html (100%) rename internal/{assets => glance}/templates/change-detection.html (100%) rename internal/{assets => glance}/templates/clock.html (100%) rename internal/{assets => glance}/templates/custom-api.html (100%) rename internal/{assets => glance}/templates/dns-stats.html (98%) rename internal/{assets => glance}/templates/document.html (100%) rename internal/{assets => glance}/templates/extension.html (100%) rename internal/{assets => glance}/templates/forum-posts.html (97%) rename internal/{assets => glance}/templates/group.html (100%) rename internal/{assets => glance}/templates/iframe.html (100%) rename internal/{assets => glance}/templates/markets.html (100%) rename internal/{assets => glance}/templates/monitor-compact.html (100%) rename internal/{assets => glance}/templates/monitor.html (100%) rename internal/{assets/templates/content.html => glance/templates/page-content.html} (100%) rename internal/{assets => glance}/templates/page.html (97%) rename internal/{assets => glance}/templates/reddit-horizontal-cards.html (100%) rename internal/{assets => glance}/templates/reddit-vertical-cards.html (100%) rename internal/{assets => glance}/templates/releases.html (88%) rename internal/{assets => glance}/templates/repository.html (55%) rename internal/{assets => glance}/templates/rss-detailed-list.html (100%) rename internal/{assets => glance}/templates/rss-horizontal-cards-2.html (100%) rename internal/{assets => glance}/templates/rss-horizontal-cards.html (100%) rename internal/{assets => glance}/templates/rss-list.html (100%) rename internal/{assets => glance}/templates/search.html (100%) rename internal/{assets => glance}/templates/split-column.html (100%) create mode 100644 internal/glance/templates/theme-style.gotmpl rename internal/{assets => glance}/templates/twitch-channels.html (100%) rename internal/{assets => glance}/templates/twitch-games-list.html (100%) create mode 100644 internal/glance/templates/v0.7-update-notice-page.html rename internal/{assets => glance}/templates/video-card-contents.html (100%) rename internal/{assets => glance}/templates/videos-grid.html (100%) rename internal/{assets => glance}/templates/videos.html (100%) rename internal/{assets => glance}/templates/weather.html (100%) rename internal/{assets => glance}/templates/widget-base.html (100%) rename internal/{feed => glance}/utils.go (57%) create mode 100644 internal/glance/widget-bookmarks.go rename internal/{feed/calendar.go => glance/widget-calendar.go} (54%) rename internal/{feed/changedetection.go => glance/widget-changedetection.go} (53%) rename internal/{widget/clock.go => glance/widget-clock.go} (58%) create mode 100644 internal/glance/widget-container.go create mode 100644 internal/glance/widget-custom-api.go create mode 100644 internal/glance/widget-dns-stats.go create mode 100644 internal/glance/widget-extension.go create mode 100644 internal/glance/widget-group.go rename internal/{feed/hacker-news.go => glance/widget-hacker-news.go} (51%) rename internal/{widget/html.go => glance/widget-html.go} (56%) rename internal/{widget/iframe.go => glance/widget-iframe.go} (56%) create mode 100644 internal/glance/widget-lobsters.go create mode 100644 internal/glance/widget-markets.go create mode 100644 internal/glance/widget-monitor.go rename internal/{feed/reddit.go => glance/widget-reddit.go} (52%) create mode 100644 internal/glance/widget-releases.go rename internal/{feed/github.go => glance/widget-repository-overview.go} (54%) rename internal/{feed/rss.go => glance/widget-rss.go} (63%) rename internal/{widget/search.go => glance/widget-search.go} (75%) create mode 100644 internal/glance/widget-shared.go create mode 100644 internal/glance/widget-split-column.go rename internal/{feed/twitch.go => glance/widget-twitch-channels.go} (62%) create mode 100644 internal/glance/widget-twitch-top-games.go rename internal/{feed/requests.go => glance/widget-utils.go} (88%) create mode 100644 internal/glance/widget-videos.go rename internal/{feed/openmeteo.go => glance/widget-weather.go} (55%) rename internal/{widget => glance}/widget.go (65%) delete mode 100644 internal/widget/bookmarks.go delete mode 100644 internal/widget/calendar.go delete mode 100644 internal/widget/changedetection.go delete mode 100644 internal/widget/container.go delete mode 100644 internal/widget/custom-api.go delete mode 100644 internal/widget/dns-stats.go delete mode 100644 internal/widget/extension.go delete mode 100644 internal/widget/group.go delete mode 100644 internal/widget/hacker-news.go delete mode 100644 internal/widget/lobsters.go delete mode 100644 internal/widget/markets.go delete mode 100644 internal/widget/monitor.go delete mode 100644 internal/widget/reddit.go delete mode 100644 internal/widget/releases.go delete mode 100644 internal/widget/repository-overview.go delete mode 100644 internal/widget/rss.go delete mode 100644 internal/widget/split-column.go delete mode 100644 internal/widget/twitch-channels.go delete mode 100644 internal/widget/twitch-top-games.go delete mode 100644 internal/widget/videos.go delete mode 100644 internal/widget/weather.go diff --git a/Dockerfile b/Dockerfile index 48f214b..63298d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,4 +10,4 @@ WORKDIR /app COPY --from=builder /app/glance . EXPOSE 8080/tcp -ENTRYPOINT ["/app/glance"] +ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index dec9ac4..2fbf915 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -5,4 +5,4 @@ COPY glance . EXPOSE 8080/tcp -ENTRYPOINT ["/app/glance"] +ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] diff --git a/README.md b/README.md index 0e8cfb4..c6193a9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Checkout the [releases page](https://github.com/glanceapp/glance/releases) for a ``` #### Docker + + > [!IMPORTANT] > > Make sure you have a valid `glance.yml` file in the same directory before running the container. diff --git a/docs/configuration.md b/docs/configuration.md index 9c3c312..d308918 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -114,6 +114,8 @@ This will give you a page that looks like the following: Configure the widgets, add more of them, add extra pages, etc. Make it your own! + + ## Server Server configuration is done through a top level `server` property. Example: diff --git a/go.mod b/go.mod index 2ade8f0..ed40bc8 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/fsnotify/fsnotify v1.8.0 github.com/mmcdole/gofeed v1.3.0 github.com/tidwall/gjson v1.18.0 - golang.org/x/text v0.18.0 + golang.org/x/text v0.20.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -19,6 +19,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/net v0.29.0 // indirect - golang.org/x/sys v0.25.0 // indirect + golang.org/x/net v0.31.0 // indirect + golang.org/x/sys v0.27.0 // indirect ) diff --git a/go.sum b/go.sum index 5c6d33b..03a2b53 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -54,8 +54,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc 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= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -65,8 +65,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/assets/templates.go b/internal/assets/templates.go deleted file mode 100644 index 4834078..0000000 --- a/internal/assets/templates.go +++ /dev/null @@ -1,112 +0,0 @@ -package assets - -import ( - "fmt" - "html/template" - "math" - "strconv" - "time" - - "golang.org/x/text/language" - "golang.org/x/text/message" -) - -var ( - PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl") - PageContentTemplate = compileTemplate("content.html") - CalendarTemplate = compileTemplate("calendar.html", "widget-base.html") - ClockTemplate = compileTemplate("clock.html", "widget-base.html") - BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html") - IFrameTemplate = compileTemplate("iframe.html", "widget-base.html") - WeatherTemplate = compileTemplate("weather.html", "widget-base.html") - ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html") - RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html") - RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html") - ReleasesTemplate = compileTemplate("releases.html", "widget-base.html") - ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html") - VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") - VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") - MarketsTemplate = compileTemplate("markets.html", "widget-base.html") - RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html") - RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html") - RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html") - RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html") - MonitorTemplate = compileTemplate("monitor.html", "widget-base.html") - MonitorCompactTemplate = compileTemplate("monitor-compact.html", "widget-base.html") - TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html") - TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html") - RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") - SearchTemplate = compileTemplate("search.html", "widget-base.html") - ExtensionTemplate = compileTemplate("extension.html", "widget-base.html") - GroupTemplate = compileTemplate("group.html", "widget-base.html") - DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html") - SplitColumnTemplate = compileTemplate("split-column.html", "widget-base.html") - CustomAPITemplate = compileTemplate("custom-api.html", "widget-base.html") -) - -var GlobalTemplateFunctions = template.FuncMap{ - "relativeTime": relativeTimeSince, - "formatViewerCount": formatViewerCount, - "formatNumber": intl.Sprint, - "absInt": func(i int) int { - return int(math.Abs(float64(i))) - }, - "formatPrice": func(price float64) string { - return intl.Sprintf("%.2f", price) - }, - "dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr { - return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix())) - }, -} - -func compileTemplate(primary string, dependencies ...string) *template.Template { - t, err := template.New(primary). - Funcs(GlobalTemplateFunctions). - ParseFS(TemplateFS, append([]string{primary}, dependencies...)...) - - if err != nil { - panic(err) - } - - return t -} - -var intl = message.NewPrinter(language.English) - -func formatViewerCount(count int) string { - if count < 1_000 { - return strconv.Itoa(count) - } - - if count < 10_000 { - return fmt.Sprintf("%.1fk", float64(count)/1_000) - } - - if count < 1_000_000 { - return fmt.Sprintf("%dk", count/1_000) - } - - return fmt.Sprintf("%.1fm", float64(count)/1_000_000) -} - -func relativeTimeSince(t time.Time) string { - delta := time.Since(t) - - if delta < time.Minute { - return "1m" - } - if delta < time.Hour { - return fmt.Sprintf("%dm", delta/time.Minute) - } - if delta < 24*time.Hour { - return fmt.Sprintf("%dh", delta/time.Hour) - } - if delta < 30*24*time.Hour { - return fmt.Sprintf("%dd", delta/(24*time.Hour)) - } - if delta < 12*30*24*time.Hour { - return fmt.Sprintf("%dmo", delta/(30*24*time.Hour)) - } - - return fmt.Sprintf("%dy", delta/(365*24*time.Hour)) -} diff --git a/internal/assets/templates/page-style-overrides.gotmpl b/internal/assets/templates/page-style-overrides.gotmpl deleted file mode 100644 index 0bf2a99..0000000 --- a/internal/assets/templates/page-style-overrides.gotmpl +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/internal/feed/adguard.go b/internal/feed/adguard.go deleted file mode 100644 index 87182c3..0000000 --- a/internal/feed/adguard.go +++ /dev/null @@ -1,120 +0,0 @@ -package feed - -import ( - "net/http" - "strings" -) - -type adguardStatsResponse struct { - TotalQueries int `json:"num_dns_queries"` - QueriesSeries []int `json:"dns_queries"` - BlockedQueries int `json:"num_blocked_filtering"` - BlockedSeries []int `json:"blocked_filtering"` - ResponseTime float64 `json:"avg_processing_time"` - TopBlockedDomains []map[string]int `json:"top_blocked_domains"` -} - -func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) { - requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats" - - request, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - request.SetBasicAuth(username, password) - - responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request) - - if err != nil { - return nil, err - } - - var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5) - - stats := &DNSStats{ - TotalQueries: responseJson.TotalQueries, - BlockedQueries: responseJson.BlockedQueries, - ResponseTime: int(responseJson.ResponseTime * 1000), - TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount), - } - - if stats.TotalQueries <= 0 { - return stats, nil - } - - stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) - - for i := 0; i < topBlockedDomainsCount; i++ { - domain := responseJson.TopBlockedDomains[i] - var firstDomain string - - for k := range domain { - firstDomain = k - break - } - - if firstDomain == "" { - continue - } - - stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{ - Domain: firstDomain, - }) - - if stats.BlockedQueries > 0 { - stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100) - } - } - - queriesSeries := responseJson.QueriesSeries - blockedSeries := responseJson.BlockedSeries - - const bars = 8 - const hoursSpan = 24 - const hoursPerBar int = hoursSpan / bars - - if len(queriesSeries) > hoursSpan { - queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:] - } else if len(queriesSeries) < hoursSpan { - queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...) - } - - if len(blockedSeries) > hoursSpan { - blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:] - } else if len(blockedSeries) < hoursSpan { - blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...) - } - - maxQueriesInSeries := 0 - - for i := 0; i < bars; i++ { - queries := 0 - blocked := 0 - - for j := 0; j < hoursPerBar; j++ { - queries += queriesSeries[i*hoursPerBar+j] - blocked += blockedSeries[i*hoursPerBar+j] - } - - stats.Series[i] = DNSStatsSeries{ - Queries: queries, - Blocked: blocked, - } - - if queries > 0 { - stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) - } - - if queries > maxQueriesInSeries { - maxQueriesInSeries = queries - } - } - - for i := 0; i < bars; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} diff --git a/internal/feed/codeberg.go b/internal/feed/codeberg.go deleted file mode 100644 index d5e7b7c..0000000 --- a/internal/feed/codeberg.go +++ /dev/null @@ -1,39 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" -) - -type codebergReleaseResponseJson struct { - TagName string `json:"tag_name"` - PublishedAt string `json:"published_at"` - HtmlUrl string `json:"html_url"` -} - -func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) { - httpRequest, err := http.NewRequest( - "GET", - fmt.Sprintf( - "https://codeberg.org/api/v1/repos/%s/releases/latest", - request.Repository, - ), - nil, - ) - if err != nil { - return nil, err - } - - response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - return &AppRelease{ - Source: ReleaseSourceCodeberg, - Name: request.Repository, - Version: normalizeVersionFormat(response.TagName), - NotesUrl: response.HtmlUrl, - TimeReleased: parseRFC3339Time(response.PublishedAt), - }, nil -} diff --git a/internal/feed/custom-api.go b/internal/feed/custom-api.go deleted file mode 100644 index 9a17785..0000000 --- a/internal/feed/custom-api.go +++ /dev/null @@ -1,148 +0,0 @@ -package feed - -import ( - "bytes" - "errors" - "html/template" - "io" - "log/slog" - "net/http" - - "github.com/glanceapp/glance/internal/assets" - "github.com/tidwall/gjson" -) - -func FetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) { - emptyBody := template.HTML("") - - resp, err := defaultClient.Do(req) - if err != nil { - return emptyBody, err - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return emptyBody, err - } - - body := string(bodyBytes) - - if !gjson.Valid(body) { - truncatedBody, isTruncated := limitStringLength(body, 100) - if isTruncated { - truncatedBody += "... " - } - - slog.Error("invalid response JSON in custom API widget", "URL", req.URL.String(), "body", truncatedBody) - return emptyBody, errors.New("invalid response JSON") - } - - var templateBuffer bytes.Buffer - - data := CustomAPITemplateData{ - JSON: DecoratedGJSONResult{gjson.Parse(body)}, - Response: resp, - } - - err = tmpl.Execute(&templateBuffer, &data) - if err != nil { - return emptyBody, err - } - - return template.HTML(templateBuffer.String()), nil -} - -type DecoratedGJSONResult struct { - gjson.Result -} - -type CustomAPITemplateData struct { - JSON DecoratedGJSONResult - Response *http.Response -} - -func GJsonResultArrayToDecoratedResultArray(results []gjson.Result) []DecoratedGJSONResult { - decoratedResults := make([]DecoratedGJSONResult, len(results)) - - for i, result := range results { - decoratedResults[i] = DecoratedGJSONResult{result} - } - - return decoratedResults -} - -func (r *DecoratedGJSONResult) Array(key string) []DecoratedGJSONResult { - if key == "" { - return GJsonResultArrayToDecoratedResultArray(r.Result.Array()) - } - - return GJsonResultArrayToDecoratedResultArray(r.Get(key).Array()) -} - -func (r *DecoratedGJSONResult) String(key string) string { - if key == "" { - return r.Result.String() - } - - return r.Get(key).String() -} - -func (r *DecoratedGJSONResult) Int(key string) int64 { - if key == "" { - return r.Result.Int() - } - - return r.Get(key).Int() -} - -func (r *DecoratedGJSONResult) Float(key string) float64 { - if key == "" { - return r.Result.Float() - } - - return r.Get(key).Float() -} - -func (r *DecoratedGJSONResult) Bool(key string) bool { - if key == "" { - return r.Result.Bool() - } - - return r.Get(key).Bool() -} - -var CustomAPITemplateFuncs = func() template.FuncMap { - funcs := template.FuncMap{ - "toFloat": func(a int64) float64 { - return float64(a) - }, - "toInt": func(a float64) int64 { - return int64(a) - }, - "mathexpr": func(left float64, op string, right float64) float64 { - if right == 0 { - return 0 - } - - switch op { - case "+": - return left + right - case "-": - return left - right - case "*": - return left * right - case "/": - return left / right - default: - return 0 - } - }, - } - - for key, value := range assets.GlobalTemplateFunctions { - funcs[key] = value - } - - return funcs -}() diff --git a/internal/feed/dockerhub.go b/internal/feed/dockerhub.go deleted file mode 100644 index e979d37..0000000 --- a/internal/feed/dockerhub.go +++ /dev/null @@ -1,102 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" - "strings" -) - -type dockerHubRepositoryTagsResponse struct { - Results []dockerHubRepositoryTagResponse `json:"results"` -} - -type dockerHubRepositoryTagResponse struct { - Name string `json:"name"` - LastPushed string `json:"tag_last_pushed"` -} - -const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s" -const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s" -const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags" -const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s" - -func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) { - - nameParts := strings.Split(request.Repository, "/") - - if len(nameParts) > 2 { - return nil, fmt.Errorf("invalid repository name: %s", request.Repository) - } else if len(nameParts) == 1 { - nameParts = []string{"library", nameParts[0]} - } - - tagParts := strings.SplitN(nameParts[1], ":", 2) - - var requestURL string - - if len(tagParts) == 2 { - requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1]) - } else { - requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1]) - } - - httpRequest, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - if request.Token != nil { - httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token)) - } - - var tag *dockerHubRepositoryTagResponse - - if len(tagParts) == 1 { - response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - if len(response.Results) == 0 { - return nil, fmt.Errorf("no tags found for repository: %s", request.Repository) - } - - tag = &response.Results[0] - } else { - response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - tag = &response - } - - var repo string - var displayName string - var notesURL string - - if len(tagParts) == 1 { - repo = nameParts[1] - } else { - repo = tagParts[0] - } - - if nameParts[0] == "library" { - displayName = repo - notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name) - } else { - displayName = nameParts[0] + "/" + repo - notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name) - } - - return &AppRelease{ - Source: ReleaseSourceDockerHub, - NotesUrl: notesURL, - Name: displayName, - Version: tag.Name, - TimeReleased: parseRFC3339Time(tag.LastPushed), - }, nil -} diff --git a/internal/feed/extension.go b/internal/feed/extension.go deleted file mode 100644 index 916ee78..0000000 --- a/internal/feed/extension.go +++ /dev/null @@ -1,102 +0,0 @@ -package feed - -import ( - "fmt" - "html" - "html/template" - "io" - "log/slog" - "net/http" - "net/url" -) - -type ExtensionType int - -const ( - ExtensionContentHTML ExtensionType = iota - ExtensionContentUnknown = iota -) - -var ExtensionStringToType = map[string]ExtensionType{ - "html": ExtensionContentHTML, -} - -const ( - ExtensionHeaderTitle = "Widget-Title" - ExtensionHeaderContentType = "Widget-Content-Type" -) - -type ExtensionRequestOptions struct { - URL string `yaml:"url"` - FallbackContentType string `yaml:"fallback-content-type"` - Parameters map[string]string `yaml:"parameters"` - AllowHtml bool `yaml:"allow-potentially-dangerous-html"` -} - -type Extension struct { - Title string - Content template.HTML -} - -func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML { - switch contentType { - case ExtensionContentHTML: - if options.AllowHtml { - return template.HTML(content) - } - - fallthrough - default: - return template.HTML(html.EscapeString(string(content))) - } -} - -func FetchExtension(options ExtensionRequestOptions) (Extension, error) { - request, _ := http.NewRequest("GET", options.URL, nil) - - query := url.Values{} - - for key, value := range options.Parameters { - query.Set(key, value) - } - - request.URL.RawQuery = query.Encode() - - response, err := http.DefaultClient.Do(request) - - if err != nil { - slog.Error("failed fetching extension", "error", err, "url", options.URL) - return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err) - } - - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) - - if err != nil { - slog.Error("failed reading response body of extension", "error", err, "url", options.URL) - return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err) - } - - extension := Extension{} - - if response.Header.Get(ExtensionHeaderTitle) == "" { - extension.Title = "Extension" - } else { - extension.Title = response.Header.Get(ExtensionHeaderTitle) - } - - contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)] - - if !ok { - contentType, ok = ExtensionStringToType[options.FallbackContentType] - - if !ok { - contentType = ExtensionContentUnknown - } - } - - extension.Content = convertExtensionContent(options, body, contentType) - - return extension, nil -} diff --git a/internal/feed/gitlab.go b/internal/feed/gitlab.go deleted file mode 100644 index 3ff0f00..0000000 --- a/internal/feed/gitlab.go +++ /dev/null @@ -1,48 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" - "net/url" -) - -type gitlabReleaseResponseJson struct { - TagName string `json:"tag_name"` - ReleasedAt string `json:"released_at"` - Links struct { - Self string `json:"self"` - } `json:"_links"` -} - -func fetchLatestGitLabRelease(request *ReleaseRequest) (*AppRelease, error) { - httpRequest, err := http.NewRequest( - "GET", - fmt.Sprintf( - "https://gitlab.com/api/v4/projects/%s/releases/permalink/latest", - url.QueryEscape(request.Repository), - ), - nil, - ) - - if err != nil { - return nil, err - } - - if request.Token != nil { - httpRequest.Header.Add("PRIVATE-TOKEN", *request.Token) - } - - response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - return &AppRelease{ - Source: ReleaseSourceGitlab, - Name: request.Repository, - Version: normalizeVersionFormat(response.TagName), - NotesUrl: response.Links.Self, - TimeReleased: parseRFC3339Time(response.ReleasedAt), - }, nil -} diff --git a/internal/feed/lobsters.go b/internal/feed/lobsters.go deleted file mode 100644 index 1bb5420..0000000 --- a/internal/feed/lobsters.go +++ /dev/null @@ -1,91 +0,0 @@ -package feed - -import ( - "net/http" - "strings" - "time" -) - -type lobstersPostResponseJson struct { - CreatedAt string `json:"created_at"` - Title string `json:"title"` - URL string `json:"url"` - Score int `json:"score"` - CommentCount int `json:"comment_count"` - CommentsURL string `json:"comments_url"` - Tags []string `json:"tags"` -} - -type lobstersFeedResponseJson []lobstersPostResponseJson - -func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) { - request, err := http.NewRequest("GET", feedUrl, nil) - - if err != nil { - return nil, err - } - - feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request) - - if err != nil { - return nil, err - } - - posts := make(ForumPosts, 0, len(feed)) - - for i := range feed { - createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt) - - posts = append(posts, ForumPost{ - Title: feed[i].Title, - DiscussionUrl: feed[i].CommentsURL, - TargetUrl: feed[i].URL, - TargetUrlDomain: extractDomainFromUrl(feed[i].URL), - CommentCount: feed[i].CommentCount, - Score: feed[i].Score, - TimePosted: createdAt, - Tags: feed[i].Tags, - }) - } - - if len(posts) == 0 { - return nil, ErrNoContent - } - - return posts, nil -} - -func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) { - var feedUrl string - - if customURL != "" { - feedUrl = customURL - } else { - if instanceURL != "" { - instanceURL = strings.TrimRight(instanceURL, "/") + "/" - } else { - instanceURL = "https://lobste.rs/" - } - - if sortBy == "hot" { - sortBy = "hottest" - } else if sortBy == "new" { - sortBy = "newest" - } - - if len(tags) == 0 { - feedUrl = instanceURL + sortBy + ".json" - } else { - tags := strings.Join(tags, ",") - feedUrl = instanceURL + "t/" + tags + ".json" - } - } - - posts, err := getLobstersPostsFromFeed(feedUrl) - - if err != nil { - return nil, err - } - - return posts, nil -} diff --git a/internal/feed/monitor.go b/internal/feed/monitor.go deleted file mode 100644 index a3da636..0000000 --- a/internal/feed/monitor.go +++ /dev/null @@ -1,77 +0,0 @@ -package feed - -import ( - "context" - "errors" - "net/http" - "time" -) - -type SiteStatusRequest struct { - URL string `yaml:"url"` - CheckURL string `yaml:"check-url"` - AllowInsecure bool `yaml:"allow-insecure"` -} - -type SiteStatus struct { - Code int - TimedOut bool - ResponseTime time.Duration - Error error -} - -func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) { - var url string - if statusRequest.CheckURL != "" { - url = statusRequest.CheckURL - } else { - url = statusRequest.URL - } - request, err := http.NewRequest(http.MethodGet, url, nil) - - if err != nil { - return SiteStatus{ - Error: err, - }, nil - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) - defer cancel() - request = request.WithContext(ctx) - requestSentAt := time.Now() - var response *http.Response - - if !statusRequest.AllowInsecure { - response, err = defaultClient.Do(request) - } else { - response, err = defaultInsecureClient.Do(request) - } - - status := SiteStatus{ResponseTime: time.Since(requestSentAt)} - - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - status.TimedOut = true - } - - status.Error = err - return status, nil - } - - defer response.Body.Close() - - status.Code = response.StatusCode - - return status, nil -} - -func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) { - job := newJob(getSiteStatusTask, requests).withWorkers(20) - results, _, err := workerPoolDo(job) - - if err != nil { - return nil, err - } - - return results, nil -} diff --git a/internal/feed/pihole.go b/internal/feed/pihole.go deleted file mode 100644 index 3c7f1b5..0000000 --- a/internal/feed/pihole.go +++ /dev/null @@ -1,136 +0,0 @@ -package feed - -import ( - "encoding/json" - "errors" - "log/slog" - "net/http" - "sort" - "strings" -) - -type piholeStatsResponse struct { - TotalQueries int `json:"dns_queries_today"` - QueriesSeries map[int64]int `json:"domains_over_time"` - BlockedQueries int `json:"ads_blocked_today"` - BlockedSeries map[int64]int `json:"ads_over_time"` - BlockedPercentage float64 `json:"ads_percentage_today"` - TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"` - DomainsBlocked int `json:"domains_being_blocked"` -} - -// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array -// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling -type piholeTopBlockedDomains map[string]int - -func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { - // NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow - // because of the UnmarshalJSON method getting called recursively - temp := make(map[string]int) - - err := json.Unmarshal(data, &temp) - - if err != nil { - *p = make(piholeTopBlockedDomains) - } else { - *p = temp - } - - return nil -} - -func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) { - if token == "" { - return nil, errors.New("missing API token") - } - - requestURL := strings.TrimRight(instanceURL, "/") + - "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token - - request, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request) - - if err != nil { - return nil, err - } - - stats := &DNSStats{ - TotalQueries: responseJson.TotalQueries, - BlockedQueries: responseJson.BlockedQueries, - BlockedPercent: int(responseJson.BlockedPercentage), - DomainsBlocked: responseJson.DomainsBlocked, - } - - if len(responseJson.TopBlockedDomains) > 0 { - domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains)) - - for domain, count := range responseJson.TopBlockedDomains { - domains = append(domains, DNSStatsBlockedDomain{ - Domain: domain, - PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100), - }) - } - - sort.Slice(domains, func(a, b int) bool { - return domains[a].PercentBlocked > domains[b].PercentBlocked - }) - - stats.TopBlockedDomains = domains[:min(len(domains), 5)] - } - - // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144 - if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 { - slog.Warn( - "DNS stats for pihole: did not get expected 144 data points", - "len(queries)", len(responseJson.QueriesSeries), - "len(blocked)", len(responseJson.BlockedSeries), - ) - return stats, nil - } - - var lowestTimestamp int64 = 0 - - for timestamp := range responseJson.QueriesSeries { - if lowestTimestamp == 0 || timestamp < lowestTimestamp { - lowestTimestamp = timestamp - } - } - - maxQueriesInSeries := 0 - - for i := 0; i < 8; i++ { - queries := 0 - blocked := 0 - - for j := 0; j < 18; j++ { - index := lowestTimestamp + int64(i*10800+j*600) - - queries += responseJson.QueriesSeries[index] - blocked += responseJson.BlockedSeries[index] - } - - if queries > maxQueriesInSeries { - maxQueriesInSeries = queries - } - - stats.Series[i] = DNSStatsSeries{ - Queries: queries, - Blocked: blocked, - } - - if queries > 0 { - stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) - } - } - - for i := 0; i < 8; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} diff --git a/internal/feed/primitives.go b/internal/feed/primitives.go deleted file mode 100644 index 90a6a52..0000000 --- a/internal/feed/primitives.go +++ /dev/null @@ -1,247 +0,0 @@ -package feed - -import ( - "math" - "sort" - "time" -) - -type ForumPost struct { - Title string - DiscussionUrl string - TargetUrl string - TargetUrlDomain string - ThumbnailUrl string - CommentCount int - Score int - Engagement float64 - TimePosted time.Time - Tags []string - IsCrosspost bool -} - -type ForumPosts []ForumPost - -type Calendar struct { - CurrentDay int - CurrentWeekNumber int - CurrentMonthName string - CurrentYear int - Days []int -} - -type Weather struct { - Temperature int - ApparentTemperature int - WeatherCode int - CurrentColumn int - SunriseColumn int - SunsetColumn int - Columns []weatherColumn -} - -type AppRelease struct { - Source ReleaseSource - SourceIconURL string - Name string - Version string - NotesUrl string - TimeReleased time.Time - Downvotes int -} - -type AppReleases []AppRelease - -type Video struct { - ThumbnailUrl string - Title string - Url string - Author string - AuthorUrl string - TimePosted time.Time -} - -type Videos []Video - -var currencyToSymbol = map[string]string{ - "USD": "$", - "EUR": "€", - "JPY": "¥", - "CAD": "C$", - "AUD": "A$", - "GBP": "£", - "CHF": "Fr", - "NZD": "N$", - "INR": "₹", - "BRL": "R$", - "RUB": "₽", - "TRY": "₺", - "ZAR": "R", - "CNY": "¥", - "KRW": "₩", - "HKD": "HK$", - "SGD": "S$", - "SEK": "kr", - "NOK": "kr", - "DKK": "kr", - "PLN": "zł", - "PHP": "₱", -} - -type DNSStats struct { - TotalQueries int - BlockedQueries int - BlockedPercent int - ResponseTime int - DomainsBlocked int - Series [8]DNSStatsSeries - TopBlockedDomains []DNSStatsBlockedDomain -} - -type DNSStatsSeries struct { - Queries int - Blocked int - PercentTotal int - PercentBlocked int -} - -type DNSStatsBlockedDomain struct { - Domain string - PercentBlocked int -} - -type MarketRequest struct { - Name string `yaml:"name"` - Symbol string `yaml:"symbol"` - ChartLink string `yaml:"chart-link"` - SymbolLink string `yaml:"symbol-link"` -} - -type Market struct { - MarketRequest - Currency string `yaml:"-"` - Price float64 `yaml:"-"` - PercentChange float64 `yaml:"-"` - SvgChartPoints string `yaml:"-"` -} - -type Markets []Market - -func (t Markets) SortByAbsChange() { - sort.Slice(t, func(i, j int) bool { - return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange) - }) -} - -func (t Markets) SortByChange() { - sort.Slice(t, func(i, j int) bool { - return t[i].PercentChange > t[j].PercentChange - }) -} - -var weatherCodeTable = map[int]string{ - 0: "Clear Sky", - 1: "Mainly Clear", - 2: "Partly Cloudy", - 3: "Overcast", - 45: "Fog", - 48: "Rime Fog", - 51: "Drizzle", - 53: "Drizzle", - 55: "Drizzle", - 56: "Drizzle", - 57: "Drizzle", - 61: "Rain", - 63: "Moderate Rain", - 65: "Heavy Rain", - 66: "Freezing Rain", - 67: "Freezing Rain", - 71: "Snow", - 73: "Moderate Snow", - 75: "Heavy Snow", - 77: "Snow Grains", - 80: "Rain", - 81: "Moderate Rain", - 82: "Heavy Rain", - 85: "Snow", - 86: "Snow", - 95: "Thunderstorm", - 96: "Thunderstorm", - 99: "Thunderstorm", -} - -func (w *Weather) WeatherCodeAsString() string { - if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok { - return weatherCode - } - - return "" -} - -const depreciatePostsOlderThanHours = 7 -const maxDepreciation = 0.9 -const maxDepreciationAfterHours = 24 - -func (p ForumPosts) CalculateEngagement() { - var totalComments int - var totalScore int - - for i := range p { - totalComments += p[i].CommentCount - totalScore += p[i].Score - } - - numberOfPosts := float64(len(p)) - averageComments := float64(totalComments) / numberOfPosts - averageScore := float64(totalScore) / numberOfPosts - - for i := range p { - p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2 - - elapsed := time.Since(p[i].TimePosted) - - if elapsed < time.Hour*depreciatePostsOlderThanHours { - continue - } - - p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation - } -} - -func (p ForumPosts) SortByEngagement() { - sort.Slice(p, func(i, j int) bool { - return p[i].Engagement > p[j].Engagement - }) -} - -func (s *ForumPost) HasTargetUrl() bool { - return s.TargetUrl != "" -} - -func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost { - recent := make([]ForumPost, 0, len(p)) - - for i := range p { - if time.Since(p[i].TimePosted) < postedBefore { - recent = append(recent, p[i]) - } - } - - return recent -} - -func (r AppReleases) SortByNewest() AppReleases { - sort.Slice(r, func(i, j int) bool { - return r[i].TimeReleased.After(r[j].TimeReleased) - }) - - return r -} - -func (v Videos) SortByNewest() Videos { - sort.Slice(v, func(i, j int) bool { - return v[i].TimePosted.After(v[j].TimePosted) - }) - - return v -} diff --git a/internal/feed/releases.go b/internal/feed/releases.go deleted file mode 100644 index b0cdc25..0000000 --- a/internal/feed/releases.go +++ /dev/null @@ -1,72 +0,0 @@ -package feed - -import ( - "errors" - "fmt" - "log/slog" -) - -type ReleaseSource string - -const ( - ReleaseSourceCodeberg ReleaseSource = "codeberg" - ReleaseSourceGithub ReleaseSource = "github" - ReleaseSourceGitlab ReleaseSource = "gitlab" - ReleaseSourceDockerHub ReleaseSource = "dockerhub" -) - -type ReleaseRequest struct { - Source ReleaseSource - Repository string - Token *string -} - -func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) { - job := newJob(fetchLatestReleaseTask, requests).withWorkers(20) - results, errs, err := workerPoolDo(job) - - if err != nil { - return nil, err - } - - var failed int - - releases := make(AppReleases, 0, len(requests)) - - for i := range results { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch release", "source", requests[i].Source, "repository", requests[i].Repository, "error", errs[i]) - continue - } - - releases = append(releases, *results[i]) - } - - if failed == len(requests) { - return nil, ErrNoContent - } - - releases.SortByNewest() - - if failed > 0 { - return releases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed) - } - - return releases, nil -} - -func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) { - switch request.Source { - case ReleaseSourceCodeberg: - return fetchLatestCodebergRelease(request) - case ReleaseSourceGithub: - return fetchLatestGithubRelease(request) - case ReleaseSourceGitlab: - return fetchLatestGitLabRelease(request) - case ReleaseSourceDockerHub: - return fetchLatestDockerHubRelease(request) - } - - return nil, errors.New("unsupported source") -} diff --git a/internal/feed/yahoo.go b/internal/feed/yahoo.go deleted file mode 100644 index f962695..0000000 --- a/internal/feed/yahoo.go +++ /dev/null @@ -1,104 +0,0 @@ -package feed - -import ( - "fmt" - "log/slog" - "net/http" -) - -type marketResponseJson struct { - Chart struct { - Result []struct { - Meta struct { - Currency string `json:"currency"` - Symbol string `json:"symbol"` - RegularMarketPrice float64 `json:"regularMarketPrice"` - ChartPreviousClose float64 `json:"chartPreviousClose"` - } `json:"meta"` - Indicators struct { - Quote []struct { - Close []float64 `json:"close,omitempty"` - } `json:"quote"` - } `json:"indicators"` - } `json:"result"` - } `json:"chart"` -} - -// TODO: allow changing chart time frame -const marketChartDays = 21 - -func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) { - requests := make([]*http.Request, 0, len(marketRequests)) - - for i := range marketRequests { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil) - requests = append(requests, request) - } - - job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests) - responses, errs, err := workerPoolDo(job) - - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) - } - - markets := make(Markets, 0, len(responses)) - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i]) - continue - } - - response := responses[i] - - if len(response.Chart.Result) == 0 { - failed++ - slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol) - continue - } - - prices := response.Chart.Result[0].Indicators.Quote[0].Close - - if len(prices) > marketChartDays { - prices = prices[len(prices)-marketChartDays:] - } - - previous := response.Chart.Result[0].Meta.RegularMarketPrice - - if len(prices) >= 2 && prices[len(prices)-2] != 0 { - previous = prices[len(prices)-2] - } - - points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) - - currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency] - - if !exists { - currency = response.Chart.Result[0].Meta.Currency - } - - markets = append(markets, Market{ - MarketRequest: marketRequests[i], - Price: response.Chart.Result[0].Meta.RegularMarketPrice, - Currency: currency, - PercentChange: percentChange( - response.Chart.Result[0].Meta.RegularMarketPrice, - previous, - ), - SvgChartPoints: points, - }) - } - - if len(markets) == 0 { - return nil, ErrNoContent - } - - if failed > 0 { - return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed) - } - - return markets, nil -} diff --git a/internal/feed/youtube.go b/internal/feed/youtube.go deleted file mode 100644 index 5016b6b..0000000 --- a/internal/feed/youtube.go +++ /dev/null @@ -1,115 +0,0 @@ -package feed - -import ( - "fmt" - "log/slog" - "net/http" - "net/url" - "strings" - "time" -) - -type youtubeFeedResponseXml struct { - Channel string `xml:"author>name"` - ChannelLink string `xml:"author>uri"` - Videos []struct { - Title string `xml:"title"` - Published string `xml:"published"` - Link struct { - Href string `xml:"href,attr"` - } `xml:"link"` - - Group struct { - Thumbnail struct { - Url string `xml:"url,attr"` - } `xml:"http://search.yahoo.com/mrss/ thumbnail"` - } `xml:"http://search.yahoo.com/mrss/ group"` - } `xml:"entry"` -} - -func parseYoutubeFeedTime(t string) time.Time { - parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t) - - if err != nil { - return time.Now() - } - - return parsedTime -} - -func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) { - requests := make([]*http.Request, 0, len(channelIds)) - - for i := range channelIds { - var feedUrl string - if !includeShorts && strings.HasPrefix(channelIds[i], "UC") { - playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1) - feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId - } else { - feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i] - } - - request, _ := http.NewRequest("GET", feedUrl, nil) - requests = append(requests, request) - } - - job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30) - - responses, errs, err := workerPoolDo(job) - - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) - } - - videos := make(Videos, 0, len(channelIds)*15) - - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i]) - continue - } - - response := responses[i] - - for j := range response.Videos { - video := &response.Videos[j] - var videoUrl string - - if videoUrlTemplate == "" { - videoUrl = video.Link.Href - } else { - parsedUrl, err := url.Parse(video.Link.Href) - - if err == nil { - videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v")) - } else { - videoUrl = "#" - } - } - - videos = append(videos, Video{ - ThumbnailUrl: video.Group.Thumbnail.Url, - Title: video.Title, - Url: videoUrl, - Author: response.Channel, - AuthorUrl: response.ChannelLink + "/videos", - TimePosted: parseYoutubeFeedTime(video.Published), - }) - } - } - - if len(videos) == 0 { - return nil, ErrNoContent - } - - videos.SortByNewest() - - if failed > 0 { - return videos, fmt.Errorf("%w: missing videos from %d channels", ErrPartialContent, failed) - } - - return videos, nil -} diff --git a/internal/widget/fields.go b/internal/glance/config-fields.go similarity index 61% rename from internal/widget/fields.go rename to internal/glance/config-fields.go index 2b60b27..16ccc85 100644 --- a/internal/widget/fields.go +++ b/internal/glance/config-fields.go @@ -1,4 +1,4 @@ -package widget +package glance import ( "fmt" @@ -12,70 +12,66 @@ import ( "gopkg.in/yaml.v3" ) -var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) -var EnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`) +var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) const ( - HSLHueMax = 360 - HSLSaturationMax = 100 - HSLLightnessMax = 100 + hslHueMax = 360 + hslSaturationMax = 100 + hslLightnessMax = 100 ) -type HSLColorField struct { +type hslColorField struct { Hue uint16 Saturation uint8 Lightness uint8 } -func (c *HSLColorField) String() string { +func (c *hslColorField) String() string { return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness) } -func (c *HSLColorField) AsCSSValue() template.CSS { +func (c *hslColorField) AsCSSValue() template.CSS { return template.CSS(c.String()) } -func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error { +func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { var value string if err := node.Decode(&value); err != nil { return err } - matches := HSLColorPattern.FindStringSubmatch(value) + matches := hslColorFieldPattern.FindStringSubmatch(value) if len(matches) != 4 { return fmt.Errorf("invalid HSL color format: %s", value) } hue, err := strconv.ParseUint(matches[1], 10, 16) - if err != nil { return err } - if hue > HSLHueMax { - return fmt.Errorf("HSL hue must be between 0 and %d", HSLHueMax) + if hue > hslHueMax { + return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax) } saturation, err := strconv.ParseUint(matches[2], 10, 8) - if err != nil { return err } - if saturation > HSLSaturationMax { - return fmt.Errorf("HSL saturation must be between 0 and %d", HSLSaturationMax) + if saturation > hslSaturationMax { + return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax) } lightness, err := strconv.ParseUint(matches[3], 10, 8) - if err != nil { return err } - if lightness > HSLLightnessMax { - return fmt.Errorf("HSL lightness must be between 0 and %d", HSLLightnessMax) + if lightness > hslLightnessMax { + return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax) } c.Hue = uint16(hue) @@ -85,18 +81,18 @@ func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error { return nil } -var DurationPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) +var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) -type DurationField time.Duration +type durationField time.Duration -func (d *DurationField) UnmarshalYAML(node *yaml.Node) error { +func (d *durationField) UnmarshalYAML(node *yaml.Node) error { var value string if err := node.Decode(&value); err != nil { return err } - matches := DurationPattern.FindStringSubmatch(value) + matches := durationFieldPattern.FindStringSubmatch(value) if len(matches) != 3 { return fmt.Errorf("invalid duration format: %s", value) @@ -110,52 +106,52 @@ func (d *DurationField) UnmarshalYAML(node *yaml.Node) error { switch matches[2] { case "s": - *d = DurationField(time.Duration(duration) * time.Second) + *d = durationField(time.Duration(duration) * time.Second) case "m": - *d = DurationField(time.Duration(duration) * time.Minute) + *d = durationField(time.Duration(duration) * time.Minute) case "h": - *d = DurationField(time.Duration(duration) * time.Hour) + *d = durationField(time.Duration(duration) * time.Hour) case "d": - *d = DurationField(time.Duration(duration) * 24 * time.Hour) + *d = durationField(time.Duration(duration) * 24 * time.Hour) } return nil } -type OptionalEnvString string +var optionalEnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`) -func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error { +type optionalEnvField string + +func (f *optionalEnvField) UnmarshalYAML(node *yaml.Node) error { var value string err := node.Decode(&value) - if err != nil { return err } - replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string { + replaced := optionalEnvFieldPattern.ReplaceAllStringFunc(value, func(match string) string { if err != nil { return "" } - groups := EnvFieldPattern.FindStringSubmatch(whole) + groups := optionalEnvFieldPattern.FindStringSubmatch(match) if len(groups) != 3 { - return whole + return match } prefix, key := groups[1], groups[2] if prefix == `\` { - if len(whole) >= 2 { - return whole[1:] + if len(match) >= 2 { + return match[1:] } else { return "" } } value, found := os.LookupEnv(key) - if !found { err = fmt.Errorf("environment variable %s not found", key) return "" @@ -168,16 +164,16 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error { return err } - *f = OptionalEnvString(replaced) + *f = optionalEnvField(replaced) return nil } -func (f *OptionalEnvString) String() string { +func (f *optionalEnvField) String() string { return string(*f) } -type CustomIcon struct { +type customIconField struct { URL string IsFlatIcon bool // TODO: along with whether the icon is flat, we also need to know @@ -185,7 +181,7 @@ type CustomIcon struct { // invert the color based on the theme being light or dark } -func (i *CustomIcon) UnmarshalYAML(node *yaml.Node) error { +func (i *customIconField) UnmarshalYAML(node *yaml.Node) error { var value string if err := node.Decode(&value); err != nil { return err diff --git a/internal/glance/config.go b/internal/glance/config.go index de4a2f3..461fd50 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -3,26 +3,72 @@ package glance import ( "bytes" "fmt" + "html/template" "log" "os" "path/filepath" "regexp" "strings" + "sync" "time" "github.com/fsnotify/fsnotify" "gopkg.in/yaml.v3" ) -type Config struct { - Server Server `yaml:"server"` - Theme Theme `yaml:"theme"` - Branding Branding `yaml:"branding"` - Pages []Page `yaml:"pages"` +type config struct { + Server struct { + Host string `yaml:"host"` + Port uint16 `yaml:"port"` + AssetsPath string `yaml:"assets-path"` + BaseURL string `yaml:"base-url"` + StartedAt time.Time `yaml:"-"` // used in custom css file + } `yaml:"server"` + + Document struct { + Head template.HTML `yaml:"head"` + } `yaml:"document"` + + Theme struct { + BackgroundColor *hslColorField `yaml:"background-color"` + PrimaryColor *hslColorField `yaml:"primary-color"` + PositiveColor *hslColorField `yaml:"positive-color"` + NegativeColor *hslColorField `yaml:"negative-color"` + Light bool `yaml:"light"` + ContrastMultiplier float32 `yaml:"contrast-multiplier"` + TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` + CustomCSSFile string `yaml:"custom-css-file"` + } `yaml:"theme"` + + Branding struct { + HideFooter bool `yaml:"hide-footer"` + CustomFooter template.HTML `yaml:"custom-footer"` + LogoText string `yaml:"logo-text"` + LogoURL string `yaml:"logo-url"` + FaviconURL string `yaml:"favicon-url"` + } `yaml:"branding"` + + Pages []page `yaml:"pages"` } -func newConfigFromYAML(contents []byte) (*Config, error) { - config := &Config{} +type page struct { + Title string `yaml:"name"` + Slug string `yaml:"slug"` + Width string `yaml:"width"` + ShowMobileHeader bool `yaml:"show-mobile-header"` + ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"` + HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` + CenterVertically bool `yaml:"center-vertically"` + Columns []struct { + Size string `yaml:"size"` + Widgets widgets `yaml:"widgets"` + } `yaml:"columns"` + PrimaryColumnIndex int8 `yaml:"-"` + mu sync.Mutex `yaml:"-"` +} + +func newConfigFromYAML(contents []byte) (*config, error) { + config := &config{} config.Server.Port = 8080 err := yaml.Unmarshal(contents, config) @@ -37,8 +83,8 @@ func newConfigFromYAML(contents []byte) (*Config, error) { for p := range config.Pages { for c := range config.Pages[p].Columns { for w := range config.Pages[p].Columns[c].Widgets { - if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil { - return nil, err + if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil { + return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w]) } } } @@ -47,6 +93,10 @@ func newConfigFromYAML(contents []byte) (*Config, error) { return config, nil } +func formatWidgetInitError(err error, w widget) error { + return fmt.Errorf("%s widget: %v", w.GetType(), err) +} + var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`) func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) { @@ -101,16 +151,6 @@ func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) return mainFileContents, includes, nil } -func prefixStringLines(prefix string, s string) string { - lines := strings.Split(s, "\n") - - for i, line := range lines { - lines[i] = prefix + line - } - - return strings.Join(lines, "\n") -} - func configFilesWatcher( mainFilePath string, lastContents []byte, @@ -209,7 +249,7 @@ func configFilesWatcher( }, nil } -func isConfigStateValid(config *Config) error { +func isConfigStateValid(config *config) error { if len(config.Pages) == 0 { return fmt.Errorf("no pages configured") } @@ -222,24 +262,24 @@ func isConfigStateValid(config *Config) error { for i := range config.Pages { if config.Pages[i].Title == "" { - return fmt.Errorf("Page %d has no title", i+1) + return fmt.Errorf("page %d has no title", i+1) } if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") { - return fmt.Errorf("Page %d: width can only be either wide or slim", i+1) + return fmt.Errorf("page %d: width can only be either wide or slim", i+1) } if len(config.Pages[i].Columns) == 0 { - return fmt.Errorf("Page %d has no columns", i+1) + return fmt.Errorf("page %d has no columns", i+1) } if config.Pages[i].Width == "slim" { if len(config.Pages[i].Columns) > 2 { - return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1) + return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1) } } else { if len(config.Pages[i].Columns) > 3 { - return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns)) + return fmt.Errorf("page %d has more than 3 columns", i+1) } } @@ -247,7 +287,7 @@ func isConfigStateValid(config *Config) error { for j := range config.Pages[i].Columns { if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" { - return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1) + return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1) } columnSizesCount[config.Pages[i].Columns[j].Size]++ @@ -256,7 +296,7 @@ func isConfigStateValid(config *Config) error { full := columnSizesCount["full"] if full > 2 || full == 0 { - return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1) + return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1) } } diff --git a/internal/glance/diagnose.go b/internal/glance/diagnose.go index c7fd141..892aa5f 100644 --- a/internal/glance/diagnose.go +++ b/internal/glance/diagnose.go @@ -6,7 +6,6 @@ import ( "io" "net" "net/http" - "os" "runtime" "strings" "sync" @@ -151,19 +150,6 @@ type diagnosticStep struct { elapsed time.Duration } -func boolToString(b bool, trueValue, falseValue string) string { - if b { - return trueValue - } - - return falseValue -} - -func isRunningInsideDockerContainer() bool { - _, err := os.Stat("/.dockerenv") - return err == nil -} - func testHttpRequest(method, url string, expectedStatusCode int) (string, error) { return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode) } diff --git a/internal/assets/files.go b/internal/glance/embed.go similarity index 68% rename from internal/assets/files.go rename to internal/glance/embed.go index 2c7c09e..65b1a72 100644 --- a/internal/assets/files.go +++ b/internal/glance/embed.go @@ -1,4 +1,4 @@ -package assets +package glance import ( "crypto/md5" @@ -6,21 +6,23 @@ import ( "encoding/hex" "io" "io/fs" - "log/slog" + "log" "strconv" "time" ) //go:embed static -var _publicFS embed.FS +var _staticFS embed.FS //go:embed templates var _templateFS embed.FS -var PublicFS, _ = fs.Sub(_publicFS, "static") -var TemplateFS, _ = fs.Sub(_templateFS, "templates") +var staticFS, _ = fs.Sub(_staticFS, "static") +var templateFS, _ = fs.Sub(_templateFS, "templates") -func getFSHash(files fs.FS) string { +var staticFSHash = computeFSHash(staticFS) + +func computeFSHash(files fs.FS) string { hash := md5.New() err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { @@ -33,7 +35,6 @@ func getFSHash(files fs.FS) string { } file, err := files.Open(path) - if err != nil { return err } @@ -49,8 +50,6 @@ func getFSHash(files fs.FS) string { return hex.EncodeToString(hash.Sum(nil))[:10] } - slog.Warn("Could not compute assets cache", "err", err) + log.Printf("Could not compute assets cache: %v", err) return strconv.FormatInt(time.Now().Unix(), 10) } - -var PublicFSHash = getFSHash(PublicFS) diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 96f238c..8c0068c 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -8,133 +8,41 @@ import ( "log" "net/http" "path/filepath" - "regexp" "strconv" "strings" "sync" "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/widget" ) -var buildVersion = "dev" +var pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl") -var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) +type application struct { + Version string + Config config + ParsedThemeStyle template.HTML -type Application struct { - Version string - Config Config - slugToPage map[string]*Page - widgetByID map[uint64]widget.Widget + slugToPage map[string]*page + widgetByID map[uint64]widget } -type Theme struct { - BackgroundColor *widget.HSLColorField `yaml:"background-color"` - PrimaryColor *widget.HSLColorField `yaml:"primary-color"` - PositiveColor *widget.HSLColorField `yaml:"positive-color"` - NegativeColor *widget.HSLColorField `yaml:"negative-color"` - Light bool `yaml:"light"` - ContrastMultiplier float32 `yaml:"contrast-multiplier"` - TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` - CustomCSSFile string `yaml:"custom-css-file"` -} - -type Server struct { - Host string `yaml:"host"` - Port uint16 `yaml:"port"` - AssetsPath string `yaml:"assets-path"` - BaseURL string `yaml:"base-url"` - AssetsHash string `yaml:"-"` - StartedAt time.Time `yaml:"-"` // used in custom css file -} - -type Branding struct { - HideFooter bool `yaml:"hide-footer"` - CustomFooter template.HTML `yaml:"custom-footer"` - LogoText string `yaml:"logo-text"` - LogoURL string `yaml:"logo-url"` - FaviconURL string `yaml:"favicon-url"` -} - -type Column struct { - Size string `yaml:"size"` - Widgets widget.Widgets `yaml:"widgets"` -} - -type templateData struct { - App *Application - Page *Page -} - -type Page struct { - Title string `yaml:"name"` - Slug string `yaml:"slug"` - Width string `yaml:"width"` - ShowMobileHeader bool `yaml:"show-mobile-header"` - ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"` - HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` - CenterVertically bool `yaml:"center-vertically"` - Columns []Column `yaml:"columns"` - PrimaryColumnIndex int8 `yaml:"-"` - mu sync.Mutex -} - -func (p *Page) UpdateOutdatedWidgets() { - now := time.Now() - - var wg sync.WaitGroup - context := context.Background() - - for c := range p.Columns { - for w := range p.Columns[c].Widgets { - widget := p.Columns[c].Widgets[w] - - if !widget.RequiresUpdate(&now) { - continue - } - - wg.Add(1) - go func() { - defer wg.Done() - widget.Update(context) - }() - } - } - - wg.Wait() -} - -// TODO: fix, currently very simple, lots of uncovered edge cases -func titleToSlug(s string) string { - s = strings.ToLower(s) - s = sequentialWhitespacePattern.ReplaceAllString(s, "-") - s = strings.Trim(s, "-") - - return s -} - -func (a *Application) TransformUserDefinedAssetPath(path string) string { - if strings.HasPrefix(path, "/assets/") { - return a.Config.Server.BaseURL + path - } - - return path -} - -func newApplication(config *Config) *Application { - app := &Application{ +func newApplication(config *config) (*application, error) { + app := &application{ Version: buildVersion, Config: *config, - slugToPage: make(map[string]*Page), - widgetByID: make(map[uint64]widget.Widget), + slugToPage: make(map[string]*page), + widgetByID: make(map[uint64]widget), } - app.Config.Server.AssetsHash = assets.PublicFSHash app.slugToPage[""] = &config.Pages[0] - providers := &widget.Providers{ - AssetResolver: app.AssetPath, + providers := &widgetProviders{ + assetResolver: app.AssetPath, + } + + var err error + app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme) + if err != nil { + return nil, fmt.Errorf("parsing theme style: %v", err) } for p := range config.Pages { @@ -156,9 +64,9 @@ func newApplication(config *Config) *Application { for w := range column.Widgets { widget := column.Widgets[w] - app.widgetByID[widget.GetID()] = widget + app.widgetByID[widget.id()] = widget - widget.SetProviders(providers) + widget.setProviders(providers) } } } @@ -166,34 +74,75 @@ func newApplication(config *Config) *Application { config = &app.Config config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") - config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile) + config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile) if config.Branding.FaviconURL == "" { config.Branding.FaviconURL = app.AssetPath("favicon.png") } else { - config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL) + config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL) } - config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL) + config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL) - return app + return app, nil } -func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) { +func (p *page) updateOutdatedWidgets() { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + + var wg sync.WaitGroup + context := context.Background() + + for c := range p.Columns { + for w := range p.Columns[c].Widgets { + widget := p.Columns[c].Widgets[w] + + if !widget.requiresUpdate(&now) { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + widget.update(context) + }() + } + } + + wg.Wait() +} + +func (a *application) transformUserDefinedAssetPath(path string) string { + if strings.HasPrefix(path, "/assets/") { + return a.Config.Server.BaseURL + path + } + + return path +} + +type pageTemplateData struct { + App *application + Page *page +} + +func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - pageData := templateData{ + pageData := pageTemplateData{ Page: page, App: a, } var responseBytes bytes.Buffer - err := assets.PageTemplate.Execute(&responseBytes, pageData) + err := pageTemplate.Execute(&responseBytes, pageData) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -204,24 +153,22 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) w.Write(responseBytes.Bytes()) } -func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) { +func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - pageData := templateData{ + pageData := pageTemplateData{ Page: page, } - page.mu.Lock() - defer page.mu.Unlock() - page.UpdateOutdatedWidgets() + page.updateOutdatedWidgets() var responseBytes bytes.Buffer - err := assets.PageContentTemplate.Execute(&responseBytes, pageData) + err := pageContentTemplate.Execute(&responseBytes, pageData) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -232,69 +179,59 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re w.Write(responseBytes.Bytes()) } -func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) { +func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { // TODO: add proper not found page w.WriteHeader(http.StatusNotFound) w.Write([]byte("Page not found")) } -func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler { - server := http.FileServer(fs) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TODO: fix always setting cache control even if the file doesn't exist - w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))) - server.ServeHTTP(w, r) - }) -} - -func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) { +func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) { widgetValue := r.PathValue("widget") widgetID, err := strconv.ParseUint(widgetValue, 10, 64) if err != nil { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } widget, exists := a.widgetByID[widgetID] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - widget.HandleRequest(w, r) + widget.handleRequest(w, r) } -func (a *Application) AssetPath(asset string) string { - return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset +func (a *application) AssetPath(asset string) string { + return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset } -func (a *Application) Server() (func() error, func() error) { +func (a *application) server() (func() error, func() error) { // TODO: add gzip support, static files must have their gzipped contents cached // TODO: add HTTPS support mux := http.NewServeMux() - mux.HandleFunc("GET /{$}", a.HandlePageRequest) - mux.HandleFunc("GET /{page}", a.HandlePageRequest) + mux.HandleFunc("GET /{$}", a.handlePageRequest) + mux.HandleFunc("GET /{page}", a.handlePageRequest) - mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest) - mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest) + mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest) + mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest) mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) mux.Handle( - fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash), - http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)), + fmt.Sprintf("GET /static/%s/{path...}", staticFSHash), + http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)), ) var absAssetsPath string if a.Config.Server.AssetsPath != "" { absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath) - assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour) + assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour) mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS)) } diff --git a/internal/glance/main.go b/internal/glance/main.go index 552ecd6..0473501 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -2,9 +2,14 @@ package glance import ( "fmt" + "io" "log" + "net/http" + "os" ) +var buildVersion = "dev" + func Main() int { options, err := parseCliOptions() @@ -15,6 +20,11 @@ func Main() int { switch options.intent { case cliIntentServe: + // remove in v0.10.0 + if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) { + return 1 + } + if err := serveApp(options.configPath); err != nil { fmt.Println(err) return 1 @@ -22,7 +32,7 @@ func Main() int { case cliIntentConfigValidate: contents, _, err := parseYAMLIncludes(options.configPath) if err != nil { - fmt.Printf("failed to parse config file: %v\n", err) + fmt.Printf("could not parse config file: %v\n", err) return 1 } @@ -33,7 +43,7 @@ func Main() int { case cliIntentConfigPrint: contents, _, err := parseYAMLIncludes(options.configPath) if err != nil { - fmt.Printf("failed to parse config file: %v\n", err) + fmt.Printf("could not parse config file: %v\n", err) return 1 } @@ -54,12 +64,12 @@ func serveApp(configPath string) error { onChange := func(newContents []byte) { if stopServer != nil { - log.Println("Config file changed, attempting to restart server") + log.Println("Config file changed, reloading...") } config, err := newConfigFromYAML(newContents) if err != nil { - log.Printf("Config file is invalid: %v", err) + log.Printf("Config has errors: %v", err) if !hadValidConfigOnStartup { close(exitChannel) @@ -70,7 +80,11 @@ func serveApp(configPath string) error { hadValidConfigOnStartup = true } - app := newApplication(config) + app, err := newApplication(config) + if err != nil { + log.Printf("Failed to create application: %v", err) + return + } if stopServer != nil { if err := stopServer(); err != nil { @@ -80,7 +94,7 @@ func serveApp(configPath string) error { go func() { var startServer func() error - startServer, stopServer = app.Server() + startServer, stopServer = app.server() if err := startServer(); err != nil { log.Printf("Failed to start server: %v", err) @@ -94,7 +108,7 @@ func serveApp(configPath string) error { configContents, configIncludes, err := parseYAMLIncludes(configPath) if err != nil { - return fmt.Errorf("failed to parse config file: %w", err) + return fmt.Errorf("parsing config: %w", err) } stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr) @@ -105,17 +119,54 @@ func serveApp(configPath string) error { config, err := newConfigFromYAML(configContents) if err != nil { - return fmt.Errorf("could not parse config file: %w", err) + return fmt.Errorf("validating config file: %w", err) } - app := newApplication(config) + app, err := newApplication(config) + if err != nil { + return fmt.Errorf("creating application: %w", err) + } - startServer, _ := app.Server() + startServer, _ := app.server() if err := startServer(); err != nil { - return fmt.Errorf("failed to start server: %w", err) + return fmt.Errorf("starting server: %w", err) } } <-exitChannel return nil } + +func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool { + if !isRunningInsideDockerContainer() { + return false + } + + if _, err := os.Stat(configPath); err == nil { + return false + } + + // glance.yml wasn't mounted to begin with or was incorrectly mounted as a directory + if stat, err := os.Stat("glance.yml"); err != nil || stat.IsDir() { + return false + } + + templateFile, _ := templateFS.Open("v0.7-update-notice-page.html") + bodyContents, _ := io.ReadAll(templateFile) + + mux := http.NewServeMux() + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(bodyContents)) + }) + + server := http.Server{ + Addr: "localhost:8080", + Handler: mux, + } + server.ListenAndServe() + + return true +} diff --git a/internal/assets/static/app-icon.png b/internal/glance/static/app-icon.png similarity index 100% rename from internal/assets/static/app-icon.png rename to internal/glance/static/app-icon.png diff --git a/internal/assets/static/favicon.png b/internal/glance/static/favicon.png similarity index 100% rename from internal/assets/static/favicon.png rename to internal/glance/static/favicon.png diff --git a/internal/assets/static/fonts/JetBrainsMono-Regular.woff2 b/internal/glance/static/fonts/JetBrainsMono-Regular.woff2 similarity index 100% rename from internal/assets/static/fonts/JetBrainsMono-Regular.woff2 rename to internal/glance/static/fonts/JetBrainsMono-Regular.woff2 diff --git a/internal/assets/static/icons/codeberg.svg b/internal/glance/static/icons/codeberg.svg similarity index 100% rename from internal/assets/static/icons/codeberg.svg rename to internal/glance/static/icons/codeberg.svg diff --git a/internal/assets/static/icons/dockerhub.svg b/internal/glance/static/icons/dockerhub.svg similarity index 100% rename from internal/assets/static/icons/dockerhub.svg rename to internal/glance/static/icons/dockerhub.svg diff --git a/internal/assets/static/icons/github.svg b/internal/glance/static/icons/github.svg similarity index 100% rename from internal/assets/static/icons/github.svg rename to internal/glance/static/icons/github.svg diff --git a/internal/assets/static/icons/gitlab.svg b/internal/glance/static/icons/gitlab.svg similarity index 100% rename from internal/assets/static/icons/gitlab.svg rename to internal/glance/static/icons/gitlab.svg diff --git a/internal/assets/static/js/main.js b/internal/glance/static/js/main.js similarity index 100% rename from internal/assets/static/js/main.js rename to internal/glance/static/js/main.js diff --git a/internal/assets/static/js/masonry.js b/internal/glance/static/js/masonry.js similarity index 100% rename from internal/assets/static/js/masonry.js rename to internal/glance/static/js/masonry.js diff --git a/internal/assets/static/js/popover.js b/internal/glance/static/js/popover.js similarity index 100% rename from internal/assets/static/js/popover.js rename to internal/glance/static/js/popover.js diff --git a/internal/assets/static/js/utils.js b/internal/glance/static/js/utils.js similarity index 100% rename from internal/assets/static/js/utils.js rename to internal/glance/static/js/utils.js diff --git a/internal/assets/static/main.css b/internal/glance/static/main.css similarity index 99% rename from internal/assets/static/main.css rename to internal/glance/static/main.css index 3ac7ee0..a486395 100644 --- a/internal/assets/static/main.css +++ b/internal/glance/static/main.css @@ -546,6 +546,10 @@ kbd:active { opacity: 1; } +.details:not([open]) .list-with-transition { + display: none; +} + .summary::after { content: "◀"; font-size: 1.2em; @@ -1106,7 +1110,6 @@ details[open] .summary::after { .dns-stats-graph-gridlines-container { position: absolute; - z-index: -1; inset: 0; } diff --git a/internal/assets/static/manifest.json b/internal/glance/static/manifest.json similarity index 100% rename from internal/assets/static/manifest.json rename to internal/glance/static/manifest.json diff --git a/internal/glance/templates.go b/internal/glance/templates.go new file mode 100644 index 0000000..db14d7e --- /dev/null +++ b/internal/glance/templates.go @@ -0,0 +1,62 @@ +package glance + +import ( + "fmt" + "html/template" + "math" + "strconv" + "time" + + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var ( + pageTemplate = mustParseTemplate("page.html", "document.html") + pageContentTemplate = mustParseTemplate("page-content.html") + forumPostsTemplate = mustParseTemplate("forum-posts.html", "widget-base.html") +) + +var globalTemplateFunctions = template.FuncMap{ + "formatViewerCount": formatViewerCount, + "formatNumber": intl.Sprint, + "absInt": func(i int) int { + return int(math.Abs(float64(i))) + }, + "formatPrice": func(price float64) string { + return intl.Sprintf("%.2f", price) + }, + "dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr { + return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix())) + }, +} + +func mustParseTemplate(primary string, dependencies ...string) *template.Template { + t, err := template.New(primary). + Funcs(globalTemplateFunctions). + ParseFS(templateFS, append([]string{primary}, dependencies...)...) + + if err != nil { + panic(err) + } + + return t +} + +var intl = message.NewPrinter(language.English) + +func formatViewerCount(count int) string { + if count < 1_000 { + return strconv.Itoa(count) + } + + if count < 10_000 { + return fmt.Sprintf("%.1fk", float64(count)/1_000) + } + + if count < 1_000_000 { + return fmt.Sprintf("%dk", count/1_000) + } + + return fmt.Sprintf("%.1fm", float64(count)/1_000_000) +} diff --git a/internal/assets/templates/bookmarks.html b/internal/glance/templates/bookmarks.html similarity index 100% rename from internal/assets/templates/bookmarks.html rename to internal/glance/templates/bookmarks.html diff --git a/internal/assets/templates/calendar.html b/internal/glance/templates/calendar.html similarity index 100% rename from internal/assets/templates/calendar.html rename to internal/glance/templates/calendar.html diff --git a/internal/assets/templates/change-detection.html b/internal/glance/templates/change-detection.html similarity index 100% rename from internal/assets/templates/change-detection.html rename to internal/glance/templates/change-detection.html diff --git a/internal/assets/templates/clock.html b/internal/glance/templates/clock.html similarity index 100% rename from internal/assets/templates/clock.html rename to internal/glance/templates/clock.html diff --git a/internal/assets/templates/custom-api.html b/internal/glance/templates/custom-api.html similarity index 100% rename from internal/assets/templates/custom-api.html rename to internal/glance/templates/custom-api.html diff --git a/internal/assets/templates/dns-stats.html b/internal/glance/templates/dns-stats.html similarity index 98% rename from internal/assets/templates/dns-stats.html rename to internal/glance/templates/dns-stats.html index 5d83508..8447ce1 100644 --- a/internal/assets/templates/dns-stats.html +++ b/internal/glance/templates/dns-stats.html @@ -73,7 +73,7 @@ Top blocked domains diff --git a/internal/assets/templates/group.html b/internal/glance/templates/group.html similarity index 100% rename from internal/assets/templates/group.html rename to internal/glance/templates/group.html diff --git a/internal/assets/templates/iframe.html b/internal/glance/templates/iframe.html similarity index 100% rename from internal/assets/templates/iframe.html rename to internal/glance/templates/iframe.html diff --git a/internal/assets/templates/markets.html b/internal/glance/templates/markets.html similarity index 100% rename from internal/assets/templates/markets.html rename to internal/glance/templates/markets.html diff --git a/internal/assets/templates/monitor-compact.html b/internal/glance/templates/monitor-compact.html similarity index 100% rename from internal/assets/templates/monitor-compact.html rename to internal/glance/templates/monitor-compact.html diff --git a/internal/assets/templates/monitor.html b/internal/glance/templates/monitor.html similarity index 100% rename from internal/assets/templates/monitor.html rename to internal/glance/templates/monitor.html diff --git a/internal/assets/templates/content.html b/internal/glance/templates/page-content.html similarity index 100% rename from internal/assets/templates/content.html rename to internal/glance/templates/page-content.html diff --git a/internal/assets/templates/page.html b/internal/glance/templates/page.html similarity index 97% rename from internal/assets/templates/page.html rename to internal/glance/templates/page.html index e452dde..2a0c776 100644 --- a/internal/assets/templates/page.html +++ b/internal/glance/templates/page.html @@ -14,10 +14,13 @@ {{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }} {{ define "document-head-after" }} -{{ template "page-style-overrides.gotmpl" . }} +{{ .App.ParsedThemeStyle }} + {{ if ne "" .App.Config.Theme.CustomCSSFile }} {{ end }} + +{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }} {{ end }} {{ define "navigation-links" }} diff --git a/internal/assets/templates/reddit-horizontal-cards.html b/internal/glance/templates/reddit-horizontal-cards.html similarity index 100% rename from internal/assets/templates/reddit-horizontal-cards.html rename to internal/glance/templates/reddit-horizontal-cards.html diff --git a/internal/assets/templates/reddit-vertical-cards.html b/internal/glance/templates/reddit-vertical-cards.html similarity index 100% rename from internal/assets/templates/reddit-vertical-cards.html rename to internal/glance/templates/reddit-vertical-cards.html diff --git a/internal/assets/templates/releases.html b/internal/glance/templates/releases.html similarity index 88% rename from internal/assets/templates/releases.html rename to internal/glance/templates/releases.html index 7cd89f7..3643524 100644 --- a/internal/assets/templates/releases.html +++ b/internal/glance/templates/releases.html @@ -7,7 +7,7 @@
{{ .Name }} {{ if $.ShowSourceIcon }} - + {{ end }}