From bd020c93f59f5c883294b06baca4adb11941a2c1 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 28 Mar 2025 14:11:17 +0000 Subject: [PATCH 1/8] Fix markets price precision --- internal/glance/templates.go | 3 +++ internal/glance/templates/markets.html | 2 +- internal/glance/widget-markets.go | 18 +++++++++++------- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/internal/glance/templates.go b/internal/glance/templates.go index ec8d0f3..699772d 100644 --- a/internal/glance/templates.go +++ b/internal/glance/templates.go @@ -27,6 +27,9 @@ var globalTemplateFunctions = template.FuncMap{ "formatPrice": func(price float64) string { return intl.Sprintf("%.2f", price) }, + "formatPriceWithPrecision": func(precision int, price float64) string { + return intl.Sprintf("%."+strconv.Itoa(precision)+"f", price) + }, "dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs, "formatServerMegabytes": func(mb uint64) template.HTML { var value string diff --git a/internal/glance/templates/markets.html b/internal/glance/templates/markets.html index a979321..cd8da49 100644 --- a/internal/glance/templates/markets.html +++ b/internal/glance/templates/markets.html @@ -17,7 +17,7 @@
{{ printf "%+.2f" .PercentChange }}%
-
{{ .Currency }}{{ .Price | formatPrice }}
+
{{ .Currency }}{{ .Price | formatPriceWithPrecision .PriceHint }}
{{ end }} diff --git a/internal/glance/widget-markets.go b/internal/glance/widget-markets.go index a3bb325..b53b10a 100644 --- a/internal/glance/widget-markets.go +++ b/internal/glance/widget-markets.go @@ -79,6 +79,7 @@ type market struct { Name string Currency string Price float64 + PriceHint int PercentChange float64 SvgChartPoints string } @@ -106,6 +107,7 @@ type marketResponseJson struct { RegularMarketPrice float64 `json:"regularMarketPrice"` ChartPreviousClose float64 `json:"chartPreviousClose"` ShortName string `json:"shortName"` + PriceHint int `json:"priceHint"` } `json:"meta"` Indicators struct { Quote []struct { @@ -152,13 +154,14 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro continue } - prices := response.Chart.Result[0].Indicators.Quote[0].Close + result := &response.Chart.Result[0] + prices := result.Indicators.Quote[0].Close if len(prices) > marketChartDays { prices = prices[len(prices)-marketChartDays:] } - previous := response.Chart.Result[0].Meta.RegularMarketPrice + previous := result.Meta.RegularMarketPrice if len(prices) >= 2 && prices[len(prices)-2] != 0 { previous = prices[len(prices)-2] @@ -166,21 +169,22 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) - currency, exists := currencyToSymbol[strings.ToUpper(response.Chart.Result[0].Meta.Currency)] + currency, exists := currencyToSymbol[strings.ToUpper(result.Meta.Currency)] if !exists { - currency = response.Chart.Result[0].Meta.Currency + currency = result.Meta.Currency } markets = append(markets, market{ marketRequest: marketRequests[i], - Price: response.Chart.Result[0].Meta.RegularMarketPrice, + Price: result.Meta.RegularMarketPrice, Currency: currency, + PriceHint: result.Meta.PriceHint, Name: ternary(marketRequests[i].CustomName == "", - response.Chart.Result[0].Meta.ShortName, + result.Meta.ShortName, marketRequests[i].CustomName, ), PercentChange: percentChange( - response.Chart.Result[0].Meta.RegularMarketPrice, + result.Meta.RegularMarketPrice, previous, ), SvgChartPoints: points, From dd74c173a5370b0a204963676e0b021811839640 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 28 Mar 2025 23:34:49 +0000 Subject: [PATCH 2/8] More custom-api additions/tweaks * Remove need to convert to int for math stuff * Add `now` and `duration` functions --- docs/custom-api.md | 6 ++- internal/glance/widget-custom-api.go | 69 ++++++++++++++++++++++++---- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/docs/custom-api.md b/docs/custom-api.md index 7eb6ca6..8e432c9 100644 --- a/docs/custom-api.md +++ b/docs/custom-api.md @@ -226,10 +226,10 @@ JSON response: } ``` -Calculations can be performed, however all numbers must be converted to floats first if they are not already: +Calculations can be performed on either ints or floats. If both numbers are ints, an int will be returned, otherwise a float will be returned. If you try to divide by zero, 0 will be returned. If you provide non-numeric values, `NaN` will be returned. ```html -
{{ sub (.JSON.Int "price" | toFloat) (.JSON.Int "discount" | toFloat) }}
+
{{ sub (.JSON.Int "price") (.JSON.Int "discount") }}
``` Output: @@ -325,6 +325,8 @@ The following helper functions provided by Glance are available: - `toFloat(i int) float`: Converts an integer to a float. - `toInt(f float) int`: Converts a float to an integer. - `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. ``. +- `now() time.Time`: Returns the current time. +- `duration(str string) time.Duration`: Parses a string such as `1h`, `1d`, `5h30m`, etc into a `time.Duration`. - `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly". - `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`. - `add(a, b float) float`: Adds two numbers. diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index ec88016..be83032 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -342,6 +342,23 @@ func (r *decoratedGJSONResult) Bool(key string) bool { return r.Get(key).Bool() } +func customAPIDoMathOp[T int | float64](a, b T, op string) T { + switch op { + case "add": + return a + b + case "sub": + return a - b + case "mul": + return a * b + case "div": + if b == 0 { + return 0 + } + return a / b + } + return 0 +} + var customAPITemplateFuncs = func() template.FuncMap { var regexpCacheMu sync.Mutex var regexpCache = make(map[string]*regexp.Regexp) @@ -359,6 +376,31 @@ var customAPITemplateFuncs = func() template.FuncMap { return regex } + doMathOpWithAny := func(a, b any, op string) any { + switch at := a.(type) { + case int: + switch bt := b.(type) { + case int: + return customAPIDoMathOp(at, bt, op) + case float64: + return customAPIDoMathOp(float64(at), bt, op) + default: + return math.NaN() + } + case float64: + switch bt := b.(type) { + case int: + return customAPIDoMathOp(at, float64(bt), op) + case float64: + return customAPIDoMathOp(at, bt, op) + default: + return math.NaN() + } + default: + return math.NaN() + } + } + funcs := template.FuncMap{ "toFloat": func(a int) float64 { return float64(a) @@ -366,21 +408,28 @@ var customAPITemplateFuncs = func() template.FuncMap { "toInt": func(a float64) int { return int(a) }, - "add": func(a, b float64) float64 { - return a + b + "add": func(a, b any) any { + return doMathOpWithAny(a, b, "add") }, - "sub": func(a, b float64) float64 { - return a - b + "sub": func(a, b any) any { + return doMathOpWithAny(a, b, "sub") }, - "mul": func(a, b float64) float64 { - return a * b + "mul": func(a, b any) any { + return doMathOpWithAny(a, b, "mul") }, - "div": func(a, b float64) float64 { - if b == 0 { - return math.NaN() + "div": func(a, b any) any { + return doMathOpWithAny(a, b, "div") + }, + "now": func() time.Time { + return time.Now() + }, + "duration": func(str string) time.Duration { + d, err := time.ParseDuration(str) + if err != nil { + return 0 } - return a / b + return d }, "parseTime": customAPIFuncParseTime, "toRelativeTime": dynamicRelativeTimeAttrs, From 958805a1fd6aad0d79d5a9b5393ed310a8d48d1c Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 29 Mar 2025 10:49:37 +0000 Subject: [PATCH 3/8] Increase z-index of mobile nav This fixes the carousel gradient side being above it since it also has z-index 10 --- internal/glance/static/main.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css index 7b7b592..9d5fde4 100644 --- a/internal/glance/static/main.css +++ b/internal/glance/static/main.css @@ -1839,7 +1839,7 @@ details[open] .summary::after { transform: translateY(calc(100% - var(--mobile-navigation-height))); left: var(--content-bounds-padding); right: var(--content-bounds-padding); - z-index: 10; + z-index: 11; background-color: var(--color-widget-background); border: 1px solid var(--color-widget-content-border); border-bottom: 0; From 26d68ba3fc1794f054a23c548b4a6476deb64495 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 29 Mar 2025 10:56:11 +0000 Subject: [PATCH 4/8] Allow extension widget to specify title-url --- docs/extensions.md | 3 +++ internal/glance/widget-extension.go | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index b1fa4fa..b6719c1 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -26,6 +26,9 @@ If you know how to setup an HTTP server and a bit of HTML and CSS you're ready t ### `Widget-Title` Used to specify the title of the widget. If not provided, the widget's title will be "Extension". +### `Widget-Title-URL` +Used to specify the URL that will be opened when the widget's title is clicked. If the user has specified a `title-url` in their config, it will take precedence over this header. + ### `Widget-Content-Type` Used to specify the content type that will be returned by the extension. If not provided, the content will be shown as plain text. diff --git a/internal/glance/widget-extension.go b/internal/glance/widget-extension.go index 3732eb8..c2b11f5 100644 --- a/internal/glance/widget-extension.go +++ b/internal/glance/widget-extension.go @@ -59,6 +59,10 @@ func (widget *extensionWidget) update(ctx context.Context) { widget.Title = extension.Title } + if widget.TitleURL == "" && extension.TitleURL != "" { + widget.TitleURL = extension.TitleURL + } + widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate) } @@ -69,8 +73,8 @@ func (widget *extensionWidget) Render() template.HTML { type extensionType int const ( - extensionContentHTML extensionType = iota - extensionContentUnknown = iota + extensionContentHTML extensionType = iota + extensionContentUnknown ) var extensionStringToType = map[string]extensionType{ @@ -79,6 +83,7 @@ var extensionStringToType = map[string]extensionType{ const ( extensionHeaderTitle = "Widget-Title" + extensionHeaderTitleURL = "Widget-Title-URL" extensionHeaderContentType = "Widget-Content-Type" extensionHeaderContentFrameless = "Widget-Content-Frameless" ) @@ -93,6 +98,7 @@ type extensionRequestOptions struct { type extension struct { Title string + TitleURL string Content template.HTML Frameless bool } @@ -142,6 +148,10 @@ func fetchExtension(options extensionRequestOptions) (extension, error) { extension.Title = response.Header.Get(extensionHeaderTitle) } + if response.Header.Get(extensionHeaderTitleURL) != "" { + extension.TitleURL = response.Header.Get(extensionHeaderTitleURL) + } + contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)] if !ok { From f15d7445bd67283790dc61c32142d0aab0b67c1a Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 29 Mar 2025 13:20:53 +0000 Subject: [PATCH 5/8] Add concat function --- docs/custom-api.md | 1 + internal/glance/widget-custom-api.go | 3 +++ 2 files changed, 4 insertions(+) diff --git a/docs/custom-api.md b/docs/custom-api.md index 8e432c9..4e4ec03 100644 --- a/docs/custom-api.md +++ b/docs/custom-api.md @@ -345,6 +345,7 @@ The following helper functions provided by Glance are available: - `sortByInt(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by an integer key in either ascending or descending order. - `sortByFloat(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a float key in either ascending or descending order. - `sortByTime(key string, layout string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a time key in either ascending or descending order. The format must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). +- `concat(strings ...string) string`: Concatenates multiple strings together. The following helper functions provided by Go's `text/template` are available: diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index be83032..5d7654f 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -514,6 +514,9 @@ var customAPITemplateFuncs = func() template.FuncMap { return results }, + "concat": func(items ...string) string { + return strings.Join(items, "") + }, } for key, value := range globalTemplateFunctions { From 779304d0351f177eeeb7a6d6270d7db2abbb0491 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 29 Mar 2025 13:36:22 +0000 Subject: [PATCH 6/8] Allow skipping JSON check and add JSONLines --- docs/configuration.md | 4 +++ docs/custom-api.md | 49 ++++++++++++++++++++++++++++ internal/glance/widget-custom-api.go | 32 ++++++++++++------ 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 15ee95f..a7edbde 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1297,6 +1297,7 @@ Examples: | body | any | no | | | frameless | boolean | no | false | | allow-insecure | boolean | no | false | +| skip-json-validation | boolean | no | false | | template | string | yes | | | parameters | key (string) & value (string|array) | no | | | subrequests | map of requests | no | | @@ -1344,6 +1345,9 @@ When set to `true`, removes the border and padding around the widget. ##### `allow-insecure` Whether to ignore invalid/self-signed certificates. +##### `skip-json-validation` +When set to `true`, skips the JSON validation step. This is useful when the API returns JSON Lines/newline-delimited JSON, which is a format that consists of several JSON objects separated by newlines. + ##### `template` The template that will be used to display the data. It relies on Go's `html/template` package so it's recommended to go through [its documentation](https://pkg.go.dev/text/template) to understand how to do basic things such as conditionals, loops, etc. In addition, it also uses [tidwall's gjson](https://github.com/tidwall/gjson) package to parse the JSON data so it's worth going through its documentation if you want to use more advanced JSON selectors. You can view additional examples with explanations and function definitions [here](custom-api.md). diff --git a/docs/custom-api.md b/docs/custom-api.md index 4e4ec03..42467da 100644 --- a/docs/custom-api.md +++ b/docs/custom-api.md @@ -309,6 +309,55 @@ You can also access the response headers:
{{ .Response.Header.Get "Content-Type" }}
``` +
+ +JSON response: + +```json +{"name": "Steve", "age": 30} +{"name": "Alex", "age": 25} +{"name": "John", "age": 35} +``` + +The above format is "[ndjson](https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson)" or "[JSON Lines](https://jsonlines.org/)", where each line is a separate JSON object. To parse this format, you must first disable the JSON validation check in your config, since by default the response is expected to be a single valid JSON object: + +```yaml +- type: custom-api + skip-json-validation: true +``` + +Then, to iterate over each object you can use `.JSONLines`: + +```html +{{ range .JSONLines }} +

{{ .String "name" }} is {{ .Int "age" }} years old

+{{ end }} +``` + +Output: + +```html +

Steve is 30 years old

+

Alex is 25 years old

+

John is 35 years old

+``` + +For other ways of selecting data from a JSON Lines response, have a look at the docs for [tidwall/gjson](https://github.com/tidwall/gjson/tree/master?tab=readme-ov-file#json-lines). For example, to get an array of all names, you can use the following: + +```html +{{ range .JSON.Array "..#.name" }} +

{{ .String "" }}

+{{ end }} +``` + +Output: + +```html +

Steve

+

Alex

+

John

+``` + ## Functions The following functions are available on the `JSON` object: diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index 5d7654f..e8ac225 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -25,15 +25,16 @@ var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base. // Needs to be exported for the YAML unmarshaler to work type CustomAPIRequest struct { - URL string `yaml:"url"` - AllowInsecure bool `yaml:"allow-insecure"` - Headers map[string]string `yaml:"headers"` - Parameters queryParametersField `yaml:"parameters"` - Method string `yaml:"method"` - BodyType string `yaml:"body-type"` - Body any `yaml:"body"` - bodyReader io.ReadSeeker `yaml:"-"` - httpRequest *http.Request `yaml:"-"` + URL string `yaml:"url"` + AllowInsecure bool `yaml:"allow-insecure"` + Headers map[string]string `yaml:"headers"` + Parameters queryParametersField `yaml:"parameters"` + Method string `yaml:"method"` + BodyType string `yaml:"body-type"` + Body any `yaml:"body"` + SkipJSONValidation bool `yaml:"skip-json-validation"` + bodyReader io.ReadSeeker `yaml:"-"` + httpRequest *http.Request `yaml:"-"` } type customAPIWidget struct { @@ -157,6 +158,17 @@ type customAPITemplateData struct { subrequests map[string]*customAPIResponseData } +func (data *customAPITemplateData) JSONLines() []decoratedGJSONResult { + result := make([]decoratedGJSONResult, 0, 5) + + gjson.ForEachLine(data.JSON.Raw, func(line gjson.Result) bool { + result = append(result, decoratedGJSONResult{line}) + return true + }) + + return result +} + func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData { req, exists := data.subrequests[key] if !exists { @@ -190,7 +202,7 @@ func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customA body := strings.TrimSpace(string(bodyBytes)) - if body != "" && !gjson.Valid(body) { + if !req.SkipJSONValidation && body != "" && !gjson.Valid(body) { truncatedBody, isTruncated := limitStringLength(body, 100) if isTruncated { truncatedBody += "... " From 964744a9ae6d99420df2b679b11dd1ede762db2d Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 29 Mar 2025 17:57:43 +0000 Subject: [PATCH 7/8] Add offsetNow function --- docs/custom-api.md | 1 + internal/glance/widget-custom-api.go | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/docs/custom-api.md b/docs/custom-api.md index 42467da..336c861 100644 --- a/docs/custom-api.md +++ b/docs/custom-api.md @@ -375,6 +375,7 @@ The following helper functions provided by Glance are available: - `toInt(f float) int`: Converts a float to an integer. - `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. ``. - `now() time.Time`: Returns the current time. +- `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s". - `duration(str string) time.Duration`: Parses a string such as `1h`, `1d`, `5h30m`, etc into a `time.Duration`. - `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly". - `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`. diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index e8ac225..c9db2df 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -435,6 +435,13 @@ var customAPITemplateFuncs = func() template.FuncMap { "now": func() time.Time { return time.Now() }, + "offsetNow": func(offset string) time.Time { + d, err := time.ParseDuration(offset) + if err != nil { + return time.Now() + } + return time.Now().Add(d) + }, "duration": func(str string) time.Duration { d, err := time.ParseDuration(str) if err != nil { From 9da79671582c91f17508662bc936c3f673595ada Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 29 Mar 2025 18:00:36 +0000 Subject: [PATCH 8/8] Update docs --- docs/configuration.md | 2 +- docs/custom-api.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a7edbde..fa0f49e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -365,7 +365,7 @@ pages: | show-mobile-header | boolean | no | false | | columns | array | yes | | -#### `title` +#### `name` The name of the page which gets shown in the navigation bar. #### `slug` diff --git a/docs/custom-api.md b/docs/custom-api.md index 336c861..99e3a5e 100644 --- a/docs/custom-api.md +++ b/docs/custom-api.md @@ -376,7 +376,7 @@ The following helper functions provided by Glance are available: - `toRelativeTime(t time.Time) template.HTMLAttr`: Converts Time to a relative time such as 2h, 1d, etc which dynamically updates. **NOTE:** the value of this function should be used as an attribute in an HTML tag, e.g. ``. - `now() time.Time`: Returns the current time. - `offsetNow(offset string) time.Time`: Returns the current time with an offset. The offset can be positive or negative and must be in the format "3h" "-1h" or "2h30m10s". -- `duration(str string) time.Duration`: Parses a string such as `1h`, `1d`, `5h30m`, etc into a `time.Duration`. +- `duration(str string) time.Duration`: Parses a string such as `1h`, `24h`, `5h30m`, etc into a `time.Duration`. - `parseTime(layout string, s string) time.Time`: Parses a string into time.Time. The layout must be provided in Go's [date format](https://pkg.go.dev/time#pkg-constants). You can alternatively use these values instead of the literal format: "unix", "RFC3339", "RFC3339Nano", "DateTime", "DateOnly". - `parseRelativeTime(layout string, s string) time.Time`: A shorthand for `{{ .String "date" | parseTime "rfc3339" | toRelativeTime }}`. - `add(a, b float) float`: Adds two numbers.