diff --git a/docs/configuration.md b/docs/configuration.md index 000edbb..06e8731 100644 --- a/docs/configuration.md +++ b/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 + + ### 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/go.mod b/go.mod index 6b39a2e..56b35a5 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index ed770ea..be33712 100644 --- a/go.sum +++ b/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= diff --git a/internal/assets/templates.go b/internal/assets/templates.go index 0533b75..324f8ca 100644 --- a/internal/assets/templates.go +++ b/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 { diff --git a/internal/assets/templates/custom-api.html b/internal/assets/templates/custom-api.html new file mode 100644 index 0000000..e1f1f6f --- /dev/null +++ b/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 }} diff --git a/internal/feed/custom-api.go b/internal/feed/custom-api.go new file mode 100644 index 0000000..9a17785 --- /dev/null +++ b/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 += "... " + } + + slog.Error("invalid response JSON in custom API widget", "URL", req.URL.String(), "body", truncatedBody) + return emptyBody, errors.New("invalid response JSON") + } + + var templateBuffer bytes.Buffer + + data := CustomAPITemplateData{ + JSON: DecoratedGJSONResult{gjson.Parse(body)}, + Response: resp, + } + + err = tmpl.Execute(&templateBuffer, &data) + if err != nil { + return emptyBody, err + } + + return template.HTML(templateBuffer.String()), nil +} + +type DecoratedGJSONResult struct { + gjson.Result +} + +type CustomAPITemplateData struct { + JSON DecoratedGJSONResult + Response *http.Response +} + +func GJsonResultArrayToDecoratedResultArray(results []gjson.Result) []DecoratedGJSONResult { + decoratedResults := make([]DecoratedGJSONResult, len(results)) + + for i, result := range results { + decoratedResults[i] = DecoratedGJSONResult{result} + } + + return decoratedResults +} + +func (r *DecoratedGJSONResult) Array(key string) []DecoratedGJSONResult { + if key == "" { + return GJsonResultArrayToDecoratedResultArray(r.Result.Array()) + } + + return GJsonResultArrayToDecoratedResultArray(r.Get(key).Array()) +} + +func (r *DecoratedGJSONResult) String(key string) string { + if key == "" { + return r.Result.String() + } + + return r.Get(key).String() +} + +func (r *DecoratedGJSONResult) Int(key string) int64 { + if key == "" { + return r.Result.Int() + } + + return r.Get(key).Int() +} + +func (r *DecoratedGJSONResult) Float(key string) float64 { + if key == "" { + return r.Result.Float() + } + + return r.Get(key).Float() +} + +func (r *DecoratedGJSONResult) Bool(key string) bool { + if key == "" { + return r.Result.Bool() + } + + return r.Get(key).Bool() +} + +var CustomAPITemplateFuncs = func() template.FuncMap { + funcs := template.FuncMap{ + "toFloat": func(a int64) float64 { + return float64(a) + }, + "toInt": func(a float64) int64 { + return int64(a) + }, + "mathexpr": func(left float64, op string, right float64) float64 { + if right == 0 { + return 0 + } + + switch op { + case "+": + return left + right + case "-": + return left - right + case "*": + return left * right + case "/": + return left / right + default: + return 0 + } + }, + } + + for key, value := range assets.GlobalTemplateFunctions { + funcs[key] = value + } + + return funcs +}() diff --git a/internal/widget/custom-api.go b/internal/widget/custom-api.go new file mode 100644 index 0000000..df45940 --- /dev/null +++ b/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) +} diff --git a/internal/widget/widget.go b/internal/widget/widget.go index 0837f43..c6c51a7 100644 --- a/internal/widget/widget.go +++ b/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) }