Merge latest changes
This commit is contained in:
commit
0651a2886e
131 files changed
+4257
-3807
No files matched your search
+1
-1
@@ -10,4 +10,4 @@ WORKDIR /app
|
|||||||
COPY --from=builder /app/glance .
|
COPY --from=builder /app/glance .
|
||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
ENTRYPOINT ["/app/glance"]
|
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||||
@@ -5,4 +5,4 @@ COPY glance .
|
|||||||
|
|
||||||
EXPOSE 8080/tcp
|
EXPOSE 8080/tcp
|
||||||
|
|
||||||
ENTRYPOINT ["/app/glance"]
|
ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"]
|
||||||
@@ -52,6 +52,8 @@ Checkout the [releases page](https://github.com/glanceapp/glance/releases) for a
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
|
<!-- TODO: update -->
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
>
|
>
|
||||||
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
|
> Make sure you have a valid `glance.yml` file in the same directory before running the container.
|
||||||
|
|||||||
+18
-1
@@ -35,6 +35,7 @@
|
|||||||
- [Docker](#docker)
|
- [Docker](#docker)
|
||||||
|
|
||||||
## Intro
|
## Intro
|
||||||
|
<!-- TODO: update -->
|
||||||
Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
|
Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error.
|
||||||
|
|
||||||
## Preconfigured page
|
## Preconfigured page
|
||||||
@@ -114,6 +115,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!
|
Configure the widgets, add more of them, add extra pages, etc. Make it your own!
|
||||||
|
|
||||||
|
<!-- TODO: update - add information about top level document key -->
|
||||||
|
|
||||||
## Server
|
## Server
|
||||||
Server configuration is done through a top level `server` property. Example:
|
Server configuration is done through a top level `server` property. Example:
|
||||||
|
|
||||||
@@ -1341,6 +1344,7 @@ Preview:
|
|||||||
| Name | Type | Required | Default |
|
| Name | Type | Required | Default |
|
||||||
| ---- | ---- | -------- | ------- |
|
| ---- | ---- | -------- | ------- |
|
||||||
| service | string | no | pihole |
|
| service | string | no | pihole |
|
||||||
|
| allow-insecure | bool | no | false |
|
||||||
| url | string | yes | |
|
| url | string | yes | |
|
||||||
| username | string | when service is `adguard` | |
|
| username | string | when service is `adguard` | |
|
||||||
| password | string | when service is `adguard` | |
|
| password | string | when service is `adguard` | |
|
||||||
@@ -1350,6 +1354,9 @@ Preview:
|
|||||||
##### `service`
|
##### `service`
|
||||||
Either `adguard` or `pihole`.
|
Either `adguard` or `pihole`.
|
||||||
|
|
||||||
|
##### `allow-insecure`
|
||||||
|
Whether to allow invalid/self-signed certificates when making the request to the service.
|
||||||
|
|
||||||
##### `url`
|
##### `url`
|
||||||
The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
|
The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`.
|
||||||
|
|
||||||
@@ -1597,15 +1604,25 @@ Example:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
- type: calendar
|
- type: calendar
|
||||||
|
start-sunday: false
|
||||||
```
|
```
|
||||||
|
|
||||||
Preview:
|
Preview:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
#### Properties
|
||||||
|
|
||||||
|
| Name | Type | Required | Default |
|
||||||
|
| ---- | ---- | -------- | ------- |
|
||||||
|
| start-sunday | boolean | no | false |
|
||||||
|
|
||||||
|
##### `start-sunday`
|
||||||
|
Whether calendar weeks start on Sunday or Monday.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
>
|
>
|
||||||
> There is currently no customizability available for the calendar. Extra features will be added in the future.
|
> There is currently little customizability available for the calendar. Extra features will be added in the future.
|
||||||
|
|
||||||
### Markets
|
### Markets
|
||||||
Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
|
Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ module github.com/glanceapp/glance
|
|||||||
go 1.23.1
|
go 1.23.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0
|
||||||
github.com/mmcdole/gofeed v1.3.0
|
github.com/mmcdole/gofeed v1.3.0
|
||||||
github.com/tidwall/gjson v1.18.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
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,5 +19,6 @@ require (
|
|||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
golang.org/x/net v0.29.0 // indirect
|
golang.org/x/net v0.31.0 // indirect
|
||||||
|
golang.org/x/sys v0.27.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -5,6 +5,8 @@ github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
|
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
@@ -40,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.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.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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
|
||||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -52,6 +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.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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@@ -61,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.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.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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
package assets
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/md5"
|
|
||||||
"embed"
|
|
||||||
"encoding/hex"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed static
|
|
||||||
var _publicFS embed.FS
|
|
||||||
|
|
||||||
//go:embed templates
|
|
||||||
var _templateFS embed.FS
|
|
||||||
|
|
||||||
var PublicFS, _ = fs.Sub(_publicFS, "static")
|
|
||||||
var TemplateFS, _ = fs.Sub(_templateFS, "templates")
|
|
||||||
|
|
||||||
func getFSHash(files fs.FS) string {
|
|
||||||
hash := md5.New()
|
|
||||||
|
|
||||||
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if d.IsDir() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := files.Open(path)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := io.Copy(hash, file); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
return hex.EncodeToString(hash.Sum(nil))[:10]
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Warn("Could not compute assets cache", "err", err)
|
|
||||||
return strconv.FormatInt(time.Now().Unix(), 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
var PublicFSHash = getFSHash(PublicFS)
|
|
||||||
@@ -1,113 +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")
|
|
||||||
DockerTemplate = compileTemplate("docker.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))
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<style>
|
|
||||||
:root {
|
|
||||||
{{ if .App.Config.Theme.BackgroundColor }}
|
|
||||||
--bgh: {{ .App.Config.Theme.BackgroundColor.Hue }};
|
|
||||||
--bgs: {{ .App.Config.Theme.BackgroundColor.Saturation }}%;
|
|
||||||
--bgl: {{ .App.Config.Theme.BackgroundColor.Lightness }}%;
|
|
||||||
{{ end }}
|
|
||||||
{{ if ne 0.0 .App.Config.Theme.ContrastMultiplier }}--cm: {{ .App.Config.Theme.ContrastMultiplier }};{{ end }}
|
|
||||||
{{ if ne 0.0 .App.Config.Theme.TextSaturationMultiplier }}--tsm: {{ .App.Config.Theme.TextSaturationMultiplier }};{{ end }}
|
|
||||||
{{ if .App.Config.Theme.PrimaryColor }}--color-primary: {{ .App.Config.Theme.PrimaryColor.AsCSSValue }};{{ end }}
|
|
||||||
{{ if .App.Config.Theme.PositiveColor }}--color-positive: {{ .App.Config.Theme.PositiveColor.AsCSSValue }};{{ end }}
|
|
||||||
{{ if .App.Config.Theme.NegativeColor }}--color-negative: {{ .App.Config.Theme.NegativeColor.AsCSSValue }};{{ end }}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package feed
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// TODO: very inflexible, refactor to allow more customizability
|
|
||||||
// TODO: allow changing first day of week
|
|
||||||
// TODO: allow changing between showing the previous and next week and the entire month
|
|
||||||
func NewCalendar(now time.Time) *Calendar {
|
|
||||||
year, week := now.ISOWeek()
|
|
||||||
weekday := now.Weekday()
|
|
||||||
|
|
||||||
if weekday == 0 {
|
|
||||||
weekday = 7
|
|
||||||
}
|
|
||||||
|
|
||||||
currentMonthDays := daysInMonth(now.Month(), year)
|
|
||||||
|
|
||||||
var previousMonthDays int
|
|
||||||
|
|
||||||
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
|
|
||||||
previousMonthDays = daysInMonth(12, year-1)
|
|
||||||
} else {
|
|
||||||
previousMonthDays = daysInMonth(previousMonthNumber, year)
|
|
||||||
}
|
|
||||||
|
|
||||||
startDaysFrom := now.Day() - int(weekday+6)
|
|
||||||
|
|
||||||
days := make([]int, 21)
|
|
||||||
|
|
||||||
for i := 0; i < 21; i++ {
|
|
||||||
day := startDaysFrom + i
|
|
||||||
|
|
||||||
if day < 1 {
|
|
||||||
day = previousMonthDays + day
|
|
||||||
} else if day > currentMonthDays {
|
|
||||||
day = day - currentMonthDays
|
|
||||||
}
|
|
||||||
|
|
||||||
days[i] = day
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Calendar{
|
|
||||||
CurrentDay: now.Day(),
|
|
||||||
CurrentWeekNumber: week,
|
|
||||||
CurrentMonthName: now.Month().String(),
|
|
||||||
CurrentYear: year,
|
|
||||||
Days: days,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func daysInMonth(m time.Month, year int) int {
|
|
||||||
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 += "... <truncated>"
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}()
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package feed
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type hackerNewsPostResponseJson struct {
|
|
||||||
Id int `json:"id"`
|
|
||||||
Score int `json:"score"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
TargetUrl string `json:"url,omitempty"`
|
|
||||||
CommentCount int `json:"descendants"`
|
|
||||||
TimePosted int64 `json:"time"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func getHackerNewsPostIds(sort string) ([]int, error) {
|
|
||||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
|
|
||||||
response, err := decodeJsonFromRequest[[]int](defaultClient, request)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) {
|
|
||||||
requests := make([]*http.Request, len(postIds))
|
|
||||||
|
|
||||||
for i, id := range postIds {
|
|
||||||
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
|
|
||||||
requests[i] = request
|
|
||||||
}
|
|
||||||
|
|
||||||
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient)
|
|
||||||
job := newJob(task, requests).withWorkers(30)
|
|
||||||
results, errs, err := workerPoolDo(job)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
posts := make(ForumPosts, 0, len(postIds))
|
|
||||||
|
|
||||||
for i := range results {
|
|
||||||
if errs[i] != nil {
|
|
||||||
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var commentsUrl string
|
|
||||||
|
|
||||||
if commentsUrlTemplate == "" {
|
|
||||||
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
|
|
||||||
} else {
|
|
||||||
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
|
|
||||||
}
|
|
||||||
|
|
||||||
posts = append(posts, ForumPost{
|
|
||||||
Title: results[i].Title,
|
|
||||||
DiscussionUrl: commentsUrl,
|
|
||||||
TargetUrl: results[i].TargetUrl,
|
|
||||||
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
|
|
||||||
CommentCount: results[i].CommentCount,
|
|
||||||
Score: results[i].Score,
|
|
||||||
TimePosted: time.Unix(results[i].TimePosted, 0),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(posts) == 0 {
|
|
||||||
return nil, ErrNoContent
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(posts) != len(postIds) {
|
|
||||||
return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
return posts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) {
|
|
||||||
postIds, err := getHackerNewsPostIds(sort)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(postIds) > limit {
|
|
||||||
postIds = postIds[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
+41
-16
@@ -2,41 +2,66 @@ package glance
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CliIntent uint8
|
type cliIntent uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CliIntentServe CliIntent = iota
|
cliIntentServe cliIntent = iota
|
||||||
CliIntentCheckConfig = iota
|
cliIntentConfigValidate = iota
|
||||||
|
cliIntentConfigPrint = iota
|
||||||
|
cliIntentDiagnose = iota
|
||||||
)
|
)
|
||||||
|
|
||||||
type CliOptions struct {
|
type cliOptions struct {
|
||||||
Intent CliIntent
|
intent cliIntent
|
||||||
ConfigPath string
|
configPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseCliOptions() (*CliOptions, error) {
|
func parseCliOptions() (*cliOptions, error) {
|
||||||
flags := flag.NewFlagSet("", flag.ExitOnError)
|
flags := flag.NewFlagSet("", flag.ExitOnError)
|
||||||
|
flags.Usage = func() {
|
||||||
|
fmt.Println("Usage: glance [options] command")
|
||||||
|
|
||||||
checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
|
fmt.Println("\nOptions:")
|
||||||
|
flags.PrintDefaults()
|
||||||
|
|
||||||
|
fmt.Println("\nCommands:")
|
||||||
|
fmt.Println(" config:validate Validate the config file")
|
||||||
|
fmt.Println(" config:print Print the parsed config file with embedded includes")
|
||||||
|
fmt.Println(" diagnose Run diagnostic checks")
|
||||||
|
}
|
||||||
configPath := flags.String("config", "glance.yml", "Set config path")
|
configPath := flags.String("config", "glance.yml", "Set config path")
|
||||||
|
|
||||||
err := flags.Parse(os.Args[1:])
|
err := flags.Parse(os.Args[1:])
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
intent := CliIntentServe
|
var intent cliIntent
|
||||||
|
var args = flags.Args()
|
||||||
|
unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " "))
|
||||||
|
|
||||||
if *checkConfig {
|
if len(args) == 0 {
|
||||||
intent = CliIntentCheckConfig
|
intent = cliIntentServe
|
||||||
|
} else if len(args) == 1 {
|
||||||
|
if args[0] == "config:validate" {
|
||||||
|
intent = cliIntentConfigValidate
|
||||||
|
} else if args[0] == "config:print" {
|
||||||
|
intent = cliIntentConfigPrint
|
||||||
|
} else if args[0] == "diagnose" {
|
||||||
|
intent = cliIntentDiagnose
|
||||||
|
} else {
|
||||||
|
return nil, unknownCommandErr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, unknownCommandErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CliOptions{
|
return &cliOptions{
|
||||||
Intent: intent,
|
intent: intent,
|
||||||
ConfigPath: *configPath,
|
configPath: *configPath,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package widget
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -12,70 +12,66 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
|
||||||
var EnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
HSLHueMax = 360
|
hslHueMax = 360
|
||||||
HSLSaturationMax = 100
|
hslSaturationMax = 100
|
||||||
HSLLightnessMax = 100
|
hslLightnessMax = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
type HSLColorField struct {
|
type hslColorField struct {
|
||||||
Hue uint16
|
Hue uint16
|
||||||
Saturation uint8
|
Saturation uint8
|
||||||
Lightness 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)
|
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())
|
return template.CSS(c.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error {
|
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
|
||||||
var value string
|
var value string
|
||||||
|
|
||||||
if err := node.Decode(&value); err != nil {
|
if err := node.Decode(&value); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := HSLColorPattern.FindStringSubmatch(value)
|
matches := hslColorFieldPattern.FindStringSubmatch(value)
|
||||||
|
|
||||||
if len(matches) != 4 {
|
if len(matches) != 4 {
|
||||||
return fmt.Errorf("invalid HSL color format: %s", value)
|
return fmt.Errorf("invalid HSL color format: %s", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
hue, err := strconv.ParseUint(matches[1], 10, 16)
|
hue, err := strconv.ParseUint(matches[1], 10, 16)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if hue > HSLHueMax {
|
if hue > hslHueMax {
|
||||||
return fmt.Errorf("HSL hue must be between 0 and %d", HSLHueMax)
|
return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
saturation, err := strconv.ParseUint(matches[2], 10, 8)
|
saturation, err := strconv.ParseUint(matches[2], 10, 8)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if saturation > HSLSaturationMax {
|
if saturation > hslSaturationMax {
|
||||||
return fmt.Errorf("HSL saturation must be between 0 and %d", HSLSaturationMax)
|
return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
lightness, err := strconv.ParseUint(matches[3], 10, 8)
|
lightness, err := strconv.ParseUint(matches[3], 10, 8)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if lightness > HSLLightnessMax {
|
if lightness > hslLightnessMax {
|
||||||
return fmt.Errorf("HSL lightness must be between 0 and %d", HSLLightnessMax)
|
return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Hue = uint16(hue)
|
c.Hue = uint16(hue)
|
||||||
@@ -85,77 +81,76 @@ func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return nil
|
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
|
var value string
|
||||||
|
|
||||||
if err := node.Decode(&value); err != nil {
|
if err := node.Decode(&value); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := DurationPattern.FindStringSubmatch(value)
|
matches := durationFieldPattern.FindStringSubmatch(value)
|
||||||
|
|
||||||
if len(matches) != 3 {
|
if len(matches) != 3 {
|
||||||
return fmt.Errorf("invalid duration format: %s", value)
|
return fmt.Errorf("invalid duration format: %s", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
duration, err := strconv.Atoi(matches[1])
|
duration, err := strconv.Atoi(matches[1])
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
switch matches[2] {
|
switch matches[2] {
|
||||||
case "s":
|
case "s":
|
||||||
*d = DurationField(time.Duration(duration) * time.Second)
|
*d = durationField(time.Duration(duration) * time.Second)
|
||||||
case "m":
|
case "m":
|
||||||
*d = DurationField(time.Duration(duration) * time.Minute)
|
*d = durationField(time.Duration(duration) * time.Minute)
|
||||||
case "h":
|
case "h":
|
||||||
*d = DurationField(time.Duration(duration) * time.Hour)
|
*d = durationField(time.Duration(duration) * time.Hour)
|
||||||
case "d":
|
case "d":
|
||||||
*d = DurationField(time.Duration(duration) * 24 * time.Hour)
|
*d = durationField(time.Duration(duration) * 24 * time.Hour)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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
|
var value string
|
||||||
|
|
||||||
err := node.Decode(&value)
|
err := node.Decode(&value)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string {
|
replaced := optionalEnvFieldPattern.ReplaceAllStringFunc(value, func(match string) string {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := EnvFieldPattern.FindStringSubmatch(whole)
|
groups := optionalEnvFieldPattern.FindStringSubmatch(match)
|
||||||
|
|
||||||
if len(groups) != 3 {
|
if len(groups) != 3 {
|
||||||
return whole
|
return match
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix, key := groups[1], groups[2]
|
prefix, key := groups[1], groups[2]
|
||||||
|
|
||||||
if prefix == `\` {
|
if prefix == `\` {
|
||||||
if len(whole) >= 2 {
|
if len(match) >= 2 {
|
||||||
return whole[1:]
|
return match[1:]
|
||||||
} else {
|
} else {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
value, found := os.LookupEnv(key)
|
value, found := os.LookupEnv(key)
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
err = fmt.Errorf("environment variable %s not found", key)
|
err = fmt.Errorf("environment variable %s not found", key)
|
||||||
return ""
|
return ""
|
||||||
@@ -168,16 +163,16 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
*f = OptionalEnvString(replaced)
|
*f = optionalEnvField(replaced)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *OptionalEnvString) String() string {
|
func (f *optionalEnvField) String() string {
|
||||||
return string(*f)
|
return string(*f)
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomIcon struct {
|
type customIconField struct {
|
||||||
URL string
|
URL string
|
||||||
IsFlatIcon bool
|
IsFlatIcon bool
|
||||||
// TODO: along with whether the icon is flat, we also need to know
|
// TODO: along with whether the icon is flat, we also need to know
|
||||||
@@ -185,8 +180,13 @@ type CustomIcon struct {
|
|||||||
// invert the color based on the theme being light or dark
|
// invert the color based on the theme being light or dark
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *CustomIcon) FromURL(url string) error {
|
func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
|
||||||
prefix, icon, found := strings.Cut(url, ":")
|
var value string
|
||||||
|
if err := node.Decode(&value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix, icon, found := strings.Cut(value, ":")
|
||||||
if !found {
|
if !found {
|
||||||
i.URL = url
|
i.URL = url
|
||||||
return nil
|
return nil
|
||||||
+258
-33
@@ -1,43 +1,91 @@
|
|||||||
package glance
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type config struct {
|
||||||
Server Server `yaml:"server"`
|
Server struct {
|
||||||
Theme Theme `yaml:"theme"`
|
Host string `yaml:"host"`
|
||||||
Branding Branding `yaml:"branding"`
|
Port uint16 `yaml:"port"`
|
||||||
Pages []Page `yaml:"pages"`
|
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 NewConfigFromYml(contents io.Reader) (*Config, error) {
|
type page struct {
|
||||||
config := NewConfig()
|
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:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
contentBytes, err := io.ReadAll(contents)
|
func newConfigFromYAML(contents []byte) (*config, error) {
|
||||||
|
config := &config{}
|
||||||
|
config.Server.Port = 8080
|
||||||
|
|
||||||
|
err := yaml.Unmarshal(contents, config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = yaml.Unmarshal(contentBytes, config)
|
if err = isConfigStateValid(config); err != nil {
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = configIsValid(config); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for p := range config.Pages {
|
for p := range config.Pages {
|
||||||
for c := range config.Pages[p].Columns {
|
for c := range config.Pages[p].Columns {
|
||||||
for w := range config.Pages[p].Columns[c].Widgets {
|
for w := range config.Pages[p].Columns[c].Widgets {
|
||||||
if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil {
|
if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil {
|
||||||
return nil, err
|
return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,36 +94,213 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
|
|||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfig() *Config {
|
func formatWidgetInitError(err error, w widget) error {
|
||||||
config := &Config{}
|
return fmt.Errorf("%s widget: %v", w.GetType(), err)
|
||||||
|
|
||||||
config.Server.Host = ""
|
|
||||||
config.Server.Port = 8080
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func configIsValid(config *Config) error {
|
var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`)
|
||||||
|
|
||||||
|
func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) {
|
||||||
|
mainFileContents, err := os.ReadFile(mainFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("reading main YAML file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err)
|
||||||
|
}
|
||||||
|
mainFileDir := filepath.Dir(mainFileAbsPath)
|
||||||
|
|
||||||
|
includes := make(map[string]struct{})
|
||||||
|
var includesLastErr error
|
||||||
|
|
||||||
|
mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte {
|
||||||
|
if includesLastErr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := includePattern.FindSubmatch(match)
|
||||||
|
if len(matches) != 3 {
|
||||||
|
includesLastErr = fmt.Errorf("invalid include match: %v", matches)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
indent := string(matches[1])
|
||||||
|
includeFilePath := strings.TrimSpace(string(matches[2]))
|
||||||
|
if !filepath.IsAbs(includeFilePath) {
|
||||||
|
includeFilePath = filepath.Join(mainFileDir, includeFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileContents []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
fileContents, err = os.ReadFile(includeFilePath)
|
||||||
|
if err != nil {
|
||||||
|
includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
includes[includeFilePath] = struct{}{}
|
||||||
|
return []byte(prefixStringLines(indent, string(fileContents)))
|
||||||
|
})
|
||||||
|
|
||||||
|
if includesLastErr != nil {
|
||||||
|
return nil, nil, includesLastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return mainFileContents, includes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func configFilesWatcher(
|
||||||
|
mainFilePath string,
|
||||||
|
lastContents []byte,
|
||||||
|
lastIncludes map[string]struct{},
|
||||||
|
onChange func(newContents []byte),
|
||||||
|
onErr func(error),
|
||||||
|
) (func() error, error) {
|
||||||
|
mainFileAbsPath, err := filepath.Abs(mainFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting absolute path of main file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: refactor, flaky
|
||||||
|
lastIncludes[mainFileAbsPath] = struct{}{}
|
||||||
|
|
||||||
|
watcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating watcher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) {
|
||||||
|
for filePath := range previousWatched {
|
||||||
|
if _, ok := newWatched[filePath]; !ok {
|
||||||
|
watcher.Remove(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for filePath := range newWatched {
|
||||||
|
if _, ok := previousWatched[filePath]; !ok {
|
||||||
|
if err := watcher.Add(filePath); err != nil {
|
||||||
|
log.Printf(
|
||||||
|
"Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
|
||||||
|
filePath, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWatchedFiles(nil, lastIncludes)
|
||||||
|
|
||||||
|
// needed for lastContents and lastIncludes because they get updated in multiple goroutines
|
||||||
|
mu := sync.Mutex{}
|
||||||
|
|
||||||
|
checkForContentChangesBeforeCallback := func() {
|
||||||
|
currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath)
|
||||||
|
if err != nil {
|
||||||
|
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: refactor, flaky
|
||||||
|
currentIncludes[mainFileAbsPath] = struct{}{}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if !maps.Equal(currentIncludes, lastIncludes) {
|
||||||
|
updateWatchedFiles(lastIncludes, currentIncludes)
|
||||||
|
lastIncludes = currentIncludes
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(lastContents, currentContents) {
|
||||||
|
lastContents = currentContents
|
||||||
|
onChange(currentContents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debounceDuration = 500 * time.Millisecond
|
||||||
|
var debounceTimer *time.Timer
|
||||||
|
debouncedCallback := func() {
|
||||||
|
if debounceTimer != nil {
|
||||||
|
debounceTimer.Stop()
|
||||||
|
debounceTimer.Reset(debounceDuration)
|
||||||
|
} else {
|
||||||
|
debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, isOpen := <-watcher.Events:
|
||||||
|
if !isOpen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if event.Has(fsnotify.Write) {
|
||||||
|
debouncedCallback()
|
||||||
|
} else if event.Has(fsnotify.Remove) {
|
||||||
|
func() {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
fileAbsPath, _ := filepath.Abs(event.Name)
|
||||||
|
delete(lastIncludes, fileAbsPath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
debouncedCallback()
|
||||||
|
}
|
||||||
|
case err, isOpen := <-watcher.Errors:
|
||||||
|
if !isOpen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onErr(fmt.Errorf("watcher error: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
onChange(lastContents)
|
||||||
|
|
||||||
|
return func() error {
|
||||||
|
if debounceTimer != nil {
|
||||||
|
debounceTimer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return watcher.Close()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isConfigStateValid(config *config) error {
|
||||||
|
if len(config.Pages) == 0 {
|
||||||
|
return fmt.Errorf("no pages configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Server.AssetsPath != "" {
|
||||||
|
if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i := range config.Pages {
|
for i := range config.Pages {
|
||||||
if config.Pages[i].Title == "" {
|
if config.Pages[i].Title == "" {
|
||||||
return fmt.Errorf("Page %d has no title", i+1)
|
return fmt.Errorf("page %d has no name", i+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") {
|
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 {
|
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 config.Pages[i].Width == "slim" {
|
||||||
if len(config.Pages[i].Columns) > 2 {
|
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 {
|
} else {
|
||||||
if len(config.Pages[i].Columns) > 3 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +308,7 @@ func configIsValid(config *Config) error {
|
|||||||
|
|
||||||
for j := range config.Pages[i].Columns {
|
for j := range config.Pages[i].Columns {
|
||||||
if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" {
|
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]++
|
columnSizesCount[config.Pages[i].Columns[j].Size]++
|
||||||
@@ -92,7 +317,7 @@ func configIsValid(config *Config) error {
|
|||||||
full := columnSizesCount["full"]
|
full := columnSizesCount["full"]
|
||||||
|
|
||||||
if full > 2 || full == 0 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const httpTestRequestTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
var diagnosticSteps = []diagnosticStep{
|
||||||
|
{
|
||||||
|
name: "resolve cloudflare.com through Cloudflare DoH",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{
|
||||||
|
"accept": "application/dns-json",
|
||||||
|
}, 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolve cloudflare.com through Google DoH",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolve github.com",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testDNSResolution("github.com")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolve reddit.com",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testDNSResolution("reddit.com")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolve twitch.tv",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testDNSResolution("twitch.tv")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch data from YouTube RSS feed",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch data from Twitch.tv GQL",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
// this should always return 0 bytes, we're mainly looking for a 200 status code
|
||||||
|
return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch data from GitHub API",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testHttpRequest("GET", "https://api.github.com", 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch data from Open-Meteo API",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch data from Reddit API",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testHttpRequest("GET", "https://www.reddit.com/search.json", 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch data from Yahoo finance API",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch data from Hacker News Firebase API",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch data from Docker Hub API",
|
||||||
|
fn: func() (string, error) {
|
||||||
|
return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDiagnostic() {
|
||||||
|
fmt.Println("```")
|
||||||
|
fmt.Println("Glance version: " + buildVersion)
|
||||||
|
fmt.Println("Go version: " + runtime.Version())
|
||||||
|
fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
|
||||||
|
fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no"))
|
||||||
|
|
||||||
|
fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds()))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range diagnosticSteps {
|
||||||
|
step := &diagnosticSteps[i]
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
start := time.Now()
|
||||||
|
step.extraInfo, step.err = step.fn()
|
||||||
|
step.elapsed = time.Since(start)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for _, step := range diagnosticSteps {
|
||||||
|
var extraInfo string
|
||||||
|
|
||||||
|
if step.extraInfo != "" {
|
||||||
|
extraInfo = "| " + step.extraInfo + " "
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(
|
||||||
|
"%s %s %s| %dms\n",
|
||||||
|
boolToString(step.err == nil, "✓ Can", "✗ Can't"),
|
||||||
|
step.name,
|
||||||
|
extraInfo,
|
||||||
|
step.elapsed.Milliseconds(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if step.err != nil {
|
||||||
|
fmt.Printf("└╴ error: %v\n", step.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("```")
|
||||||
|
}
|
||||||
|
|
||||||
|
type diagnosticStep struct {
|
||||||
|
name string
|
||||||
|
fn func() (string, error)
|
||||||
|
extraInfo string
|
||||||
|
err error
|
||||||
|
elapsed time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHttpRequest(method, url string, expectedStatusCode int) (string, error) {
|
||||||
|
return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
request, _ := http.NewRequestWithContext(ctx, method, url, nil)
|
||||||
|
for key, value := range headers {
|
||||||
|
request.Header.Add(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := http.DefaultClient.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
printableBody := strings.ReplaceAll(string(body), "\n", "")
|
||||||
|
if len(printableBody) > 50 {
|
||||||
|
printableBody = printableBody[:50] + "..."
|
||||||
|
}
|
||||||
|
if len(printableBody) > 0 {
|
||||||
|
printableBody = ", " + printableBody
|
||||||
|
}
|
||||||
|
|
||||||
|
extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody)
|
||||||
|
|
||||||
|
if response.StatusCode != expectedStatusCode {
|
||||||
|
return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return extraInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDNSResolution(domain string) (string, error) {
|
||||||
|
ips, err := net.LookupIP(domain)
|
||||||
|
|
||||||
|
var ipStrings []string
|
||||||
|
if err == nil {
|
||||||
|
for i := range ips {
|
||||||
|
ipStrings = append(ipStrings, ips[i].String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(ipStrings, ", "), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var _staticFS embed.FS
|
||||||
|
|
||||||
|
//go:embed templates
|
||||||
|
var _templateFS embed.FS
|
||||||
|
|
||||||
|
var staticFS, _ = fs.Sub(_staticFS, "static")
|
||||||
|
var templateFS, _ = fs.Sub(_templateFS, "templates")
|
||||||
|
|
||||||
|
var staticFSHash = func() string {
|
||||||
|
hash, err := computeFSHash(staticFS)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Could not compute static assets cache key: %v", err)
|
||||||
|
return strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash
|
||||||
|
}()
|
||||||
|
|
||||||
|
func computeFSHash(files fs.FS) (string, error) {
|
||||||
|
hash := md5.New()
|
||||||
|
|
||||||
|
err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := files.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(hash, file); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))[:10], nil
|
||||||
|
}
|
||||||
+115
-169
@@ -5,140 +5,48 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glanceapp/glance/internal/assets"
|
|
||||||
"github.com/glanceapp/glance/internal/widget"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var buildVersion = "dev"
|
var (
|
||||||
|
pageTemplate = mustParseTemplate("page.html", "document.html")
|
||||||
|
pageContentTemplate = mustParseTemplate("page-content.html")
|
||||||
|
pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl")
|
||||||
|
)
|
||||||
|
|
||||||
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
type application struct {
|
||||||
|
|
||||||
type Application struct {
|
|
||||||
Version string
|
Version string
|
||||||
Config Config
|
Config config
|
||||||
slugToPage map[string]*Page
|
ParsedThemeStyle template.HTML
|
||||||
widgetByID map[uint64]widget.Widget
|
|
||||||
|
slugToPage map[string]*page
|
||||||
|
widgetByID map[uint64]widget
|
||||||
}
|
}
|
||||||
|
|
||||||
type Theme struct {
|
func newApplication(config *config) (*application, error) {
|
||||||
BackgroundColor *widget.HSLColorField `yaml:"background-color"`
|
app := &application{
|
||||||
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, error) {
|
|
||||||
if len(config.Pages) == 0 {
|
|
||||||
return nil, fmt.Errorf("no pages configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
app := &Application{
|
|
||||||
Version: buildVersion,
|
Version: buildVersion,
|
||||||
Config: *config,
|
Config: *config,
|
||||||
slugToPage: make(map[string]*Page),
|
slugToPage: make(map[string]*page),
|
||||||
widgetByID: make(map[uint64]widget.Widget),
|
widgetByID: make(map[uint64]widget),
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Config.Server.AssetsHash = assets.PublicFSHash
|
|
||||||
app.slugToPage[""] = &config.Pages[0]
|
app.slugToPage[""] = &config.Pages[0]
|
||||||
|
|
||||||
providers := &widget.Providers{
|
providers := &widgetProviders{
|
||||||
AssetResolver: app.AssetPath,
|
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 {
|
for p := range config.Pages {
|
||||||
@@ -160,9 +68,9 @@ func NewApplication(config *Config) (*Application, error) {
|
|||||||
|
|
||||||
for w := range column.Widgets {
|
for w := range column.Widgets {
|
||||||
widget := column.Widgets[w]
|
widget := column.Widgets[w]
|
||||||
app.widgetByID[widget.GetID()] = widget
|
app.widgetByID[widget.id()] = widget
|
||||||
|
|
||||||
widget.SetProviders(providers)
|
widget.setProviders(providers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,35 +78,75 @@ func NewApplication(config *Config) (*Application, error) {
|
|||||||
config = &app.Config
|
config = &app.Config
|
||||||
|
|
||||||
config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/")
|
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 == "" {
|
if config.Branding.FaviconURL == "" {
|
||||||
config.Branding.FaviconURL = app.AssetPath("favicon.png")
|
config.Branding.FaviconURL = app.AssetPath("favicon.png")
|
||||||
} else {
|
} 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, nil
|
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")]
|
page, exists := a.slugToPage[r.PathValue("page")]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
a.HandleNotFound(w, r)
|
a.handleNotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pageData := templateData{
|
pageData := pageTemplateData{
|
||||||
Page: page,
|
Page: page,
|
||||||
App: a,
|
App: a,
|
||||||
}
|
}
|
||||||
|
|
||||||
var responseBytes bytes.Buffer
|
var responseBytes bytes.Buffer
|
||||||
err := assets.PageTemplate.Execute(&responseBytes, pageData)
|
err := pageTemplate.Execute(&responseBytes, pageData)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
@@ -208,25 +156,22 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request)
|
|||||||
w.Write(responseBytes.Bytes())
|
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")]
|
page, exists := a.slugToPage[r.PathValue("page")]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
a.HandleNotFound(w, r)
|
a.handleNotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pageData := templateData{
|
pageData := pageTemplateData{
|
||||||
Page: page,
|
Page: page,
|
||||||
}
|
}
|
||||||
|
|
||||||
page.mu.Lock()
|
page.updateOutdatedWidgets()
|
||||||
defer page.mu.Unlock()
|
|
||||||
page.UpdateOutdatedWidgets()
|
|
||||||
|
|
||||||
var responseBytes bytes.Buffer
|
var responseBytes bytes.Buffer
|
||||||
err := assets.PageContentTemplate.Execute(&responseBytes, pageData)
|
err := pageContentTemplate.Execute(&responseBytes, pageData)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
@@ -236,74 +181,58 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re
|
|||||||
w.Write(responseBytes.Bytes())
|
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
|
// TODO: add proper not found page
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
w.Write([]byte("Page not found"))
|
w.Write([]byte("Page not found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
|
func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
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) {
|
|
||||||
widgetValue := r.PathValue("widget")
|
widgetValue := r.PathValue("widget")
|
||||||
|
|
||||||
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
|
widgetID, err := strconv.ParseUint(widgetValue, 10, 64)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.HandleNotFound(w, r)
|
a.handleNotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
widget, exists := a.widgetByID[widgetID]
|
widget, exists := a.widgetByID[widgetID]
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
a.HandleNotFound(w, r)
|
a.handleNotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
widget.HandleRequest(w, r)
|
widget.handleRequest(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Application) AssetPath(asset string) string {
|
func (a *application) AssetPath(asset string) string {
|
||||||
return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset
|
return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Application) Serve() error {
|
func (a *application) server() (func() error, func() error) {
|
||||||
// TODO: add gzip support, static files must have their gzipped contents cached
|
// TODO: add gzip support, static files must have their gzipped contents cached
|
||||||
// TODO: add HTTPS support
|
// TODO: add HTTPS support
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("GET /{$}", a.HandlePageRequest)
|
mux.HandleFunc("GET /{$}", a.handlePageRequest)
|
||||||
mux.HandleFunc("GET /{page}", a.HandlePageRequest)
|
mux.HandleFunc("GET /{page}", a.handlePageRequest)
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
|
mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest)
|
||||||
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest)
|
mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest)
|
||||||
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.Handle(
|
mux.Handle(
|
||||||
fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash),
|
fmt.Sprintf("GET /static/%s/{path...}", staticFSHash),
|
||||||
http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)),
|
http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var absAssetsPath string
|
||||||
if a.Config.Server.AssetsPath != "" {
|
if a.Config.Server.AssetsPath != "" {
|
||||||
absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath)
|
absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath)
|
||||||
|
assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("Serving assets", "path", absAssetsPath)
|
|
||||||
assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
|
|
||||||
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
|
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,8 +241,25 @@ func (a *Application) Serve() error {
|
|||||||
Handler: mux,
|
Handler: mux,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
start := func() error {
|
||||||
a.Config.Server.StartedAt = time.Now()
|
a.Config.Server.StartedAt = time.Now()
|
||||||
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL)
|
log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n",
|
||||||
|
a.Config.Server.Host,
|
||||||
|
a.Config.Server.Port,
|
||||||
|
a.Config.Server.BaseURL,
|
||||||
|
absAssetsPath,
|
||||||
|
)
|
||||||
|
|
||||||
return server.ListenAndServe()
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
stop := func() error {
|
||||||
|
return server.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return start, stop
|
||||||
}
|
}
|
||||||
+149
-20
@@ -2,45 +2,174 @@ package glance
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Main() int {
|
var buildVersion = "dev"
|
||||||
options, err := ParseCliOptions()
|
|
||||||
|
|
||||||
|
func Main() int {
|
||||||
|
options, err := parseCliOptions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
configFile, err := os.Open(options.ConfigPath)
|
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
|
||||||
|
}
|
||||||
|
case cliIntentConfigValidate:
|
||||||
|
contents, _, err := parseYAMLIncludes(options.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("failed opening config file: %v\n", err)
|
fmt.Printf("Could not parse config file: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := NewConfigFromYml(configFile)
|
if _, err := newConfigFromYAML(contents); err != nil {
|
||||||
configFile.Close()
|
fmt.Printf("Config file is invalid: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
case cliIntentConfigPrint:
|
||||||
|
contents, _, err := parseYAMLIncludes(options.configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("failed parsing config file: %v\n", err)
|
fmt.Printf("Could not parse config file: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.Intent == CliIntentServe {
|
fmt.Println(string(contents))
|
||||||
app, err := NewApplication(config)
|
case cliIntentDiagnose:
|
||||||
|
runDiagnostic()
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("failed creating application: %v\n", err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := app.Serve(); err != nil {
|
|
||||||
fmt.Printf("http server error: %v\n", err)
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveApp(configPath string) error {
|
||||||
|
exitChannel := make(chan struct{})
|
||||||
|
// the onChange method gets called at most once per 500ms due to debouncing so we shouldn't
|
||||||
|
// need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason
|
||||||
|
hadValidConfigOnStartup := false
|
||||||
|
var stopServer func() error
|
||||||
|
|
||||||
|
onChange := func(newContents []byte) {
|
||||||
|
if stopServer != nil {
|
||||||
|
log.Println("Config file changed, reloading...")
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := newConfigFromYAML(newContents)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Config has errors: %v", err)
|
||||||
|
|
||||||
|
if !hadValidConfigOnStartup {
|
||||||
|
close(exitChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
} else if !hadValidConfigOnStartup {
|
||||||
|
hadValidConfigOnStartup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := newApplication(config)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create application: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if stopServer != nil {
|
||||||
|
if err := stopServer(); err != nil {
|
||||||
|
log.Printf("Error while trying to stop server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
var startServer func() error
|
||||||
|
startServer, stopServer = app.server()
|
||||||
|
|
||||||
|
if err := startServer(); err != nil {
|
||||||
|
log.Printf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
onErr := func(err error) {
|
||||||
|
log.Printf("Error watching config files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configContents, configIncludes, err := parseYAMLIncludes(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr)
|
||||||
|
if err == nil {
|
||||||
|
defer stopWatching()
|
||||||
|
} else {
|
||||||
|
log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err)
|
||||||
|
|
||||||
|
config, err := newConfigFromYAML(configContents)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("validating config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app, err := newApplication(config)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating application: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer, _ := app.server()
|
||||||
|
if err := startServer(); err != nil {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// TODO: update - add link
|
||||||
|
fmt.Println("!!! WARNING !!!")
|
||||||
|
fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0, please see <link> for more information.")
|
||||||
|
|
||||||
|
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: ":8080",
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
server.ListenAndServe()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -28,6 +28,9 @@ export function clamp(value, min, max) {
|
|||||||
return Math.min(Math.max(value, min), max);
|
return Math.min(Math.max(value, min), max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: inconsistent behavior between browsers when it comes to
|
||||||
|
// whether the newly opened tab gets focused or not, potentially
|
||||||
|
// depending on the event that this function is called from
|
||||||
export function openURLInNewTab(url, focus = true) {
|
export function openURLInNewTab(url, focus = true) {
|
||||||
const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
|
const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
|
||||||
@@ -525,6 +525,7 @@ kbd:active {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details[open] .summary {
|
.details[open] .summary {
|
||||||
@@ -546,6 +547,10 @@ kbd:active {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.details:not([open]) .list-with-transition {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.summary::after {
|
.summary::after {
|
||||||
content: "◀";
|
content: "◀";
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
@@ -1106,7 +1111,6 @@ details[open] .summary::after {
|
|||||||
|
|
||||||
.dns-stats-graph-gridlines-container {
|
.dns-stats-graph-gridlines-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: -1;
|
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,7 +1137,6 @@ details[open] .summary::after {
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 1px 0;
|
inset: 1px 0;
|
||||||
z-index: -1;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
background: var(--color-text-base);
|
background: var(--color-text-base);
|
||||||
transition: opacity .2s;
|
transition: opacity .2s;
|
||||||
@@ -1275,7 +1278,6 @@ details[open] .summary::after {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
mask-image: linear-gradient(0deg, transparent 40%, #000);
|
mask-image: linear-gradient(0deg, transparent 40%, #000);
|
||||||
-webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
|
-webkit-mask-image: linear-gradient(0deg, transparent 40%, #000);
|
||||||
z-index: -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.weather-column-rain::before {
|
.weather-column-rain::before {
|
||||||
File renamed without changes.
@@ -0,0 +1,56 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
"golang.org/x/text/message"
|
||||||
|
)
|
||||||
|
|
||||||
|
var intl = message.NewPrinter(language.English)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -11,13 +11,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
|
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
|
||||||
|
{{ if .StartSunday }}
|
||||||
|
<div class="calendar-day">Su</div>
|
||||||
|
{{ end }}
|
||||||
<div class="calendar-day">Mo</div>
|
<div class="calendar-day">Mo</div>
|
||||||
<div class="calendar-day">Tu</div>
|
<div class="calendar-day">Tu</div>
|
||||||
<div class="calendar-day">We</div>
|
<div class="calendar-day">We</div>
|
||||||
<div class="calendar-day">Th</div>
|
<div class="calendar-day">Th</div>
|
||||||
<div class="calendar-day">Fr</div>
|
<div class="calendar-day">Fr</div>
|
||||||
<div class="calendar-day">Sa</div>
|
<div class="calendar-day">Sa</div>
|
||||||
|
{{ if not .StartSunday }}
|
||||||
<div class="calendar-day">Su</div>
|
<div class="calendar-day">Su</div>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -73,7 +73,7 @@
|
|||||||
<summary class="summary">Top blocked domains</summary>
|
<summary class="summary">Top blocked domains</summary>
|
||||||
<ul class="list list-gap-4 list-with-transition size-h5">
|
<ul class="list list-gap-4 list-with-transition size-h5">
|
||||||
{{ range .Stats.TopBlockedDomains }}
|
{{ range .Stats.TopBlockedDomains }}
|
||||||
<li class="flex justify-between align-center">
|
<li class="flex justify-between">
|
||||||
<div class="text-truncate rtl">{{ .Domain }}</div>
|
<div class="text-truncate rtl">{{ .Domain }}</div>
|
||||||
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
|
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
|
||||||
</li>
|
</li>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-2
@@ -12,7 +12,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ else if ne .ThumbnailUrl "" }}
|
{{ else if ne .ThumbnailUrl "" }}
|
||||||
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
|
||||||
{{ else if .HasTargetUrl }}
|
{{ else if ne "" .TargetUrl }}
|
||||||
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
|
||||||
<li>{{ .Score | formatNumber }} points</li>
|
<li>{{ .Score | formatNumber }} points</li>
|
||||||
<li>{{ .CommentCount | formatNumber }} comments</li>
|
<li>{{ .CommentCount | formatNumber }} comments</li>
|
||||||
{{ if .HasTargetUrl }}
|
{{ if ne "" .TargetUrl }}
|
||||||
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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-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" }}
|
{{ define "document-head-after" }}
|
||||||
{{ template "page-style-overrides.gotmpl" . }}
|
{{ .App.ParsedThemeStyle }}
|
||||||
|
|
||||||
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
|
||||||
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
|
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "navigation-links" }}
|
{{ define "navigation-links" }}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -7,7 +7,7 @@
|
|||||||
<div class="flex items-center gap-10">
|
<div class="flex items-center gap-10">
|
||||||
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
|
||||||
{{ if $.ShowSourceIcon }}
|
{{ if $.ShowSourceIcon }}
|
||||||
<img class="simple-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
|
<img class="flat-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
+18
-18
@@ -1,58 +1,58 @@
|
|||||||
{{ template "widget-base.html" . }}
|
{{ template "widget-base.html" . }}
|
||||||
|
|
||||||
{{ define "widget-content" }}
|
{{ define "widget-content" }}
|
||||||
<a class="size-h4 color-highlight" href="https://github.com/{{ $.RepositoryDetails.Name }}" target="_blank" rel="noreferrer">{{ .RepositoryDetails.Name }}</a>
|
<a class="size-h4 color-highlight" href="https://github.com/{{ $.Repository.Name }}" target="_blank" rel="noreferrer">{{ .Repository.Name }}</a>
|
||||||
<ul class="list-horizontal-text">
|
<ul class="list-horizontal-text">
|
||||||
<li>{{ .RepositoryDetails.Stars | formatNumber }} stars</li>
|
<li>{{ .Repository.Stars | formatNumber }} stars</li>
|
||||||
<li>{{ .RepositoryDetails.Forks | formatNumber }} forks</li>
|
<li>{{ .Repository.Forks | formatNumber }} forks</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{{ if gt (len .RepositoryDetails.Commits) 0 }}
|
{{ if gt (len .Repository.Commits) 0 }}
|
||||||
<hr class="margin-block-8">
|
<hr class="margin-block-8">
|
||||||
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
|
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
|
||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .RepositoryDetails.Commits }}
|
{{ range .Repository.Commits }}
|
||||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="list list-gap-2 min-width-0">
|
<ul class="list list-gap-2 min-width-0">
|
||||||
{{ range .RepositoryDetails.Commits }}
|
{{ range .Repository.Commits }}
|
||||||
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
|
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ if gt (len .RepositoryDetails.PullRequests) 0 }}
|
{{ if gt (len .Repository.PullRequests) 0 }}
|
||||||
<hr class="margin-block-8">
|
<hr class="margin-block-8">
|
||||||
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total)</a>
|
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
|
||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .RepositoryDetails.PullRequests }}
|
{{ range .Repository.PullRequests }}
|
||||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="list list-gap-2 min-width-0">
|
<ul class="list list-gap-2 min-width-0">
|
||||||
{{ range .RepositoryDetails.PullRequests }}
|
{{ range .Repository.PullRequests }}
|
||||||
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
|
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ if gt (len .RepositoryDetails.Issues) 0 }}
|
{{ if gt (len .Repository.Issues) 0 }}
|
||||||
<hr class="margin-block-10">
|
<hr class="margin-block-10">
|
||||||
<a class="text-compact" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total)</a>
|
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
|
||||||
<div class="flex gap-7 size-h5 margin-top-3">
|
<div class="flex gap-7 size-h5 margin-top-3">
|
||||||
<ul class="list list-gap-2">
|
<ul class="list list-gap-2">
|
||||||
{{ range .RepositoryDetails.Issues }}
|
{{ range .Repository.Issues }}
|
||||||
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="list list-gap-2 min-width-0">
|
<ul class="list list-gap-2 min-width-0">
|
||||||
{{ range .RepositoryDetails.Issues }}
|
{{ range .Repository.Issues }}
|
||||||
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.RepositoryDetails.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
|
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,14 @@
|
|||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
{{ if .BackgroundColor }}
|
||||||
|
--bgh: {{ .BackgroundColor.Hue }};
|
||||||
|
--bgs: {{ .BackgroundColor.Saturation }}%;
|
||||||
|
--bgl: {{ .BackgroundColor.Lightness }}%;
|
||||||
|
{{ end }}
|
||||||
|
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
|
||||||
|
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
|
||||||
|
{{ if .PrimaryColor }}--color-primary: {{ .PrimaryColor.AsCSSValue }};{{ end }}
|
||||||
|
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.AsCSSValue }};{{ end }}
|
||||||
|
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.AsCSSValue }};{{ end }}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,44 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="static/main.css">
|
||||||
|
<title>Update notice</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-bounds {
|
||||||
|
max-width: 700px;
|
||||||
|
margin-top: -10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comfy-line-height {
|
||||||
|
line-height: 1.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- TODO: update - add links -->
|
||||||
|
|
||||||
|
<div class="content-bounds color-highlight">
|
||||||
|
<p class="uppercase size-h5 color-negative padding-inline-widget">UPDATE NOTICE</p>
|
||||||
|
<div class="widget-content-frame margin-top-10 padding-widget">
|
||||||
|
<p class="comfy-line-height">
|
||||||
|
The default location of glance.yml in the Docker image has
|
||||||
|
changed since v0.7.0, please see the <a class="color-primary" href="#">migration guide</a>
|
||||||
|
for instructions or visit the <a class="color-primary" href="#">release notes</a>
|
||||||
|
to find out more about why this change was necessary. Sorry for the inconvenience.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="margin-top-15 color-base">Migration should take around 5 minutes.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,19 +1,19 @@
|
|||||||
package feed
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
|
||||||
ErrNoContent = errors.New("failed to retrieve any content")
|
|
||||||
ErrPartialContent = errors.New("failed to retrieve some of the content")
|
|
||||||
)
|
|
||||||
|
|
||||||
func percentChange(current, previous float64) float64 {
|
func percentChange(current, previous float64) float64 {
|
||||||
return (current/previous - 1) * 100
|
return (current/previous - 1) * 100
|
||||||
@@ -25,7 +25,6 @@ func extractDomainFromUrl(u string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := url.Parse(u)
|
parsed, err := url.Parse(u)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -33,7 +32,7 @@ func extractDomainFromUrl(u string) string {
|
|||||||
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
|
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
|
func svgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
|
||||||
if len(values) < 2 {
|
if len(values) < 2 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -86,6 +85,21 @@ func stripURLScheme(url string) string {
|
|||||||
return urlSchemePattern.ReplaceAllString(url, "")
|
return urlSchemePattern.ReplaceAllString(url, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isRunningInsideDockerContainer() bool {
|
||||||
|
_, err := os.Stat("/.dockerenv")
|
||||||
|
return err == 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 limitStringLength(s string, max int) (string, bool) {
|
func limitStringLength(s string, max int) (string, bool) {
|
||||||
asRunes := []rune(s)
|
asRunes := []rune(s)
|
||||||
|
|
||||||
@@ -98,7 +112,6 @@ func limitStringLength(s string, max int) (string, bool) {
|
|||||||
|
|
||||||
func parseRFC3339Time(t string) time.Time {
|
func parseRFC3339Time(t string) time.Time {
|
||||||
parsed, err := time.Parse(time.RFC3339, t)
|
parsed, err := time.Parse(time.RFC3339, t)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return time.Now()
|
return time.Now()
|
||||||
}
|
}
|
||||||
@@ -106,6 +119,14 @@ func parseRFC3339Time(t string) time.Time {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolToString(b bool, trueValue, falseValue string) string {
|
||||||
|
if b {
|
||||||
|
return trueValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return falseValue
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeVersionFormat(version string) string {
|
func normalizeVersionFormat(version string) string {
|
||||||
version = strings.ToLower(strings.TrimSpace(version))
|
version = strings.ToLower(strings.TrimSpace(version))
|
||||||
|
|
||||||
@@ -115,3 +136,33 @@ func normalizeVersionFormat(version string) string {
|
|||||||
|
|
||||||
return version
|
return version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func titleToSlug(s string) string {
|
||||||
|
s = strings.ToLower(s)
|
||||||
|
s = sequentialWhitespacePattern.ReplaceAllString(s, "-")
|
||||||
|
s = strings.Trim(s, "-")
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler {
|
||||||
|
server := http.FileServer(fs)
|
||||||
|
cacheControlValue := fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))
|
||||||
|
|
||||||
|
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", cacheControlValue)
|
||||||
|
server.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTML, error) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
|
||||||
|
err := t.Execute(&b, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("executing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.HTML(b.String()), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
var bookmarksWidgetTemplate = mustParseTemplate("bookmarks.html", "widget-base.html")
|
||||||
|
|
||||||
|
type bookmarksWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
Groups []struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Color *hslColorField `yaml:"color"`
|
||||||
|
Links []struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Icon customIconField `yaml:"icon"`
|
||||||
|
SameTab bool `yaml:"same-tab"`
|
||||||
|
HideArrow bool `yaml:"hide-arrow"`
|
||||||
|
} `yaml:"links"`
|
||||||
|
} `yaml:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *bookmarksWidget) initialize() error {
|
||||||
|
widget.withTitle("Bookmarks").withError(nil)
|
||||||
|
widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *bookmarksWidget) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html")
|
||||||
|
|
||||||
|
type calendarWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
Calendar *calendar
|
||||||
|
StartSunday bool `yaml:"start-sunday"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *calendarWidget) initialize() error {
|
||||||
|
widget.withTitle("Calendar").withCacheOnTheHour()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *calendarWidget) update(ctx context.Context) {
|
||||||
|
widget.Calendar = newCalendar(time.Now(), widget.StartSunday)
|
||||||
|
widget.withError(nil).scheduleNextUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *calendarWidget) Render() template.HTML {
|
||||||
|
return widget.renderTemplate(widget, calendarWidgetTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type calendar struct {
|
||||||
|
CurrentDay int
|
||||||
|
CurrentWeekNumber int
|
||||||
|
CurrentMonthName string
|
||||||
|
CurrentYear int
|
||||||
|
Days []int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: very inflexible, refactor to allow more customizability
|
||||||
|
// TODO: allow changing between showing the previous and next week and the entire month
|
||||||
|
func newCalendar(now time.Time, startSunday bool) *calendar {
|
||||||
|
year, week := now.ISOWeek()
|
||||||
|
weekday := now.Weekday()
|
||||||
|
if !startSunday {
|
||||||
|
weekday = (weekday + 6) % 7 // Shift Monday to 0
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMonthDays := daysInMonth(now.Month(), year)
|
||||||
|
|
||||||
|
var previousMonthDays int
|
||||||
|
|
||||||
|
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
|
||||||
|
previousMonthDays = daysInMonth(12, year-1)
|
||||||
|
} else {
|
||||||
|
previousMonthDays = daysInMonth(previousMonthNumber, year)
|
||||||
|
}
|
||||||
|
|
||||||
|
startDaysFrom := now.Day() - int(weekday) - 7
|
||||||
|
|
||||||
|
days := make([]int, 21)
|
||||||
|
|
||||||
|
for i := 0; i < 21; i++ {
|
||||||
|
day := startDaysFrom + i
|
||||||
|
|
||||||
|
if day < 1 {
|
||||||
|
day = previousMonthDays + day
|
||||||
|
} else if day > currentMonthDays {
|
||||||
|
day = day - currentMonthDays
|
||||||
|
}
|
||||||
|
|
||||||
|
days[i] = day
|
||||||
|
}
|
||||||
|
|
||||||
|
return &calendar{
|
||||||
|
CurrentDay: now.Day(),
|
||||||
|
CurrentWeekNumber: week,
|
||||||
|
CurrentMonthName: now.Month().String(),
|
||||||
|
CurrentYear: year,
|
||||||
|
Days: days,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func daysInMonth(m time.Month, year int) int {
|
||||||
|
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package feed
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -9,7 +11,65 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChangeDetectionWatch struct {
|
var changeDetectionWidgetTemplate = mustParseTemplate("change-detection.html", "widget-base.html")
|
||||||
|
|
||||||
|
type changeDetectionWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
ChangeDetections changeDetectionWatchList `yaml:"-"`
|
||||||
|
WatchUUIDs []string `yaml:"watches"`
|
||||||
|
InstanceURL string `yaml:"instance-url"`
|
||||||
|
Token optionalEnvField `yaml:"token"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *changeDetectionWidget) initialize() error {
|
||||||
|
widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour)
|
||||||
|
|
||||||
|
if widget.Limit <= 0 {
|
||||||
|
widget.Limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||||
|
widget.CollapseAfter = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.InstanceURL == "" {
|
||||||
|
widget.InstanceURL = "https://www.changedetection.io"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *changeDetectionWidget) update(ctx context.Context) {
|
||||||
|
if len(widget.WatchUUIDs) == 0 {
|
||||||
|
uuids, err := fetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token))
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.WatchUUIDs = uuids
|
||||||
|
}
|
||||||
|
|
||||||
|
watches, err := fetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token))
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(watches) > widget.Limit {
|
||||||
|
watches = watches[:widget.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.ChangeDetections = watches
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *changeDetectionWidget) Render() template.HTML {
|
||||||
|
return widget.renderTemplate(widget, changeDetectionWidgetTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type changeDetectionWatch struct {
|
||||||
Title string
|
Title string
|
||||||
URL string
|
URL string
|
||||||
LastChanged time.Time
|
LastChanged time.Time
|
||||||
@@ -17,9 +77,9 @@ type ChangeDetectionWatch struct {
|
|||||||
PreviousHash string
|
PreviousHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangeDetectionWatches []ChangeDetectionWatch
|
type changeDetectionWatchList []changeDetectionWatch
|
||||||
|
|
||||||
func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches {
|
func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList {
|
||||||
sort.Slice(r, func(i, j int) bool {
|
sort.Slice(r, func(i, j int) bool {
|
||||||
return r[i].LastChanged.After(r[j].LastChanged)
|
return r[i].LastChanged.After(r[j].LastChanged)
|
||||||
})
|
})
|
||||||
@@ -35,15 +95,14 @@ type changeDetectionResponseJson struct {
|
|||||||
PreviousHash string `json:"previous_md5"`
|
PreviousHash string `json:"previous_md5"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
|
func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
|
||||||
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
|
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
|
||||||
|
|
||||||
if token != "" {
|
if token != "" {
|
||||||
request.Header.Add("x-api-key", token)
|
request.Header.Add("x-api-key", token)
|
||||||
}
|
}
|
||||||
|
|
||||||
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request)
|
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultHTTPClient, request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
|
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,8 +116,8 @@ func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]str
|
|||||||
return uuids, nil
|
return uuids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) {
|
func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (changeDetectionWatchList, error) {
|
||||||
watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs))
|
watches := make(changeDetectionWatchList, 0, len(requestedWatchIDs))
|
||||||
|
|
||||||
if len(requestedWatchIDs) == 0 {
|
if len(requestedWatchIDs) == 0 {
|
||||||
return watches, nil
|
return watches, nil
|
||||||
@@ -76,10 +135,9 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
|
|||||||
requests[i] = request
|
requests[i] = request
|
||||||
}
|
}
|
||||||
|
|
||||||
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient)
|
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultHTTPClient)
|
||||||
job := newJob(task, requests).withWorkers(15)
|
job := newJob(task, requests).withWorkers(15)
|
||||||
responses, errs, err := workerPoolDo(job)
|
responses, errs, err := workerPoolDo(job)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -89,13 +147,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
|
|||||||
for i := range responses {
|
for i := range responses {
|
||||||
if errs[i] != nil {
|
if errs[i] != nil {
|
||||||
failed++
|
failed++
|
||||||
slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL)
|
slog.Error("Failed to fetch or parse change detection watch", "url", requests[i].URL, "error", errs[i])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
watchJson := responses[i]
|
watchJson := responses[i]
|
||||||
|
|
||||||
watch := ChangeDetectionWatch{
|
watch := changeDetectionWatch{
|
||||||
URL: watchJson.URL,
|
URL: watchJson.URL,
|
||||||
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
|
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
|
||||||
}
|
}
|
||||||
@@ -126,13 +184,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(watches) == 0 {
|
if len(watches) == 0 {
|
||||||
return nil, ErrNoContent
|
return nil, errNoContent
|
||||||
}
|
}
|
||||||
|
|
||||||
watches.SortByNewest()
|
watches.sortByNewest()
|
||||||
|
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed)
|
return watches, fmt.Errorf("%w: could not get %d watches", errPartialContent, failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return watches, nil
|
return watches, nil
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
package widget
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glanceapp/glance/internal/assets"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Clock struct {
|
var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html")
|
||||||
|
|
||||||
|
type clockWidget struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
cachedHTML template.HTML `yaml:"-"`
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
HourFormat string `yaml:"hour-format"`
|
HourFormat string `yaml:"hour-format"`
|
||||||
@@ -19,32 +19,30 @@ type Clock struct {
|
|||||||
} `yaml:"timezones"`
|
} `yaml:"timezones"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Clock) Initialize() error {
|
func (widget *clockWidget) initialize() error {
|
||||||
widget.withTitle("Clock").withError(nil)
|
widget.withTitle("Clock").withError(nil)
|
||||||
|
|
||||||
if widget.HourFormat == "" {
|
if widget.HourFormat == "" {
|
||||||
widget.HourFormat = "24h"
|
widget.HourFormat = "24h"
|
||||||
} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
|
} else if widget.HourFormat != "12h" && widget.HourFormat != "24h" {
|
||||||
return errors.New("invalid hour format for clock widget, must be either 12h or 24h")
|
return errors.New("hour-format must be either 12h or 24h")
|
||||||
}
|
}
|
||||||
|
|
||||||
for t := range widget.Timezones {
|
for t := range widget.Timezones {
|
||||||
if widget.Timezones[t].Timezone == "" {
|
if widget.Timezones[t].Timezone == "" {
|
||||||
return errors.New("missing timezone value for clock widget")
|
return errors.New("missing timezone value")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := time.LoadLocation(widget.Timezones[t].Timezone)
|
if _, err := time.LoadLocation(widget.Timezones[t].Timezone); err != nil {
|
||||||
|
return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
widget.cachedHTML = widget.render(widget, assets.ClockTemplate)
|
widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *Clock) Render() template.HTML {
|
func (widget *clockWidget) Render() template.HTML {
|
||||||
return widget.cachedHTML
|
return widget.cachedHTML
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type containerWidgetBase struct {
|
||||||
|
Widgets widgets `yaml:"widgets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *containerWidgetBase) _initializeWidgets() error {
|
||||||
|
for i := range widget.Widgets {
|
||||||
|
if err := widget.Widgets[i].initialize(); err != nil {
|
||||||
|
return formatWidgetInitError(err, widget.Widgets[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *containerWidgetBase) _update(ctx context.Context) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for w := range widget.Widgets {
|
||||||
|
widget := widget.Widgets[w]
|
||||||
|
|
||||||
|
if !widget.requiresUpdate(&now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
widget.update(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *containerWidgetBase) _setProviders(providers *widgetProviders) {
|
||||||
|
for i := range widget.Widgets {
|
||||||
|
widget.Widgets[i].setProviders(providers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *containerWidgetBase) _requiresUpdate(now *time.Time) bool {
|
||||||
|
for i := range widget.Widgets {
|
||||||
|
if widget.Widgets[i].requiresUpdate(now) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html")
|
||||||
|
|
||||||
|
type customAPIWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
URL optionalEnvField `yaml:"url"`
|
||||||
|
Template string `yaml:"template"`
|
||||||
|
Frameless bool `yaml:"frameless"`
|
||||||
|
Headers map[string]optionalEnvField `yaml:"headers"`
|
||||||
|
APIRequest *http.Request `yaml:"-"`
|
||||||
|
compiledTemplate *template.Template `yaml:"-"`
|
||||||
|
CompiledHTML template.HTML `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *customAPIWidget) initialize() error {
|
||||||
|
widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
|
||||||
|
|
||||||
|
if widget.URL == "" {
|
||||||
|
return errors.New("URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Template == "" {
|
||||||
|
return errors.New("template is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
compiledTemplate, err := template.New("").Funcs(customAPITemplateFuncs).Parse(widget.Template)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.compiledTemplate = compiledTemplate
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, widget.URL.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range widget.Headers {
|
||||||
|
req.Header.Add(key, value.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.APIRequest = req
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *customAPIWidget) update(ctx context.Context) {
|
||||||
|
compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.CompiledHTML = compiledHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *customAPIWidget) Render() template.HTML {
|
||||||
|
return widget.renderTemplate(widget, customAPIWidgetTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) {
|
||||||
|
emptyBody := template.HTML("")
|
||||||
|
|
||||||
|
resp, err := defaultHTTPClient.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 += "... <truncated>"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 globalTemplateFunctions {
|
||||||
|
funcs[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return funcs
|
||||||
|
}()
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html")
|
||||||
|
|
||||||
|
type dnsStatsWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
|
||||||
|
TimeLabels [8]string `yaml:"-"`
|
||||||
|
Stats *dnsStats `yaml:"-"`
|
||||||
|
|
||||||
|
HourFormat string `yaml:"hour-format"`
|
||||||
|
Service string `yaml:"service"`
|
||||||
|
AllowInsecure bool `yaml:"allow-insecure"`
|
||||||
|
URL optionalEnvField `yaml:"url"`
|
||||||
|
Token optionalEnvField `yaml:"token"`
|
||||||
|
Username optionalEnvField `yaml:"username"`
|
||||||
|
Password optionalEnvField `yaml:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeDNSWidgetTimeLabels(format string) [8]string {
|
||||||
|
now := time.Now()
|
||||||
|
var labels [8]string
|
||||||
|
|
||||||
|
for h := 24; h > 0; h -= 3 {
|
||||||
|
labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format))
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *dnsStatsWidget) initialize() error {
|
||||||
|
widget.
|
||||||
|
withTitle("DNS Stats").
|
||||||
|
withTitleURL(string(widget.URL)).
|
||||||
|
withCacheDuration(10 * time.Minute)
|
||||||
|
|
||||||
|
if widget.Service != "adguard" && widget.Service != "pihole" {
|
||||||
|
return errors.New("service must be either 'adguard' or 'pihole'")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *dnsStatsWidget) update(ctx context.Context) {
|
||||||
|
var stats *dnsStats
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if widget.Service == "adguard" {
|
||||||
|
stats, err = fetchAdguardStats(string(widget.URL), widget.AllowInsecure, string(widget.Username), string(widget.Password))
|
||||||
|
} else {
|
||||||
|
stats, err = fetchPiholeStats(string(widget.URL), widget.AllowInsecure, string(widget.Token))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.HourFormat == "24h" {
|
||||||
|
widget.TimeLabels = makeDNSWidgetTimeLabels("15:00")
|
||||||
|
} else {
|
||||||
|
widget.TimeLabels = makeDNSWidgetTimeLabels("3PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.Stats = stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *dnsStatsWidget) Render() template.HTML {
|
||||||
|
return widget.renderTemplate(widget, dnsStatsWidgetTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 string, allowInsecure bool, 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)
|
||||||
|
|
||||||
|
var client requestDoer
|
||||||
|
if !allowInsecure {
|
||||||
|
client = defaultHTTPClient
|
||||||
|
} else {
|
||||||
|
client = defaultInsecureHTTPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 string, allowInsecure bool, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
var client requestDoer
|
||||||
|
if !allowInsecure {
|
||||||
|
client = defaultHTTPClient
|
||||||
|
} else {
|
||||||
|
client = defaultInsecureHTTPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var extensionWidgetTemplate = mustParseTemplate("extension.html", "widget-base.html")
|
||||||
|
|
||||||
|
type extensionWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
FallbackContentType string `yaml:"fallback-content-type"`
|
||||||
|
Parameters map[string]string `yaml:"parameters"`
|
||||||
|
AllowHtml bool `yaml:"allow-potentially-dangerous-html"`
|
||||||
|
Extension extension `yaml:"-"`
|
||||||
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *extensionWidget) initialize() error {
|
||||||
|
widget.withTitle("Extension").withCacheDuration(time.Minute * 30)
|
||||||
|
|
||||||
|
if widget.URL == "" {
|
||||||
|
return errors.New("URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := url.Parse(widget.URL); err != nil {
|
||||||
|
return fmt.Errorf("parsing URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *extensionWidget) update(ctx context.Context) {
|
||||||
|
extension, err := fetchExtension(extensionRequestOptions{
|
||||||
|
URL: widget.URL,
|
||||||
|
FallbackContentType: widget.FallbackContentType,
|
||||||
|
Parameters: widget.Parameters,
|
||||||
|
AllowHtml: widget.AllowHtml,
|
||||||
|
})
|
||||||
|
|
||||||
|
widget.canContinueUpdateAfterHandlingErr(err)
|
||||||
|
|
||||||
|
widget.Extension = extension
|
||||||
|
|
||||||
|
if extension.Title != "" {
|
||||||
|
widget.Title = extension.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *extensionWidget) Render() template.HTML {
|
||||||
|
return widget.cachedHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "url", options.URL, "error", err)
|
||||||
|
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", "url", options.URL, "error", err)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var groupWidgetTemplate = mustParseTemplate("group.html", "widget-base.html")
|
||||||
|
|
||||||
|
type groupWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
containerWidgetBase `yaml:",inline"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *groupWidget) initialize() error {
|
||||||
|
widget.withError(nil)
|
||||||
|
widget.HideHeader = true
|
||||||
|
|
||||||
|
for i := range widget.Widgets {
|
||||||
|
widget.Widgets[i].setHideHeader(true)
|
||||||
|
|
||||||
|
if widget.Widgets[i].GetType() == "group" {
|
||||||
|
return errors.New("nested groups are not supported")
|
||||||
|
} else if widget.Widgets[i].GetType() == "split-column" {
|
||||||
|
return errors.New("split columns inside of groups are not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := widget.containerWidgetBase._initializeWidgets(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *groupWidget) update(ctx context.Context) {
|
||||||
|
widget.containerWidgetBase._update(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *groupWidget) setProviders(providers *widgetProviders) {
|
||||||
|
widget.containerWidgetBase._setProviders(providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *groupWidget) requiresUpdate(now *time.Time) bool {
|
||||||
|
return widget.containerWidgetBase._requiresUpdate(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *groupWidget) Render() template.HTML {
|
||||||
|
return widget.renderTemplate(widget, groupWidgetTemplate)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hackerNewsWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
Posts forumPostList `yaml:"-"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
SortBy string `yaml:"sort-by"`
|
||||||
|
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
||||||
|
ShowThumbnails bool `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *hackerNewsWidget) initialize() error {
|
||||||
|
widget.
|
||||||
|
withTitle("Hacker News").
|
||||||
|
withTitleURL("https://news.ycombinator.com/").
|
||||||
|
withCacheDuration(30 * time.Minute)
|
||||||
|
|
||||||
|
if widget.Limit <= 0 {
|
||||||
|
widget.Limit = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||||
|
widget.CollapseAfter = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" {
|
||||||
|
widget.SortBy = "top"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *hackerNewsWidget) update(ctx context.Context) {
|
||||||
|
posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.ExtraSortBy == "engagement" {
|
||||||
|
posts.calculateEngagement()
|
||||||
|
posts.sortByEngagement()
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Limit < len(posts) {
|
||||||
|
posts = posts[:widget.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.Posts = posts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *hackerNewsWidget) Render() template.HTML {
|
||||||
|
return widget.renderTemplate(widget, forumPostsTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type hackerNewsPostResponseJson struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
TargetUrl string `json:"url,omitempty"`
|
||||||
|
CommentCount int `json:"descendants"`
|
||||||
|
TimePosted int64 `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchHackerNewsPostIds(sort string) ([]int, error) {
|
||||||
|
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
|
||||||
|
response, err := decodeJsonFromRequest[[]int](defaultHTTPClient, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) {
|
||||||
|
requests := make([]*http.Request, len(postIds))
|
||||||
|
|
||||||
|
for i, id := range postIds {
|
||||||
|
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
|
||||||
|
requests[i] = request
|
||||||
|
}
|
||||||
|
|
||||||
|
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultHTTPClient)
|
||||||
|
job := newJob(task, requests).withWorkers(30)
|
||||||
|
results, errs, err := workerPoolDo(job)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
posts := make(forumPostList, 0, len(postIds))
|
||||||
|
|
||||||
|
for i := range results {
|
||||||
|
if errs[i] != nil {
|
||||||
|
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var commentsUrl string
|
||||||
|
|
||||||
|
if commentsUrlTemplate == "" {
|
||||||
|
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
|
||||||
|
} else {
|
||||||
|
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
|
||||||
|
}
|
||||||
|
|
||||||
|
posts = append(posts, forumPost{
|
||||||
|
Title: results[i].Title,
|
||||||
|
DiscussionUrl: commentsUrl,
|
||||||
|
TargetUrl: results[i].TargetUrl,
|
||||||
|
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
|
||||||
|
CommentCount: results[i].CommentCount,
|
||||||
|
Score: results[i].Score,
|
||||||
|
TimePosted: time.Unix(results[i].TimePosted, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(posts) == 0 {
|
||||||
|
return nil, errNoContent
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(posts) != len(postIds) {
|
||||||
|
return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) {
|
||||||
|
postIds, err := fetchHackerNewsPostIds(sort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(postIds) > limit {
|
||||||
|
postIds = postIds[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
|
||||||
|
}
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
package widget
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HTML struct {
|
type htmlWidget struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
Source template.HTML `yaml:"source"`
|
Source template.HTML `yaml:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *HTML) Initialize() error {
|
func (widget *htmlWidget) initialize() error {
|
||||||
widget.withTitle("").withError(nil)
|
widget.withTitle("").withError(nil)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *HTML) Render() template.HTML {
|
func (widget *htmlWidget) Render() template.HTML {
|
||||||
return widget.Source
|
return widget.Source
|
||||||
}
|
}
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
package widget
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/glanceapp/glance/internal/assets"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type IFrame struct {
|
var iframeWidgetTemplate = mustParseTemplate("iframe.html", "widget-base.html")
|
||||||
|
|
||||||
|
type iframeWidget struct {
|
||||||
widgetBase `yaml:",inline"`
|
widgetBase `yaml:",inline"`
|
||||||
cachedHTML template.HTML `yaml:"-"`
|
cachedHTML template.HTML `yaml:"-"`
|
||||||
Source string `yaml:"source"`
|
Source string `yaml:"source"`
|
||||||
Height int `yaml:"height"`
|
Height int `yaml:"height"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *IFrame) Initialize() error {
|
func (widget *iframeWidget) initialize() error {
|
||||||
widget.withTitle("IFrame").withError(nil)
|
widget.withTitle("IFrame").withError(nil)
|
||||||
|
|
||||||
if widget.Source == "" {
|
if widget.Source == "" {
|
||||||
return errors.New("missing source for iframe")
|
return errors.New("source is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := url.Parse(widget.Source)
|
if _, err := url.Parse(widget.Source); err != nil {
|
||||||
|
return fmt.Errorf("parsing URL: %v", err)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid source for iframe: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if widget.Height == 50 {
|
if widget.Height == 50 {
|
||||||
@@ -35,11 +33,11 @@ func (widget *IFrame) Initialize() error {
|
|||||||
widget.Height = 50
|
widget.Height = 50
|
||||||
}
|
}
|
||||||
|
|
||||||
widget.cachedHTML = widget.render(widget, assets.IFrameTemplate)
|
widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *IFrame) Render() template.HTML {
|
func (widget *iframeWidget) Render() template.HTML {
|
||||||
return widget.cachedHTML
|
return widget.cachedHTML
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type lobstersWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
Posts forumPostList `yaml:"-"`
|
||||||
|
InstanceURL string `yaml:"instance-url"`
|
||||||
|
CustomURL string `yaml:"custom-url"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
SortBy string `yaml:"sort-by"`
|
||||||
|
Tags []string `yaml:"tags"`
|
||||||
|
ShowThumbnails bool `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *lobstersWidget) initialize() error {
|
||||||
|
widget.withTitle("Lobsters").withCacheDuration(time.Hour)
|
||||||
|
|
||||||
|
if widget.InstanceURL == "" {
|
||||||
|
widget.withTitleURL("https://lobste.rs")
|
||||||
|
} else {
|
||||||
|
widget.withTitleURL(widget.InstanceURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") {
|
||||||
|
widget.SortBy = "hot"
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Limit <= 0 {
|
||||||
|
widget.Limit = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||||
|
widget.CollapseAfter = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *lobstersWidget) update(ctx context.Context) {
|
||||||
|
posts, err := fetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags)
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Limit < len(posts) {
|
||||||
|
posts = posts[:widget.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.Posts = posts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *lobstersWidget) Render() template.HTML {
|
||||||
|
return widget.renderTemplate(widget, forumPostsTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) {
|
||||||
|
request, err := http.NewRequest("GET", feedUrl, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultHTTPClient, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
posts := make(forumPostList, 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) (forumPostList, 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 := fetchLobstersPostsFromFeed(feedUrl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var marketsWidgetTemplate = mustParseTemplate("markets.html", "widget-base.html")
|
||||||
|
|
||||||
|
type marketsWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
StocksRequests []marketRequest `yaml:"stocks"`
|
||||||
|
MarketRequests []marketRequest `yaml:"markets"`
|
||||||
|
Sort string `yaml:"sort-by"`
|
||||||
|
Markets marketList `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *marketsWidget) initialize() error {
|
||||||
|
widget.withTitle("Markets").withCacheDuration(time.Hour)
|
||||||
|
|
||||||
|
// legacy support, remove in v0.10.0
|
||||||
|
if len(widget.MarketRequests) == 0 {
|
||||||
|
widget.MarketRequests = widget.StocksRequests
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *marketsWidget) update(ctx context.Context) {
|
||||||
|
markets, err := fetchMarketsDataFromYahoo(widget.MarketRequests)
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Sort == "absolute-change" {
|
||||||
|
markets.sortByAbsChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Sort == "change" {
|
||||||
|
markets.sortByChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.Markets = markets
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *marketsWidget) Render() template.HTML {
|
||||||
|
return widget.renderTemplate(widget, marketsWidgetTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
Price float64
|
||||||
|
PercentChange float64
|
||||||
|
SvgChartPoints string
|
||||||
|
}
|
||||||
|
|
||||||
|
type marketList []market
|
||||||
|
|
||||||
|
func (t marketList) sortByAbsChange() {
|
||||||
|
sort.Slice(t, func(i, j int) bool {
|
||||||
|
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t marketList) sortByChange() {
|
||||||
|
sort.Slice(t, func(i, j int) bool {
|
||||||
|
return t[i].PercentChange > t[j].PercentChange
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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) (marketList, 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](defaultHTTPClient), requests)
|
||||||
|
responses, errs, err := workerPoolDo(job)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", errNoContent, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
markets := make(marketList, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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": "₱",
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
monitorWidgetTemplate = mustParseTemplate("monitor.html", "widget-base.html")
|
||||||
|
monitorWidgetCompactTemplate = mustParseTemplate("monitor-compact.html", "widget-base.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
type monitorWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
Sites []struct {
|
||||||
|
*SiteStatusRequest `yaml:",inline"`
|
||||||
|
Status *SiteStatus `yaml:"-"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Icon customIconField `yaml:"icon"`
|
||||||
|
SameTab bool `yaml:"same-tab"`
|
||||||
|
StatusText string `yaml:"-"`
|
||||||
|
StatusStyle string `yaml:"-"`
|
||||||
|
AltStatusCodes []int `yaml:"alt-status-codes"`
|
||||||
|
} `yaml:"sites"`
|
||||||
|
Style string `yaml:"style"`
|
||||||
|
ShowFailingOnly bool `yaml:"show-failing-only"`
|
||||||
|
HasFailing bool `yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *monitorWidget) initialize() error {
|
||||||
|
widget.withTitle("Monitor").withCacheDuration(5 * time.Minute)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *monitorWidget) update(ctx context.Context) {
|
||||||
|
requests := make([]*SiteStatusRequest, len(widget.Sites))
|
||||||
|
|
||||||
|
for i := range widget.Sites {
|
||||||
|
requests[i] = widget.Sites[i].SiteStatusRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, err := fetchStatusForSites(requests)
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.HasFailing = false
|
||||||
|
|
||||||
|
for i := range widget.Sites {
|
||||||
|
site := &widget.Sites[i]
|
||||||
|
status := &statuses[i]
|
||||||
|
site.Status = status
|
||||||
|
|
||||||
|
if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.TimedOut || status.Error != nil) {
|
||||||
|
widget.HasFailing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !status.TimedOut {
|
||||||
|
site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes)
|
||||||
|
site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *monitorWidget) Render() template.HTML {
|
||||||
|
if widget.Style == "compact" {
|
||||||
|
return widget.renderTemplate(widget, monitorWidgetCompactTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget.renderTemplate(widget, monitorWidgetTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusCodeToText(status int, altStatusCodes []int) string {
|
||||||
|
if status == 200 || slices.Contains(altStatusCodes, status) {
|
||||||
|
return "OK"
|
||||||
|
}
|
||||||
|
if status == 404 {
|
||||||
|
return "Not Found"
|
||||||
|
}
|
||||||
|
if status == 403 {
|
||||||
|
return "Forbidden"
|
||||||
|
}
|
||||||
|
if status == 401 {
|
||||||
|
return "Unauthorized"
|
||||||
|
}
|
||||||
|
if status >= 400 {
|
||||||
|
return "Client Error"
|
||||||
|
}
|
||||||
|
if status >= 500 {
|
||||||
|
return "Server Error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strconv.Itoa(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusCodeToStyle(status int, altStatusCodes []int) string {
|
||||||
|
if status == 200 || slices.Contains(altStatusCodes, status) {
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fetchSiteStatusTask(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 = defaultHTTPClient.Do(request)
|
||||||
|
} else {
|
||||||
|
response, err = defaultInsecureHTTPClient.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(fetchSiteStatusTask, requests).withWorkers(20)
|
||||||
|
results, _, err := workerPoolDo(job)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -1,14 +1,131 @@
|
|||||||
package feed
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
redditWidgetHorizontalCardsTemplate = mustParseTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
||||||
|
redditWidgetVerticalCardsTemplate = mustParseTemplate("reddit-vertical-cards.html", "widget-base.html")
|
||||||
|
)
|
||||||
|
|
||||||
|
type redditWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
Posts forumPostList `yaml:"-"`
|
||||||
|
Subreddit string `yaml:"subreddit"`
|
||||||
|
Style string `yaml:"style"`
|
||||||
|
ShowThumbnails bool `yaml:"show-thumbnails"`
|
||||||
|
ShowFlairs bool `yaml:"show-flairs"`
|
||||||
|
SortBy string `yaml:"sort-by"`
|
||||||
|
TopPeriod string `yaml:"top-period"`
|
||||||
|
Search string `yaml:"search"`
|
||||||
|
ExtraSortBy string `yaml:"extra-sort-by"`
|
||||||
|
CommentsUrlTemplate string `yaml:"comments-url-template"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
RequestUrlTemplate string `yaml:"request-url-template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *redditWidget) initialize() error {
|
||||||
|
if widget.Subreddit == "" {
|
||||||
|
return errors.New("subreddit is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Limit <= 0 {
|
||||||
|
widget.Limit = 15
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||||
|
widget.CollapseAfter = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidRedditSortType(widget.SortBy) {
|
||||||
|
widget.SortBy = "hot"
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidRedditTopPeriod(widget.TopPeriod) {
|
||||||
|
widget.TopPeriod = "day"
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.RequestUrlTemplate != "" {
|
||||||
|
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
|
||||||
|
return errors.New("no `{REQUEST-URL}` placeholder specified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.
|
||||||
|
withTitle("/r/" + widget.Subreddit).
|
||||||
|
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
|
||||||
|
withCacheDuration(30 * time.Minute)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidRedditSortType(sortBy string) bool {
|
||||||
|
return sortBy == "hot" ||
|
||||||
|
sortBy == "new" ||
|
||||||
|
sortBy == "top" ||
|
||||||
|
sortBy == "rising"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidRedditTopPeriod(period string) bool {
|
||||||
|
return period == "hour" ||
|
||||||
|
period == "day" ||
|
||||||
|
period == "week" ||
|
||||||
|
period == "month" ||
|
||||||
|
period == "year" ||
|
||||||
|
period == "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *redditWidget) update(ctx context.Context) {
|
||||||
|
// TODO: refactor, use a struct to pass all of these
|
||||||
|
posts, err := fetchSubredditPosts(
|
||||||
|
widget.Subreddit,
|
||||||
|
widget.SortBy,
|
||||||
|
widget.TopPeriod,
|
||||||
|
widget.Search,
|
||||||
|
widget.CommentsUrlTemplate,
|
||||||
|
widget.RequestUrlTemplate,
|
||||||
|
widget.ShowFlairs,
|
||||||
|
)
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(posts) > widget.Limit {
|
||||||
|
posts = posts[:widget.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.ExtraSortBy == "engagement" {
|
||||||
|
posts.calculateEngagement()
|
||||||
|
posts.sortByEngagement()
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.Posts = posts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *redditWidget) Render() template.HTML {
|
||||||
|
if widget.Style == "horizontal-cards" {
|
||||||
|
return widget.renderTemplate(widget, redditWidgetHorizontalCardsTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Style == "vertical-cards" {
|
||||||
|
return widget.renderTemplate(widget, redditWidgetVerticalCardsTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget.renderTemplate(widget, forumPostsTemplate)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
type subredditResponseJson struct {
|
type subredditResponseJson struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
Children []struct {
|
Children []struct {
|
||||||
@@ -44,7 +161,7 @@ func templateRedditCommentsURL(template, subreddit, postId, postPath string) str
|
|||||||
return template
|
return template
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) {
|
func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (forumPostList, error) {
|
||||||
query := url.Values{}
|
query := url.Values{}
|
||||||
var requestUrl string
|
var requestUrl string
|
||||||
|
|
||||||
@@ -68,15 +185,13 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
|
|||||||
}
|
}
|
||||||
|
|
||||||
request, err := http.NewRequest("GET", requestUrl, nil)
|
request, err := http.NewRequest("GET", requestUrl, nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
|
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
|
||||||
addBrowserUserAgentHeader(request)
|
setBrowserUserAgentHeader(request)
|
||||||
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request)
|
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultHTTPClient, request)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -85,7 +200,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
|
|||||||
return nil, fmt.Errorf("no posts found")
|
return nil, fmt.Errorf("no posts found")
|
||||||
}
|
}
|
||||||
|
|
||||||
posts := make(ForumPosts, 0, len(responseJson.Data.Children))
|
posts := make(forumPostList, 0, len(responseJson.Data.Children))
|
||||||
|
|
||||||
for i := range responseJson.Data.Children {
|
for i := range responseJson.Data.Children {
|
||||||
post := &responseJson.Data.Children[i].Data
|
post := &responseJson.Data.Children[i].Data
|
||||||
@@ -102,7 +217,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate
|
|||||||
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
|
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
|
||||||
}
|
}
|
||||||
|
|
||||||
forumPost := ForumPost{
|
forumPost := forumPost{
|
||||||
Title: html.UnescapeString(post.Title),
|
Title: html.UnescapeString(post.Title),
|
||||||
DiscussionUrl: commentsUrl,
|
DiscussionUrl: commentsUrl,
|
||||||
TargetUrlDomain: post.Domain,
|
TargetUrlDomain: post.Domain,
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var releasesWidgetTemplate = mustParseTemplate("releases.html", "widget-base.html")
|
||||||
|
|
||||||
|
type releasesWidget struct {
|
||||||
|
widgetBase `yaml:",inline"`
|
||||||
|
Releases appReleaseList `yaml:"-"`
|
||||||
|
releaseRequests []*releaseRequest `yaml:"-"`
|
||||||
|
Repositories []string `yaml:"repositories"`
|
||||||
|
Token optionalEnvField `yaml:"token"`
|
||||||
|
GitLabToken optionalEnvField `yaml:"gitlab-token"`
|
||||||
|
Limit int `yaml:"limit"`
|
||||||
|
CollapseAfter int `yaml:"collapse-after"`
|
||||||
|
ShowSourceIcon bool `yaml:"show-source-icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *releasesWidget) initialize() error {
|
||||||
|
widget.withTitle("Releases").withCacheDuration(2 * time.Hour)
|
||||||
|
|
||||||
|
if widget.Limit <= 0 {
|
||||||
|
widget.Limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
||||||
|
widget.CollapseAfter = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenAsString = widget.Token.String()
|
||||||
|
var gitLabTokenAsString = widget.GitLabToken.String()
|
||||||
|
|
||||||
|
for _, repository := range widget.Repositories {
|
||||||
|
parts := strings.SplitN(repository, ":", 2)
|
||||||
|
var request *releaseRequest
|
||||||
|
if len(parts) == 1 {
|
||||||
|
request = &releaseRequest{
|
||||||
|
source: releaseSourceGithub,
|
||||||
|
repository: repository,
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.Token != "" {
|
||||||
|
request.token = &tokenAsString
|
||||||
|
}
|
||||||
|
} else if len(parts) == 2 {
|
||||||
|
if parts[0] == string(releaseSourceGitlab) {
|
||||||
|
request = &releaseRequest{
|
||||||
|
source: releaseSourceGitlab,
|
||||||
|
repository: parts[1],
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.GitLabToken != "" {
|
||||||
|
request.token = &gitLabTokenAsString
|
||||||
|
}
|
||||||
|
} else if parts[0] == string(releaseSourceDockerHub) {
|
||||||
|
request = &releaseRequest{
|
||||||
|
source: releaseSourceDockerHub,
|
||||||
|
repository: parts[1],
|
||||||
|
}
|
||||||
|
} else if parts[0] == string(releaseSourceCodeberg) {
|
||||||
|
request = &releaseRequest{
|
||||||
|
source: releaseSourceCodeberg,
|
||||||
|
repository: parts[1],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return errors.New("invalid repository source " + parts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.releaseRequests = append(widget.releaseRequests, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *releasesWidget) update(ctx context.Context) {
|
||||||
|
releases, err := fetchLatestReleases(widget.releaseRequests)
|
||||||
|
|
||||||
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(releases) > widget.Limit {
|
||||||
|
releases = releases[:widget.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range releases {
|
||||||
|
releases[i].SourceIconURL = widget.Providers.assetResolver("icons/" + string(releases[i].Source) + ".svg")
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.Releases = releases
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *releasesWidget) Render() template.HTML {
|
||||||
|
return widget.renderTemplate(widget, releasesWidgetTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
releaseSourceCodeberg releaseSource = "codeberg"
|
||||||
|
releaseSourceGithub releaseSource = "github"
|
||||||
|
releaseSourceGitlab releaseSource = "gitlab"
|
||||||
|
releaseSourceDockerHub releaseSource = "dockerhub"
|
||||||
|
)
|
||||||
|
|
||||||
|
type appRelease struct {
|
||||||
|
Source releaseSource
|
||||||
|
SourceIconURL string
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
NotesUrl string
|
||||||
|
TimeReleased time.Time
|
||||||
|
Downvotes int
|
||||||
|
}
|
||||||
|
|
||||||
|
type appReleaseList []appRelease
|
||||||
|
|
||||||
|
func (r appReleaseList) sortByNewest() appReleaseList {
|
||||||
|
sort.Slice(r, func(i, j int) bool {
|
||||||
|
return r[i].TimeReleased.After(r[j].TimeReleased)
|
||||||
|
})
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseRequest struct {
|
||||||
|
source releaseSource
|
||||||
|
repository string
|
||||||
|
token *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) {
|
||||||
|
job := newJob(fetchLatestReleaseTask, requests).withWorkers(20)
|
||||||
|
results, errs, err := workerPoolDo(job)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed int
|
||||||
|
|
||||||
|
releases := make(appReleaseList, 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubReleaseLatestResponseJson struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
PublishedAt string `json:"published_at"`
|
||||||
|
HtmlUrl string `json:"html_url"`
|
||||||
|
Reactions struct {
|
||||||
|
Downvotes int `json:"-1"`
|
||||||
|
} `json:"reactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) {
|
||||||
|
httpRequest, err := http.NewRequest(
|
||||||
|
"GET",
|
||||||
|
fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.repository),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.token != nil {
|
||||||
|
httpRequest.Header.Add("Authorization", "Bearer "+(*request.token))
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultHTTPClient, httpRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &appRelease{
|
||||||
|
Source: releaseSourceGithub,
|
||||||
|
Name: request.repository,
|
||||||
|
Version: normalizeVersionFormat(response.TagName),
|
||||||
|
NotesUrl: response.HtmlUrl,
|
||||||
|
TimeReleased: parseRFC3339Time(response.PublishedAt),
|
||||||
|
Downvotes: response.Reactions.Downvotes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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](defaultHTTPClient, 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](defaultHTTPClient, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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](defaultHTTPClient, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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](defaultHTTPClient, 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
|
||||||
|
}
|
||||||
@@ -1,72 +1,91 @@
|
|||||||
package feed
|
package glance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type githubReleaseLatestResponseJson struct {
|
var repositoryWidgetTemplate = mustParseTemplate("repository.html", "widget-base.html")
|
||||||
TagName string `json:"tag_name"`
|
|
||||||
PublishedAt string `json:"published_at"`
|
type repositoryWidget struct {
|
||||||
HtmlUrl string `json:"html_url"`
|
widgetBase `yaml:",inline"`
|
||||||
Reactions struct {
|
RequestedRepository string `yaml:"repository"`
|
||||||
Downvotes int `json:"-1"`
|
Token optionalEnvField `yaml:"token"`
|
||||||
} `json:"reactions"`
|
PullRequestsLimit int `yaml:"pull-requests-limit"`
|
||||||
|
IssuesLimit int `yaml:"issues-limit"`
|
||||||
|
CommitsLimit int `yaml:"commits-limit"`
|
||||||
|
Repository repository `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) {
|
func (widget *repositoryWidget) initialize() error {
|
||||||
httpRequest, err := http.NewRequest(
|
widget.withTitle("Repository").withCacheDuration(1 * time.Hour)
|
||||||
"GET",
|
|
||||||
fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository),
|
if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 {
|
||||||
nil,
|
widget.PullRequestsLimit = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 {
|
||||||
|
widget.IssuesLimit = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 {
|
||||||
|
widget.CommitsLimit = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (widget *repositoryWidget) update(ctx context.Context) {
|
||||||
|
details, err := fetchRepositoryDetailsFromGithub(
|
||||||
|
widget.RequestedRepository,
|
||||||
|
string(widget.Token),
|
||||||
|
widget.PullRequestsLimit,
|
||||||
|
widget.IssuesLimit,
|
||||||
|
widget.CommitsLimit,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
||||||
return nil, err
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.Token != nil {
|
widget.Repository = details
|
||||||
httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token))
|
|
||||||
}
|
|
||||||
|
|
||||||
response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &AppRelease{
|
|
||||||
Source: ReleaseSourceGithub,
|
|
||||||
Name: request.Repository,
|
|
||||||
Version: normalizeVersionFormat(response.TagName),
|
|
||||||
NotesUrl: response.HtmlUrl,
|
|
||||||
TimeReleased: parseRFC3339Time(response.PublishedAt),
|
|
||||||
Downvotes: response.Reactions.Downvotes,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GithubTicket struct {
|
func (widget *repositoryWidget) Render() template.HTML {
|
||||||
|
return widget.renderTemplate(widget, repositoryWidgetTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
type repository struct {
|
||||||
|
Name string
|
||||||
|
Stars int
|
||||||
|
Forks int
|
||||||
|
OpenPullRequests int
|
||||||
|
PullRequests []githubTicket
|
||||||
|
OpenIssues int
|
||||||
|
Issues []githubTicket
|
||||||
|
LastCommits int
|
||||||
|
Commits []githubCommitDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubTicket struct {
|
||||||
Number int
|
Number int
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Title string
|
Title string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RepositoryDetails struct {
|
type githubCommitDetails struct {
|
||||||
Name string
|
Sha string
|
||||||
Stars int
|
Author string
|
||||||
Forks int
|
CreatedAt time.Time
|
||||||
OpenPullRequests int
|
Message string
|
||||||
PullRequests []GithubTicket
|
|
||||||
OpenIssues int
|
|
||||||
Issues []GithubTicket
|
|
||||||
LastCommits int
|
|
||||||
Commits []CommitDetails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type githubRepositoryDetailsResponseJson struct {
|
type githubRepositoryResponseJson struct {
|
||||||
Name string `json:"full_name"`
|
Name string `json:"full_name"`
|
||||||
Stars int `json:"stargazers_count"`
|
Stars int `json:"stargazers_count"`
|
||||||
Forks int `json:"forks_count"`
|
Forks int `json:"forks_count"`
|
||||||
@@ -81,13 +100,6 @@ type githubTicketResponseJson struct {
|
|||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommitDetails struct {
|
|
||||||
Sha string
|
|
||||||
Author string
|
|
||||||
CreatedAt time.Time
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
type gitHubCommitResponseJson struct {
|
type gitHubCommitResponseJson struct {
|
||||||
Sha string `json:"sha"`
|
Sha string `json:"sha"`
|
||||||
Commit struct {
|
Commit struct {
|
||||||
@@ -99,15 +111,15 @@ type gitHubCommitResponseJson struct {
|
|||||||
} `json:"commit"`
|
} `json:"commit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) {
|
func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, maxIssues int, maxCommits int) (repository, error) {
|
||||||
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil)
|
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repo), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err)
|
return repository{}, fmt.Errorf("%w: could not create request with repository: %v", errNoContent, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil)
|
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repo, maxPRs), nil)
|
||||||
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil)
|
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repo, maxIssues), nil)
|
||||||
CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil)
|
CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repo, maxCommits), nil)
|
||||||
|
|
||||||
if token != "" {
|
if token != "" {
|
||||||
token = fmt.Sprintf("Bearer %s", token)
|
token = fmt.Sprintf("Bearer %s", token)
|
||||||
@@ -117,7 +129,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
|
|||||||
CommitsRequest.Header.Add("Authorization", token)
|
CommitsRequest.Header.Add("Authorization", token)
|
||||||
}
|
}
|
||||||
|
|
||||||
var detailsResponse githubRepositoryDetailsResponseJson
|
var repositoryResponse githubRepositoryResponseJson
|
||||||
var detailsErr error
|
var detailsErr error
|
||||||
var PRsResponse githubTicketResponseJson
|
var PRsResponse githubTicketResponseJson
|
||||||
var PRsErr error
|
var PRsErr error
|
||||||
@@ -130,14 +142,14 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go (func() {
|
go (func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest)
|
repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultHTTPClient, repositoryRequest)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
if maxPRs > 0 {
|
if maxPRs > 0 {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go (func() {
|
go (func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest)
|
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, PRsRequest)
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +157,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go (func() {
|
go (func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest)
|
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, issuesRequest)
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,35 +165,35 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go (func() {
|
go (func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest)
|
commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultHTTPClient, CommitsRequest)
|
||||||
})()
|
})()
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if detailsErr != nil {
|
if detailsErr != nil {
|
||||||
return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr)
|
return repository{}, fmt.Errorf("%w: could not get repository details: %s", errNoContent, detailsErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
details := RepositoryDetails{
|
details := repository{
|
||||||
Name: detailsResponse.Name,
|
Name: repositoryResponse.Name,
|
||||||
Stars: detailsResponse.Stars,
|
Stars: repositoryResponse.Stars,
|
||||||
Forks: detailsResponse.Forks,
|
Forks: repositoryResponse.Forks,
|
||||||
PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)),
|
PullRequests: make([]githubTicket, 0, len(PRsResponse.Tickets)),
|
||||||
Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)),
|
Issues: make([]githubTicket, 0, len(issuesResponse.Tickets)),
|
||||||
Commits: make([]CommitDetails, 0, len(commitsResponse)),
|
Commits: make([]githubCommitDetails, 0, len(commitsResponse)),
|
||||||
}
|
}
|
||||||
|
|
||||||
err = nil
|
err = nil
|
||||||
|
|
||||||
if maxPRs > 0 {
|
if maxPRs > 0 {
|
||||||
if PRsErr != nil {
|
if PRsErr != nil {
|
||||||
err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr)
|
err = fmt.Errorf("%w: could not get PRs: %s", errPartialContent, PRsErr)
|
||||||
} else {
|
} else {
|
||||||
details.OpenPullRequests = PRsResponse.Count
|
details.OpenPullRequests = PRsResponse.Count
|
||||||
|
|
||||||
for i := range PRsResponse.Tickets {
|
for i := range PRsResponse.Tickets {
|
||||||
details.PullRequests = append(details.PullRequests, GithubTicket{
|
details.PullRequests = append(details.PullRequests, githubTicket{
|
||||||
Number: PRsResponse.Tickets[i].Number,
|
Number: PRsResponse.Tickets[i].Number,
|
||||||
CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
|
CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
|
||||||
Title: PRsResponse.Tickets[i].Title,
|
Title: PRsResponse.Tickets[i].Title,
|
||||||
@@ -193,12 +205,12 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
|
|||||||
if maxIssues > 0 {
|
if maxIssues > 0 {
|
||||||
if issuesErr != nil {
|
if issuesErr != nil {
|
||||||
// TODO: fix, overwriting the previous error
|
// TODO: fix, overwriting the previous error
|
||||||
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr)
|
err = fmt.Errorf("%w: could not get issues: %s", errPartialContent, issuesErr)
|
||||||
} else {
|
} else {
|
||||||
details.OpenIssues = issuesResponse.Count
|
details.OpenIssues = issuesResponse.Count
|
||||||
|
|
||||||
for i := range issuesResponse.Tickets {
|
for i := range issuesResponse.Tickets {
|
||||||
details.Issues = append(details.Issues, GithubTicket{
|
details.Issues = append(details.Issues, githubTicket{
|
||||||
Number: issuesResponse.Tickets[i].Number,
|
Number: issuesResponse.Tickets[i].Number,
|
||||||
CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
|
CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
|
||||||
Title: issuesResponse.Tickets[i].Title,
|
Title: issuesResponse.Tickets[i].Title,
|
||||||
@@ -209,10 +221,10 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in
|
|||||||
|
|
||||||
if maxCommits > 0 {
|
if maxCommits > 0 {
|
||||||
if CommitsErr != nil {
|
if CommitsErr != nil {
|
||||||
err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr)
|
err = fmt.Errorf("%w: could not get commits: %s", errPartialContent, CommitsErr)
|
||||||
} else {
|
} else {
|
||||||
for i := range commitsResponse {
|
for i := range commitsResponse {
|
||||||
details.Commits = append(details.Commits, CommitDetails{
|
details.Commits = append(details.Commits, githubCommitDetails{
|
||||||
Sha: commitsResponse[i].Sha,
|
Sha: commitsResponse[i].Sha,
|
||||||
Author: commitsResponse[i].Commit.Author.Name,
|
Author: commitsResponse[i].Commit.Author.Name,
|
||||||
CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),
|
CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),
|
||||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user