浏览代码

Add custom API widget

Svilen Markov 8 月之前
父节点
当前提交
84a7f90129

+ 4 - 0
docs/configuration.md

@@ -16,6 +16,7 @@
   - [Search](#search-widget)
   - [Group](#group)
   - [Split Column](#split-column)
+  - [Custom API](#custom-api)
   - [Extension](#extension)
   - [Weather](#weather)
   - [Monitor](#monitor)
@@ -991,6 +992,9 @@ Preview:
 
 ![](images/split-column-widget-preview.png)
 
+### Custom API
+<!-- TODO -->
+
 ### 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).
 

+ 3 - 0
go.mod

@@ -4,6 +4,7 @@ go 1.23.1
 
 require (
 	github.com/mmcdole/gofeed v1.3.0
+	github.com/tidwall/gjson v1.18.0
 	golang.org/x/text v0.18.0
 	gopkg.in/yaml.v3 v3.0.1
 )
@@ -15,5 +16,7 @@ require (
 	github.com/mmcdole/goxpp v1.1.1 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/tidwall/match v1.1.1 // indirect
+	github.com/tidwall/pretty v1.2.1 // indirect
 	golang.org/x/net v0.29.0 // indirect
 )

+ 7 - 6
go.sum

@@ -1,5 +1,3 @@
-github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
-github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
 github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4=
 github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
 github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
@@ -25,6 +23,13 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -35,8 +40,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
-golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
-golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
 golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
 golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -58,8 +61,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
 golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
 golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

+ 3 - 2
internal/assets/templates.go

@@ -40,9 +40,10 @@ var (
 	GroupTemplate                 = compileTemplate("group.html", "widget-base.html")
 	DNSStatsTemplate              = compileTemplate("dns-stats.html", "widget-base.html")
 	SplitColumnTemplate           = compileTemplate("split-column.html", "widget-base.html")
+	CustomAPITemplate             = compileTemplate("custom-api.html", "widget-base.html")
 )
 
-var globalTemplateFunctions = template.FuncMap{
+var GlobalTemplateFunctions = template.FuncMap{
 	"relativeTime":      relativeTimeSince,
 	"formatViewerCount": formatViewerCount,
 	"formatNumber":      intl.Sprint,
@@ -59,7 +60,7 @@ var globalTemplateFunctions = template.FuncMap{
 
 func compileTemplate(primary string, dependencies ...string) *template.Template {
 	t, err := template.New(primary).
-		Funcs(globalTemplateFunctions).
+		Funcs(GlobalTemplateFunctions).
 		ParseFS(TemplateFS, append([]string{primary}, dependencies...)...)
 
 	if err != nil {

+ 7 - 0
internal/assets/templates/custom-api.html

@@ -0,0 +1,7 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }}
+
+{{ define "widget-content" }}
+{{ .CompiledHTML }}
+{{ end }}

+ 148 - 0
internal/feed/custom-api.go

@@ -0,0 +1,148 @@
+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
+}()

+ 70 - 0
internal/widget/custom-api.go

@@ -0,0 +1,70 @@
+package widget
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"html/template"
+	"net/http"
+	"time"
+
+	"github.com/glanceapp/glance/internal/assets"
+	"github.com/glanceapp/glance/internal/feed"
+)
+
+type CustomApi struct {
+	widgetBase       `yaml:",inline"`
+	URL              string                       `yaml:"url"`
+	Template         string                       `yaml:"template"`
+	Frameless        bool                         `yaml:"frameless"`
+	Headers          map[string]OptionalEnvString `yaml:"headers"`
+	APIRequest       *http.Request                `yaml:"-"`
+	compiledTemplate *template.Template           `yaml:"-"`
+	CompiledHTML     template.HTML                `yaml:"-"`
+}
+
+func (widget *CustomApi) Initialize() error {
+	widget.withTitle("Custom API").withCacheDuration(1 * time.Hour)
+
+	if widget.URL == "" {
+		return errors.New("URL is required for the custom API widget")
+	}
+
+	if widget.Template == "" {
+		return errors.New("template is required for the custom API widget")
+	}
+
+	compiledTemplate, err := template.New("").Funcs(feed.CustomAPITemplateFuncs).Parse(widget.Template)
+
+	if err != nil {
+		return fmt.Errorf("failed parsing custom API widget template: %w", err)
+	}
+
+	widget.compiledTemplate = compiledTemplate
+
+	req, err := http.NewRequest(http.MethodGet, widget.URL, 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 *CustomApi) Update(ctx context.Context) {
+	compiledHTML, err := feed.FetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate)
+	if !widget.canContinueUpdateAfterHandlingErr(err) {
+		return
+	}
+
+	widget.CompiledHTML = compiledHTML
+}
+
+func (widget *CustomApi) Render() template.HTML {
+	return widget.render(widget, assets.CustomAPITemplate)
+}

+ 2 - 0
internal/widget/widget.go

@@ -69,6 +69,8 @@ func New(widgetType string) (Widget, error) {
 		widget = &DNSStats{}
 	case "split-column":
 		widget = &SplitColumn{}
+	case "custom-api":
+		widget = &CustomApi{}
 	default:
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)
 	}