From 844e32a11e7fed48f38cdfa7aceebd6f96237108 Mon Sep 17 00:00:00 2001 From: slashformotion Date: Mon, 17 Mar 2025 16:26:43 +0100 Subject: [PATCH 01/17] refacto: remove duplicate --- internal/glance/diagnose.go | 4 ++-- internal/glance/utils.go | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/internal/glance/diagnose.go b/internal/glance/diagnose.go index 892aa5f..27d0e19 100644 --- a/internal/glance/diagnose.go +++ b/internal/glance/diagnose.go @@ -103,7 +103,7 @@ func runDiagnostic() { 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.Println("In Docker container: " + ternary(isRunningInsideDockerContainer(), "yes", "no")) fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds())) @@ -129,7 +129,7 @@ func runDiagnostic() { fmt.Printf( "%s %s %s| %dms\n", - boolToString(step.err == nil, "✓ Can", "✗ Can't"), + ternary(step.err == nil, "✓ Can", "✗ Can't"), step.name, extraInfo, step.elapsed.Milliseconds(), diff --git a/internal/glance/utils.go b/internal/glance/utils.go index 72d5a28..a614bda 100644 --- a/internal/glance/utils.go +++ b/internal/glance/utils.go @@ -119,14 +119,6 @@ func parseRFC3339Time(t string) time.Time { return parsed } -func boolToString(b bool, trueValue, falseValue string) string { - if b { - return trueValue - } - - return falseValue -} - func normalizeVersionFormat(version string) string { version = strings.ToLower(strings.TrimSpace(version)) From 1512718d416f4580f7e7bb60c8d5878320e1f032 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Thu, 20 Mar 2025 19:27:29 +0000 Subject: [PATCH 02/17] Update readme Added "Common issues" --- README.md | 113 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 71 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index cf64d2f..127a7d4 100644 --- a/README.md +++ b/README.md @@ -264,59 +264,31 @@ Glance can also be installed through the following 3rd party channels:
-## Building from source - -Choose one of the following methods: - +## Common issues
-Build binary with Go -
+Requests timing out -Requirements: [Go](https://go.dev/dl/) >= v1.23 +The most common cause of this is when using Pi-Hole, AdGuard Home or other ad-blocking DNS services, which by default have a fairly low rate limit. Depending on the number of widgets you have in a single page, this limit can very easily be exceeded. To fix this, increase the rate limit in the settings of your DNS service. -To build the project for your current OS and architecture, run: - -```bash -go build -o build/glance . +If using Podman, in some rare cases the timeout can be caused by an unknown issue, in which case it may be resolved by adding the following to the bottom of your `docker-compose.yml` file: +```yaml +networks: + podman: + external: true ``` - -To build for a specific OS and architecture, run: - -```bash -GOOS=linux GOARCH=amd64 go build -o build/glance . -``` - -[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH) - -Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run: - -```bash -go run . -``` -
-Build project and Docker image with Docker -
+Broken layout for markets, bookmarks or other widgets -Requirements: [Docker](https://docs.docker.com/engine/install/) +This is almost always caused by the browser extension Dark Reader. To fix this, disable dark mode for the domain where Glance is hosted. +
-To build the project and image using just Docker, run: +
+cannot unmarshal !!map into []glance.page -*(replace `owner` with your name or organization)* +The most common cause of this is having a `pages` key in your `glance.yml` and then also having a `pages` key inside one of your included pages. To fix this, remove the `pages` key from the top of your included pages. -```bash -docker build -t owner/glance:latest . -``` - -If you wish to push the image to a registry (by default Docker Hub), run: - -```bash -docker push owner/glance:latest -``` - -

@@ -375,6 +347,63 @@ Feature requests are tagged with one of the following:
+## Building from source + +Choose one of the following methods: + +
+Build binary with Go +
+ +Requirements: [Go](https://go.dev/dl/) >= v1.23 + +To build the project for your current OS and architecture, run: + +```bash +go build -o build/glance . +``` + +To build for a specific OS and architecture, run: + +```bash +GOOS=linux GOARCH=amd64 go build -o build/glance . +``` + +[*click here for a full list of GOOS and GOARCH combinations*](https://go.dev/doc/install/source#:~:text=$GOOS%20and%20$GOARCH) + +Alternatively, if you just want to run the app without creating a binary, like when you're testing out changes, you can run: + +```bash +go run . +``` +
+
+ +
+Build project and Docker image with Docker +
+ +Requirements: [Docker](https://docs.docker.com/engine/install/) + +To build the project and image using just Docker, run: + +*(replace `owner` with your name or organization)* + +```bash +docker build -t owner/glance:latest . +``` + +If you wish to push the image to a registry (by default Docker Hub), run: + +```bash +docker push owner/glance:latest +``` + +
+
+ +
+ ## Contributing guidelines * Before working on a new feature it's preferable to submit a feature request first and state that you'd like to implement it yourself From 026b644630d3b9823be59e0de2d337dc7071b0d5 Mon Sep 17 00:00:00 2001 From: Ralph Ocdol Date: Fri, 28 Feb 2025 08:48:07 +0800 Subject: [PATCH 03/17] feat: add parameters and array parameters support --- docs/configuration.md | 12 ++++++++ internal/glance/widget-custom-api.go | 42 +++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 231c04c..3c5dcc4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1294,6 +1294,7 @@ Examples: | headers | key (string) & value (string) | no | | | frameless | boolean | no | false | | template | string | yes | | +| parameters | key & value | no | | ##### `url` The URL to fetch the data from. It must be accessible from the server that Glance is running on. @@ -1313,6 +1314,17 @@ When set to `true`, removes the border and padding around the widget. ##### `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). +##### `parameters` +A list of keys and values that will be sent to the custom-api as query paramters. + +```yaml +parameters: + param1: value1 + param2: + - item1 + - item2 +``` + ### Extension Display a widget provided by an external source (3rd party). If you want to learn more about developing extensions, checkout the [extensions documentation](extensions.md) (WIP). diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index 197ba68..6866c1e 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -10,6 +10,7 @@ import ( "log/slog" "math" "net/http" + "net/url" "time" "github.com/tidwall/gjson" @@ -19,13 +20,14 @@ var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base. type customAPIWidget struct { widgetBase `yaml:",inline"` - URL string `yaml:"url"` - Template string `yaml:"template"` - Frameless bool `yaml:"frameless"` - Headers map[string]string `yaml:"headers"` - APIRequest *http.Request `yaml:"-"` - compiledTemplate *template.Template `yaml:"-"` - CompiledHTML template.HTML `yaml:"-"` + URL string `yaml:"url"` + Template string `yaml:"template"` + Frameless bool `yaml:"frameless"` + Headers map[string]string `yaml:"headers"` + Parameters map[string]interface{} `yaml:"parameters"` + APIRequest *http.Request `yaml:"-"` + compiledTemplate *template.Template `yaml:"-"` + CompiledHTML template.HTML `yaml:"-"` } func (widget *customAPIWidget) initialize() error { @@ -51,6 +53,32 @@ func (widget *customAPIWidget) initialize() error { return err } + query := url.Values{} + + for key, value := range widget.Parameters { + switch v := value.(type) { + case string: + query.Add(key, v) + case int, int8, int16, int32, int64, float32, float64: + query.Add(key, fmt.Sprintf("%v", v)) + case []string: + for _, item := range v { + query.Add(key, item) + } + case []interface{}: + for _, item := range v { + switch item := item.(type) { + case string: + query.Add(key, item) + case int, int8, int16, int32, int64, float32, float64: + query.Add(key, fmt.Sprintf("%v", item)) + } + } + } + } + + req.URL.RawQuery = query.Encode() + for key, value := range widget.Headers { req.Header.Add(key, value) } From 58bbfbbc424c50e6caee25e5ca3da8a33c08b12c Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 1 Mar 2025 23:29:28 +0000 Subject: [PATCH 04/17] Make query parameters field reusable --- internal/glance/config-fields.go | 52 ++++++++++++++++++++++++++++ internal/glance/widget-custom-api.go | 43 +++++------------------ 2 files changed, 61 insertions(+), 34 deletions(-) diff --git a/internal/glance/config-fields.go b/internal/glance/config-fields.go index f3c836e..966b366 100644 --- a/internal/glance/config-fields.go +++ b/internal/glance/config-fields.go @@ -219,3 +219,55 @@ func (p *proxyOptionsField) UnmarshalYAML(node *yaml.Node) error { return nil } + +type queryParametersField map[string][]string + +func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error { + var decoded map[string]any + + if err := node.Decode(&decoded); err != nil { + return err + } + + *q = make(queryParametersField) + + for key, value := range decoded { + switch v := value.(type) { + case string: + (*q)[key] = []string{v} + case int, int8, int16, int32, int64, float32, float64: + (*q)[key] = []string{fmt.Sprintf("%v", v)} + case []string: + (*q)[key] = append((*q)[key], v...) + case []any: + for _, item := range v { + switch item := item.(type) { + case string: + (*q)[key] = append((*q)[key], item) + case int, int8, int16, int32, int64, float32, float64: + (*q)[key] = append((*q)[key], fmt.Sprintf("%v", item)) + case bool: + (*q)[key] = append((*q)[key], fmt.Sprintf("%t", item)) + default: + return fmt.Errorf("invalid query parameter value type: %T", item) + } + } + default: + return fmt.Errorf("invalid query parameter value type: %T", value) + } + } + + return nil +} + +func (q *queryParametersField) toQueryString() string { + query := url.Values{} + + for key, values := range *q { + for _, value := range values { + query.Add(key, value) + } + } + + return query.Encode() +} diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index 6866c1e..f7830ce 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -10,7 +10,6 @@ import ( "log/slog" "math" "net/http" - "net/url" "time" "github.com/tidwall/gjson" @@ -20,14 +19,14 @@ var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base. type customAPIWidget struct { widgetBase `yaml:",inline"` - URL string `yaml:"url"` - Template string `yaml:"template"` - Frameless bool `yaml:"frameless"` - Headers map[string]string `yaml:"headers"` - Parameters map[string]interface{} `yaml:"parameters"` - APIRequest *http.Request `yaml:"-"` - compiledTemplate *template.Template `yaml:"-"` - CompiledHTML template.HTML `yaml:"-"` + URL string `yaml:"url"` + Template string `yaml:"template"` + Frameless bool `yaml:"frameless"` + Headers map[string]string `yaml:"headers"` + Parameters queryParametersField `yaml:"parameters"` + APIRequest *http.Request `yaml:"-"` + compiledTemplate *template.Template `yaml:"-"` + CompiledHTML template.HTML `yaml:"-"` } func (widget *customAPIWidget) initialize() error { @@ -53,31 +52,7 @@ func (widget *customAPIWidget) initialize() error { return err } - query := url.Values{} - - for key, value := range widget.Parameters { - switch v := value.(type) { - case string: - query.Add(key, v) - case int, int8, int16, int32, int64, float32, float64: - query.Add(key, fmt.Sprintf("%v", v)) - case []string: - for _, item := range v { - query.Add(key, item) - } - case []interface{}: - for _, item := range v { - switch item := item.(type) { - case string: - query.Add(key, item) - case int, int8, int16, int32, int64, float32, float64: - query.Add(key, fmt.Sprintf("%v", item)) - } - } - } - } - - req.URL.RawQuery = query.Encode() + req.URL.RawQuery = widget.Parameters.toQueryString() for key, value := range widget.Headers { req.Header.Add(key, value) From a943555d864420b78de5264f7d775ca04f020fea Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 1 Mar 2025 23:29:56 +0000 Subject: [PATCH 05/17] Add note to docs --- docs/configuration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 3c5dcc4..480a0d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1317,6 +1317,10 @@ The template that will be used to display the data. It relies on Go's `html/temp ##### `parameters` A list of keys and values that will be sent to the custom-api as query paramters. +> [!NOTE] +> +> Setting this property will override any query parameters that are already in the URL. + ```yaml parameters: param1: value1 From 176fc896ae51fb78f634aba22a16c29850af3adf Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 1 Mar 2025 23:30:20 +0000 Subject: [PATCH 06/17] Also apply to extension widget --- internal/glance/widget-extension.go | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/internal/glance/widget-extension.go b/internal/glance/widget-extension.go index 72a4a09..47034f3 100644 --- a/internal/glance/widget-extension.go +++ b/internal/glance/widget-extension.go @@ -19,12 +19,12 @@ const extensionWidgetDefaultTitle = "Extension" 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:"-"` + URL string `yaml:"url"` + FallbackContentType string `yaml:"fallback-content-type"` + Parameters queryParametersField `yaml:"parameters"` + AllowHtml bool `yaml:"allow-potentially-dangerous-html"` + Extension extension `yaml:"-"` + cachedHTML template.HTML `yaml:"-"` } func (widget *extensionWidget) initialize() error { @@ -82,10 +82,10 @@ const ( ) 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"` + URL string `yaml:"url"` + FallbackContentType string `yaml:"fallback-content-type"` + Parameters queryParametersField `yaml:"parameters"` + AllowHtml bool `yaml:"allow-potentially-dangerous-html"` } type extension struct { @@ -109,14 +109,7 @@ func convertExtensionContent(options extensionRequestOptions, content []byte, co 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() + request.URL.RawQuery = options.Parameters.toQueryString() response, err := http.DefaultClient.Do(request) if err != nil { From 2be59c3eb66dc41ce74d8de1f286566f9acfc870 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 1 Mar 2025 23:43:33 +0000 Subject: [PATCH 07/17] Fix failing to parse empty response body in custom api widget --- internal/glance/widget-custom-api.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index f7830ce..54db2d7 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -10,6 +10,7 @@ import ( "log/slog" "math" "net/http" + "strings" "time" "github.com/tidwall/gjson" @@ -90,9 +91,9 @@ func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (templat return emptyBody, err } - body := string(bodyBytes) + body := strings.TrimSpace(string(bodyBytes)) - if !gjson.Valid(body) { + if body != "" && !gjson.Valid(body) { truncatedBody, isTruncated := limitStringLength(body, 100) if isTruncated { truncatedBody += "... " From b2c52050350c669791ba65fb7cd76bef55ad68db Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:56:47 +0000 Subject: [PATCH 08/17] Add support for bool in query params fields --- internal/glance/config-fields.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/glance/config-fields.go b/internal/glance/config-fields.go index 966b366..e2ece3f 100644 --- a/internal/glance/config-fields.go +++ b/internal/glance/config-fields.go @@ -231,12 +231,15 @@ func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error { *q = make(queryParametersField) + // TODO: refactor the duplication in the switch cases if any more types get added for key, value := range decoded { switch v := value.(type) { case string: (*q)[key] = []string{v} case int, int8, int16, int32, int64, float32, float64: (*q)[key] = []string{fmt.Sprintf("%v", v)} + case bool: + (*q)[key] = []string{fmt.Sprintf("%t", v)} case []string: (*q)[key] = append((*q)[key], v...) case []any: From cee7c857804fb5565432530e49d8c246cf9119b4 Mon Sep 17 00:00:00 2001 From: Ralph Ocdol Date: Wed, 12 Mar 2025 18:30:29 +0800 Subject: [PATCH 09/17] Add subrequests to custom-api (#385) * feat: custom-api multiple API queries * fix template check * refactor * Update implementation & docs * Swap statement * Update docs --------- Co-authored-by: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> --- docs/configuration.md | 37 +++++- internal/glance/widget-custom-api.go | 189 +++++++++++++++++++++------ 2 files changed, 185 insertions(+), 41 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 480a0d7..a421e0e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1294,7 +1294,8 @@ Examples: | headers | key (string) & value (string) | no | | | frameless | boolean | no | false | | template | string | yes | | -| parameters | key & value | no | | +| parameters | key (string) & value (string|array) | no | | +| subrequests | map of requests | no | | ##### `url` The URL to fetch the data from. It must be accessible from the server that Glance is running on. @@ -1317,6 +1318,40 @@ The template that will be used to display the data. It relies on Go's `html/temp ##### `parameters` A list of keys and values that will be sent to the custom-api as query paramters. +##### `subrequests` +A map of additional requests that will be executed concurrently and then made available in the template via the `.Subrequest` property. Example: + +```yaml +- type: custom-api + cache: 2h + subrequests: + another-one: + url: https://uselessfacts.jsph.pl/api/v2/facts/random + title: Random Fact + url: https://uselessfacts.jsph.pl/api/v2/facts/random + template: | +

{{ .JSON.String "text" }}

+

{{ (.Subrequest "another-one").JSON.String "text" }}

+``` + +The subrequests support all the same properties as the main request, except for `subrequests` itself, so you can use `headers`, `parameters`, etc. + +`(.Subrequest "key")` can be a little cumbersome to write, so you can define a variable to make it easier: + +```yaml + template: | + {{ $anotherOne := .Subrequest "another-one" }} +

{{ $anotherOne.JSON.String "text" }}

+``` + +You can also access the `.Response` property of a subrequest as you would with the main request: + +```yaml + template: | + {{ $anotherOne := .Subrequest "another-one" }} +

{{ $anotherOne.Response.StatusCode }}

+``` + > [!NOTE] > > Setting this property will override any query parameters that are already in the URL. diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index 54db2d7..287cc47 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -11,6 +11,7 @@ import ( "math" "net/http" "strings" + "sync" "time" "github.com/tidwall/gjson" @@ -18,23 +19,35 @@ import ( var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html") +// Needs to be exported for the YAML unmarshaler to work +type CustomAPIRequest struct { + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Parameters queryParametersField `json:"parameters"` + httpRequest *http.Request `yaml:"-"` +} + type customAPIWidget struct { - widgetBase `yaml:",inline"` - URL string `yaml:"url"` - Template string `yaml:"template"` - Frameless bool `yaml:"frameless"` - Headers map[string]string `yaml:"headers"` - Parameters queryParametersField `yaml:"parameters"` - APIRequest *http.Request `yaml:"-"` - compiledTemplate *template.Template `yaml:"-"` - CompiledHTML template.HTML `yaml:"-"` + widgetBase `yaml:",inline"` + *CustomAPIRequest `yaml:",inline"` // the primary request + Subrequests map[string]*CustomAPIRequest `yaml:"subrequests"` + Template string `yaml:"template"` + Frameless bool `yaml:"frameless"` + 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 err := widget.CustomAPIRequest.initialize(); err != nil { + return fmt.Errorf("initializing primary request: %v", err) + } + + for key := range widget.Subrequests { + if err := widget.Subrequests[key].initialize(); err != nil { + return fmt.Errorf("initializing subrequest %q: %v", key, err) + } } if widget.Template == "" { @@ -48,24 +61,11 @@ func (widget *customAPIWidget) initialize() error { widget.compiledTemplate = compiledTemplate - req, err := http.NewRequest(http.MethodGet, widget.URL, nil) - if err != nil { - return err - } - - req.URL.RawQuery = widget.Parameters.toQueryString() - - for key, value := range widget.Headers { - req.Header.Add(key, value) - } - - widget.APIRequest = req - return nil } func (widget *customAPIWidget) update(ctx context.Context) { - compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate) + compiledHTML, err := fetchAndParseCustomAPI(widget.CustomAPIRequest, widget.Subrequests, widget.compiledTemplate) if !widget.canContinueUpdateAfterHandlingErr(err) { return } @@ -77,18 +77,63 @@ 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("") +func (req *CustomAPIRequest) initialize() error { + if req.URL == "" { + return errors.New("URL is required") + } - resp, err := defaultHTTPClient.Do(req) + httpReq, err := http.NewRequest(http.MethodGet, req.URL, nil) if err != nil { - return emptyBody, err + return err + } + + if len(req.Parameters) > 0 { + httpReq.URL.RawQuery = req.Parameters.toQueryString() + } + + for key, value := range req.Headers { + httpReq.Header.Add(key, value) + } + + req.httpRequest = httpReq + + return nil +} + +type customAPIResponseData struct { + JSON decoratedGJSONResult + Response *http.Response +} + +type customAPITemplateData struct { + *customAPIResponseData + subrequests map[string]*customAPIResponseData +} + +func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData { + req, exists := data.subrequests[key] + if !exists { + // We have to panic here since there's nothing sensible we can return and the + // lack of an error would cause requested data to return zero values which + // would be confusing from the user's perspective. Go's template module + // handles recovering from panics and will return the panic message as an + // error during template execution. + panic(fmt.Sprintf("subrequest with key %q has not been defined", key)) + } + + return req +} + +func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) { + resp, err := defaultHTTPClient.Do(req.httpRequest.WithContext(ctx)) + if err != nil { + return nil, err } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return emptyBody, err + return nil, err } body := strings.TrimSpace(string(bodyBytes)) @@ -99,17 +144,86 @@ func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (templat truncatedBody += "... " } - slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), "body", truncatedBody) - return emptyBody, errors.New("invalid response JSON") + slog.Error("Invalid response JSON in custom API widget", "url", req.httpRequest.URL.String(), "body", truncatedBody) + return nil, errors.New("invalid response JSON") } - var templateBuffer bytes.Buffer - - data := customAPITemplateData{ + data := &customAPIResponseData{ JSON: decoratedGJSONResult{gjson.Parse(body)}, Response: resp, } + return data, nil +} + +func fetchAndParseCustomAPI( + primaryReq *CustomAPIRequest, + subReqs map[string]*CustomAPIRequest, + tmpl *template.Template, +) (template.HTML, error) { + var primaryData *customAPIResponseData + subData := make(map[string]*customAPIResponseData, len(subReqs)) + var err error + + if len(subReqs) == 0 { + // If there are no subrequests, we can fetch the primary request in a much simpler way + primaryData, err = fetchCustomAPIRequest(context.Background(), primaryReq) + } else { + // If there are subrequests, we need to fetch them concurrently + // and cancel all requests if any of them fail. There's probably + // a more elegant way to do this, but this works for now. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + var mu sync.Mutex // protects subData and err + + wg.Add(1) + go func() { + defer wg.Done() + var localErr error + primaryData, localErr = fetchCustomAPIRequest(ctx, primaryReq) + mu.Lock() + if localErr != nil && err == nil { + err = localErr + cancel() + } + mu.Unlock() + }() + + for key, req := range subReqs { + wg.Add(1) + go func() { + defer wg.Done() + var localErr error + var data *customAPIResponseData + data, localErr = fetchCustomAPIRequest(ctx, req) + mu.Lock() + if localErr == nil { + subData[key] = data + } else if err == nil { + err = localErr + cancel() + } + mu.Unlock() + }() + } + + wg.Wait() + } + + emptyBody := template.HTML("") + + if err != nil { + return emptyBody, err + } + + data := customAPITemplateData{ + customAPIResponseData: primaryData, + subrequests: subData, + } + + var templateBuffer bytes.Buffer err = tmpl.Execute(&templateBuffer, &data) if err != nil { return emptyBody, err @@ -122,11 +236,6 @@ type decoratedGJSONResult struct { gjson.Result } -type customAPITemplateData struct { - JSON decoratedGJSONResult - Response *http.Response -} - func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult { decoratedResults := make([]decoratedGJSONResult, len(results)) From 2f88ac419bcf2c52cf66d53ce3939b9ccdfa9b0f Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Thu, 13 Mar 2025 00:55:31 +0000 Subject: [PATCH 10/17] Add parseTime to custom-api --- docs/custom-api.md | 53 ++++++++++++++++++++++++++++ internal/glance/templates.go | 8 +++-- internal/glance/widget-custom-api.go | 22 ++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/custom-api.md b/docs/custom-api.md index 91c501b..9848276 100644 --- a/docs/custom-api.md +++ b/docs/custom-api.md @@ -242,6 +242,57 @@ Other operations include `add`, `mul`, and `div`.
+JSON response: + +```json +{ + "posts": [ + { + "title": "Exploring the Depths of Quantum Computing", + "date": "2023-10-27T10:00:00Z" + }, + { + "title": "A Beginner's Guide to Sustainable Living", + "date": "2023-11-15T14:30:00+01:00" + }, + { + "title": "The Art of Baking Sourdough Bread", + "date": "2023-12-03T08:45:22-08:00" + } + ] +} +``` + +To parse the date and display the relative time (e.g. 2h, 1d, etc), you would use the following: + +``` +{{ range .JSON.Array "posts" }} +
{{ .String "title" }}
+
+{{ end }} +``` + +The `parseTime` function takes two arguments: the layout of the date string and the date string itself. The layout can be one of the following: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly" or a custom layout in Go's [date format](https://pkg.go.dev/time#pkg-constants). + +Output: + +```html +
Exploring the Depths of Quantum Computing
+
+ +
A Beginner's Guide to Sustainable Living
+
+ +
The Art of Baking Sourdough Bread
+
+``` + +You don't have to worry about the internal implementation, this will then be dynamically populated by Glance on the client side to show the correct relative time. + +The important thing to notice here is that the return value of `toRelativeTime` must be used as an attribute in an HTML tag, be it a `div`, `li`, `span`, etc. + +
+ In some instances, you may want to know the status code of the response. This can be done using the following: ```html @@ -273,6 +324,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. ``. +- `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: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly". - `add(a, b float) float`: Adds two numbers. - `sub(a, b float) float`: Subtracts two numbers. - `mul(a, b float) float`: Multiplies two numbers. diff --git a/internal/glance/templates.go b/internal/glance/templates.go index ed83842..ec8d0f3 100644 --- a/internal/glance/templates.go +++ b/internal/glance/templates.go @@ -27,9 +27,7 @@ var globalTemplateFunctions = template.FuncMap{ "formatPrice": func(price float64) string { return intl.Sprintf("%.2f", price) }, - "dynamicRelativeTimeAttrs": func(t interface{ Unix() int64 }) template.HTMLAttr { - return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`) - }, + "dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs, "formatServerMegabytes": func(mb uint64) template.HTML { var value string var label string @@ -81,3 +79,7 @@ func formatApproxNumber(count int) string { return strconv.FormatFloat(float64(count)/1_000_000, 'f', 1, 64) + "m" } + +func dynamicRelativeTimeAttrs(t interface{ Unix() int64 }) template.HTMLAttr { + return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`) +} diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index 287cc47..a73dfc4 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -314,6 +314,28 @@ var customAPITemplateFuncs = func() template.FuncMap { return a / b }, + "parseTime": func(layout, value string) time.Time { + switch strings.ToLower(layout) { + case "rfc3339": + layout = time.RFC3339 + case "rfc3339nano": + layout = time.RFC3339Nano + case "datetime": + layout = time.DateTime + case "dateonly": + layout = time.DateOnly + case "timeonly": + layout = time.TimeOnly + } + + parsed, err := time.Parse(layout, value) + if err != nil { + return time.Unix(0, 0) + } + + return parsed + }, + "toRelativeTime": dynamicRelativeTimeAttrs, } for key, value := range globalTemplateFunctions { From 1ef8315462fc61731ec5f31fb2187943da41bbc8 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Mon, 17 Mar 2025 14:16:47 +0000 Subject: [PATCH 11/17] Only override extension url query if parameters property present --- internal/glance/widget-extension.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/glance/widget-extension.go b/internal/glance/widget-extension.go index 47034f3..c044a8a 100644 --- a/internal/glance/widget-extension.go +++ b/internal/glance/widget-extension.go @@ -109,7 +109,9 @@ func convertExtensionContent(options extensionRequestOptions, content []byte, co func fetchExtension(options extensionRequestOptions) (extension, error) { request, _ := http.NewRequest("GET", options.URL, nil) - request.URL.RawQuery = options.Parameters.toQueryString() + if len(options.Parameters) > 0 { + request.URL.RawQuery = options.Parameters.toQueryString() + } response, err := http.DefaultClient.Do(request) if err != nil { From 7276b3d5ef670e0f8f8d773339ead8265b8eb0af Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Thu, 20 Mar 2025 23:02:17 +0000 Subject: [PATCH 12/17] Add allow-insecure to custom-api widget --- docs/configuration.md | 4 ++++ internal/glance/widget-custom-api.go | 12 +++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a421e0e..0dd8219 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1293,6 +1293,7 @@ Examples: | url | string | yes | | | headers | key (string) & value (string) | no | | | frameless | boolean | no | false | +| allow-insecure | boolean | no | false | | template | string | yes | | | parameters | key (string) & value (string|array) | no | | | subrequests | map of requests | no | | @@ -1312,6 +1313,9 @@ headers: ##### `frameless` When set to `true`, removes the border and padding around the widget. +##### `allow-insecure` +Whether to ignore invalid/self-signed certificates. + ##### `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/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index a73dfc4..563d588 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -21,10 +21,11 @@ var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base. // Needs to be exported for the YAML unmarshaler to work type CustomAPIRequest struct { - URL string `json:"url"` - Headers map[string]string `json:"headers"` - Parameters queryParametersField `json:"parameters"` - httpRequest *http.Request `yaml:"-"` + URL string `json:"url"` + AllowInsecure bool `json:"allow-insecure"` + Headers map[string]string `json:"headers"` + Parameters queryParametersField `json:"parameters"` + httpRequest *http.Request `yaml:"-"` } type customAPIWidget struct { @@ -125,7 +126,8 @@ func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData } func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) { - resp, err := defaultHTTPClient.Do(req.httpRequest.WithContext(ctx)) + client := ternary(req.AllowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) + resp, err := client.Do(req.httpRequest.WithContext(ctx)) if err != nil { return nil, err } From a2c58bea8235cebb9f067b0540162ead5b034f6b Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Thu, 20 Mar 2025 23:49:09 +0000 Subject: [PATCH 13/17] Add parseRelativeTime function --- internal/glance/widget-custom-api.go | 48 ++++++++++++++++------------ 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index 563d588..d3ab123 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -316,28 +316,12 @@ var customAPITemplateFuncs = func() template.FuncMap { return a / b }, - "parseTime": func(layout, value string) time.Time { - switch strings.ToLower(layout) { - case "rfc3339": - layout = time.RFC3339 - case "rfc3339nano": - layout = time.RFC3339Nano - case "datetime": - layout = time.DateTime - case "dateonly": - layout = time.DateOnly - case "timeonly": - layout = time.TimeOnly - } - - parsed, err := time.Parse(layout, value) - if err != nil { - return time.Unix(0, 0) - } - - return parsed - }, + "parseTime": customAPIFuncParseTime, "toRelativeTime": dynamicRelativeTimeAttrs, + "parseRelativeTime": func(layout, value string) template.HTMLAttr { + // Shorthand to do both of the above with a single function call + return dynamicRelativeTimeAttrs(customAPIFuncParseTime(layout, value)) + }, } for key, value := range globalTemplateFunctions { @@ -348,3 +332,25 @@ var customAPITemplateFuncs = func() template.FuncMap { return funcs }() + +func customAPIFuncParseTime(layout, value string) time.Time { + switch strings.ToLower(layout) { + case "rfc3339": + layout = time.RFC3339 + case "rfc3339nano": + layout = time.RFC3339Nano + case "datetime": + layout = time.DateTime + case "dateonly": + layout = time.DateOnly + case "timeonly": + layout = time.TimeOnly + } + + parsed, err := time.Parse(layout, value) + if err != nil { + return time.Unix(0, 0) + } + + return parsed +} From 456b76ac968d9c432a0999a64106340226cb8687 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:58:35 +0000 Subject: [PATCH 14/17] Allow specifying method and request body --- docs/configuration.md | 28 ++++++++++++++ internal/glance/widget-custom-api.go | 57 +++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0dd8219..be5a988 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1292,6 +1292,9 @@ Examples: | ---- | ---- | -------- | ------- | | url | string | yes | | | headers | key (string) & value (string) | no | | +| method | string | no | GET | +| body-type | string | no | json | +| body | any | no | | | frameless | boolean | no | false | | allow-insecure | boolean | no | false | | template | string | yes | | @@ -1310,6 +1313,31 @@ headers: Accept: application/json ``` +##### `method` +The HTTP method to use when making the request. Possible values are `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` and `HEAD`. + +##### `body-type` +The type of the body that will be sent with the request. Possible values are `json`, and `string`. + +##### `body` +The body that will be sent with the request. It can be a string or a map. Example: + +```yaml +body-type: json +body: + key1: value1 + key2: value2 + multiple-items: + - item1 + - item2 +``` + +```yaml +body-type: string +body: | + key1=value1&key2=value2 +``` + ##### `frameless` When set to `true`, removes the border and padding around the widget. diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index d3ab123..e20ff5a 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -3,6 +3,7 @@ package glance import ( "bytes" "context" + "encoding/json" "errors" "fmt" "html/template" @@ -21,10 +22,14 @@ var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base. // Needs to be exported for the YAML unmarshaler to work type CustomAPIRequest struct { - URL string `json:"url"` - AllowInsecure bool `json:"allow-insecure"` - Headers map[string]string `json:"headers"` - Parameters queryParametersField `json:"parameters"` + 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:"-"` } @@ -83,7 +88,41 @@ func (req *CustomAPIRequest) initialize() error { return errors.New("URL is required") } - httpReq, err := http.NewRequest(http.MethodGet, req.URL, nil) + if req.Body != nil { + if req.Method == "" { + req.Method = http.MethodPost + } + + if req.BodyType == "" { + req.BodyType = "json" + } + + if req.BodyType != "json" && req.BodyType != "string" { + return errors.New("invalid body type, must be either 'json' or 'string'") + } + + switch req.BodyType { + case "json": + encoded, err := json.Marshal(req.Body) + if err != nil { + return fmt.Errorf("marshaling body: %v", err) + } + + req.bodyReader = bytes.NewReader(encoded) + case "string": + bodyAsString, ok := req.Body.(string) + if !ok { + return errors.New("body must be a string when body-type is 'string'") + } + + req.bodyReader = strings.NewReader(bodyAsString) + } + + } else if req.Method == "" { + req.Method = http.MethodGet + } + + httpReq, err := http.NewRequest(strings.ToUpper(req.Method), req.URL, req.bodyReader) if err != nil { return err } @@ -92,6 +131,10 @@ func (req *CustomAPIRequest) initialize() error { httpReq.URL.RawQuery = req.Parameters.toQueryString() } + if req.BodyType == "json" { + httpReq.Header.Set("Content-Type", "application/json") + } + for key, value := range req.Headers { httpReq.Header.Add(key, value) } @@ -126,6 +169,10 @@ func (data *customAPITemplateData) Subrequest(key string) *customAPIResponseData } func fetchCustomAPIRequest(ctx context.Context, req *CustomAPIRequest) (*customAPIResponseData, error) { + if req.bodyReader != nil { + req.bodyReader.Seek(0, io.SeekStart) + } + client := ternary(req.AllowInsecure, defaultInsecureHTTPClient, defaultHTTPClient) resp, err := client.Do(req.httpRequest.WithContext(ctx)) if err != nil { From ca2732668ecb133a59f486f8595668295e4f89a7 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:58:52 +0000 Subject: [PATCH 15/17] Update docs --- docs/configuration.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index be5a988..4d42c08 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,7 @@ - [Document](#document) - [Branding](#branding) - [Theme](#theme) - - [Themes](#themes) + - [Available themes](#available-themes) - [Pages & Columns](#pages--columns) - [Widgets](#widgets) - [RSS](#rss) @@ -274,7 +274,7 @@ theme: contrast-multiplier: 1.1 ``` -### Themes +### Available themes If you don't want to spend time configuring your own theme, there are [several available themes](themes.md) which you can simply copy the values for. ### Properties From c6e0230e5d7bcb9d9cef538d99e4430e6f44aa41 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:59:41 +0000 Subject: [PATCH 16/17] Allow sending headers in extension widget --- docs/configuration.md | 9 +++++++++ internal/glance/widget-extension.go | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 4d42c08..15ee95f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1413,6 +1413,7 @@ Display a widget provided by an external source (3rd party). If you want to lear | url | string | yes | | | fallback-content-type | string | no | | | allow-potentially-dangerous-html | boolean | no | false | +| headers | key & value | no | | | parameters | key & value | no | | ##### `url` @@ -1421,6 +1422,14 @@ The URL of the extension. **Note that the query gets stripped from this URL and ##### `fallback-content-type` Optionally specify the fallback content type of the extension if the URL does not return a valid `Widget-Content-Type` header. Currently the only supported value for this property is `html`. +##### `headers` +Optionally specify the headers that will be sent with the request. Example: + +```yaml +headers: + x-api-key: ${SECRET_KEY} +``` + ##### `allow-potentially-dangerous-html` Whether to allow the extension to display HTML. diff --git a/internal/glance/widget-extension.go b/internal/glance/widget-extension.go index c044a8a..3732eb8 100644 --- a/internal/glance/widget-extension.go +++ b/internal/glance/widget-extension.go @@ -22,6 +22,7 @@ type extensionWidget struct { URL string `yaml:"url"` FallbackContentType string `yaml:"fallback-content-type"` Parameters queryParametersField `yaml:"parameters"` + Headers map[string]string `yaml:"headers"` AllowHtml bool `yaml:"allow-potentially-dangerous-html"` Extension extension `yaml:"-"` cachedHTML template.HTML `yaml:"-"` @@ -46,6 +47,7 @@ func (widget *extensionWidget) update(ctx context.Context) { URL: widget.URL, FallbackContentType: widget.FallbackContentType, Parameters: widget.Parameters, + Headers: widget.Headers, AllowHtml: widget.AllowHtml, }) @@ -85,6 +87,7 @@ type extensionRequestOptions struct { URL string `yaml:"url"` FallbackContentType string `yaml:"fallback-content-type"` Parameters queryParametersField `yaml:"parameters"` + Headers map[string]string `yaml:"headers"` AllowHtml bool `yaml:"allow-potentially-dangerous-html"` } @@ -113,6 +116,10 @@ func fetchExtension(options extensionRequestOptions) (extension, error) { request.URL.RawQuery = options.Parameters.toQueryString() } + for key, value := range options.Headers { + request.Header.Add(key, value) + } + response, err := http.DefaultClient.Do(request) if err != nil { slog.Error("Failed fetching extension", "url", options.URL, "error", err) From 233de7fc37da6976ac57c24e1607aa6e8dfd2fb7 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Wed, 26 Mar 2025 19:44:35 +0000 Subject: [PATCH 17/17] Add new functions to custom-api widget --- docs/custom-api.md | 13 +++- internal/glance/widget-custom-api.go | 105 ++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/docs/custom-api.md b/docs/custom-api.md index 9848276..7eb6ca6 100644 --- a/docs/custom-api.md +++ b/docs/custom-api.md @@ -325,13 +325,24 @@ 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. ``. -- `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: "RFC3339", "RFC3339Nano", "DateTime", "DateOnly", "TimeOnly". +- `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. - `sub(a, b float) float`: Subtracts two numbers. - `mul(a, b float) float`: Multiplies two numbers. - `div(a, b float) float`: Divides two numbers. - `formatApproxNumber(n int) string`: Formats a number to be more human-readable, e.g. 1000 -> 1k. - `formatNumber(n float|int) string`: Formats a number with commas, e.g. 1000 -> 1,000. +- `trimPrefix(prefix string, str string) string`: Trims the prefix from a string. +- `trimSuffix(suffix string, str string) string`: Trims the suffix from a string. +- `trimSpace(str string) string`: Trims whitespace from a string on both ends. +- `replaceAll(old string, new string, str string) string`: Replaces all occurrences of a string in a string. +- `findMatch(pattern string, str string) string`: Finds the first match of a regular expression in a string. +- `findSubmatch(pattern string, str string) string`: Finds the first submatch of a regular expression in a string. +- `sortByString(key string, order string, arr []JSON): []JSON`: Sorts an array of JSON objects by a string key in either ascending or descending order. +- `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). 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 e20ff5a..ec88016 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -11,6 +11,9 @@ import ( "log/slog" "math" "net/http" + "regexp" + "sort" + "strconv" "strings" "sync" "time" @@ -340,6 +343,22 @@ func (r *decoratedGJSONResult) Bool(key string) bool { } var customAPITemplateFuncs = func() template.FuncMap { + var regexpCacheMu sync.Mutex + var regexpCache = make(map[string]*regexp.Regexp) + + getCachedRegexp := func(pattern string) *regexp.Regexp { + regexpCacheMu.Lock() + defer regexpCacheMu.Unlock() + + regex, exists := regexpCache[pattern] + if !exists { + regex = regexp.MustCompile(pattern) + regexpCache[pattern] = regex + } + + return regex + } + funcs := template.FuncMap{ "toFloat": func(a int) float64 { return float64(a) @@ -369,6 +388,83 @@ var customAPITemplateFuncs = func() template.FuncMap { // Shorthand to do both of the above with a single function call return dynamicRelativeTimeAttrs(customAPIFuncParseTime(layout, value)) }, + // The reason we flip the parameter order is so that you can chain multiple calls together like this: + // {{ .JSON.String "foo" | trimPrefix "bar" | doSomethingElse }} + // instead of doing this: + // {{ trimPrefix (.JSON.String "foo") "bar" | doSomethingElse }} + // since the piped value gets passed as the last argument to the function. + "trimPrefix": func(prefix, s string) string { + return strings.TrimPrefix(s, prefix) + }, + "trimSuffix": func(suffix, s string) string { + return strings.TrimSuffix(s, suffix) + }, + "trimSpace": strings.TrimSpace, + "replaceAll": func(old, new, s string) string { + return strings.ReplaceAll(s, old, new) + }, + "findMatch": func(pattern, s string) string { + if s == "" { + return "" + } + + return getCachedRegexp(pattern).FindString(s) + }, + "findSubmatch": func(pattern, s string) string { + if s == "" { + return "" + } + + regex := getCachedRegexp(pattern) + return itemAtIndexOrDefault(regex.FindStringSubmatch(s), 1, "") + }, + "sortByString": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult { + sort.Slice(results, func(a, b int) bool { + if order == "asc" { + return results[a].String(key) < results[b].String(key) + } + + return results[a].String(key) > results[b].String(key) + }) + + return results + }, + "sortByInt": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult { + sort.Slice(results, func(a, b int) bool { + if order == "asc" { + return results[a].Int(key) < results[b].Int(key) + } + + return results[a].Int(key) > results[b].Int(key) + }) + + return results + }, + "sortByFloat": func(key, order string, results []decoratedGJSONResult) []decoratedGJSONResult { + sort.Slice(results, func(a, b int) bool { + if order == "asc" { + return results[a].Float(key) < results[b].Float(key) + } + + return results[a].Float(key) > results[b].Float(key) + }) + + return results + }, + "sortByTime": func(key, layout, order string, results []decoratedGJSONResult) []decoratedGJSONResult { + sort.Slice(results, func(a, b int) bool { + timeA := customAPIFuncParseTime(layout, results[a].String(key)) + timeB := customAPIFuncParseTime(layout, results[b].String(key)) + + if order == "asc" { + return timeA.Before(timeB) + } + + return timeA.After(timeB) + }) + + return results + }, } for key, value := range globalTemplateFunctions { @@ -382,6 +478,13 @@ var customAPITemplateFuncs = func() template.FuncMap { func customAPIFuncParseTime(layout, value string) time.Time { switch strings.ToLower(layout) { + case "unix": + asInt, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return time.Unix(0, 0) + } + + return time.Unix(asInt, 0) case "rfc3339": layout = time.RFC3339 case "rfc3339nano": @@ -390,8 +493,6 @@ func customAPIFuncParseTime(layout, value string) time.Time { layout = time.DateTime case "dateonly": layout = time.DateOnly - case "timeonly": - layout = time.TimeOnly } parsed, err := time.Parse(layout, value)