From 7e345dd1f91608d69fe2b75cd99bdeab67c0bb7c Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sun, 17 Nov 2024 10:18:11 +0000 Subject: [PATCH 01/26] Add comment --- internal/assets/static/js/utils.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/assets/static/js/utils.js b/internal/assets/static/js/utils.js index 5f5b2c7..1d1816a 100644 --- a/internal/assets/static/js/utils.js +++ b/internal/assets/static/js/utils.js @@ -28,6 +28,9 @@ export function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } +// NOTE: inconsistent behavior between browsers when it comes to +// whether the newly opened tab gets focused or not, potentially +// depending on the event that this function is called from export function openURLInNewTab(url, focus = true) { const newWindow = window.open(url, '_blank', 'noopener,noreferrer'); From 2b0dd3ab995c595d351492e0bfb7dd8ee22b4c43 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sun, 24 Nov 2024 15:39:14 +0000 Subject: [PATCH 02/26] Refactor 1/2 + new stuff * Refactor CLI * Add config:print command * Add diagnose command * Allow including other files in config * Watch for file changes and automatically restart server --- docs/configuration.md | 1 + go.mod | 2 + go.sum | 4 + internal/glance/cli.go | 57 +++++++--- internal/glance/config.go | 200 +++++++++++++++++++++++++++++--- internal/glance/diagnose.go | 219 ++++++++++++++++++++++++++++++++++++ internal/glance/glance.go | 44 +++++--- internal/glance/main.go | 121 ++++++++++++++++---- 8 files changed, 573 insertions(+), 75 deletions(-) create mode 100644 internal/glance/diagnose.go diff --git a/docs/configuration.md b/docs/configuration.md index 832d035..f17f8fe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,7 @@ - [HTML](#html) ## Intro + Configuration is done via a single YAML file and a server restart is required in order for any changes to take effect. Trying to start the server with an invalid config file will result in an error. ## Preconfigured page diff --git a/go.mod b/go.mod index 56b35a5..2ade8f0 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/glanceapp/glance go 1.23.1 require ( + github.com/fsnotify/fsnotify v1.8.0 github.com/mmcdole/gofeed v1.3.0 github.com/tidwall/gjson v1.18.0 golang.org/x/text v0.18.0 @@ -19,4 +20,5 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect golang.org/x/net v0.29.0 // indirect + golang.org/x/sys v0.25.0 // indirect ) diff --git a/go.sum b/go.sum index be33712..5c6d33b 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= @@ -52,6 +54,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/internal/glance/cli.go b/internal/glance/cli.go index 5987368..e231706 100644 --- a/internal/glance/cli.go +++ b/internal/glance/cli.go @@ -2,41 +2,66 @@ package glance import ( "flag" + "fmt" "os" + "strings" ) -type CliIntent uint8 +type cliIntent uint8 const ( - CliIntentServe CliIntent = iota - CliIntentCheckConfig = iota + cliIntentServe cliIntent = iota + cliIntentConfigValidate = iota + cliIntentConfigPrint = iota + cliIntentDiagnose = iota ) -type CliOptions struct { - Intent CliIntent - ConfigPath string +type cliOptions struct { + intent cliIntent + configPath string } -func ParseCliOptions() (*CliOptions, error) { +func parseCliOptions() (*cliOptions, error) { flags := flag.NewFlagSet("", flag.ExitOnError) + flags.Usage = func() { + fmt.Println("Usage: glance [options] command") - checkConfig := flags.Bool("check-config", false, "Check whether the config is valid") + fmt.Println("\nOptions:") + flags.PrintDefaults() + + fmt.Println("\nCommands:") + fmt.Println(" config:validate Validate the config file") + fmt.Println(" config:print Print the parsed config file with embedded includes") + fmt.Println(" diagnose Run diagnostic checks") + } configPath := flags.String("config", "glance.yml", "Set config path") - err := flags.Parse(os.Args[1:]) - if err != nil { return nil, err } - intent := CliIntentServe + var intent cliIntent + var args = flags.Args() + unknownCommandErr := fmt.Errorf("unknown command: %s", strings.Join(args, " ")) - if *checkConfig { - intent = CliIntentCheckConfig + if len(args) == 0 { + intent = cliIntentServe + } else if len(args) == 1 { + if args[0] == "config:validate" { + intent = cliIntentConfigValidate + } else if args[0] == "config:print" { + intent = cliIntentConfigPrint + } else if args[0] == "diagnose" { + intent = cliIntentDiagnose + } else { + return nil, unknownCommandErr + } + } else { + return nil, unknownCommandErr } - return &CliOptions{ - Intent: intent, - ConfigPath: *configPath, + return &cliOptions{ + intent: intent, + configPath: *configPath, }, nil } diff --git a/internal/glance/config.go b/internal/glance/config.go index 131ef7f..de4a2f3 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -1,9 +1,16 @@ package glance import ( + "bytes" "fmt" - "io" + "log" + "os" + "path/filepath" + "regexp" + "strings" + "time" + "github.com/fsnotify/fsnotify" "gopkg.in/yaml.v3" ) @@ -14,22 +21,16 @@ type Config struct { Pages []Page `yaml:"pages"` } -func NewConfigFromYml(contents io.Reader) (*Config, error) { - config := NewConfig() - - contentBytes, err := io.ReadAll(contents) +func newConfigFromYAML(contents []byte) (*Config, error) { + config := &Config{} + config.Server.Port = 8080 + err := yaml.Unmarshal(contents, config) if err != nil { return nil, err } - err = yaml.Unmarshal(contentBytes, config) - - if err != nil { - return nil, err - } - - if err = configIsValid(config); err != nil { + if err = isConfigStateValid(config); err != nil { return nil, err } @@ -46,16 +47,179 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) { return config, nil } -func NewConfig() *Config { - config := &Config{} +var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`) - config.Server.Host = "" - config.Server.Port = 8080 +func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) { + mainFileContents, err := os.ReadFile(mainFilePath) + if err != nil { + return nil, nil, fmt.Errorf("could not read main YAML file: %w", err) + } - return config + mainFileAbsPath, err := filepath.Abs(mainFilePath) + if err != nil { + return nil, nil, fmt.Errorf("could not get absolute path of main YAML file: %w", err) + } + mainFileDir := filepath.Dir(mainFileAbsPath) + + includes := make(map[string]struct{}) + var includesLastErr error + + mainFileContents = includePattern.ReplaceAllFunc(mainFileContents, func(match []byte) []byte { + if includesLastErr != nil { + return nil + } + + matches := includePattern.FindSubmatch(match) + if len(matches) != 3 { + includesLastErr = fmt.Errorf("invalid include match: %v", matches) + return nil + } + + indent := string(matches[1]) + includeFilePath := strings.TrimSpace(string(matches[2])) + if !filepath.IsAbs(includeFilePath) { + includeFilePath = filepath.Join(mainFileDir, includeFilePath) + } + + var fileContents []byte + var err error + + fileContents, err = os.ReadFile(includeFilePath) + if err != nil { + includesLastErr = fmt.Errorf("could not read included file: %w", err) + return nil + } + + includes[includeFilePath] = struct{}{} + return []byte(prefixStringLines(indent, string(fileContents))) + }) + + if includesLastErr != nil { + return nil, nil, includesLastErr + } + + return mainFileContents, includes, nil } -func configIsValid(config *Config) error { +func prefixStringLines(prefix string, s string) string { + lines := strings.Split(s, "\n") + + for i, line := range lines { + lines[i] = prefix + line + } + + return strings.Join(lines, "\n") +} + +func configFilesWatcher( + mainFilePath string, + lastContents []byte, + lastIncludes map[string]struct{}, + onChange func(newContents []byte), + onErr func(error), +) (func() error, error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, fmt.Errorf("could not create watcher: %w", err) + } + + if err = watcher.Add(mainFilePath); err != nil { + watcher.Close() + return nil, fmt.Errorf("could not add main file to watcher: %w", err) + } + + updateWatchedIncludes := func(previousIncludes map[string]struct{}, newIncludes map[string]struct{}) { + for includePath := range previousIncludes { + if _, ok := newIncludes[includePath]; !ok { + watcher.Remove(includePath) + } + } + + for includePath := range newIncludes { + if _, ok := previousIncludes[includePath]; !ok { + if err := watcher.Add(includePath); err != nil { + log.Printf( + "Could not add included config file to watcher, changes to this file will not trigger a reload. path: %s, error: %v", + includePath, err, + ) + } + } + } + } + + updateWatchedIncludes(nil, lastIncludes) + + checkForContentChangesBeforeCallback := func() { + currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath) + if err != nil { + onErr(fmt.Errorf("could not parse main file contents for comparison: %w", err)) + return + } + + if !bytes.Equal(lastContents, currentContents) { + updateWatchedIncludes(lastIncludes, currentIncludes) + lastContents, lastIncludes = currentContents, currentIncludes + onChange(currentContents) + } + } + + const debounceDuration = 500 * time.Millisecond + var debounceTimer *time.Timer + debouncedCallback := func() { + if debounceTimer != nil { + debounceTimer.Stop() + debounceTimer.Reset(debounceDuration) + } else { + debounceTimer = time.AfterFunc(debounceDuration, checkForContentChangesBeforeCallback) + } + } + + go func() { + for { + select { + case event, isOpen := <-watcher.Events: + if !isOpen { + return + } + if event.Has(fsnotify.Write) { + debouncedCallback() + } + // maybe also handle .Remove event? + // from testing it appears that a removed file will stop triggering .Write events + // when it gets recreated, in which case we may need to watch the directory for the + // creation of that file and then re-add it to the watcher, though that's + // a lot of effort for a hopefully rare edge case + case err, isOpen := <-watcher.Errors: + if !isOpen { + return + } + onErr(fmt.Errorf("watcher error: %w", err)) + } + } + }() + + onChange(lastContents) + + return func() error { + if debounceTimer != nil { + debounceTimer.Stop() + } + + return watcher.Close() + }, nil +} + +func isConfigStateValid(config *Config) error { + if len(config.Pages) == 0 { + return fmt.Errorf("no pages configured") + } + + if config.Server.AssetsPath != "" { + if _, err := os.Stat(config.Server.AssetsPath); os.IsNotExist(err) { + return fmt.Errorf("assets directory does not exist: %s", config.Server.AssetsPath) + } + } + for i := range config.Pages { if config.Pages[i].Title == "" { return fmt.Errorf("Page %d has no title", i+1) diff --git a/internal/glance/diagnose.go b/internal/glance/diagnose.go new file mode 100644 index 0000000..c7fd141 --- /dev/null +++ b/internal/glance/diagnose.go @@ -0,0 +1,219 @@ +package glance + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "runtime" + "strings" + "sync" + "time" +) + +const httpTestRequestTimeout = 10 * time.Second + +var diagnosticSteps = []diagnosticStep{ + { + name: "resolve cloudflare.com through Cloudflare DoH", + fn: func() (string, error) { + return testHttpRequestWithHeaders("GET", "https://1.1.1.1/dns-query?name=cloudflare.com", map[string]string{ + "accept": "application/dns-json", + }, 200) + }, + }, + { + name: "resolve cloudflare.com through Google DoH", + fn: func() (string, error) { + return testHttpRequest("GET", "https://8.8.8.8/resolve?name=cloudflare.com", 200) + }, + }, + { + name: "resolve github.com", + fn: func() (string, error) { + return testDNSResolution("github.com") + }, + }, + { + name: "resolve reddit.com", + fn: func() (string, error) { + return testDNSResolution("reddit.com") + }, + }, + { + name: "resolve twitch.tv", + fn: func() (string, error) { + return testDNSResolution("twitch.tv") + }, + }, + { + name: "fetch data from YouTube RSS feed", + fn: func() (string, error) { + return testHttpRequest("GET", "https://www.youtube.com/feeds/videos.xml?channel_id=UCZU9T1ceaOgwfLRq7OKFU4Q", 200) + }, + }, + { + name: "fetch data from Twitch.tv GQL", + fn: func() (string, error) { + // this should always return 0 bytes, we're mainly looking for a 200 status code + return testHttpRequest("OPTIONS", "https://gql.twitch.tv/gql", 200) + }, + }, + { + name: "fetch data from GitHub API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://api.github.com", 200) + }, + }, + { + name: "fetch data from Open-Meteo API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://geocoding-api.open-meteo.com/v1/search?name=London", 200) + }, + }, + { + name: "fetch data from Reddit API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://www.reddit.com/search.json", 200) + }, + }, + { + name: "fetch data from Yahoo finance API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://query1.finance.yahoo.com/v8/finance/chart/NVDA", 200) + }, + }, + { + name: "fetch data from Hacker News Firebase API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://hacker-news.firebaseio.com/v0/topstories.json", 200) + }, + }, + { + name: "fetch data from Docker Hub API", + fn: func() (string, error) { + return testHttpRequest("GET", "https://hub.docker.com/v2/namespaces/library/repositories/ubuntu/tags/latest", 200) + }, + }, +} + +func runDiagnostic() { + fmt.Println("```") + fmt.Println("Glance version: " + buildVersion) + fmt.Println("Go version: " + runtime.Version()) + fmt.Printf("Platform: %s / %s / %d CPUs\n", runtime.GOOS, runtime.GOARCH, runtime.NumCPU()) + fmt.Println("In Docker container: " + boolToString(isRunningInsideDockerContainer(), "yes", "no")) + + fmt.Printf("\nChecking network connectivity, this may take up to %d seconds...\n\n", int(httpTestRequestTimeout.Seconds())) + + var wg sync.WaitGroup + for i := range diagnosticSteps { + step := &diagnosticSteps[i] + wg.Add(1) + go func() { + defer wg.Done() + start := time.Now() + step.extraInfo, step.err = step.fn() + step.elapsed = time.Since(start) + }() + } + wg.Wait() + + for _, step := range diagnosticSteps { + var extraInfo string + + if step.extraInfo != "" { + extraInfo = "| " + step.extraInfo + " " + } + + fmt.Printf( + "%s %s %s| %dms\n", + boolToString(step.err == nil, "✓ Can", "✗ Can't"), + step.name, + extraInfo, + step.elapsed.Milliseconds(), + ) + + if step.err != nil { + fmt.Printf("└╴ error: %v\n", step.err) + } + } + fmt.Println("```") +} + +type diagnosticStep struct { + name string + fn func() (string, error) + extraInfo string + err error + elapsed time.Duration +} + +func boolToString(b bool, trueValue, falseValue string) string { + if b { + return trueValue + } + + return falseValue +} + +func isRunningInsideDockerContainer() bool { + _, err := os.Stat("/.dockerenv") + return err == nil +} + +func testHttpRequest(method, url string, expectedStatusCode int) (string, error) { + return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode) +} + +func testHttpRequestWithHeaders(method, url string, headers map[string]string, expectedStatusCode int) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), httpTestRequestTimeout) + defer cancel() + + request, _ := http.NewRequestWithContext(ctx, method, url, nil) + for key, value := range headers { + request.Header.Add(key, value) + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + printableBody := strings.ReplaceAll(string(body), "\n", "") + if len(printableBody) > 50 { + printableBody = printableBody[:50] + "..." + } + if len(printableBody) > 0 { + printableBody = ", " + printableBody + } + + extraInfo := fmt.Sprintf("%d bytes%s", len(body), printableBody) + + if response.StatusCode != expectedStatusCode { + return extraInfo, fmt.Errorf("expected status code %d, got %d", expectedStatusCode, response.StatusCode) + } + + return extraInfo, nil +} + +func testDNSResolution(domain string) (string, error) { + ips, err := net.LookupIP(domain) + + var ipStrings []string + if err == nil { + for i := range ips { + ipStrings = append(ipStrings, ips[i].String()) + } + } + + return strings.Join(ipStrings, ", "), err +} diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 1ef654a..96f238c 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -5,7 +5,7 @@ import ( "context" "fmt" "html/template" - "log/slog" + "log" "net/http" "path/filepath" "regexp" @@ -122,11 +122,7 @@ func (a *Application) TransformUserDefinedAssetPath(path string) string { return path } -func NewApplication(config *Config) (*Application, error) { - if len(config.Pages) == 0 { - return nil, fmt.Errorf("no pages configured") - } - +func newApplication(config *Config) *Application { app := &Application{ Version: buildVersion, Config: *config, @@ -180,7 +176,7 @@ func NewApplication(config *Config) (*Application, error) { config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL) - return app, nil + return app } func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) { @@ -276,7 +272,7 @@ func (a *Application) AssetPath(asset string) string { return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset } -func (a *Application) Serve() error { +func (a *Application) Server() (func() error, func() error) { // TODO: add gzip support, static files must have their gzipped contents cached // TODO: add HTTPS support mux := http.NewServeMux() @@ -295,14 +291,9 @@ func (a *Application) Serve() error { http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)), ) + var absAssetsPath string if a.Config.Server.AssetsPath != "" { - absAssetsPath, err := filepath.Abs(a.Config.Server.AssetsPath) - - if err != nil { - return fmt.Errorf("invalid assets path: %s", a.Config.Server.AssetsPath) - } - - slog.Info("Serving assets", "path", absAssetsPath) + absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath) assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour) mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS)) } @@ -312,8 +303,25 @@ func (a *Application) Serve() error { Handler: mux, } - a.Config.Server.StartedAt = time.Now() - slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port, "base-url", a.Config.Server.BaseURL) + start := func() error { + a.Config.Server.StartedAt = time.Now() + log.Printf("Starting server on %s:%d (base-url: \"%s\", assets-path: \"%s\")\n", + a.Config.Server.Host, + a.Config.Server.Port, + a.Config.Server.BaseURL, + absAssetsPath, + ) - return server.ListenAndServe() + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return err + } + + return nil + } + + stop := func() error { + return server.Close() + } + + return start, stop } diff --git a/internal/glance/main.go b/internal/glance/main.go index 426c41f..552ecd6 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -2,45 +2,120 @@ package glance import ( "fmt" - "os" + "log" ) func Main() int { - options, err := ParseCliOptions() + options, err := parseCliOptions() if err != nil { fmt.Println(err) return 1 } - configFile, err := os.Open(options.ConfigPath) - - if err != nil { - fmt.Printf("failed opening config file: %v\n", err) - return 1 - } - - config, err := NewConfigFromYml(configFile) - configFile.Close() - - if err != nil { - fmt.Printf("failed parsing config file: %v\n", err) - return 1 - } - - if options.Intent == CliIntentServe { - app, err := NewApplication(config) - + switch options.intent { + case cliIntentServe: + if err := serveApp(options.configPath); err != nil { + fmt.Println(err) + return 1 + } + case cliIntentConfigValidate: + contents, _, err := parseYAMLIncludes(options.configPath) if err != nil { - fmt.Printf("failed creating application: %v\n", err) + fmt.Printf("failed to parse config file: %v\n", err) return 1 } - if err := app.Serve(); err != nil { - fmt.Printf("http server error: %v\n", err) + if _, err := newConfigFromYAML(contents); err != nil { + fmt.Printf("config file is invalid: %v\n", err) return 1 } + case cliIntentConfigPrint: + contents, _, err := parseYAMLIncludes(options.configPath) + if err != nil { + fmt.Printf("failed to parse config file: %v\n", err) + return 1 + } + + fmt.Println(string(contents)) + case cliIntentDiagnose: + runDiagnostic() } return 0 } + +func serveApp(configPath string) error { + exitChannel := make(chan struct{}) + // the onChange method gets called at most once per 500ms due to debouncing so we shouldn't + // need to use atomic.Bool here unless newConfigFromYAML is very slow for some reason + hadValidConfigOnStartup := false + var stopServer func() error + + onChange := func(newContents []byte) { + if stopServer != nil { + log.Println("Config file changed, attempting to restart server") + } + + config, err := newConfigFromYAML(newContents) + if err != nil { + log.Printf("Config file is invalid: %v", err) + + if !hadValidConfigOnStartup { + close(exitChannel) + } + + return + } else if !hadValidConfigOnStartup { + hadValidConfigOnStartup = true + } + + app := newApplication(config) + + if stopServer != nil { + if err := stopServer(); err != nil { + log.Printf("Error while trying to stop server: %v", err) + } + } + + go func() { + var startServer func() error + startServer, stopServer = app.Server() + + if err := startServer(); err != nil { + log.Printf("Failed to start server: %v", err) + } + }() + } + + onErr := func(err error) { + log.Printf("Error watching config files: %v", err) + } + + configContents, configIncludes, err := parseYAMLIncludes(configPath) + if err != nil { + return fmt.Errorf("failed to parse config file: %w", err) + } + + stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr) + if err == nil { + defer stopWatching() + } else { + log.Printf("Error starting file watcher, config file changes will require a manual restart. (%v)", err) + + config, err := newConfigFromYAML(configContents) + if err != nil { + return fmt.Errorf("could not parse config file: %w", err) + } + + app := newApplication(config) + + startServer, _ := app.Server() + if err := startServer(); err != nil { + return fmt.Errorf("failed to start server: %w", err) + } + } + + <-exitChannel + return nil +} From 3e467c502157f60b15c4751b8d2f4f3320c51bed Mon Sep 17 00:00:00 2001 From: Juan Xavier Gomez Date: Mon, 25 Nov 2024 13:04:52 -0500 Subject: [PATCH 03/26] optionally start calendar weeks on sunday --- docs/configuration.md | 12 +++++++++++- internal/assets/templates/calendar.html | 7 ++++++- internal/feed/calendar.go | 9 ++++++--- internal/widget/calendar.go | 7 ++++--- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6f9d602..20156d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1494,15 +1494,25 @@ Example: ```yaml - type: calendar + start-sunday: false ``` Preview: ![](images/calendar-widget-preview.png) +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| start-sunday | boolean | no | false | + +##### `start-sunday` +Whether calendar weeks start on Sunday or Monday. + > [!NOTE] > -> There is currently no customizability available for the calendar. Extra features will be added in the future. +> There is currently little customizability available for the calendar. Extra features will be added in the future. ### Markets Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance. diff --git a/internal/assets/templates/calendar.html b/internal/assets/templates/calendar.html index af15e5a..020d6ac 100644 --- a/internal/assets/templates/calendar.html +++ b/internal/assets/templates/calendar.html @@ -11,13 +11,18 @@
+ {{ if .StartSunday }} +
Su
+ {{ end }}
Mo
Tu
We
Th
Fr
Sa
-
Su
+ {{ if not .StartSunday }} +
Su
+ {{ end }}
diff --git a/internal/feed/calendar.go b/internal/feed/calendar.go index f7ec5d4..a4beae3 100644 --- a/internal/feed/calendar.go +++ b/internal/feed/calendar.go @@ -3,9 +3,8 @@ package feed import "time" // TODO: very inflexible, refactor to allow more customizability -// TODO: allow changing first day of week // TODO: allow changing between showing the previous and next week and the entire month -func NewCalendar(now time.Time) *Calendar { +func NewCalendar(now time.Time, startSunday bool) *Calendar { year, week := now.ISOWeek() weekday := now.Weekday() @@ -23,7 +22,11 @@ func NewCalendar(now time.Time) *Calendar { previousMonthDays = daysInMonth(previousMonthNumber, year) } - startDaysFrom := now.Day() - int(weekday+6) + var offset time.Weekday = 6 + if startSunday { + offset = 7 + } + startDaysFrom := now.Day() - int(weekday+offset) days := make([]int, 21) diff --git a/internal/widget/calendar.go b/internal/widget/calendar.go index a126353..5bfbf37 100644 --- a/internal/widget/calendar.go +++ b/internal/widget/calendar.go @@ -10,8 +10,9 @@ import ( ) type Calendar struct { - widgetBase `yaml:",inline"` - Calendar *feed.Calendar + widgetBase `yaml:",inline"` + Calendar *feed.Calendar + StartSunday bool `yaml:"start-sunday"` } func (widget *Calendar) Initialize() error { @@ -21,7 +22,7 @@ func (widget *Calendar) Initialize() error { } func (widget *Calendar) Update(ctx context.Context) { - widget.Calendar = feed.NewCalendar(time.Now()) + widget.Calendar = feed.NewCalendar(time.Now(), widget.StartSunday) widget.withError(nil).scheduleNextUpdate() } From 44ee813c6ffb62645cd76cdfca3815d43db86cfc Mon Sep 17 00:00:00 2001 From: Juan Xavier Gomez Date: Mon, 25 Nov 2024 15:49:28 -0500 Subject: [PATCH 04/26] simplify cal weekday index --- internal/feed/calendar.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/internal/feed/calendar.go b/internal/feed/calendar.go index a4beae3..e608dbe 100644 --- a/internal/feed/calendar.go +++ b/internal/feed/calendar.go @@ -7,9 +7,8 @@ import "time" func NewCalendar(now time.Time, startSunday bool) *Calendar { year, week := now.ISOWeek() weekday := now.Weekday() - - if weekday == 0 { - weekday = 7 + if !startSunday { + weekday = (weekday + 6) % 7 // Shift Monday to 0 } currentMonthDays := daysInMonth(now.Month(), year) @@ -22,11 +21,7 @@ func NewCalendar(now time.Time, startSunday bool) *Calendar { previousMonthDays = daysInMonth(previousMonthNumber, year) } - var offset time.Weekday = 6 - if startSunday { - offset = 7 - } - startDaysFrom := now.Day() - int(weekday+offset) + startDaysFrom := now.Day() - int(weekday) - 7 days := make([]int, 21) From 90fbba600f5b29d9649ae2f748a9f745dc9160ba Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:34:15 +0000 Subject: [PATCH 05/26] Restructure & refactor codebase --- Dockerfile | 2 +- Dockerfile.goreleaser | 2 +- README.md | 2 + docs/configuration.md | 2 + go.mod | 6 +- go.sum | 12 +- internal/assets/templates.go | 112 ----- .../templates/page-style-overrides.gotmpl | 14 - internal/feed/adguard.go | 120 ------ internal/feed/codeberg.go | 39 -- internal/feed/custom-api.go | 148 ------- internal/feed/dockerhub.go | 102 ----- internal/feed/extension.go | 102 ----- internal/feed/gitlab.go | 48 --- internal/feed/lobsters.go | 91 ---- internal/feed/monitor.go | 77 ---- internal/feed/pihole.go | 136 ------ internal/feed/primitives.go | 247 ----------- internal/feed/releases.go | 72 ---- internal/feed/yahoo.go | 104 ----- internal/feed/youtube.go | 115 ----- .../fields.go => glance/config-fields.go} | 78 ++-- internal/glance/config.go | 94 ++-- internal/glance/diagnose.go | 14 - internal/{assets/files.go => glance/embed.go} | 19 +- internal/glance/glance.go | 243 ++++------- internal/glance/main.go | 73 +++- .../{assets => glance}/static/app-icon.png | Bin .../{assets => glance}/static/favicon.png | Bin .../static/fonts/JetBrainsMono-Regular.woff2 | Bin .../static/icons/codeberg.svg | 0 .../static/icons/dockerhub.svg | 0 .../static/icons/github.svg | 0 .../static/icons/gitlab.svg | 0 internal/{assets => glance}/static/js/main.js | 0 .../{assets => glance}/static/js/masonry.js | 0 .../{assets => glance}/static/js/popover.js | 0 .../{assets => glance}/static/js/utils.js | 0 internal/{assets => glance}/static/main.css | 5 +- .../{assets => glance}/static/manifest.json | 0 internal/glance/templates.go | 62 +++ .../templates/bookmarks.html | 0 .../templates/calendar.html | 0 .../templates/change-detection.html | 0 .../{assets => glance}/templates/clock.html | 0 .../templates/custom-api.html | 0 .../templates/dns-stats.html | 2 +- .../templates/document.html | 0 .../templates/extension.html | 0 .../templates/forum-posts.html | 4 +- .../{assets => glance}/templates/group.html | 0 .../{assets => glance}/templates/iframe.html | 0 .../{assets => glance}/templates/markets.html | 0 .../templates/monitor-compact.html | 0 .../{assets => glance}/templates/monitor.html | 0 .../templates/page-content.html} | 0 .../{assets => glance}/templates/page.html | 5 +- .../templates/reddit-horizontal-cards.html | 0 .../templates/reddit-vertical-cards.html | 0 .../templates/releases.html | 2 +- .../templates/repository.html | 36 +- .../templates/rss-detailed-list.html | 0 .../templates/rss-horizontal-cards-2.html | 0 .../templates/rss-horizontal-cards.html | 0 .../templates/rss-list.html | 0 .../{assets => glance}/templates/search.html | 0 .../templates/split-column.html | 0 internal/glance/templates/theme-style.gotmpl | 14 + .../templates/twitch-channels.html | 0 .../templates/twitch-games-list.html | 0 .../templates/v0.7-update-notice-page.html | 44 ++ .../templates/video-card-contents.html | 0 .../templates/videos-grid.html | 0 .../{assets => glance}/templates/videos.html | 0 .../{assets => glance}/templates/weather.html | 0 .../templates/widget-base.html | 0 internal/{feed => glance}/utils.go | 67 ++- internal/glance/widget-bookmarks.go | 34 ++ .../calendar.go => glance/widget-calendar.go} | 43 +- .../widget-changedetection.go} | 84 +++- .../clock.go => glance/widget-clock.go} | 20 +- internal/glance/widget-container.go | 58 +++ internal/glance/widget-custom-api.go | 209 +++++++++ internal/glance/widget-dns-stats.go | 342 +++++++++++++++ internal/glance/widget-extension.go | 156 +++++++ internal/glance/widget-group.go | 52 +++ .../widget-hacker-news.go} | 79 +++- .../{widget/html.go => glance/widget-html.go} | 8 +- .../iframe.go => glance/widget-iframe.go} | 18 +- internal/glance/widget-lobsters.go | 147 +++++++ internal/glance/widget-markets.go | 206 +++++++++ internal/glance/widget-monitor.go | 178 ++++++++ .../reddit.go => glance/widget-reddit.go} | 127 +++++- internal/glance/widget-releases.go | 401 ++++++++++++++++++ .../widget-repository-overview.go} | 160 +++---- .../{feed/rss.go => glance/widget-rss.go} | 113 ++++- .../search.go => glance/widget-search.go} | 18 +- internal/glance/widget-shared.go | 62 +++ internal/glance/widget-split-column.go | 45 ++ .../widget-twitch-channels.go} | 147 +++---- internal/glance/widget-twitch-top-games.go | 126 ++++++ .../requests.go => glance/widget-utils.go} | 38 +- internal/glance/widget-videos.go | 189 +++++++++ .../openmeteo.go => glance/widget-weather.go} | 155 ++++++- internal/{widget => glance}/widget.go | 142 +++---- internal/widget/bookmarks.go | 34 -- internal/widget/calendar.go | 31 -- internal/widget/changedetection.go | 66 --- internal/widget/container.go | 48 --- internal/widget/custom-api.go | 70 --- internal/widget/dns-stats.go | 77 ---- internal/widget/extension.go | 61 --- internal/widget/group.go | 52 --- internal/widget/hacker-news.go | 65 --- internal/widget/lobsters.go | 64 --- internal/widget/markets.go | 50 --- internal/widget/monitor.go | 105 ----- internal/widget/reddit.go | 121 ------ internal/widget/releases.go | 103 ----- internal/widget/repository-overview.go | 58 --- internal/widget/rss.go | 83 ---- internal/widget/split-column.go | 47 -- internal/widget/twitch-channels.go | 55 --- internal/widget/twitch-top-games.go | 49 --- internal/widget/videos.go | 57 --- internal/widget/weather.go | 74 ---- 126 files changed, 3492 insertions(+), 3550 deletions(-) delete mode 100644 internal/assets/templates.go delete mode 100644 internal/assets/templates/page-style-overrides.gotmpl delete mode 100644 internal/feed/adguard.go delete mode 100644 internal/feed/codeberg.go delete mode 100644 internal/feed/custom-api.go delete mode 100644 internal/feed/dockerhub.go delete mode 100644 internal/feed/extension.go delete mode 100644 internal/feed/gitlab.go delete mode 100644 internal/feed/lobsters.go delete mode 100644 internal/feed/monitor.go delete mode 100644 internal/feed/pihole.go delete mode 100644 internal/feed/primitives.go delete mode 100644 internal/feed/releases.go delete mode 100644 internal/feed/yahoo.go delete mode 100644 internal/feed/youtube.go rename internal/{widget/fields.go => glance/config-fields.go} (61%) rename internal/{assets/files.go => glance/embed.go} (68%) rename internal/{assets => glance}/static/app-icon.png (100%) rename internal/{assets => glance}/static/favicon.png (100%) rename internal/{assets => glance}/static/fonts/JetBrainsMono-Regular.woff2 (100%) rename internal/{assets => glance}/static/icons/codeberg.svg (100%) rename internal/{assets => glance}/static/icons/dockerhub.svg (100%) rename internal/{assets => glance}/static/icons/github.svg (100%) rename internal/{assets => glance}/static/icons/gitlab.svg (100%) rename internal/{assets => glance}/static/js/main.js (100%) rename internal/{assets => glance}/static/js/masonry.js (100%) rename internal/{assets => glance}/static/js/popover.js (100%) rename internal/{assets => glance}/static/js/utils.js (100%) rename internal/{assets => glance}/static/main.css (99%) rename internal/{assets => glance}/static/manifest.json (100%) create mode 100644 internal/glance/templates.go rename internal/{assets => glance}/templates/bookmarks.html (100%) rename internal/{assets => glance}/templates/calendar.html (100%) rename internal/{assets => glance}/templates/change-detection.html (100%) rename internal/{assets => glance}/templates/clock.html (100%) rename internal/{assets => glance}/templates/custom-api.html (100%) rename internal/{assets => glance}/templates/dns-stats.html (98%) rename internal/{assets => glance}/templates/document.html (100%) rename internal/{assets => glance}/templates/extension.html (100%) rename internal/{assets => glance}/templates/forum-posts.html (97%) rename internal/{assets => glance}/templates/group.html (100%) rename internal/{assets => glance}/templates/iframe.html (100%) rename internal/{assets => glance}/templates/markets.html (100%) rename internal/{assets => glance}/templates/monitor-compact.html (100%) rename internal/{assets => glance}/templates/monitor.html (100%) rename internal/{assets/templates/content.html => glance/templates/page-content.html} (100%) rename internal/{assets => glance}/templates/page.html (97%) rename internal/{assets => glance}/templates/reddit-horizontal-cards.html (100%) rename internal/{assets => glance}/templates/reddit-vertical-cards.html (100%) rename internal/{assets => glance}/templates/releases.html (88%) rename internal/{assets => glance}/templates/repository.html (55%) rename internal/{assets => glance}/templates/rss-detailed-list.html (100%) rename internal/{assets => glance}/templates/rss-horizontal-cards-2.html (100%) rename internal/{assets => glance}/templates/rss-horizontal-cards.html (100%) rename internal/{assets => glance}/templates/rss-list.html (100%) rename internal/{assets => glance}/templates/search.html (100%) rename internal/{assets => glance}/templates/split-column.html (100%) create mode 100644 internal/glance/templates/theme-style.gotmpl rename internal/{assets => glance}/templates/twitch-channels.html (100%) rename internal/{assets => glance}/templates/twitch-games-list.html (100%) create mode 100644 internal/glance/templates/v0.7-update-notice-page.html rename internal/{assets => glance}/templates/video-card-contents.html (100%) rename internal/{assets => glance}/templates/videos-grid.html (100%) rename internal/{assets => glance}/templates/videos.html (100%) rename internal/{assets => glance}/templates/weather.html (100%) rename internal/{assets => glance}/templates/widget-base.html (100%) rename internal/{feed => glance}/utils.go (57%) create mode 100644 internal/glance/widget-bookmarks.go rename internal/{feed/calendar.go => glance/widget-calendar.go} (54%) rename internal/{feed/changedetection.go => glance/widget-changedetection.go} (53%) rename internal/{widget/clock.go => glance/widget-clock.go} (58%) create mode 100644 internal/glance/widget-container.go create mode 100644 internal/glance/widget-custom-api.go create mode 100644 internal/glance/widget-dns-stats.go create mode 100644 internal/glance/widget-extension.go create mode 100644 internal/glance/widget-group.go rename internal/{feed/hacker-news.go => glance/widget-hacker-news.go} (51%) rename internal/{widget/html.go => glance/widget-html.go} (56%) rename internal/{widget/iframe.go => glance/widget-iframe.go} (56%) create mode 100644 internal/glance/widget-lobsters.go create mode 100644 internal/glance/widget-markets.go create mode 100644 internal/glance/widget-monitor.go rename internal/{feed/reddit.go => glance/widget-reddit.go} (52%) create mode 100644 internal/glance/widget-releases.go rename internal/{feed/github.go => glance/widget-repository-overview.go} (54%) rename internal/{feed/rss.go => glance/widget-rss.go} (63%) rename internal/{widget/search.go => glance/widget-search.go} (75%) create mode 100644 internal/glance/widget-shared.go create mode 100644 internal/glance/widget-split-column.go rename internal/{feed/twitch.go => glance/widget-twitch-channels.go} (62%) create mode 100644 internal/glance/widget-twitch-top-games.go rename internal/{feed/requests.go => glance/widget-utils.go} (88%) create mode 100644 internal/glance/widget-videos.go rename internal/{feed/openmeteo.go => glance/widget-weather.go} (55%) rename internal/{widget => glance}/widget.go (65%) delete mode 100644 internal/widget/bookmarks.go delete mode 100644 internal/widget/calendar.go delete mode 100644 internal/widget/changedetection.go delete mode 100644 internal/widget/container.go delete mode 100644 internal/widget/custom-api.go delete mode 100644 internal/widget/dns-stats.go delete mode 100644 internal/widget/extension.go delete mode 100644 internal/widget/group.go delete mode 100644 internal/widget/hacker-news.go delete mode 100644 internal/widget/lobsters.go delete mode 100644 internal/widget/markets.go delete mode 100644 internal/widget/monitor.go delete mode 100644 internal/widget/reddit.go delete mode 100644 internal/widget/releases.go delete mode 100644 internal/widget/repository-overview.go delete mode 100644 internal/widget/rss.go delete mode 100644 internal/widget/split-column.go delete mode 100644 internal/widget/twitch-channels.go delete mode 100644 internal/widget/twitch-top-games.go delete mode 100644 internal/widget/videos.go delete mode 100644 internal/widget/weather.go diff --git a/Dockerfile b/Dockerfile index 48f214b..63298d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,4 +10,4 @@ WORKDIR /app COPY --from=builder /app/glance . EXPOSE 8080/tcp -ENTRYPOINT ["/app/glance"] +ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser index dec9ac4..2fbf915 100644 --- a/Dockerfile.goreleaser +++ b/Dockerfile.goreleaser @@ -5,4 +5,4 @@ COPY glance . EXPOSE 8080/tcp -ENTRYPOINT ["/app/glance"] +ENTRYPOINT ["/app/glance", "--config", "/app/config/glance.yml"] diff --git a/README.md b/README.md index 0e8cfb4..c6193a9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Checkout the [releases page](https://github.com/glanceapp/glance/releases) for a ``` #### Docker + + > [!IMPORTANT] > > Make sure you have a valid `glance.yml` file in the same directory before running the container. diff --git a/docs/configuration.md b/docs/configuration.md index 9c3c312..d308918 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -114,6 +114,8 @@ This will give you a page that looks like the following: Configure the widgets, add more of them, add extra pages, etc. Make it your own! + + ## Server Server configuration is done through a top level `server` property. Example: diff --git a/go.mod b/go.mod index 2ade8f0..ed40bc8 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/fsnotify/fsnotify v1.8.0 github.com/mmcdole/gofeed v1.3.0 github.com/tidwall/gjson v1.18.0 - golang.org/x/text v0.18.0 + golang.org/x/text v0.20.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -19,6 +19,6 @@ require ( 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 - golang.org/x/sys v0.25.0 // indirect + golang.org/x/net v0.31.0 // indirect + golang.org/x/sys v0.27.0 // indirect ) diff --git a/go.sum b/go.sum index 5c6d33b..03a2b53 100644 --- a/go.sum +++ b/go.sum @@ -42,8 +42,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.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.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= +golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -54,8 +54,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -65,8 +65,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.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.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/assets/templates.go b/internal/assets/templates.go deleted file mode 100644 index 4834078..0000000 --- a/internal/assets/templates.go +++ /dev/null @@ -1,112 +0,0 @@ -package assets - -import ( - "fmt" - "html/template" - "math" - "strconv" - "time" - - "golang.org/x/text/language" - "golang.org/x/text/message" -) - -var ( - PageTemplate = compileTemplate("page.html", "document.html", "page-style-overrides.gotmpl") - PageContentTemplate = compileTemplate("content.html") - CalendarTemplate = compileTemplate("calendar.html", "widget-base.html") - ClockTemplate = compileTemplate("clock.html", "widget-base.html") - BookmarksTemplate = compileTemplate("bookmarks.html", "widget-base.html") - IFrameTemplate = compileTemplate("iframe.html", "widget-base.html") - WeatherTemplate = compileTemplate("weather.html", "widget-base.html") - ForumPostsTemplate = compileTemplate("forum-posts.html", "widget-base.html") - RedditCardsHorizontalTemplate = compileTemplate("reddit-horizontal-cards.html", "widget-base.html") - RedditCardsVerticalTemplate = compileTemplate("reddit-vertical-cards.html", "widget-base.html") - ReleasesTemplate = compileTemplate("releases.html", "widget-base.html") - ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html") - VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") - VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") - MarketsTemplate = compileTemplate("markets.html", "widget-base.html") - RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html") - RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html") - RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html") - RSSHorizontalCards2Template = compileTemplate("rss-horizontal-cards-2.html", "widget-base.html") - MonitorTemplate = compileTemplate("monitor.html", "widget-base.html") - MonitorCompactTemplate = compileTemplate("monitor-compact.html", "widget-base.html") - TwitchGamesListTemplate = compileTemplate("twitch-games-list.html", "widget-base.html") - TwitchChannelsTemplate = compileTemplate("twitch-channels.html", "widget-base.html") - RepositoryTemplate = compileTemplate("repository.html", "widget-base.html") - SearchTemplate = compileTemplate("search.html", "widget-base.html") - ExtensionTemplate = compileTemplate("extension.html", "widget-base.html") - GroupTemplate = compileTemplate("group.html", "widget-base.html") - DNSStatsTemplate = compileTemplate("dns-stats.html", "widget-base.html") - SplitColumnTemplate = compileTemplate("split-column.html", "widget-base.html") - CustomAPITemplate = compileTemplate("custom-api.html", "widget-base.html") -) - -var GlobalTemplateFunctions = template.FuncMap{ - "relativeTime": relativeTimeSince, - "formatViewerCount": formatViewerCount, - "formatNumber": intl.Sprint, - "absInt": func(i int) int { - return int(math.Abs(float64(i))) - }, - "formatPrice": func(price float64) string { - return intl.Sprintf("%.2f", price) - }, - "dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr { - return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix())) - }, -} - -func compileTemplate(primary string, dependencies ...string) *template.Template { - t, err := template.New(primary). - Funcs(GlobalTemplateFunctions). - ParseFS(TemplateFS, append([]string{primary}, dependencies...)...) - - if err != nil { - panic(err) - } - - return t -} - -var intl = message.NewPrinter(language.English) - -func formatViewerCount(count int) string { - if count < 1_000 { - return strconv.Itoa(count) - } - - if count < 10_000 { - return fmt.Sprintf("%.1fk", float64(count)/1_000) - } - - if count < 1_000_000 { - return fmt.Sprintf("%dk", count/1_000) - } - - return fmt.Sprintf("%.1fm", float64(count)/1_000_000) -} - -func relativeTimeSince(t time.Time) string { - delta := time.Since(t) - - if delta < time.Minute { - return "1m" - } - if delta < time.Hour { - return fmt.Sprintf("%dm", delta/time.Minute) - } - if delta < 24*time.Hour { - return fmt.Sprintf("%dh", delta/time.Hour) - } - if delta < 30*24*time.Hour { - return fmt.Sprintf("%dd", delta/(24*time.Hour)) - } - if delta < 12*30*24*time.Hour { - return fmt.Sprintf("%dmo", delta/(30*24*time.Hour)) - } - - return fmt.Sprintf("%dy", delta/(365*24*time.Hour)) -} diff --git a/internal/assets/templates/page-style-overrides.gotmpl b/internal/assets/templates/page-style-overrides.gotmpl deleted file mode 100644 index 0bf2a99..0000000 --- a/internal/assets/templates/page-style-overrides.gotmpl +++ /dev/null @@ -1,14 +0,0 @@ - diff --git a/internal/feed/adguard.go b/internal/feed/adguard.go deleted file mode 100644 index 87182c3..0000000 --- a/internal/feed/adguard.go +++ /dev/null @@ -1,120 +0,0 @@ -package feed - -import ( - "net/http" - "strings" -) - -type adguardStatsResponse struct { - TotalQueries int `json:"num_dns_queries"` - QueriesSeries []int `json:"dns_queries"` - BlockedQueries int `json:"num_blocked_filtering"` - BlockedSeries []int `json:"blocked_filtering"` - ResponseTime float64 `json:"avg_processing_time"` - TopBlockedDomains []map[string]int `json:"top_blocked_domains"` -} - -func FetchAdguardStats(instanceURL, username, password string) (*DNSStats, error) { - requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats" - - request, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - request.SetBasicAuth(username, password) - - responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request) - - if err != nil { - return nil, err - } - - var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5) - - stats := &DNSStats{ - TotalQueries: responseJson.TotalQueries, - BlockedQueries: responseJson.BlockedQueries, - ResponseTime: int(responseJson.ResponseTime * 1000), - TopBlockedDomains: make([]DNSStatsBlockedDomain, 0, topBlockedDomainsCount), - } - - if stats.TotalQueries <= 0 { - return stats, nil - } - - stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) - - for i := 0; i < topBlockedDomainsCount; i++ { - domain := responseJson.TopBlockedDomains[i] - var firstDomain string - - for k := range domain { - firstDomain = k - break - } - - if firstDomain == "" { - continue - } - - stats.TopBlockedDomains = append(stats.TopBlockedDomains, DNSStatsBlockedDomain{ - Domain: firstDomain, - }) - - if stats.BlockedQueries > 0 { - stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100) - } - } - - queriesSeries := responseJson.QueriesSeries - blockedSeries := responseJson.BlockedSeries - - const bars = 8 - const hoursSpan = 24 - const hoursPerBar int = hoursSpan / bars - - if len(queriesSeries) > hoursSpan { - queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:] - } else if len(queriesSeries) < hoursSpan { - queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...) - } - - if len(blockedSeries) > hoursSpan { - blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:] - } else if len(blockedSeries) < hoursSpan { - blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...) - } - - maxQueriesInSeries := 0 - - for i := 0; i < bars; i++ { - queries := 0 - blocked := 0 - - for j := 0; j < hoursPerBar; j++ { - queries += queriesSeries[i*hoursPerBar+j] - blocked += blockedSeries[i*hoursPerBar+j] - } - - stats.Series[i] = DNSStatsSeries{ - Queries: queries, - Blocked: blocked, - } - - if queries > 0 { - stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) - } - - if queries > maxQueriesInSeries { - maxQueriesInSeries = queries - } - } - - for i := 0; i < bars; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} diff --git a/internal/feed/codeberg.go b/internal/feed/codeberg.go deleted file mode 100644 index d5e7b7c..0000000 --- a/internal/feed/codeberg.go +++ /dev/null @@ -1,39 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" -) - -type codebergReleaseResponseJson struct { - TagName string `json:"tag_name"` - PublishedAt string `json:"published_at"` - HtmlUrl string `json:"html_url"` -} - -func fetchLatestCodebergRelease(request *ReleaseRequest) (*AppRelease, error) { - httpRequest, err := http.NewRequest( - "GET", - fmt.Sprintf( - "https://codeberg.org/api/v1/repos/%s/releases/latest", - request.Repository, - ), - nil, - ) - if err != nil { - return nil, err - } - - response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - return &AppRelease{ - Source: ReleaseSourceCodeberg, - Name: request.Repository, - Version: normalizeVersionFormat(response.TagName), - NotesUrl: response.HtmlUrl, - TimeReleased: parseRFC3339Time(response.PublishedAt), - }, nil -} diff --git a/internal/feed/custom-api.go b/internal/feed/custom-api.go deleted file mode 100644 index 9a17785..0000000 --- a/internal/feed/custom-api.go +++ /dev/null @@ -1,148 +0,0 @@ -package feed - -import ( - "bytes" - "errors" - "html/template" - "io" - "log/slog" - "net/http" - - "github.com/glanceapp/glance/internal/assets" - "github.com/tidwall/gjson" -) - -func FetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) { - emptyBody := template.HTML("") - - resp, err := defaultClient.Do(req) - if err != nil { - return emptyBody, err - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return emptyBody, err - } - - body := string(bodyBytes) - - if !gjson.Valid(body) { - truncatedBody, isTruncated := limitStringLength(body, 100) - if isTruncated { - truncatedBody += "... " - } - - 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/feed/dockerhub.go b/internal/feed/dockerhub.go deleted file mode 100644 index e979d37..0000000 --- a/internal/feed/dockerhub.go +++ /dev/null @@ -1,102 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" - "strings" -) - -type dockerHubRepositoryTagsResponse struct { - Results []dockerHubRepositoryTagResponse `json:"results"` -} - -type dockerHubRepositoryTagResponse struct { - Name string `json:"name"` - LastPushed string `json:"tag_last_pushed"` -} - -const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s" -const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s" -const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags" -const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s" - -func fetchLatestDockerHubRelease(request *ReleaseRequest) (*AppRelease, error) { - - nameParts := strings.Split(request.Repository, "/") - - if len(nameParts) > 2 { - return nil, fmt.Errorf("invalid repository name: %s", request.Repository) - } else if len(nameParts) == 1 { - nameParts = []string{"library", nameParts[0]} - } - - tagParts := strings.SplitN(nameParts[1], ":", 2) - - var requestURL string - - if len(tagParts) == 2 { - requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1]) - } else { - requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1]) - } - - httpRequest, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - if request.Token != nil { - httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token)) - } - - var tag *dockerHubRepositoryTagResponse - - if len(tagParts) == 1 { - response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - if len(response.Results) == 0 { - return nil, fmt.Errorf("no tags found for repository: %s", request.Repository) - } - - tag = &response.Results[0] - } else { - response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - tag = &response - } - - var repo string - var displayName string - var notesURL string - - if len(tagParts) == 1 { - repo = nameParts[1] - } else { - repo = tagParts[0] - } - - if nameParts[0] == "library" { - displayName = repo - notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name) - } else { - displayName = nameParts[0] + "/" + repo - notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name) - } - - return &AppRelease{ - Source: ReleaseSourceDockerHub, - NotesUrl: notesURL, - Name: displayName, - Version: tag.Name, - TimeReleased: parseRFC3339Time(tag.LastPushed), - }, nil -} diff --git a/internal/feed/extension.go b/internal/feed/extension.go deleted file mode 100644 index 916ee78..0000000 --- a/internal/feed/extension.go +++ /dev/null @@ -1,102 +0,0 @@ -package feed - -import ( - "fmt" - "html" - "html/template" - "io" - "log/slog" - "net/http" - "net/url" -) - -type ExtensionType int - -const ( - ExtensionContentHTML ExtensionType = iota - ExtensionContentUnknown = iota -) - -var ExtensionStringToType = map[string]ExtensionType{ - "html": ExtensionContentHTML, -} - -const ( - ExtensionHeaderTitle = "Widget-Title" - ExtensionHeaderContentType = "Widget-Content-Type" -) - -type ExtensionRequestOptions struct { - URL string `yaml:"url"` - FallbackContentType string `yaml:"fallback-content-type"` - Parameters map[string]string `yaml:"parameters"` - AllowHtml bool `yaml:"allow-potentially-dangerous-html"` -} - -type Extension struct { - Title string - Content template.HTML -} - -func convertExtensionContent(options ExtensionRequestOptions, content []byte, contentType ExtensionType) template.HTML { - switch contentType { - case ExtensionContentHTML: - if options.AllowHtml { - return template.HTML(content) - } - - fallthrough - default: - return template.HTML(html.EscapeString(string(content))) - } -} - -func FetchExtension(options ExtensionRequestOptions) (Extension, error) { - request, _ := http.NewRequest("GET", options.URL, nil) - - query := url.Values{} - - for key, value := range options.Parameters { - query.Set(key, value) - } - - request.URL.RawQuery = query.Encode() - - response, err := http.DefaultClient.Do(request) - - if err != nil { - slog.Error("failed fetching extension", "error", err, "url", options.URL) - return Extension{}, fmt.Errorf("%w: request failed: %w", ErrNoContent, err) - } - - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) - - if err != nil { - slog.Error("failed reading response body of extension", "error", err, "url", options.URL) - return Extension{}, fmt.Errorf("%w: could not read body: %w", ErrNoContent, err) - } - - extension := Extension{} - - if response.Header.Get(ExtensionHeaderTitle) == "" { - extension.Title = "Extension" - } else { - extension.Title = response.Header.Get(ExtensionHeaderTitle) - } - - contentType, ok := ExtensionStringToType[response.Header.Get(ExtensionHeaderContentType)] - - if !ok { - contentType, ok = ExtensionStringToType[options.FallbackContentType] - - if !ok { - contentType = ExtensionContentUnknown - } - } - - extension.Content = convertExtensionContent(options, body, contentType) - - return extension, nil -} diff --git a/internal/feed/gitlab.go b/internal/feed/gitlab.go deleted file mode 100644 index 3ff0f00..0000000 --- a/internal/feed/gitlab.go +++ /dev/null @@ -1,48 +0,0 @@ -package feed - -import ( - "fmt" - "net/http" - "net/url" -) - -type gitlabReleaseResponseJson struct { - TagName string `json:"tag_name"` - ReleasedAt string `json:"released_at"` - Links struct { - Self string `json:"self"` - } `json:"_links"` -} - -func fetchLatestGitLabRelease(request *ReleaseRequest) (*AppRelease, error) { - httpRequest, err := http.NewRequest( - "GET", - fmt.Sprintf( - "https://gitlab.com/api/v4/projects/%s/releases/permalink/latest", - url.QueryEscape(request.Repository), - ), - nil, - ) - - if err != nil { - return nil, err - } - - if request.Token != nil { - httpRequest.Header.Add("PRIVATE-TOKEN", *request.Token) - } - - response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - return &AppRelease{ - Source: ReleaseSourceGitlab, - Name: request.Repository, - Version: normalizeVersionFormat(response.TagName), - NotesUrl: response.Links.Self, - TimeReleased: parseRFC3339Time(response.ReleasedAt), - }, nil -} diff --git a/internal/feed/lobsters.go b/internal/feed/lobsters.go deleted file mode 100644 index 1bb5420..0000000 --- a/internal/feed/lobsters.go +++ /dev/null @@ -1,91 +0,0 @@ -package feed - -import ( - "net/http" - "strings" - "time" -) - -type lobstersPostResponseJson struct { - CreatedAt string `json:"created_at"` - Title string `json:"title"` - URL string `json:"url"` - Score int `json:"score"` - CommentCount int `json:"comment_count"` - CommentsURL string `json:"comments_url"` - Tags []string `json:"tags"` -} - -type lobstersFeedResponseJson []lobstersPostResponseJson - -func getLobstersPostsFromFeed(feedUrl string) (ForumPosts, error) { - request, err := http.NewRequest("GET", feedUrl, nil) - - if err != nil { - return nil, err - } - - feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request) - - if err != nil { - return nil, err - } - - posts := make(ForumPosts, 0, len(feed)) - - for i := range feed { - createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt) - - posts = append(posts, ForumPost{ - Title: feed[i].Title, - DiscussionUrl: feed[i].CommentsURL, - TargetUrl: feed[i].URL, - TargetUrlDomain: extractDomainFromUrl(feed[i].URL), - CommentCount: feed[i].CommentCount, - Score: feed[i].Score, - TimePosted: createdAt, - Tags: feed[i].Tags, - }) - } - - if len(posts) == 0 { - return nil, ErrNoContent - } - - return posts, nil -} - -func FetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (ForumPosts, error) { - var feedUrl string - - if customURL != "" { - feedUrl = customURL - } else { - if instanceURL != "" { - instanceURL = strings.TrimRight(instanceURL, "/") + "/" - } else { - instanceURL = "https://lobste.rs/" - } - - if sortBy == "hot" { - sortBy = "hottest" - } else if sortBy == "new" { - sortBy = "newest" - } - - if len(tags) == 0 { - feedUrl = instanceURL + sortBy + ".json" - } else { - tags := strings.Join(tags, ",") - feedUrl = instanceURL + "t/" + tags + ".json" - } - } - - posts, err := getLobstersPostsFromFeed(feedUrl) - - if err != nil { - return nil, err - } - - return posts, nil -} diff --git a/internal/feed/monitor.go b/internal/feed/monitor.go deleted file mode 100644 index a3da636..0000000 --- a/internal/feed/monitor.go +++ /dev/null @@ -1,77 +0,0 @@ -package feed - -import ( - "context" - "errors" - "net/http" - "time" -) - -type SiteStatusRequest struct { - URL string `yaml:"url"` - CheckURL string `yaml:"check-url"` - AllowInsecure bool `yaml:"allow-insecure"` -} - -type SiteStatus struct { - Code int - TimedOut bool - ResponseTime time.Duration - Error error -} - -func getSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) { - var url string - if statusRequest.CheckURL != "" { - url = statusRequest.CheckURL - } else { - url = statusRequest.URL - } - request, err := http.NewRequest(http.MethodGet, url, nil) - - if err != nil { - return SiteStatus{ - Error: err, - }, nil - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) - defer cancel() - request = request.WithContext(ctx) - requestSentAt := time.Now() - var response *http.Response - - if !statusRequest.AllowInsecure { - response, err = defaultClient.Do(request) - } else { - response, err = defaultInsecureClient.Do(request) - } - - status := SiteStatus{ResponseTime: time.Since(requestSentAt)} - - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - status.TimedOut = true - } - - status.Error = err - return status, nil - } - - defer response.Body.Close() - - status.Code = response.StatusCode - - return status, nil -} - -func FetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) { - job := newJob(getSiteStatusTask, requests).withWorkers(20) - results, _, err := workerPoolDo(job) - - if err != nil { - return nil, err - } - - return results, nil -} diff --git a/internal/feed/pihole.go b/internal/feed/pihole.go deleted file mode 100644 index 3c7f1b5..0000000 --- a/internal/feed/pihole.go +++ /dev/null @@ -1,136 +0,0 @@ -package feed - -import ( - "encoding/json" - "errors" - "log/slog" - "net/http" - "sort" - "strings" -) - -type piholeStatsResponse struct { - TotalQueries int `json:"dns_queries_today"` - QueriesSeries map[int64]int `json:"domains_over_time"` - BlockedQueries int `json:"ads_blocked_today"` - BlockedSeries map[int64]int `json:"ads_over_time"` - BlockedPercentage float64 `json:"ads_percentage_today"` - TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"` - DomainsBlocked int `json:"domains_being_blocked"` -} - -// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array -// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling -type piholeTopBlockedDomains map[string]int - -func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { - // NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow - // because of the UnmarshalJSON method getting called recursively - temp := make(map[string]int) - - err := json.Unmarshal(data, &temp) - - if err != nil { - *p = make(piholeTopBlockedDomains) - } else { - *p = temp - } - - return nil -} - -func FetchPiholeStats(instanceURL, token string) (*DNSStats, error) { - if token == "" { - return nil, errors.New("missing API token") - } - - requestURL := strings.TrimRight(instanceURL, "/") + - "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token - - request, err := http.NewRequest("GET", requestURL, nil) - - if err != nil { - return nil, err - } - - responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request) - - if err != nil { - return nil, err - } - - stats := &DNSStats{ - TotalQueries: responseJson.TotalQueries, - BlockedQueries: responseJson.BlockedQueries, - BlockedPercent: int(responseJson.BlockedPercentage), - DomainsBlocked: responseJson.DomainsBlocked, - } - - if len(responseJson.TopBlockedDomains) > 0 { - domains := make([]DNSStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains)) - - for domain, count := range responseJson.TopBlockedDomains { - domains = append(domains, DNSStatsBlockedDomain{ - Domain: domain, - PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100), - }) - } - - sort.Slice(domains, func(a, b int) bool { - return domains[a].PercentBlocked > domains[b].PercentBlocked - }) - - stats.TopBlockedDomains = domains[:min(len(domains), 5)] - } - - // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144 - if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 { - slog.Warn( - "DNS stats for pihole: did not get expected 144 data points", - "len(queries)", len(responseJson.QueriesSeries), - "len(blocked)", len(responseJson.BlockedSeries), - ) - return stats, nil - } - - var lowestTimestamp int64 = 0 - - for timestamp := range responseJson.QueriesSeries { - if lowestTimestamp == 0 || timestamp < lowestTimestamp { - lowestTimestamp = timestamp - } - } - - maxQueriesInSeries := 0 - - for i := 0; i < 8; i++ { - queries := 0 - blocked := 0 - - for j := 0; j < 18; j++ { - index := lowestTimestamp + int64(i*10800+j*600) - - queries += responseJson.QueriesSeries[index] - blocked += responseJson.BlockedSeries[index] - } - - if queries > maxQueriesInSeries { - maxQueriesInSeries = queries - } - - stats.Series[i] = DNSStatsSeries{ - Queries: queries, - Blocked: blocked, - } - - if queries > 0 { - stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) - } - } - - for i := 0; i < 8; i++ { - stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) - } - - return stats, nil -} diff --git a/internal/feed/primitives.go b/internal/feed/primitives.go deleted file mode 100644 index 90a6a52..0000000 --- a/internal/feed/primitives.go +++ /dev/null @@ -1,247 +0,0 @@ -package feed - -import ( - "math" - "sort" - "time" -) - -type ForumPost struct { - Title string - DiscussionUrl string - TargetUrl string - TargetUrlDomain string - ThumbnailUrl string - CommentCount int - Score int - Engagement float64 - TimePosted time.Time - Tags []string - IsCrosspost bool -} - -type ForumPosts []ForumPost - -type Calendar struct { - CurrentDay int - CurrentWeekNumber int - CurrentMonthName string - CurrentYear int - Days []int -} - -type Weather struct { - Temperature int - ApparentTemperature int - WeatherCode int - CurrentColumn int - SunriseColumn int - SunsetColumn int - Columns []weatherColumn -} - -type AppRelease struct { - Source ReleaseSource - SourceIconURL string - Name string - Version string - NotesUrl string - TimeReleased time.Time - Downvotes int -} - -type AppReleases []AppRelease - -type Video struct { - ThumbnailUrl string - Title string - Url string - Author string - AuthorUrl string - TimePosted time.Time -} - -type Videos []Video - -var currencyToSymbol = map[string]string{ - "USD": "$", - "EUR": "€", - "JPY": "¥", - "CAD": "C$", - "AUD": "A$", - "GBP": "£", - "CHF": "Fr", - "NZD": "N$", - "INR": "₹", - "BRL": "R$", - "RUB": "₽", - "TRY": "₺", - "ZAR": "R", - "CNY": "¥", - "KRW": "₩", - "HKD": "HK$", - "SGD": "S$", - "SEK": "kr", - "NOK": "kr", - "DKK": "kr", - "PLN": "zł", - "PHP": "₱", -} - -type DNSStats struct { - TotalQueries int - BlockedQueries int - BlockedPercent int - ResponseTime int - DomainsBlocked int - Series [8]DNSStatsSeries - TopBlockedDomains []DNSStatsBlockedDomain -} - -type DNSStatsSeries struct { - Queries int - Blocked int - PercentTotal int - PercentBlocked int -} - -type DNSStatsBlockedDomain struct { - Domain string - PercentBlocked int -} - -type MarketRequest struct { - Name string `yaml:"name"` - Symbol string `yaml:"symbol"` - ChartLink string `yaml:"chart-link"` - SymbolLink string `yaml:"symbol-link"` -} - -type Market struct { - MarketRequest - Currency string `yaml:"-"` - Price float64 `yaml:"-"` - PercentChange float64 `yaml:"-"` - SvgChartPoints string `yaml:"-"` -} - -type Markets []Market - -func (t Markets) SortByAbsChange() { - sort.Slice(t, func(i, j int) bool { - return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange) - }) -} - -func (t Markets) SortByChange() { - sort.Slice(t, func(i, j int) bool { - return t[i].PercentChange > t[j].PercentChange - }) -} - -var weatherCodeTable = map[int]string{ - 0: "Clear Sky", - 1: "Mainly Clear", - 2: "Partly Cloudy", - 3: "Overcast", - 45: "Fog", - 48: "Rime Fog", - 51: "Drizzle", - 53: "Drizzle", - 55: "Drizzle", - 56: "Drizzle", - 57: "Drizzle", - 61: "Rain", - 63: "Moderate Rain", - 65: "Heavy Rain", - 66: "Freezing Rain", - 67: "Freezing Rain", - 71: "Snow", - 73: "Moderate Snow", - 75: "Heavy Snow", - 77: "Snow Grains", - 80: "Rain", - 81: "Moderate Rain", - 82: "Heavy Rain", - 85: "Snow", - 86: "Snow", - 95: "Thunderstorm", - 96: "Thunderstorm", - 99: "Thunderstorm", -} - -func (w *Weather) WeatherCodeAsString() string { - if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok { - return weatherCode - } - - return "" -} - -const depreciatePostsOlderThanHours = 7 -const maxDepreciation = 0.9 -const maxDepreciationAfterHours = 24 - -func (p ForumPosts) CalculateEngagement() { - var totalComments int - var totalScore int - - for i := range p { - totalComments += p[i].CommentCount - totalScore += p[i].Score - } - - numberOfPosts := float64(len(p)) - averageComments := float64(totalComments) / numberOfPosts - averageScore := float64(totalScore) / numberOfPosts - - for i := range p { - p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2 - - elapsed := time.Since(p[i].TimePosted) - - if elapsed < time.Hour*depreciatePostsOlderThanHours { - continue - } - - p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation - } -} - -func (p ForumPosts) SortByEngagement() { - sort.Slice(p, func(i, j int) bool { - return p[i].Engagement > p[j].Engagement - }) -} - -func (s *ForumPost) HasTargetUrl() bool { - return s.TargetUrl != "" -} - -func (p ForumPosts) FilterPostedBefore(postedBefore time.Duration) []ForumPost { - recent := make([]ForumPost, 0, len(p)) - - for i := range p { - if time.Since(p[i].TimePosted) < postedBefore { - recent = append(recent, p[i]) - } - } - - return recent -} - -func (r AppReleases) SortByNewest() AppReleases { - sort.Slice(r, func(i, j int) bool { - return r[i].TimeReleased.After(r[j].TimeReleased) - }) - - return r -} - -func (v Videos) SortByNewest() Videos { - sort.Slice(v, func(i, j int) bool { - return v[i].TimePosted.After(v[j].TimePosted) - }) - - return v -} diff --git a/internal/feed/releases.go b/internal/feed/releases.go deleted file mode 100644 index b0cdc25..0000000 --- a/internal/feed/releases.go +++ /dev/null @@ -1,72 +0,0 @@ -package feed - -import ( - "errors" - "fmt" - "log/slog" -) - -type ReleaseSource string - -const ( - ReleaseSourceCodeberg ReleaseSource = "codeberg" - ReleaseSourceGithub ReleaseSource = "github" - ReleaseSourceGitlab ReleaseSource = "gitlab" - ReleaseSourceDockerHub ReleaseSource = "dockerhub" -) - -type ReleaseRequest struct { - Source ReleaseSource - Repository string - Token *string -} - -func FetchLatestReleases(requests []*ReleaseRequest) (AppReleases, error) { - job := newJob(fetchLatestReleaseTask, requests).withWorkers(20) - results, errs, err := workerPoolDo(job) - - if err != nil { - return nil, err - } - - var failed int - - releases := make(AppReleases, 0, len(requests)) - - for i := range results { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch release", "source", requests[i].Source, "repository", requests[i].Repository, "error", errs[i]) - continue - } - - releases = append(releases, *results[i]) - } - - if failed == len(requests) { - return nil, ErrNoContent - } - - releases.SortByNewest() - - if failed > 0 { - return releases, fmt.Errorf("%w: could not get %d releases", ErrPartialContent, failed) - } - - return releases, nil -} - -func fetchLatestReleaseTask(request *ReleaseRequest) (*AppRelease, error) { - switch request.Source { - case ReleaseSourceCodeberg: - return fetchLatestCodebergRelease(request) - case ReleaseSourceGithub: - return fetchLatestGithubRelease(request) - case ReleaseSourceGitlab: - return fetchLatestGitLabRelease(request) - case ReleaseSourceDockerHub: - return fetchLatestDockerHubRelease(request) - } - - return nil, errors.New("unsupported source") -} diff --git a/internal/feed/yahoo.go b/internal/feed/yahoo.go deleted file mode 100644 index f962695..0000000 --- a/internal/feed/yahoo.go +++ /dev/null @@ -1,104 +0,0 @@ -package feed - -import ( - "fmt" - "log/slog" - "net/http" -) - -type marketResponseJson struct { - Chart struct { - Result []struct { - Meta struct { - Currency string `json:"currency"` - Symbol string `json:"symbol"` - RegularMarketPrice float64 `json:"regularMarketPrice"` - ChartPreviousClose float64 `json:"chartPreviousClose"` - } `json:"meta"` - Indicators struct { - Quote []struct { - Close []float64 `json:"close,omitempty"` - } `json:"quote"` - } `json:"indicators"` - } `json:"result"` - } `json:"chart"` -} - -// TODO: allow changing chart time frame -const marketChartDays = 21 - -func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) { - requests := make([]*http.Request, 0, len(marketRequests)) - - for i := range marketRequests { - request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil) - requests = append(requests, request) - } - - job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests) - responses, errs, err := workerPoolDo(job) - - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) - } - - markets := make(Markets, 0, len(responses)) - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i]) - continue - } - - response := responses[i] - - if len(response.Chart.Result) == 0 { - failed++ - slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol) - continue - } - - prices := response.Chart.Result[0].Indicators.Quote[0].Close - - if len(prices) > marketChartDays { - prices = prices[len(prices)-marketChartDays:] - } - - previous := response.Chart.Result[0].Meta.RegularMarketPrice - - if len(prices) >= 2 && prices[len(prices)-2] != 0 { - previous = prices[len(prices)-2] - } - - points := SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) - - currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency] - - if !exists { - currency = response.Chart.Result[0].Meta.Currency - } - - markets = append(markets, Market{ - MarketRequest: marketRequests[i], - Price: response.Chart.Result[0].Meta.RegularMarketPrice, - Currency: currency, - PercentChange: percentChange( - response.Chart.Result[0].Meta.RegularMarketPrice, - previous, - ), - SvgChartPoints: points, - }) - } - - if len(markets) == 0 { - return nil, ErrNoContent - } - - if failed > 0 { - return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed) - } - - return markets, nil -} diff --git a/internal/feed/youtube.go b/internal/feed/youtube.go deleted file mode 100644 index 5016b6b..0000000 --- a/internal/feed/youtube.go +++ /dev/null @@ -1,115 +0,0 @@ -package feed - -import ( - "fmt" - "log/slog" - "net/http" - "net/url" - "strings" - "time" -) - -type youtubeFeedResponseXml struct { - Channel string `xml:"author>name"` - ChannelLink string `xml:"author>uri"` - Videos []struct { - Title string `xml:"title"` - Published string `xml:"published"` - Link struct { - Href string `xml:"href,attr"` - } `xml:"link"` - - Group struct { - Thumbnail struct { - Url string `xml:"url,attr"` - } `xml:"http://search.yahoo.com/mrss/ thumbnail"` - } `xml:"http://search.yahoo.com/mrss/ group"` - } `xml:"entry"` -} - -func parseYoutubeFeedTime(t string) time.Time { - parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t) - - if err != nil { - return time.Now() - } - - return parsedTime -} - -func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (Videos, error) { - requests := make([]*http.Request, 0, len(channelIds)) - - for i := range channelIds { - var feedUrl string - if !includeShorts && strings.HasPrefix(channelIds[i], "UC") { - playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1) - feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId - } else { - feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i] - } - - request, _ := http.NewRequest("GET", feedUrl, nil) - requests = append(requests, request) - } - - job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30) - - responses, errs, err := workerPoolDo(job) - - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) - } - - videos := make(Videos, 0, len(channelIds)*15) - - var failed int - - for i := range responses { - if errs[i] != nil { - failed++ - slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i]) - continue - } - - response := responses[i] - - for j := range response.Videos { - video := &response.Videos[j] - var videoUrl string - - if videoUrlTemplate == "" { - videoUrl = video.Link.Href - } else { - parsedUrl, err := url.Parse(video.Link.Href) - - if err == nil { - videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v")) - } else { - videoUrl = "#" - } - } - - videos = append(videos, Video{ - ThumbnailUrl: video.Group.Thumbnail.Url, - Title: video.Title, - Url: videoUrl, - Author: response.Channel, - AuthorUrl: response.ChannelLink + "/videos", - TimePosted: parseYoutubeFeedTime(video.Published), - }) - } - } - - if len(videos) == 0 { - return nil, ErrNoContent - } - - videos.SortByNewest() - - if failed > 0 { - return videos, fmt.Errorf("%w: missing videos from %d channels", ErrPartialContent, failed) - } - - return videos, nil -} diff --git a/internal/widget/fields.go b/internal/glance/config-fields.go similarity index 61% rename from internal/widget/fields.go rename to internal/glance/config-fields.go index 2b60b27..16ccc85 100644 --- a/internal/widget/fields.go +++ b/internal/glance/config-fields.go @@ -1,4 +1,4 @@ -package widget +package glance import ( "fmt" @@ -12,70 +12,66 @@ import ( "gopkg.in/yaml.v3" ) -var HSLColorPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) -var EnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`) +var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`) const ( - HSLHueMax = 360 - HSLSaturationMax = 100 - HSLLightnessMax = 100 + hslHueMax = 360 + hslSaturationMax = 100 + hslLightnessMax = 100 ) -type HSLColorField struct { +type hslColorField struct { Hue uint16 Saturation uint8 Lightness uint8 } -func (c *HSLColorField) String() string { +func (c *hslColorField) String() string { return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness) } -func (c *HSLColorField) AsCSSValue() template.CSS { +func (c *hslColorField) AsCSSValue() template.CSS { return template.CSS(c.String()) } -func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error { +func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error { var value string if err := node.Decode(&value); err != nil { return err } - matches := HSLColorPattern.FindStringSubmatch(value) + matches := hslColorFieldPattern.FindStringSubmatch(value) if len(matches) != 4 { return fmt.Errorf("invalid HSL color format: %s", value) } hue, err := strconv.ParseUint(matches[1], 10, 16) - if err != nil { return err } - if hue > HSLHueMax { - return fmt.Errorf("HSL hue must be between 0 and %d", HSLHueMax) + if hue > hslHueMax { + return fmt.Errorf("HSL hue must be between 0 and %d", hslHueMax) } saturation, err := strconv.ParseUint(matches[2], 10, 8) - if err != nil { return err } - if saturation > HSLSaturationMax { - return fmt.Errorf("HSL saturation must be between 0 and %d", HSLSaturationMax) + if saturation > hslSaturationMax { + return fmt.Errorf("HSL saturation must be between 0 and %d", hslSaturationMax) } lightness, err := strconv.ParseUint(matches[3], 10, 8) - if err != nil { return err } - if lightness > HSLLightnessMax { - return fmt.Errorf("HSL lightness must be between 0 and %d", HSLLightnessMax) + if lightness > hslLightnessMax { + return fmt.Errorf("HSL lightness must be between 0 and %d", hslLightnessMax) } c.Hue = uint16(hue) @@ -85,18 +81,18 @@ func (c *HSLColorField) UnmarshalYAML(node *yaml.Node) error { return nil } -var DurationPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) +var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`) -type DurationField time.Duration +type durationField time.Duration -func (d *DurationField) UnmarshalYAML(node *yaml.Node) error { +func (d *durationField) UnmarshalYAML(node *yaml.Node) error { var value string if err := node.Decode(&value); err != nil { return err } - matches := DurationPattern.FindStringSubmatch(value) + matches := durationFieldPattern.FindStringSubmatch(value) if len(matches) != 3 { return fmt.Errorf("invalid duration format: %s", value) @@ -110,52 +106,52 @@ func (d *DurationField) UnmarshalYAML(node *yaml.Node) error { switch matches[2] { case "s": - *d = DurationField(time.Duration(duration) * time.Second) + *d = durationField(time.Duration(duration) * time.Second) case "m": - *d = DurationField(time.Duration(duration) * time.Minute) + *d = durationField(time.Duration(duration) * time.Minute) case "h": - *d = DurationField(time.Duration(duration) * time.Hour) + *d = durationField(time.Duration(duration) * time.Hour) case "d": - *d = DurationField(time.Duration(duration) * 24 * time.Hour) + *d = durationField(time.Duration(duration) * 24 * time.Hour) } return nil } -type OptionalEnvString string +var optionalEnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`) -func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error { +type optionalEnvField string + +func (f *optionalEnvField) UnmarshalYAML(node *yaml.Node) error { var value string err := node.Decode(&value) - if err != nil { return err } - replaced := EnvFieldPattern.ReplaceAllStringFunc(value, func(whole string) string { + replaced := optionalEnvFieldPattern.ReplaceAllStringFunc(value, func(match string) string { if err != nil { return "" } - groups := EnvFieldPattern.FindStringSubmatch(whole) + groups := optionalEnvFieldPattern.FindStringSubmatch(match) if len(groups) != 3 { - return whole + return match } prefix, key := groups[1], groups[2] if prefix == `\` { - if len(whole) >= 2 { - return whole[1:] + if len(match) >= 2 { + return match[1:] } else { return "" } } value, found := os.LookupEnv(key) - if !found { err = fmt.Errorf("environment variable %s not found", key) return "" @@ -168,16 +164,16 @@ func (f *OptionalEnvString) UnmarshalYAML(node *yaml.Node) error { return err } - *f = OptionalEnvString(replaced) + *f = optionalEnvField(replaced) return nil } -func (f *OptionalEnvString) String() string { +func (f *optionalEnvField) String() string { return string(*f) } -type CustomIcon struct { +type customIconField struct { URL string IsFlatIcon bool // TODO: along with whether the icon is flat, we also need to know @@ -185,7 +181,7 @@ type CustomIcon struct { // invert the color based on the theme being light or dark } -func (i *CustomIcon) UnmarshalYAML(node *yaml.Node) error { +func (i *customIconField) UnmarshalYAML(node *yaml.Node) error { var value string if err := node.Decode(&value); err != nil { return err diff --git a/internal/glance/config.go b/internal/glance/config.go index de4a2f3..461fd50 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -3,26 +3,72 @@ package glance import ( "bytes" "fmt" + "html/template" "log" "os" "path/filepath" "regexp" "strings" + "sync" "time" "github.com/fsnotify/fsnotify" "gopkg.in/yaml.v3" ) -type Config struct { - Server Server `yaml:"server"` - Theme Theme `yaml:"theme"` - Branding Branding `yaml:"branding"` - Pages []Page `yaml:"pages"` +type config struct { + Server struct { + Host string `yaml:"host"` + Port uint16 `yaml:"port"` + AssetsPath string `yaml:"assets-path"` + BaseURL string `yaml:"base-url"` + StartedAt time.Time `yaml:"-"` // used in custom css file + } `yaml:"server"` + + Document struct { + Head template.HTML `yaml:"head"` + } `yaml:"document"` + + Theme struct { + BackgroundColor *hslColorField `yaml:"background-color"` + PrimaryColor *hslColorField `yaml:"primary-color"` + PositiveColor *hslColorField `yaml:"positive-color"` + NegativeColor *hslColorField `yaml:"negative-color"` + Light bool `yaml:"light"` + ContrastMultiplier float32 `yaml:"contrast-multiplier"` + TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` + CustomCSSFile string `yaml:"custom-css-file"` + } `yaml:"theme"` + + Branding struct { + HideFooter bool `yaml:"hide-footer"` + CustomFooter template.HTML `yaml:"custom-footer"` + LogoText string `yaml:"logo-text"` + LogoURL string `yaml:"logo-url"` + FaviconURL string `yaml:"favicon-url"` + } `yaml:"branding"` + + Pages []page `yaml:"pages"` } -func newConfigFromYAML(contents []byte) (*Config, error) { - config := &Config{} +type page struct { + Title string `yaml:"name"` + Slug string `yaml:"slug"` + Width string `yaml:"width"` + ShowMobileHeader bool `yaml:"show-mobile-header"` + ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"` + HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` + CenterVertically bool `yaml:"center-vertically"` + Columns []struct { + Size string `yaml:"size"` + Widgets widgets `yaml:"widgets"` + } `yaml:"columns"` + PrimaryColumnIndex int8 `yaml:"-"` + mu sync.Mutex `yaml:"-"` +} + +func newConfigFromYAML(contents []byte) (*config, error) { + config := &config{} config.Server.Port = 8080 err := yaml.Unmarshal(contents, config) @@ -37,8 +83,8 @@ func newConfigFromYAML(contents []byte) (*Config, error) { for p := range config.Pages { for c := range config.Pages[p].Columns { for w := range config.Pages[p].Columns[c].Widgets { - if err := config.Pages[p].Columns[c].Widgets[w].Initialize(); err != nil { - return nil, err + if err := config.Pages[p].Columns[c].Widgets[w].initialize(); err != nil { + return nil, formatWidgetInitError(err, config.Pages[p].Columns[c].Widgets[w]) } } } @@ -47,6 +93,10 @@ func newConfigFromYAML(contents []byte) (*Config, error) { return config, nil } +func formatWidgetInitError(err error, w widget) error { + return fmt.Errorf("%s widget: %v", w.GetType(), err) +} + var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`) func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) { @@ -101,16 +151,6 @@ func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) return mainFileContents, includes, nil } -func prefixStringLines(prefix string, s string) string { - lines := strings.Split(s, "\n") - - for i, line := range lines { - lines[i] = prefix + line - } - - return strings.Join(lines, "\n") -} - func configFilesWatcher( mainFilePath string, lastContents []byte, @@ -209,7 +249,7 @@ func configFilesWatcher( }, nil } -func isConfigStateValid(config *Config) error { +func isConfigStateValid(config *config) error { if len(config.Pages) == 0 { return fmt.Errorf("no pages configured") } @@ -222,24 +262,24 @@ func isConfigStateValid(config *Config) error { for i := range config.Pages { if config.Pages[i].Title == "" { - return fmt.Errorf("Page %d has no title", i+1) + return fmt.Errorf("page %d has no title", i+1) } if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") { - return fmt.Errorf("Page %d: width can only be either wide or slim", i+1) + return fmt.Errorf("page %d: width can only be either wide or slim", i+1) } if len(config.Pages[i].Columns) == 0 { - return fmt.Errorf("Page %d has no columns", i+1) + return fmt.Errorf("page %d has no columns", i+1) } if config.Pages[i].Width == "slim" { if len(config.Pages[i].Columns) > 2 { - return fmt.Errorf("Page %d is slim and cannot have more than 2 columns", i+1) + return fmt.Errorf("page %d is slim and cannot have more than 2 columns", i+1) } } else { if len(config.Pages[i].Columns) > 3 { - return fmt.Errorf("Page %d has more than 3 columns: %d", i+1, len(config.Pages[i].Columns)) + return fmt.Errorf("page %d has more than 3 columns", i+1) } } @@ -247,7 +287,7 @@ func isConfigStateValid(config *Config) error { for j := range config.Pages[i].Columns { if config.Pages[i].Columns[j].Size != "small" && config.Pages[i].Columns[j].Size != "full" { - return fmt.Errorf("Column %d of page %d: size can only be either small or full", j+1, i+1) + return fmt.Errorf("column %d of page %d: size can only be either small or full", j+1, i+1) } columnSizesCount[config.Pages[i].Columns[j].Size]++ @@ -256,7 +296,7 @@ func isConfigStateValid(config *Config) error { full := columnSizesCount["full"] if full > 2 || full == 0 { - return fmt.Errorf("Page %d must have either 1 or 2 full width columns", i+1) + return fmt.Errorf("page %d must have either 1 or 2 full width columns", i+1) } } diff --git a/internal/glance/diagnose.go b/internal/glance/diagnose.go index c7fd141..892aa5f 100644 --- a/internal/glance/diagnose.go +++ b/internal/glance/diagnose.go @@ -6,7 +6,6 @@ import ( "io" "net" "net/http" - "os" "runtime" "strings" "sync" @@ -151,19 +150,6 @@ type diagnosticStep struct { elapsed time.Duration } -func boolToString(b bool, trueValue, falseValue string) string { - if b { - return trueValue - } - - return falseValue -} - -func isRunningInsideDockerContainer() bool { - _, err := os.Stat("/.dockerenv") - return err == nil -} - func testHttpRequest(method, url string, expectedStatusCode int) (string, error) { return testHttpRequestWithHeaders(method, url, nil, expectedStatusCode) } diff --git a/internal/assets/files.go b/internal/glance/embed.go similarity index 68% rename from internal/assets/files.go rename to internal/glance/embed.go index 2c7c09e..65b1a72 100644 --- a/internal/assets/files.go +++ b/internal/glance/embed.go @@ -1,4 +1,4 @@ -package assets +package glance import ( "crypto/md5" @@ -6,21 +6,23 @@ import ( "encoding/hex" "io" "io/fs" - "log/slog" + "log" "strconv" "time" ) //go:embed static -var _publicFS embed.FS +var _staticFS embed.FS //go:embed templates var _templateFS embed.FS -var PublicFS, _ = fs.Sub(_publicFS, "static") -var TemplateFS, _ = fs.Sub(_templateFS, "templates") +var staticFS, _ = fs.Sub(_staticFS, "static") +var templateFS, _ = fs.Sub(_templateFS, "templates") -func getFSHash(files fs.FS) string { +var staticFSHash = computeFSHash(staticFS) + +func computeFSHash(files fs.FS) string { hash := md5.New() err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { @@ -33,7 +35,6 @@ func getFSHash(files fs.FS) string { } file, err := files.Open(path) - if err != nil { return err } @@ -49,8 +50,6 @@ func getFSHash(files fs.FS) string { return hex.EncodeToString(hash.Sum(nil))[:10] } - slog.Warn("Could not compute assets cache", "err", err) + log.Printf("Could not compute assets cache: %v", err) return strconv.FormatInt(time.Now().Unix(), 10) } - -var PublicFSHash = getFSHash(PublicFS) diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 96f238c..8c0068c 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -8,133 +8,41 @@ import ( "log" "net/http" "path/filepath" - "regexp" "strconv" "strings" "sync" "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/widget" ) -var buildVersion = "dev" +var pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl") -var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) +type application struct { + Version string + Config config + ParsedThemeStyle template.HTML -type Application struct { - Version string - Config Config - slugToPage map[string]*Page - widgetByID map[uint64]widget.Widget + slugToPage map[string]*page + widgetByID map[uint64]widget } -type Theme struct { - BackgroundColor *widget.HSLColorField `yaml:"background-color"` - PrimaryColor *widget.HSLColorField `yaml:"primary-color"` - PositiveColor *widget.HSLColorField `yaml:"positive-color"` - NegativeColor *widget.HSLColorField `yaml:"negative-color"` - Light bool `yaml:"light"` - ContrastMultiplier float32 `yaml:"contrast-multiplier"` - TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` - CustomCSSFile string `yaml:"custom-css-file"` -} - -type Server struct { - Host string `yaml:"host"` - Port uint16 `yaml:"port"` - AssetsPath string `yaml:"assets-path"` - BaseURL string `yaml:"base-url"` - AssetsHash string `yaml:"-"` - StartedAt time.Time `yaml:"-"` // used in custom css file -} - -type Branding struct { - HideFooter bool `yaml:"hide-footer"` - CustomFooter template.HTML `yaml:"custom-footer"` - LogoText string `yaml:"logo-text"` - LogoURL string `yaml:"logo-url"` - FaviconURL string `yaml:"favicon-url"` -} - -type Column struct { - Size string `yaml:"size"` - Widgets widget.Widgets `yaml:"widgets"` -} - -type templateData struct { - App *Application - Page *Page -} - -type Page struct { - Title string `yaml:"name"` - Slug string `yaml:"slug"` - Width string `yaml:"width"` - ShowMobileHeader bool `yaml:"show-mobile-header"` - ExpandMobilePageNavigation bool `yaml:"expand-mobile-page-navigation"` - HideDesktopNavigation bool `yaml:"hide-desktop-navigation"` - CenterVertically bool `yaml:"center-vertically"` - Columns []Column `yaml:"columns"` - PrimaryColumnIndex int8 `yaml:"-"` - mu sync.Mutex -} - -func (p *Page) UpdateOutdatedWidgets() { - now := time.Now() - - var wg sync.WaitGroup - context := context.Background() - - for c := range p.Columns { - for w := range p.Columns[c].Widgets { - widget := p.Columns[c].Widgets[w] - - if !widget.RequiresUpdate(&now) { - continue - } - - wg.Add(1) - go func() { - defer wg.Done() - widget.Update(context) - }() - } - } - - wg.Wait() -} - -// TODO: fix, currently very simple, lots of uncovered edge cases -func titleToSlug(s string) string { - s = strings.ToLower(s) - s = sequentialWhitespacePattern.ReplaceAllString(s, "-") - s = strings.Trim(s, "-") - - return s -} - -func (a *Application) TransformUserDefinedAssetPath(path string) string { - if strings.HasPrefix(path, "/assets/") { - return a.Config.Server.BaseURL + path - } - - return path -} - -func newApplication(config *Config) *Application { - app := &Application{ +func newApplication(config *config) (*application, error) { + app := &application{ Version: buildVersion, Config: *config, - slugToPage: make(map[string]*Page), - widgetByID: make(map[uint64]widget.Widget), + slugToPage: make(map[string]*page), + widgetByID: make(map[uint64]widget), } - app.Config.Server.AssetsHash = assets.PublicFSHash app.slugToPage[""] = &config.Pages[0] - providers := &widget.Providers{ - AssetResolver: app.AssetPath, + providers := &widgetProviders{ + assetResolver: app.AssetPath, + } + + var err error + app.ParsedThemeStyle, err = executeTemplateToHTML(pageThemeStyleTemplate, &app.Config.Theme) + if err != nil { + return nil, fmt.Errorf("parsing theme style: %v", err) } for p := range config.Pages { @@ -156,9 +64,9 @@ func newApplication(config *Config) *Application { for w := range column.Widgets { widget := column.Widgets[w] - app.widgetByID[widget.GetID()] = widget + app.widgetByID[widget.id()] = widget - widget.SetProviders(providers) + widget.setProviders(providers) } } } @@ -166,34 +74,75 @@ func newApplication(config *Config) *Application { config = &app.Config config.Server.BaseURL = strings.TrimRight(config.Server.BaseURL, "/") - config.Theme.CustomCSSFile = app.TransformUserDefinedAssetPath(config.Theme.CustomCSSFile) + config.Theme.CustomCSSFile = app.transformUserDefinedAssetPath(config.Theme.CustomCSSFile) if config.Branding.FaviconURL == "" { config.Branding.FaviconURL = app.AssetPath("favicon.png") } else { - config.Branding.FaviconURL = app.TransformUserDefinedAssetPath(config.Branding.FaviconURL) + config.Branding.FaviconURL = app.transformUserDefinedAssetPath(config.Branding.FaviconURL) } - config.Branding.LogoURL = app.TransformUserDefinedAssetPath(config.Branding.LogoURL) + config.Branding.LogoURL = app.transformUserDefinedAssetPath(config.Branding.LogoURL) - return app + return app, nil } -func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) { +func (p *page) updateOutdatedWidgets() { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + + var wg sync.WaitGroup + context := context.Background() + + for c := range p.Columns { + for w := range p.Columns[c].Widgets { + widget := p.Columns[c].Widgets[w] + + if !widget.requiresUpdate(&now) { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + widget.update(context) + }() + } + } + + wg.Wait() +} + +func (a *application) transformUserDefinedAssetPath(path string) string { + if strings.HasPrefix(path, "/assets/") { + return a.Config.Server.BaseURL + path + } + + return path +} + +type pageTemplateData struct { + App *application + Page *page +} + +func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - pageData := templateData{ + pageData := pageTemplateData{ Page: page, App: a, } var responseBytes bytes.Buffer - err := assets.PageTemplate.Execute(&responseBytes, pageData) + err := pageTemplate.Execute(&responseBytes, pageData) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -204,24 +153,22 @@ func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) w.Write(responseBytes.Bytes()) } -func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) { +func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Request) { page, exists := a.slugToPage[r.PathValue("page")] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - pageData := templateData{ + pageData := pageTemplateData{ Page: page, } - page.mu.Lock() - defer page.mu.Unlock() - page.UpdateOutdatedWidgets() + page.updateOutdatedWidgets() var responseBytes bytes.Buffer - err := assets.PageContentTemplate.Execute(&responseBytes, pageData) + err := pageContentTemplate.Execute(&responseBytes, pageData) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -232,69 +179,59 @@ func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Re w.Write(responseBytes.Bytes()) } -func (a *Application) HandleNotFound(w http.ResponseWriter, r *http.Request) { +func (a *application) handleNotFound(w http.ResponseWriter, _ *http.Request) { // TODO: add proper not found page w.WriteHeader(http.StatusNotFound) w.Write([]byte("Page not found")) } -func FileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler { - server := http.FileServer(fs) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // TODO: fix always setting cache control even if the file doesn't exist - w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))) - server.ServeHTTP(w, r) - }) -} - -func (a *Application) HandleWidgetRequest(w http.ResponseWriter, r *http.Request) { +func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request) { widgetValue := r.PathValue("widget") widgetID, err := strconv.ParseUint(widgetValue, 10, 64) if err != nil { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } widget, exists := a.widgetByID[widgetID] if !exists { - a.HandleNotFound(w, r) + a.handleNotFound(w, r) return } - widget.HandleRequest(w, r) + widget.handleRequest(w, r) } -func (a *Application) AssetPath(asset string) string { - return a.Config.Server.BaseURL + "/static/" + a.Config.Server.AssetsHash + "/" + asset +func (a *application) AssetPath(asset string) string { + return a.Config.Server.BaseURL + "/static/" + staticFSHash + "/" + asset } -func (a *Application) Server() (func() error, func() error) { +func (a *application) server() (func() error, func() error) { // TODO: add gzip support, static files must have their gzipped contents cached // TODO: add HTTPS support mux := http.NewServeMux() - mux.HandleFunc("GET /{$}", a.HandlePageRequest) - mux.HandleFunc("GET /{page}", a.HandlePageRequest) + mux.HandleFunc("GET /{$}", a.handlePageRequest) + mux.HandleFunc("GET /{page}", a.handlePageRequest) - mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.HandlePageContentRequest) - mux.HandleFunc("/api/widgets/{widget}/{path...}", a.HandleWidgetRequest) + mux.HandleFunc("GET /api/pages/{page}/content/{$}", a.handlePageContentRequest) + mux.HandleFunc("/api/widgets/{widget}/{path...}", a.handleWidgetRequest) mux.HandleFunc("GET /api/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) mux.Handle( - fmt.Sprintf("GET /static/%s/{path...}", a.Config.Server.AssetsHash), - http.StripPrefix("/static/"+a.Config.Server.AssetsHash, FileServerWithCache(http.FS(assets.PublicFS), 24*time.Hour)), + fmt.Sprintf("GET /static/%s/{path...}", staticFSHash), + http.StripPrefix("/static/"+staticFSHash, fileServerWithCache(http.FS(staticFS), 24*time.Hour)), ) var absAssetsPath string if a.Config.Server.AssetsPath != "" { absAssetsPath, _ = filepath.Abs(a.Config.Server.AssetsPath) - assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour) + assetsFS := fileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour) mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS)) } diff --git a/internal/glance/main.go b/internal/glance/main.go index 552ecd6..0473501 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -2,9 +2,14 @@ package glance import ( "fmt" + "io" "log" + "net/http" + "os" ) +var buildVersion = "dev" + func Main() int { options, err := parseCliOptions() @@ -15,6 +20,11 @@ func Main() int { switch options.intent { case cliIntentServe: + // remove in v0.10.0 + if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) { + return 1 + } + if err := serveApp(options.configPath); err != nil { fmt.Println(err) return 1 @@ -22,7 +32,7 @@ func Main() int { case cliIntentConfigValidate: contents, _, err := parseYAMLIncludes(options.configPath) if err != nil { - fmt.Printf("failed to parse config file: %v\n", err) + fmt.Printf("could not parse config file: %v\n", err) return 1 } @@ -33,7 +43,7 @@ func Main() int { case cliIntentConfigPrint: contents, _, err := parseYAMLIncludes(options.configPath) if err != nil { - fmt.Printf("failed to parse config file: %v\n", err) + fmt.Printf("could not parse config file: %v\n", err) return 1 } @@ -54,12 +64,12 @@ func serveApp(configPath string) error { onChange := func(newContents []byte) { if stopServer != nil { - log.Println("Config file changed, attempting to restart server") + log.Println("Config file changed, reloading...") } config, err := newConfigFromYAML(newContents) if err != nil { - log.Printf("Config file is invalid: %v", err) + log.Printf("Config has errors: %v", err) if !hadValidConfigOnStartup { close(exitChannel) @@ -70,7 +80,11 @@ func serveApp(configPath string) error { hadValidConfigOnStartup = true } - app := newApplication(config) + app, err := newApplication(config) + if err != nil { + log.Printf("Failed to create application: %v", err) + return + } if stopServer != nil { if err := stopServer(); err != nil { @@ -80,7 +94,7 @@ func serveApp(configPath string) error { go func() { var startServer func() error - startServer, stopServer = app.Server() + startServer, stopServer = app.server() if err := startServer(); err != nil { log.Printf("Failed to start server: %v", err) @@ -94,7 +108,7 @@ func serveApp(configPath string) error { configContents, configIncludes, err := parseYAMLIncludes(configPath) if err != nil { - return fmt.Errorf("failed to parse config file: %w", err) + return fmt.Errorf("parsing config: %w", err) } stopWatching, err := configFilesWatcher(configPath, configContents, configIncludes, onChange, onErr) @@ -105,17 +119,54 @@ func serveApp(configPath string) error { config, err := newConfigFromYAML(configContents) if err != nil { - return fmt.Errorf("could not parse config file: %w", err) + return fmt.Errorf("validating config file: %w", err) } - app := newApplication(config) + app, err := newApplication(config) + if err != nil { + return fmt.Errorf("creating application: %w", err) + } - startServer, _ := app.Server() + startServer, _ := app.server() if err := startServer(); err != nil { - return fmt.Errorf("failed to start server: %w", err) + return fmt.Errorf("starting server: %w", err) } } <-exitChannel return nil } + +func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool { + if !isRunningInsideDockerContainer() { + return false + } + + if _, err := os.Stat(configPath); err == nil { + return false + } + + // glance.yml wasn't mounted to begin with or was incorrectly mounted as a directory + if stat, err := os.Stat("glance.yml"); err != nil || stat.IsDir() { + return false + } + + templateFile, _ := templateFS.Open("v0.7-update-notice-page.html") + bodyContents, _ := io.ReadAll(templateFile) + + mux := http.NewServeMux() + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(bodyContents)) + }) + + server := http.Server{ + Addr: "localhost:8080", + Handler: mux, + } + server.ListenAndServe() + + return true +} diff --git a/internal/assets/static/app-icon.png b/internal/glance/static/app-icon.png similarity index 100% rename from internal/assets/static/app-icon.png rename to internal/glance/static/app-icon.png diff --git a/internal/assets/static/favicon.png b/internal/glance/static/favicon.png similarity index 100% rename from internal/assets/static/favicon.png rename to internal/glance/static/favicon.png diff --git a/internal/assets/static/fonts/JetBrainsMono-Regular.woff2 b/internal/glance/static/fonts/JetBrainsMono-Regular.woff2 similarity index 100% rename from internal/assets/static/fonts/JetBrainsMono-Regular.woff2 rename to internal/glance/static/fonts/JetBrainsMono-Regular.woff2 diff --git a/internal/assets/static/icons/codeberg.svg b/internal/glance/static/icons/codeberg.svg similarity index 100% rename from internal/assets/static/icons/codeberg.svg rename to internal/glance/static/icons/codeberg.svg diff --git a/internal/assets/static/icons/dockerhub.svg b/internal/glance/static/icons/dockerhub.svg similarity index 100% rename from internal/assets/static/icons/dockerhub.svg rename to internal/glance/static/icons/dockerhub.svg diff --git a/internal/assets/static/icons/github.svg b/internal/glance/static/icons/github.svg similarity index 100% rename from internal/assets/static/icons/github.svg rename to internal/glance/static/icons/github.svg diff --git a/internal/assets/static/icons/gitlab.svg b/internal/glance/static/icons/gitlab.svg similarity index 100% rename from internal/assets/static/icons/gitlab.svg rename to internal/glance/static/icons/gitlab.svg diff --git a/internal/assets/static/js/main.js b/internal/glance/static/js/main.js similarity index 100% rename from internal/assets/static/js/main.js rename to internal/glance/static/js/main.js diff --git a/internal/assets/static/js/masonry.js b/internal/glance/static/js/masonry.js similarity index 100% rename from internal/assets/static/js/masonry.js rename to internal/glance/static/js/masonry.js diff --git a/internal/assets/static/js/popover.js b/internal/glance/static/js/popover.js similarity index 100% rename from internal/assets/static/js/popover.js rename to internal/glance/static/js/popover.js diff --git a/internal/assets/static/js/utils.js b/internal/glance/static/js/utils.js similarity index 100% rename from internal/assets/static/js/utils.js rename to internal/glance/static/js/utils.js diff --git a/internal/assets/static/main.css b/internal/glance/static/main.css similarity index 99% rename from internal/assets/static/main.css rename to internal/glance/static/main.css index 3ac7ee0..a486395 100644 --- a/internal/assets/static/main.css +++ b/internal/glance/static/main.css @@ -546,6 +546,10 @@ kbd:active { opacity: 1; } +.details:not([open]) .list-with-transition { + display: none; +} + .summary::after { content: "◀"; font-size: 1.2em; @@ -1106,7 +1110,6 @@ details[open] .summary::after { .dns-stats-graph-gridlines-container { position: absolute; - z-index: -1; inset: 0; } diff --git a/internal/assets/static/manifest.json b/internal/glance/static/manifest.json similarity index 100% rename from internal/assets/static/manifest.json rename to internal/glance/static/manifest.json diff --git a/internal/glance/templates.go b/internal/glance/templates.go new file mode 100644 index 0000000..db14d7e --- /dev/null +++ b/internal/glance/templates.go @@ -0,0 +1,62 @@ +package glance + +import ( + "fmt" + "html/template" + "math" + "strconv" + "time" + + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var ( + pageTemplate = mustParseTemplate("page.html", "document.html") + pageContentTemplate = mustParseTemplate("page-content.html") + forumPostsTemplate = mustParseTemplate("forum-posts.html", "widget-base.html") +) + +var globalTemplateFunctions = template.FuncMap{ + "formatViewerCount": formatViewerCount, + "formatNumber": intl.Sprint, + "absInt": func(i int) int { + return int(math.Abs(float64(i))) + }, + "formatPrice": func(price float64) string { + return intl.Sprintf("%.2f", price) + }, + "dynamicRelativeTimeAttrs": func(t time.Time) template.HTMLAttr { + return template.HTMLAttr(fmt.Sprintf(`data-dynamic-relative-time="%d"`, t.Unix())) + }, +} + +func mustParseTemplate(primary string, dependencies ...string) *template.Template { + t, err := template.New(primary). + Funcs(globalTemplateFunctions). + ParseFS(templateFS, append([]string{primary}, dependencies...)...) + + if err != nil { + panic(err) + } + + return t +} + +var intl = message.NewPrinter(language.English) + +func formatViewerCount(count int) string { + if count < 1_000 { + return strconv.Itoa(count) + } + + if count < 10_000 { + return fmt.Sprintf("%.1fk", float64(count)/1_000) + } + + if count < 1_000_000 { + return fmt.Sprintf("%dk", count/1_000) + } + + return fmt.Sprintf("%.1fm", float64(count)/1_000_000) +} diff --git a/internal/assets/templates/bookmarks.html b/internal/glance/templates/bookmarks.html similarity index 100% rename from internal/assets/templates/bookmarks.html rename to internal/glance/templates/bookmarks.html diff --git a/internal/assets/templates/calendar.html b/internal/glance/templates/calendar.html similarity index 100% rename from internal/assets/templates/calendar.html rename to internal/glance/templates/calendar.html diff --git a/internal/assets/templates/change-detection.html b/internal/glance/templates/change-detection.html similarity index 100% rename from internal/assets/templates/change-detection.html rename to internal/glance/templates/change-detection.html diff --git a/internal/assets/templates/clock.html b/internal/glance/templates/clock.html similarity index 100% rename from internal/assets/templates/clock.html rename to internal/glance/templates/clock.html diff --git a/internal/assets/templates/custom-api.html b/internal/glance/templates/custom-api.html similarity index 100% rename from internal/assets/templates/custom-api.html rename to internal/glance/templates/custom-api.html diff --git a/internal/assets/templates/dns-stats.html b/internal/glance/templates/dns-stats.html similarity index 98% rename from internal/assets/templates/dns-stats.html rename to internal/glance/templates/dns-stats.html index 5d83508..8447ce1 100644 --- a/internal/assets/templates/dns-stats.html +++ b/internal/glance/templates/dns-stats.html @@ -73,7 +73,7 @@ Top blocked domains
    {{ range .Stats.TopBlockedDomains }} -
  • +
  • {{ .Domain }}
    {{ .PercentBlocked }}%
  • diff --git a/internal/assets/templates/document.html b/internal/glance/templates/document.html similarity index 100% rename from internal/assets/templates/document.html rename to internal/glance/templates/document.html diff --git a/internal/assets/templates/extension.html b/internal/glance/templates/extension.html similarity index 100% rename from internal/assets/templates/extension.html rename to internal/glance/templates/extension.html diff --git a/internal/assets/templates/forum-posts.html b/internal/glance/templates/forum-posts.html similarity index 97% rename from internal/assets/templates/forum-posts.html rename to internal/glance/templates/forum-posts.html index 8a71d22..ae1ca44 100644 --- a/internal/assets/templates/forum-posts.html +++ b/internal/glance/templates/forum-posts.html @@ -12,7 +12,7 @@ {{ else if ne .ThumbnailUrl "" }} - {{ else if .HasTargetUrl }} + {{ else if ne "" .TargetUrl }} @@ -37,7 +37,7 @@
  • {{ .Score | formatNumber }} points
  • {{ .CommentCount | formatNumber }} comments
  • - {{ if .HasTargetUrl }} + {{ if ne "" .TargetUrl }}
  • {{ .TargetUrlDomain }}
  • {{ end }}
diff --git a/internal/assets/templates/group.html b/internal/glance/templates/group.html similarity index 100% rename from internal/assets/templates/group.html rename to internal/glance/templates/group.html diff --git a/internal/assets/templates/iframe.html b/internal/glance/templates/iframe.html similarity index 100% rename from internal/assets/templates/iframe.html rename to internal/glance/templates/iframe.html diff --git a/internal/assets/templates/markets.html b/internal/glance/templates/markets.html similarity index 100% rename from internal/assets/templates/markets.html rename to internal/glance/templates/markets.html diff --git a/internal/assets/templates/monitor-compact.html b/internal/glance/templates/monitor-compact.html similarity index 100% rename from internal/assets/templates/monitor-compact.html rename to internal/glance/templates/monitor-compact.html diff --git a/internal/assets/templates/monitor.html b/internal/glance/templates/monitor.html similarity index 100% rename from internal/assets/templates/monitor.html rename to internal/glance/templates/monitor.html diff --git a/internal/assets/templates/content.html b/internal/glance/templates/page-content.html similarity index 100% rename from internal/assets/templates/content.html rename to internal/glance/templates/page-content.html diff --git a/internal/assets/templates/page.html b/internal/glance/templates/page.html similarity index 97% rename from internal/assets/templates/page.html rename to internal/glance/templates/page.html index e452dde..2a0c776 100644 --- a/internal/assets/templates/page.html +++ b/internal/glance/templates/page.html @@ -14,10 +14,13 @@ {{ define "document-root-attrs" }}class="{{ if .App.Config.Theme.Light }}light-scheme {{ end }}{{ if ne "" .Page.Width }}page-width-{{ .Page.Width }} {{ end }}{{ if .Page.CenterVertically }}page-center-vertically{{ end }}"{{ end }} {{ define "document-head-after" }} -{{ template "page-style-overrides.gotmpl" . }} +{{ .App.ParsedThemeStyle }} + {{ if ne "" .App.Config.Theme.CustomCSSFile }} {{ end }} + +{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }} {{ end }} {{ define "navigation-links" }} diff --git a/internal/assets/templates/reddit-horizontal-cards.html b/internal/glance/templates/reddit-horizontal-cards.html similarity index 100% rename from internal/assets/templates/reddit-horizontal-cards.html rename to internal/glance/templates/reddit-horizontal-cards.html diff --git a/internal/assets/templates/reddit-vertical-cards.html b/internal/glance/templates/reddit-vertical-cards.html similarity index 100% rename from internal/assets/templates/reddit-vertical-cards.html rename to internal/glance/templates/reddit-vertical-cards.html diff --git a/internal/assets/templates/releases.html b/internal/glance/templates/releases.html similarity index 88% rename from internal/assets/templates/releases.html rename to internal/glance/templates/releases.html index 7cd89f7..3643524 100644 --- a/internal/assets/templates/releases.html +++ b/internal/glance/templates/releases.html @@ -7,7 +7,7 @@
{{ .Name }} {{ if $.ShowSourceIcon }} - + {{ end }}
    diff --git a/internal/assets/templates/repository.html b/internal/glance/templates/repository.html similarity index 55% rename from internal/assets/templates/repository.html rename to internal/glance/templates/repository.html index d4d4744..5013b00 100644 --- a/internal/assets/templates/repository.html +++ b/internal/glance/templates/repository.html @@ -1,58 +1,58 @@ {{ template "widget-base.html" . }} {{ define "widget-content" }} -{{ .RepositoryDetails.Name }} +{{ .Repository.Name }}
      -
    • {{ .RepositoryDetails.Stars | formatNumber }} stars
    • -
    • {{ .RepositoryDetails.Forks | formatNumber }} forks
    • +
    • {{ .Repository.Stars | formatNumber }} stars
    • +
    • {{ .Repository.Forks | formatNumber }} forks
    -{{ if gt (len .RepositoryDetails.Commits) 0 }} +{{ if gt (len .Repository.Commits) 0 }}
    -Last {{ .CommitsLimit }} commits +Last {{ .CommitsLimit }} commits
      - {{ range .RepositoryDetails.Commits }} + {{ range .Repository.Commits }}
    • {{ end }}
    {{ end }} -{{ if gt (len .RepositoryDetails.PullRequests) 0 }} +{{ if gt (len .Repository.PullRequests) 0 }}
    -Open pull requests ({{ .RepositoryDetails.OpenPullRequests | formatNumber }} total) +Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)
      - {{ range .RepositoryDetails.PullRequests }} + {{ range .Repository.PullRequests }}
    • {{ end }}
      - {{ range .RepositoryDetails.PullRequests }} -
    • {{ .Title }}
    • + {{ range .Repository.PullRequests }} +
    • {{ .Title }}
    • {{ end }}
    {{ end }} -{{ if gt (len .RepositoryDetails.Issues) 0 }} +{{ if gt (len .Repository.Issues) 0 }}
    -Open issues ({{ .RepositoryDetails.OpenIssues | formatNumber }} total) +Open issues ({{ .Repository.OpenIssues | formatNumber }} total)
      - {{ range .RepositoryDetails.Issues }} + {{ range .Repository.Issues }}
    • {{ end }}
    diff --git a/internal/assets/templates/rss-detailed-list.html b/internal/glance/templates/rss-detailed-list.html similarity index 100% rename from internal/assets/templates/rss-detailed-list.html rename to internal/glance/templates/rss-detailed-list.html diff --git a/internal/assets/templates/rss-horizontal-cards-2.html b/internal/glance/templates/rss-horizontal-cards-2.html similarity index 100% rename from internal/assets/templates/rss-horizontal-cards-2.html rename to internal/glance/templates/rss-horizontal-cards-2.html diff --git a/internal/assets/templates/rss-horizontal-cards.html b/internal/glance/templates/rss-horizontal-cards.html similarity index 100% rename from internal/assets/templates/rss-horizontal-cards.html rename to internal/glance/templates/rss-horizontal-cards.html diff --git a/internal/assets/templates/rss-list.html b/internal/glance/templates/rss-list.html similarity index 100% rename from internal/assets/templates/rss-list.html rename to internal/glance/templates/rss-list.html diff --git a/internal/assets/templates/search.html b/internal/glance/templates/search.html similarity index 100% rename from internal/assets/templates/search.html rename to internal/glance/templates/search.html diff --git a/internal/assets/templates/split-column.html b/internal/glance/templates/split-column.html similarity index 100% rename from internal/assets/templates/split-column.html rename to internal/glance/templates/split-column.html diff --git a/internal/glance/templates/theme-style.gotmpl b/internal/glance/templates/theme-style.gotmpl new file mode 100644 index 0000000..2cd03e6 --- /dev/null +++ b/internal/glance/templates/theme-style.gotmpl @@ -0,0 +1,14 @@ + diff --git a/internal/assets/templates/twitch-channels.html b/internal/glance/templates/twitch-channels.html similarity index 100% rename from internal/assets/templates/twitch-channels.html rename to internal/glance/templates/twitch-channels.html diff --git a/internal/assets/templates/twitch-games-list.html b/internal/glance/templates/twitch-games-list.html similarity index 100% rename from internal/assets/templates/twitch-games-list.html rename to internal/glance/templates/twitch-games-list.html diff --git a/internal/glance/templates/v0.7-update-notice-page.html b/internal/glance/templates/v0.7-update-notice-page.html new file mode 100644 index 0000000..1f3f524 --- /dev/null +++ b/internal/glance/templates/v0.7-update-notice-page.html @@ -0,0 +1,44 @@ + + + + + + + Update notice + + + + + + +
    +

    UPDATE NOTICE

    +
    +

    + The default location of glance.yml in the Docker image has + changed since v0.7.0, please see the migration guide + for instructions or visit the release notes + to find out more about why this change was necessary. Sorry for the inconvenience. +

    + +

    Migration should take around 5 minutes.

    +
    +
    + + + diff --git a/internal/assets/templates/video-card-contents.html b/internal/glance/templates/video-card-contents.html similarity index 100% rename from internal/assets/templates/video-card-contents.html rename to internal/glance/templates/video-card-contents.html diff --git a/internal/assets/templates/videos-grid.html b/internal/glance/templates/videos-grid.html similarity index 100% rename from internal/assets/templates/videos-grid.html rename to internal/glance/templates/videos-grid.html diff --git a/internal/assets/templates/videos.html b/internal/glance/templates/videos.html similarity index 100% rename from internal/assets/templates/videos.html rename to internal/glance/templates/videos.html diff --git a/internal/assets/templates/weather.html b/internal/glance/templates/weather.html similarity index 100% rename from internal/assets/templates/weather.html rename to internal/glance/templates/weather.html diff --git a/internal/assets/templates/widget-base.html b/internal/glance/templates/widget-base.html similarity index 100% rename from internal/assets/templates/widget-base.html rename to internal/glance/templates/widget-base.html diff --git a/internal/feed/utils.go b/internal/glance/utils.go similarity index 57% rename from internal/feed/utils.go rename to internal/glance/utils.go index a6e3f8d..7d621f1 100644 --- a/internal/feed/utils.go +++ b/internal/glance/utils.go @@ -1,19 +1,19 @@ -package feed +package glance import ( - "errors" + "bytes" "fmt" + "html/template" + "net/http" "net/url" + "os" "regexp" "slices" "strings" "time" ) -var ( - ErrNoContent = errors.New("failed to retrieve any content") - ErrPartialContent = errors.New("failed to retrieve some of the content") -) +var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) func percentChange(current, previous float64) float64 { return (current/previous - 1) * 100 @@ -33,7 +33,7 @@ func extractDomainFromUrl(u string) string { return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.") } -func SvgPolylineCoordsFromYValues(width float64, height float64, values []float64) string { +func svgPolylineCoordsFromYValues(width float64, height float64, values []float64) string { if len(values) < 2 { return "" } @@ -86,6 +86,21 @@ func stripURLScheme(url string) string { return urlSchemePattern.ReplaceAllString(url, "") } +func isRunningInsideDockerContainer() bool { + _, err := os.Stat("/.dockerenv") + return err == nil +} + +func prefixStringLines(prefix string, s string) string { + lines := strings.Split(s, "\n") + + for i, line := range lines { + lines[i] = prefix + line + } + + return strings.Join(lines, "\n") +} + func limitStringLength(s string, max int) (string, bool) { asRunes := []rune(s) @@ -98,7 +113,6 @@ func limitStringLength(s string, max int) (string, bool) { func parseRFC3339Time(t string) time.Time { parsed, err := time.Parse(time.RFC3339, t) - if err != nil { return time.Now() } @@ -106,6 +120,14 @@ 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)) @@ -115,3 +137,32 @@ func normalizeVersionFormat(version string) string { return version } + +func titleToSlug(s string) string { + s = strings.ToLower(s) + s = sequentialWhitespacePattern.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + + return s +} + +func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler { + server := http.FileServer(fs) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO: fix always setting cache control even if the file doesn't exist + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))) + server.ServeHTTP(w, r) + }) +} + +func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTML, error) { + var b bytes.Buffer + + err := t.Execute(&b, data) + if err != nil { + return "", fmt.Errorf("executing template: %w", err) + } + + return template.HTML(b.String()), nil +} diff --git a/internal/glance/widget-bookmarks.go b/internal/glance/widget-bookmarks.go new file mode 100644 index 0000000..3c7a69c --- /dev/null +++ b/internal/glance/widget-bookmarks.go @@ -0,0 +1,34 @@ +package glance + +import ( + "html/template" +) + +var bookmarksWidgetTemplate = mustParseTemplate("bookmarks.html", "widget-base.html") + +type bookmarksWidget struct { + widgetBase `yaml:",inline"` + cachedHTML template.HTML `yaml:"-"` + Groups []struct { + Title string `yaml:"title"` + Color *hslColorField `yaml:"color"` + Links []struct { + Title string `yaml:"title"` + URL string `yaml:"url"` + Icon customIconField `yaml:"icon"` + SameTab bool `yaml:"same-tab"` + HideArrow bool `yaml:"hide-arrow"` + } `yaml:"links"` + } `yaml:"groups"` +} + +func (widget *bookmarksWidget) initialize() error { + widget.withTitle("Bookmarks").withError(nil) + widget.cachedHTML = widget.renderTemplate(widget, bookmarksWidgetTemplate) + + return nil +} + +func (widget *bookmarksWidget) Render() template.HTML { + return widget.cachedHTML +} diff --git a/internal/feed/calendar.go b/internal/glance/widget-calendar.go similarity index 54% rename from internal/feed/calendar.go rename to internal/glance/widget-calendar.go index e608dbe..518bc22 100644 --- a/internal/feed/calendar.go +++ b/internal/glance/widget-calendar.go @@ -1,10 +1,45 @@ -package feed +package glance -import "time" +import ( + "context" + "html/template" + "time" +) + +var calendarWidgetTemplate = mustParseTemplate("calendar.html", "widget-base.html") + +type calendarWidget struct { + widgetBase `yaml:",inline"` + Calendar *calendar + StartSunday bool `yaml:"start-sunday"` +} + +func (widget *calendarWidget) initialize() error { + widget.withTitle("Calendar").withCacheOnTheHour() + + return nil +} + +func (widget *calendarWidget) update(ctx context.Context) { + widget.Calendar = newCalendar(time.Now(), widget.StartSunday) + widget.withError(nil).scheduleNextUpdate() +} + +func (widget *calendarWidget) Render() template.HTML { + return widget.renderTemplate(widget, calendarWidgetTemplate) +} + +type calendar struct { + CurrentDay int + CurrentWeekNumber int + CurrentMonthName string + CurrentYear int + Days []int +} // TODO: very inflexible, refactor to allow more customizability // TODO: allow changing between showing the previous and next week and the entire month -func NewCalendar(now time.Time, startSunday bool) *Calendar { +func newCalendar(now time.Time, startSunday bool) *calendar { year, week := now.ISOWeek() weekday := now.Weekday() if !startSunday { @@ -37,7 +72,7 @@ func NewCalendar(now time.Time, startSunday bool) *Calendar { days[i] = day } - return &Calendar{ + return &calendar{ CurrentDay: now.Day(), CurrentWeekNumber: week, CurrentMonthName: now.Month().String(), diff --git a/internal/feed/changedetection.go b/internal/glance/widget-changedetection.go similarity index 53% rename from internal/feed/changedetection.go rename to internal/glance/widget-changedetection.go index 793416d..eb5e129 100644 --- a/internal/feed/changedetection.go +++ b/internal/glance/widget-changedetection.go @@ -1,7 +1,9 @@ -package feed +package glance import ( + "context" "fmt" + "html/template" "log/slog" "net/http" "sort" @@ -9,7 +11,65 @@ import ( "time" ) -type ChangeDetectionWatch struct { +var changeDetectionWidgetTemplate = mustParseTemplate("change-detection.html", "widget-base.html") + +type changeDetectionWidget struct { + widgetBase `yaml:",inline"` + ChangeDetections changeDetectionWatchList `yaml:"-"` + WatchUUIDs []string `yaml:"watches"` + InstanceURL string `yaml:"instance-url"` + Token optionalEnvField `yaml:"token"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` +} + +func (widget *changeDetectionWidget) initialize() error { + widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour) + + if widget.Limit <= 0 { + widget.Limit = 10 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + if widget.InstanceURL == "" { + widget.InstanceURL = "https://www.changedetection.io" + } + + return nil +} + +func (widget *changeDetectionWidget) update(ctx context.Context) { + if len(widget.WatchUUIDs) == 0 { + uuids, err := fetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token)) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + widget.WatchUUIDs = uuids + } + + watches, err := fetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token)) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if len(watches) > widget.Limit { + watches = watches[:widget.Limit] + } + + widget.ChangeDetections = watches +} + +func (widget *changeDetectionWidget) Render() template.HTML { + return widget.renderTemplate(widget, changeDetectionWidgetTemplate) +} + +type changeDetectionWatch struct { Title string URL string LastChanged time.Time @@ -17,9 +77,9 @@ type ChangeDetectionWatch struct { PreviousHash string } -type ChangeDetectionWatches []ChangeDetectionWatch +type changeDetectionWatchList []changeDetectionWatch -func (r ChangeDetectionWatches) SortByNewest() ChangeDetectionWatches { +func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList { sort.Slice(r, func(i, j int) bool { return r[i].LastChanged.After(r[j].LastChanged) }) @@ -35,7 +95,7 @@ type changeDetectionResponseJson struct { PreviousHash string `json:"previous_md5"` } -func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) { +func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) { request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil) if token != "" { @@ -57,8 +117,8 @@ func FetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]str return uuids, nil } -func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (ChangeDetectionWatches, error) { - watches := make(ChangeDetectionWatches, 0, len(requestedWatchIDs)) +func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (changeDetectionWatchList, error) { + watches := make(changeDetectionWatchList, 0, len(requestedWatchIDs)) if len(requestedWatchIDs) == 0 { return watches, nil @@ -89,13 +149,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str for i := range responses { if errs[i] != nil { failed++ - slog.Error("Failed to fetch or parse change detection watch", "error", errs[i], "url", requests[i].URL) + slog.Error("Failed to fetch or parse change detection watch", "url", requests[i].URL, "error", errs[i]) continue } watchJson := responses[i] - watch := ChangeDetectionWatch{ + watch := changeDetectionWatch{ URL: watchJson.URL, DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1), } @@ -126,13 +186,13 @@ func FetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str } if len(watches) == 0 { - return nil, ErrNoContent + return nil, errNoContent } - watches.SortByNewest() + watches.sortByNewest() if failed > 0 { - return watches, fmt.Errorf("%w: could not get %d watches", ErrPartialContent, failed) + return watches, fmt.Errorf("%w: could not get %d watches", errPartialContent, failed) } return watches, nil diff --git a/internal/widget/clock.go b/internal/glance/widget-clock.go similarity index 58% rename from internal/widget/clock.go rename to internal/glance/widget-clock.go index efe8ccd..0b1688c 100644 --- a/internal/widget/clock.go +++ b/internal/glance/widget-clock.go @@ -1,15 +1,15 @@ -package widget +package glance import ( "errors" "fmt" "html/template" "time" - - "github.com/glanceapp/glance/internal/assets" ) -type Clock struct { +var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html") + +type clockWidget struct { widgetBase `yaml:",inline"` cachedHTML template.HTML `yaml:"-"` HourFormat string `yaml:"hour-format"` @@ -19,32 +19,32 @@ type Clock struct { } `yaml:"timezones"` } -func (widget *Clock) Initialize() error { +func (widget *clockWidget) initialize() error { widget.withTitle("Clock").withError(nil) if widget.HourFormat == "" { widget.HourFormat = "24h" } else if widget.HourFormat != "12h" && widget.HourFormat != "24h" { - return errors.New("invalid hour format for clock widget, must be either 12h or 24h") + return errors.New("hour-format must be either 12h or 24h") } for t := range widget.Timezones { if widget.Timezones[t].Timezone == "" { - return errors.New("missing timezone value for clock widget") + return errors.New("missing timezone value") } _, err := time.LoadLocation(widget.Timezones[t].Timezone) if err != nil { - return fmt.Errorf("invalid timezone '%s' for clock widget: %v", widget.Timezones[t].Timezone, err) + return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err) } } - widget.cachedHTML = widget.render(widget, assets.ClockTemplate) + widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate) return nil } -func (widget *Clock) Render() template.HTML { +func (widget *clockWidget) Render() template.HTML { return widget.cachedHTML } diff --git a/internal/glance/widget-container.go b/internal/glance/widget-container.go new file mode 100644 index 0000000..4c9f33a --- /dev/null +++ b/internal/glance/widget-container.go @@ -0,0 +1,58 @@ +package glance + +import ( + "context" + "sync" + "time" +) + +type containerWidgetBase struct { + Widgets widgets `yaml:"widgets"` +} + +func (widget *containerWidgetBase) _initializeWidgets() error { + for i := range widget.Widgets { + if err := widget.Widgets[i].initialize(); err != nil { + return formatWidgetInitError(err, widget.Widgets[i]) + } + } + + return nil +} + +func (widget *containerWidgetBase) _update(ctx context.Context) { + var wg sync.WaitGroup + now := time.Now() + + for w := range widget.Widgets { + widget := widget.Widgets[w] + + if !widget.requiresUpdate(&now) { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + widget.update(ctx) + }() + } + + wg.Wait() +} + +func (widget *containerWidgetBase) _setProviders(providers *widgetProviders) { + for i := range widget.Widgets { + widget.Widgets[i].setProviders(providers) + } +} + +func (widget *containerWidgetBase) _requiresUpdate(now *time.Time) bool { + for i := range widget.Widgets { + if widget.Widgets[i].requiresUpdate(now) { + return true + } + } + + return false +} diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go new file mode 100644 index 0000000..4f6a1cf --- /dev/null +++ b/internal/glance/widget-custom-api.go @@ -0,0 +1,209 @@ +package glance + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "io" + "log/slog" + "net/http" + "time" + + "github.com/tidwall/gjson" +) + +var customAPIWidgetTemplate = mustParseTemplate("custom-api.html", "widget-base.html") + +type customAPIWidget struct { + widgetBase `yaml:",inline"` + URL optionalEnvField `yaml:"url"` + Template string `yaml:"template"` + Frameless bool `yaml:"frameless"` + Headers map[string]optionalEnvField `yaml:"headers"` + APIRequest *http.Request `yaml:"-"` + compiledTemplate *template.Template `yaml:"-"` + CompiledHTML template.HTML `yaml:"-"` +} + +func (widget *customAPIWidget) initialize() error { + widget.withTitle("Custom API").withCacheDuration(1 * time.Hour) + + if widget.URL == "" { + return errors.New("URL is required") + } + + if widget.Template == "" { + return errors.New("template is required") + } + + compiledTemplate, err := template.New("").Funcs(customAPITemplateFuncs).Parse(widget.Template) + + if err != nil { + return fmt.Errorf("parsing template: %w", err) + } + + widget.compiledTemplate = compiledTemplate + + req, err := http.NewRequest(http.MethodGet, widget.URL.String(), nil) + if err != nil { + return err + } + + for key, value := range widget.Headers { + req.Header.Add(key, value.String()) + } + + widget.APIRequest = req + + return nil +} + +func (widget *customAPIWidget) update(ctx context.Context) { + compiledHTML, err := fetchAndParseCustomAPI(widget.APIRequest, widget.compiledTemplate) + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + widget.CompiledHTML = compiledHTML +} + +func (widget *customAPIWidget) Render() template.HTML { + return widget.renderTemplate(widget, customAPIWidgetTemplate) +} + +func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) { + emptyBody := template.HTML("") + + resp, err := 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 globalTemplateFunctions { + funcs[key] = value + } + + return funcs +}() diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go new file mode 100644 index 0000000..2943208 --- /dev/null +++ b/internal/glance/widget-dns-stats.go @@ -0,0 +1,342 @@ +package glance + +import ( + "context" + "encoding/json" + "errors" + "html/template" + "log/slog" + "net/http" + "sort" + "strings" + "time" +) + +var dnsStatsWidgetTemplate = mustParseTemplate("dns-stats.html", "widget-base.html") + +type dnsStatsWidget struct { + widgetBase `yaml:",inline"` + + TimeLabels [8]string `yaml:"-"` + Stats *dnsStats `yaml:"-"` + + HourFormat string `yaml:"hour-format"` + Service string `yaml:"service"` + URL optionalEnvField `yaml:"url"` + Token optionalEnvField `yaml:"token"` + Username optionalEnvField `yaml:"username"` + Password optionalEnvField `yaml:"password"` +} + +func makeDNSWidgetTimeLabels(format string) [8]string { + now := time.Now() + var labels [8]string + + for h := 24; h > 0; h -= 3 { + labels[7-(h/3-1)] = strings.ToLower(now.Add(-time.Duration(h) * time.Hour).Format(format)) + } + + return labels +} + +func (widget *dnsStatsWidget) initialize() error { + widget. + withTitle("DNS Stats"). + withTitleURL(string(widget.URL)). + withCacheDuration(10 * time.Minute) + + if widget.Service != "adguard" && widget.Service != "pihole" { + return errors.New("service must be either 'adguard' or 'pihole'") + } + + return nil +} + +func (widget *dnsStatsWidget) update(ctx context.Context) { + var stats *dnsStats + var err error + + if widget.Service == "adguard" { + stats, err = fetchAdguardStats(string(widget.URL), string(widget.Username), string(widget.Password)) + } else { + stats, err = fetchPiholeStats(string(widget.URL), string(widget.Token)) + } + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.HourFormat == "24h" { + widget.TimeLabels = makeDNSWidgetTimeLabels("15:00") + } else { + widget.TimeLabels = makeDNSWidgetTimeLabels("3PM") + } + + widget.Stats = stats +} + +func (widget *dnsStatsWidget) Render() template.HTML { + return widget.renderTemplate(widget, dnsStatsWidgetTemplate) +} + +type dnsStats struct { + TotalQueries int + BlockedQueries int + BlockedPercent int + ResponseTime int + DomainsBlocked int + Series [8]dnsStatsSeries + TopBlockedDomains []dnsStatsBlockedDomain +} + +type dnsStatsSeries struct { + Queries int + Blocked int + PercentTotal int + PercentBlocked int +} + +type dnsStatsBlockedDomain struct { + Domain string + PercentBlocked int +} + +type adguardStatsResponse struct { + TotalQueries int `json:"num_dns_queries"` + QueriesSeries []int `json:"dns_queries"` + BlockedQueries int `json:"num_blocked_filtering"` + BlockedSeries []int `json:"blocked_filtering"` + ResponseTime float64 `json:"avg_processing_time"` + TopBlockedDomains []map[string]int `json:"top_blocked_domains"` +} + +func fetchAdguardStats(instanceURL, username, password string) (*dnsStats, error) { + requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats" + + request, err := http.NewRequest("GET", requestURL, nil) + + if err != nil { + return nil, err + } + + request.SetBasicAuth(username, password) + + responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request) + + if err != nil { + return nil, err + } + + var topBlockedDomainsCount = min(len(responseJson.TopBlockedDomains), 5) + + stats := &dnsStats{ + TotalQueries: responseJson.TotalQueries, + BlockedQueries: responseJson.BlockedQueries, + ResponseTime: int(responseJson.ResponseTime * 1000), + TopBlockedDomains: make([]dnsStatsBlockedDomain, 0, topBlockedDomainsCount), + } + + if stats.TotalQueries <= 0 { + return stats, nil + } + + stats.BlockedPercent = int(float64(responseJson.BlockedQueries) / float64(responseJson.TotalQueries) * 100) + + for i := 0; i < topBlockedDomainsCount; i++ { + domain := responseJson.TopBlockedDomains[i] + var firstDomain string + + for k := range domain { + firstDomain = k + break + } + + if firstDomain == "" { + continue + } + + stats.TopBlockedDomains = append(stats.TopBlockedDomains, dnsStatsBlockedDomain{ + Domain: firstDomain, + }) + + if stats.BlockedQueries > 0 { + stats.TopBlockedDomains[i].PercentBlocked = int(float64(domain[firstDomain]) / float64(responseJson.BlockedQueries) * 100) + } + } + + queriesSeries := responseJson.QueriesSeries + blockedSeries := responseJson.BlockedSeries + + const bars = 8 + const hoursSpan = 24 + const hoursPerBar int = hoursSpan / bars + + if len(queriesSeries) > hoursSpan { + queriesSeries = queriesSeries[len(queriesSeries)-hoursSpan:] + } else if len(queriesSeries) < hoursSpan { + queriesSeries = append(make([]int, hoursSpan-len(queriesSeries)), queriesSeries...) + } + + if len(blockedSeries) > hoursSpan { + blockedSeries = blockedSeries[len(blockedSeries)-hoursSpan:] + } else if len(blockedSeries) < hoursSpan { + blockedSeries = append(make([]int, hoursSpan-len(blockedSeries)), blockedSeries...) + } + + maxQueriesInSeries := 0 + + for i := 0; i < bars; i++ { + queries := 0 + blocked := 0 + + for j := 0; j < hoursPerBar; j++ { + queries += queriesSeries[i*hoursPerBar+j] + blocked += blockedSeries[i*hoursPerBar+j] + } + + stats.Series[i] = dnsStatsSeries{ + Queries: queries, + Blocked: blocked, + } + + if queries > 0 { + stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) + } + + if queries > maxQueriesInSeries { + maxQueriesInSeries = queries + } + } + + for i := 0; i < bars; i++ { + stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) + } + + return stats, nil +} + +type piholeStatsResponse struct { + TotalQueries int `json:"dns_queries_today"` + QueriesSeries map[int64]int `json:"domains_over_time"` + BlockedQueries int `json:"ads_blocked_today"` + BlockedSeries map[int64]int `json:"ads_over_time"` + BlockedPercentage float64 `json:"ads_percentage_today"` + TopBlockedDomains piholeTopBlockedDomains `json:"top_ads"` + DomainsBlocked int `json:"domains_being_blocked"` +} + +// If user has some level of privacy enabled on Pihole, `json:"top_ads"` is an empty array +// Use custom unmarshal behavior to avoid not getting the rest of the valid data when unmarshalling +type piholeTopBlockedDomains map[string]int + +func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { + // NOTE: do not change to piholeTopBlockedDomains type here or it will cause a stack overflow + // because of the UnmarshalJSON method getting called recursively + temp := make(map[string]int) + + err := json.Unmarshal(data, &temp) + + if err != nil { + *p = make(piholeTopBlockedDomains) + } else { + *p = temp + } + + return nil +} + +func fetchPiholeStats(instanceURL, token string) (*dnsStats, error) { + if token == "" { + return nil, errors.New("missing API token") + } + + requestURL := strings.TrimRight(instanceURL, "/") + + "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token + + request, err := http.NewRequest("GET", requestURL, nil) + + if err != nil { + return nil, err + } + + responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request) + + if err != nil { + return nil, err + } + + stats := &dnsStats{ + TotalQueries: responseJson.TotalQueries, + BlockedQueries: responseJson.BlockedQueries, + BlockedPercent: int(responseJson.BlockedPercentage), + DomainsBlocked: responseJson.DomainsBlocked, + } + + if len(responseJson.TopBlockedDomains) > 0 { + domains := make([]dnsStatsBlockedDomain, 0, len(responseJson.TopBlockedDomains)) + + for domain, count := range responseJson.TopBlockedDomains { + domains = append(domains, dnsStatsBlockedDomain{ + Domain: domain, + PercentBlocked: int(float64(count) / float64(responseJson.BlockedQueries) * 100), + }) + } + + sort.Slice(domains, func(a, b int) bool { + return domains[a].PercentBlocked > domains[b].PercentBlocked + }) + + stats.TopBlockedDomains = domains[:min(len(domains), 5)] + } + + // Pihole _should_ return data for the last 24 hours in a 10 minute interval, 6*24 = 144 + if len(responseJson.QueriesSeries) != 144 || len(responseJson.BlockedSeries) != 144 { + slog.Warn( + "DNS stats for pihole: did not get expected 144 data points", + "len(queries)", len(responseJson.QueriesSeries), + "len(blocked)", len(responseJson.BlockedSeries), + ) + return stats, nil + } + + var lowestTimestamp int64 = 0 + + for timestamp := range responseJson.QueriesSeries { + if lowestTimestamp == 0 || timestamp < lowestTimestamp { + lowestTimestamp = timestamp + } + } + + maxQueriesInSeries := 0 + + for i := 0; i < 8; i++ { + queries := 0 + blocked := 0 + + for j := 0; j < 18; j++ { + index := lowestTimestamp + int64(i*10800+j*600) + + queries += responseJson.QueriesSeries[index] + blocked += responseJson.BlockedSeries[index] + } + + if queries > maxQueriesInSeries { + maxQueriesInSeries = queries + } + + stats.Series[i] = dnsStatsSeries{ + Queries: queries, + Blocked: blocked, + } + + if queries > 0 { + stats.Series[i].PercentBlocked = int(float64(blocked) / float64(queries) * 100) + } + } + + for i := 0; i < 8; i++ { + stats.Series[i].PercentTotal = int(float64(stats.Series[i].Queries) / float64(maxQueriesInSeries) * 100) + } + + return stats, nil +} diff --git a/internal/glance/widget-extension.go b/internal/glance/widget-extension.go new file mode 100644 index 0000000..9c23368 --- /dev/null +++ b/internal/glance/widget-extension.go @@ -0,0 +1,156 @@ +package glance + +import ( + "context" + "errors" + "fmt" + "html" + "html/template" + "io" + "log/slog" + "net/http" + "net/url" + "time" +) + +var extensionWidgetTemplate = mustParseTemplate("extension.html", "widget-base.html") + +type extensionWidget struct { + widgetBase `yaml:",inline"` + URL string `yaml:"url"` + FallbackContentType string `yaml:"fallback-content-type"` + Parameters map[string]string `yaml:"parameters"` + AllowHtml bool `yaml:"allow-potentially-dangerous-html"` + Extension extension `yaml:"-"` + cachedHTML template.HTML `yaml:"-"` +} + +func (widget *extensionWidget) initialize() error { + widget.withTitle("Extension").withCacheDuration(time.Minute * 30) + + if widget.URL == "" { + return errors.New("URL is required") + } + + _, err := url.Parse(widget.URL) + + if err != nil { + return err + } + + return nil +} + +func (widget *extensionWidget) update(ctx context.Context) { + extension, err := fetchExtension(extensionRequestOptions{ + URL: widget.URL, + FallbackContentType: widget.FallbackContentType, + Parameters: widget.Parameters, + AllowHtml: widget.AllowHtml, + }) + + widget.canContinueUpdateAfterHandlingErr(err) + + widget.Extension = extension + + if extension.Title != "" { + widget.Title = extension.Title + } + + widget.cachedHTML = widget.renderTemplate(widget, extensionWidgetTemplate) +} + +func (widget *extensionWidget) Render() template.HTML { + return widget.cachedHTML +} + +type extensionType int + +const ( + extensionContentHTML extensionType = iota + extensionContentUnknown = iota +) + +var extensionStringToType = map[string]extensionType{ + "html": extensionContentHTML, +} + +const ( + extensionHeaderTitle = "Widget-Title" + extensionHeaderContentType = "Widget-Content-Type" +) + +type extensionRequestOptions struct { + URL string `yaml:"url"` + FallbackContentType string `yaml:"fallback-content-type"` + Parameters map[string]string `yaml:"parameters"` + AllowHtml bool `yaml:"allow-potentially-dangerous-html"` +} + +type extension struct { + Title string + Content template.HTML +} + +func convertExtensionContent(options extensionRequestOptions, content []byte, contentType extensionType) template.HTML { + switch contentType { + case extensionContentHTML: + if options.AllowHtml { + return template.HTML(content) + } + + fallthrough + default: + return template.HTML(html.EscapeString(string(content))) + } +} + +func fetchExtension(options extensionRequestOptions) (extension, error) { + request, _ := http.NewRequest("GET", options.URL, nil) + + query := url.Values{} + + for key, value := range options.Parameters { + query.Set(key, value) + } + + request.URL.RawQuery = query.Encode() + + response, err := http.DefaultClient.Do(request) + + if err != nil { + slog.Error("Failed fetching extension", "url", options.URL, "error", err) + return extension{}, fmt.Errorf("%w: request failed: %w", errNoContent, err) + } + + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + + if err != nil { + slog.Error("Failed reading response body of extension", "url", options.URL, "error", err) + return extension{}, fmt.Errorf("%w: could not read body: %w", errNoContent, err) + } + + extension := extension{} + + if response.Header.Get(extensionHeaderTitle) == "" { + extension.Title = "Extension" + } else { + extension.Title = response.Header.Get(extensionHeaderTitle) + } + + contentType, ok := extensionStringToType[response.Header.Get(extensionHeaderContentType)] + + if !ok { + contentType, ok = extensionStringToType[options.FallbackContentType] + + if !ok { + contentType = extensionContentUnknown + } + } + + extension.Content = convertExtensionContent(options, body, contentType) + + return extension, nil +} diff --git a/internal/glance/widget-group.go b/internal/glance/widget-group.go new file mode 100644 index 0000000..2ea3813 --- /dev/null +++ b/internal/glance/widget-group.go @@ -0,0 +1,52 @@ +package glance + +import ( + "context" + "errors" + "html/template" + "time" +) + +var groupWidgetTemplate = mustParseTemplate("group.html", "widget-base.html") + +type groupWidget struct { + widgetBase `yaml:",inline"` + containerWidgetBase `yaml:",inline"` +} + +func (widget *groupWidget) initialize() error { + widget.withError(nil) + widget.HideHeader = true + + for i := range widget.Widgets { + widget.Widgets[i].setHideHeader(true) + + if widget.Widgets[i].GetType() == "group" { + return errors.New("nested groups are not supported") + } else if widget.Widgets[i].GetType() == "split-column" { + return errors.New("split columns inside of groups are not supported") + } + } + + if err := widget.containerWidgetBase._initializeWidgets(); err != nil { + return err + } + + return nil +} + +func (widget *groupWidget) update(ctx context.Context) { + widget.containerWidgetBase._update(ctx) +} + +func (widget *groupWidget) setProviders(providers *widgetProviders) { + widget.containerWidgetBase._setProviders(providers) +} + +func (widget *groupWidget) requiresUpdate(now *time.Time) bool { + return widget.containerWidgetBase._requiresUpdate(now) +} + +func (widget *groupWidget) Render() template.HTML { + return widget.renderTemplate(widget, groupWidgetTemplate) +} diff --git a/internal/feed/hacker-news.go b/internal/glance/widget-hacker-news.go similarity index 51% rename from internal/feed/hacker-news.go rename to internal/glance/widget-hacker-news.go index f1db111..e8cabca 100644 --- a/internal/feed/hacker-news.go +++ b/internal/glance/widget-hacker-news.go @@ -1,7 +1,9 @@ -package feed +package glance import ( + "context" "fmt" + "html/template" "log/slog" "net/http" "strconv" @@ -9,6 +11,61 @@ import ( "time" ) +type hackerNewsWidget struct { + widgetBase `yaml:",inline"` + Posts forumPostList `yaml:"-"` + Limit int `yaml:"limit"` + SortBy string `yaml:"sort-by"` + ExtraSortBy string `yaml:"extra-sort-by"` + CollapseAfter int `yaml:"collapse-after"` + CommentsUrlTemplate string `yaml:"comments-url-template"` + ShowThumbnails bool `yaml:"-"` +} + +func (widget *hackerNewsWidget) initialize() error { + widget. + withTitle("Hacker News"). + withTitleURL("https://news.ycombinator.com/"). + withCacheDuration(30 * time.Minute) + + if widget.Limit <= 0 { + widget.Limit = 15 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" { + widget.SortBy = "top" + } + + return nil +} + +func (widget *hackerNewsWidget) update(ctx context.Context) { + posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.ExtraSortBy == "engagement" { + posts.calculateEngagement() + posts.sortByEngagement() + } + + if widget.Limit < len(posts) { + posts = posts[:widget.Limit] + } + + widget.Posts = posts +} + +func (widget *hackerNewsWidget) Render() template.HTML { + return widget.renderTemplate(widget, forumPostsTemplate) +} + type hackerNewsPostResponseJson struct { Id int `json:"id"` Score int `json:"score"` @@ -18,18 +75,18 @@ type hackerNewsPostResponseJson struct { TimePosted int64 `json:"time"` } -func getHackerNewsPostIds(sort string) ([]int, error) { +func fetchHackerNewsPostIds(sort string) ([]int, error) { request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil) response, err := decodeJsonFromRequest[[]int](defaultClient, request) if err != nil { - return nil, fmt.Errorf("%w: could not fetch list of post IDs", ErrNoContent) + return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent) } return response, nil } -func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (ForumPosts, error) { +func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) { requests := make([]*http.Request, len(postIds)) for i, id := range postIds { @@ -45,7 +102,7 @@ func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (Forum return nil, err } - posts := make(ForumPosts, 0, len(postIds)) + posts := make(forumPostList, 0, len(postIds)) for i := range results { if errs[i] != nil { @@ -61,7 +118,7 @@ func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (Forum commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id)) } - posts = append(posts, ForumPost{ + posts = append(posts, forumPost{ Title: results[i].Title, DiscussionUrl: commentsUrl, TargetUrl: results[i].TargetUrl, @@ -73,18 +130,18 @@ func getHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (Forum } if len(posts) == 0 { - return nil, ErrNoContent + return nil, errNoContent } if len(posts) != len(postIds) { - return posts, fmt.Errorf("%w could not fetch some hacker news posts", ErrPartialContent) + return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent) } return posts, nil } -func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (ForumPosts, error) { - postIds, err := getHackerNewsPostIds(sort) +func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) { + postIds, err := fetchHackerNewsPostIds(sort) if err != nil { return nil, err @@ -94,5 +151,5 @@ func FetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (F postIds = postIds[:limit] } - return getHackerNewsPostsFromIds(postIds, commentsUrlTemplate) + return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate) } diff --git a/internal/widget/html.go b/internal/glance/widget-html.go similarity index 56% rename from internal/widget/html.go rename to internal/glance/widget-html.go index 6c66488..0e32a46 100644 --- a/internal/widget/html.go +++ b/internal/glance/widget-html.go @@ -1,20 +1,20 @@ -package widget +package glance import ( "html/template" ) -type HTML struct { +type htmlWidget struct { widgetBase `yaml:",inline"` Source template.HTML `yaml:"source"` } -func (widget *HTML) Initialize() error { +func (widget *htmlWidget) initialize() error { widget.withTitle("").withError(nil) return nil } -func (widget *HTML) Render() template.HTML { +func (widget *htmlWidget) Render() template.HTML { return widget.Source } diff --git a/internal/widget/iframe.go b/internal/glance/widget-iframe.go similarity index 56% rename from internal/widget/iframe.go rename to internal/glance/widget-iframe.go index 44d0822..0badf00 100644 --- a/internal/widget/iframe.go +++ b/internal/glance/widget-iframe.go @@ -1,32 +1,32 @@ -package widget +package glance import ( "errors" "fmt" "html/template" "net/url" - - "github.com/glanceapp/glance/internal/assets" ) -type IFrame struct { +var iframeWidgetTemplate = mustParseTemplate("iframe.html", "widget-base.html") + +type iframeWidget struct { widgetBase `yaml:",inline"` cachedHTML template.HTML `yaml:"-"` Source string `yaml:"source"` Height int `yaml:"height"` } -func (widget *IFrame) Initialize() error { +func (widget *iframeWidget) initialize() error { widget.withTitle("IFrame").withError(nil) if widget.Source == "" { - return errors.New("missing source for iframe") + return errors.New("source is required") } _, err := url.Parse(widget.Source) if err != nil { - return fmt.Errorf("invalid source for iframe: %v", err) + return fmt.Errorf("parsing URL: %v", err) } if widget.Height == 50 { @@ -35,11 +35,11 @@ func (widget *IFrame) Initialize() error { widget.Height = 50 } - widget.cachedHTML = widget.render(widget, assets.IFrameTemplate) + widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate) return nil } -func (widget *IFrame) Render() template.HTML { +func (widget *iframeWidget) Render() template.HTML { return widget.cachedHTML } diff --git a/internal/glance/widget-lobsters.go b/internal/glance/widget-lobsters.go new file mode 100644 index 0000000..625686d --- /dev/null +++ b/internal/glance/widget-lobsters.go @@ -0,0 +1,147 @@ +package glance + +import ( + "context" + "html/template" + "net/http" + "strings" + "time" +) + +type lobstersWidget struct { + widgetBase `yaml:",inline"` + Posts forumPostList `yaml:"-"` + InstanceURL string `yaml:"instance-url"` + CustomURL string `yaml:"custom-url"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + SortBy string `yaml:"sort-by"` + Tags []string `yaml:"tags"` + ShowThumbnails bool `yaml:"-"` +} + +func (widget *lobstersWidget) initialize() error { + widget.withTitle("Lobsters").withCacheDuration(time.Hour) + + if widget.InstanceURL == "" { + widget.withTitleURL("https://lobste.rs") + } else { + widget.withTitleURL(widget.InstanceURL) + } + + if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") { + widget.SortBy = "hot" + } + + if widget.Limit <= 0 { + widget.Limit = 15 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + return nil +} + +func (widget *lobstersWidget) update(ctx context.Context) { + posts, err := fetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.Limit < len(posts) { + posts = posts[:widget.Limit] + } + + widget.Posts = posts +} + +func (widget *lobstersWidget) Render() template.HTML { + return widget.renderTemplate(widget, forumPostsTemplate) +} + +type lobstersPostResponseJson struct { + CreatedAt string `json:"created_at"` + Title string `json:"title"` + URL string `json:"url"` + Score int `json:"score"` + CommentCount int `json:"comment_count"` + CommentsURL string `json:"comments_url"` + Tags []string `json:"tags"` +} + +type lobstersFeedResponseJson []lobstersPostResponseJson + +func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) { + request, err := http.NewRequest("GET", feedUrl, nil) + + if err != nil { + return nil, err + } + + feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request) + + if err != nil { + return nil, err + } + + posts := make(forumPostList, 0, len(feed)) + + for i := range feed { + createdAt, _ := time.Parse(time.RFC3339, feed[i].CreatedAt) + + posts = append(posts, forumPost{ + Title: feed[i].Title, + DiscussionUrl: feed[i].CommentsURL, + TargetUrl: feed[i].URL, + TargetUrlDomain: extractDomainFromUrl(feed[i].URL), + CommentCount: feed[i].CommentCount, + Score: feed[i].Score, + TimePosted: createdAt, + Tags: feed[i].Tags, + }) + } + + if len(posts) == 0 { + return nil, errNoContent + } + + return posts, nil +} + +func fetchLobstersPosts(customURL string, instanceURL string, sortBy string, tags []string) (forumPostList, error) { + var feedUrl string + + if customURL != "" { + feedUrl = customURL + } else { + if instanceURL != "" { + instanceURL = strings.TrimRight(instanceURL, "/") + "/" + } else { + instanceURL = "https://lobste.rs/" + } + + if sortBy == "hot" { + sortBy = "hottest" + } else if sortBy == "new" { + sortBy = "newest" + } + + if len(tags) == 0 { + feedUrl = instanceURL + sortBy + ".json" + } else { + tags := strings.Join(tags, ",") + feedUrl = instanceURL + "t/" + tags + ".json" + } + } + + posts, err := fetchLobstersPostsFromFeed(feedUrl) + + if err != nil { + return nil, err + } + + return posts, nil +} diff --git a/internal/glance/widget-markets.go b/internal/glance/widget-markets.go new file mode 100644 index 0000000..481d020 --- /dev/null +++ b/internal/glance/widget-markets.go @@ -0,0 +1,206 @@ +package glance + +import ( + "context" + "fmt" + "html/template" + "log/slog" + "math" + "net/http" + "sort" + "time" +) + +var marketsWidgetTemplate = mustParseTemplate("markets.html", "widget-base.html") + +type marketsWidget struct { + widgetBase `yaml:",inline"` + StocksRequests []marketRequest `yaml:"stocks"` + MarketRequests []marketRequest `yaml:"markets"` + Sort string `yaml:"sort-by"` + Markets marketList `yaml:"-"` +} + +func (widget *marketsWidget) initialize() error { + widget.withTitle("Markets").withCacheDuration(time.Hour) + + // legacy support, remove in v0.10.0 + if len(widget.MarketRequests) == 0 { + widget.MarketRequests = widget.StocksRequests + } + + return nil +} + +func (widget *marketsWidget) update(ctx context.Context) { + markets, err := fetchMarketsDataFromYahoo(widget.MarketRequests) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.Sort == "absolute-change" { + markets.sortByAbsChange() + } + + if widget.Sort == "change" { + markets.sortByChange() + } + + widget.Markets = markets +} + +func (widget *marketsWidget) Render() template.HTML { + return widget.renderTemplate(widget, marketsWidgetTemplate) +} + +type marketRequest struct { + Name string `yaml:"name"` + Symbol string `yaml:"symbol"` + ChartLink string `yaml:"chart-link"` + SymbolLink string `yaml:"symbol-link"` +} + +type market struct { + marketRequest + Currency string + Price float64 + PercentChange float64 + SvgChartPoints string +} + +type marketList []market + +func (t marketList) sortByAbsChange() { + sort.Slice(t, func(i, j int) bool { + return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange) + }) +} + +func (t marketList) sortByChange() { + sort.Slice(t, func(i, j int) bool { + return t[i].PercentChange > t[j].PercentChange + }) +} + +type marketResponseJson struct { + Chart struct { + Result []struct { + Meta struct { + Currency string `json:"currency"` + Symbol string `json:"symbol"` + RegularMarketPrice float64 `json:"regularMarketPrice"` + ChartPreviousClose float64 `json:"chartPreviousClose"` + } `json:"meta"` + Indicators struct { + Quote []struct { + Close []float64 `json:"close,omitempty"` + } `json:"quote"` + } `json:"indicators"` + } `json:"result"` + } `json:"chart"` +} + +// TODO: allow changing chart time frame +const marketChartDays = 21 + +func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, error) { + requests := make([]*http.Request, 0, len(marketRequests)) + + for i := range marketRequests { + request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil) + requests = append(requests, request) + } + + job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests) + responses, errs, err := workerPoolDo(job) + + if err != nil { + return nil, fmt.Errorf("%w: %v", errNoContent, err) + } + + markets := make(marketList, 0, len(responses)) + var failed int + + for i := range responses { + if errs[i] != nil { + failed++ + slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i]) + continue + } + + response := responses[i] + + if len(response.Chart.Result) == 0 { + failed++ + slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol) + continue + } + + prices := response.Chart.Result[0].Indicators.Quote[0].Close + + if len(prices) > marketChartDays { + prices = prices[len(prices)-marketChartDays:] + } + + previous := response.Chart.Result[0].Meta.RegularMarketPrice + + if len(prices) >= 2 && prices[len(prices)-2] != 0 { + previous = prices[len(prices)-2] + } + + points := svgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) + + currency, exists := currencyToSymbol[response.Chart.Result[0].Meta.Currency] + + if !exists { + currency = response.Chart.Result[0].Meta.Currency + } + + markets = append(markets, market{ + marketRequest: marketRequests[i], + Price: response.Chart.Result[0].Meta.RegularMarketPrice, + Currency: currency, + PercentChange: percentChange( + response.Chart.Result[0].Meta.RegularMarketPrice, + previous, + ), + SvgChartPoints: points, + }) + } + + if len(markets) == 0 { + return nil, errNoContent + } + + if failed > 0 { + return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", errPartialContent, failed) + } + + return markets, nil +} + +var currencyToSymbol = map[string]string{ + "USD": "$", + "EUR": "€", + "JPY": "¥", + "CAD": "C$", + "AUD": "A$", + "GBP": "£", + "CHF": "Fr", + "NZD": "N$", + "INR": "₹", + "BRL": "R$", + "RUB": "₽", + "TRY": "₺", + "ZAR": "R", + "CNY": "¥", + "KRW": "₩", + "HKD": "HK$", + "SGD": "S$", + "SEK": "kr", + "NOK": "kr", + "DKK": "kr", + "PLN": "zł", + "PHP": "₱", +} diff --git a/internal/glance/widget-monitor.go b/internal/glance/widget-monitor.go new file mode 100644 index 0000000..8d26221 --- /dev/null +++ b/internal/glance/widget-monitor.go @@ -0,0 +1,178 @@ +package glance + +import ( + "context" + "errors" + "html/template" + "net/http" + "slices" + "strconv" + "time" +) + +var ( + monitorWidgetTemplate = mustParseTemplate("monitor.html", "widget-base.html") + monitorWidgetCompactTemplate = mustParseTemplate("monitor-compact.html", "widget-base.html") +) + +type monitorWidget struct { + widgetBase `yaml:",inline"` + Sites []struct { + *SiteStatusRequest `yaml:",inline"` + Status *SiteStatus `yaml:"-"` + Title string `yaml:"title"` + Icon customIconField `yaml:"icon"` + SameTab bool `yaml:"same-tab"` + StatusText string `yaml:"-"` + StatusStyle string `yaml:"-"` + AltStatusCodes []int `yaml:"alt-status-codes"` + } `yaml:"sites"` + Style string `yaml:"style"` + ShowFailingOnly bool `yaml:"show-failing-only"` + HasFailing bool `yaml:"-"` +} + +func (widget *monitorWidget) initialize() error { + widget.withTitle("Monitor").withCacheDuration(5 * time.Minute) + + return nil +} + +func (widget *monitorWidget) update(ctx context.Context) { + requests := make([]*SiteStatusRequest, len(widget.Sites)) + + for i := range widget.Sites { + requests[i] = widget.Sites[i].SiteStatusRequest + } + + statuses, err := fetchStatusForSites(requests) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + widget.HasFailing = false + + for i := range widget.Sites { + site := &widget.Sites[i] + status := &statuses[i] + site.Status = status + + if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.TimedOut || status.Error != nil) { + widget.HasFailing = true + } + + if !status.TimedOut { + site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes) + site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes) + } + } +} + +func (widget *monitorWidget) Render() template.HTML { + if widget.Style == "compact" { + return widget.renderTemplate(widget, monitorWidgetCompactTemplate) + } + + return widget.renderTemplate(widget, monitorWidgetTemplate) +} + +func statusCodeToText(status int, altStatusCodes []int) string { + if status == 200 || slices.Contains(altStatusCodes, status) { + return "OK" + } + if status == 404 { + return "Not Found" + } + if status == 403 { + return "Forbidden" + } + if status == 401 { + return "Unauthorized" + } + if status >= 400 { + return "Client Error" + } + if status >= 500 { + return "Server Error" + } + + return strconv.Itoa(status) +} + +func statusCodeToStyle(status int, altStatusCodes []int) string { + if status == 200 || slices.Contains(altStatusCodes, status) { + return "ok" + } + + return "error" +} + +type SiteStatusRequest struct { + URL string `yaml:"url"` + CheckURL string `yaml:"check-url"` + AllowInsecure bool `yaml:"allow-insecure"` +} + +type SiteStatus struct { + Code int + TimedOut bool + ResponseTime time.Duration + Error error +} + +func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) { + var url string + if statusRequest.CheckURL != "" { + url = statusRequest.CheckURL + } else { + url = statusRequest.URL + } + request, err := http.NewRequest(http.MethodGet, url, nil) + + if err != nil { + return SiteStatus{ + Error: err, + }, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*3) + defer cancel() + request = request.WithContext(ctx) + requestSentAt := time.Now() + var response *http.Response + + if !statusRequest.AllowInsecure { + response, err = defaultClient.Do(request) + } else { + response, err = defaultInsecureClient.Do(request) + } + + status := SiteStatus{ResponseTime: time.Since(requestSentAt)} + + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + status.TimedOut = true + } + + status.Error = err + return status, nil + } + + defer response.Body.Close() + + status.Code = response.StatusCode + + return status, nil +} + +func fetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) { + job := newJob(fetchSiteStatusTask, requests).withWorkers(20) + results, _, err := workerPoolDo(job) + + if err != nil { + return nil, err + } + + return results, nil +} diff --git a/internal/feed/reddit.go b/internal/glance/widget-reddit.go similarity index 52% rename from internal/feed/reddit.go rename to internal/glance/widget-reddit.go index 297020c..84f49cd 100644 --- a/internal/feed/reddit.go +++ b/internal/glance/widget-reddit.go @@ -1,14 +1,131 @@ -package feed +package glance import ( + "context" + "errors" "fmt" "html" + "html/template" "net/http" "net/url" "strings" "time" ) +var ( + redditWidgetHorizontalCardsTemplate = mustParseTemplate("reddit-horizontal-cards.html", "widget-base.html") + redditWidgetVerticalCardsTemplate = mustParseTemplate("reddit-vertical-cards.html", "widget-base.html") +) + +type redditWidget struct { + widgetBase `yaml:",inline"` + Posts forumPostList `yaml:"-"` + Subreddit string `yaml:"subreddit"` + Style string `yaml:"style"` + ShowThumbnails bool `yaml:"show-thumbnails"` + ShowFlairs bool `yaml:"show-flairs"` + SortBy string `yaml:"sort-by"` + TopPeriod string `yaml:"top-period"` + Search string `yaml:"search"` + ExtraSortBy string `yaml:"extra-sort-by"` + CommentsUrlTemplate string `yaml:"comments-url-template"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + RequestUrlTemplate string `yaml:"request-url-template"` +} + +func (widget *redditWidget) initialize() error { + if widget.Subreddit == "" { + return errors.New("subreddit is required") + } + + if widget.Limit <= 0 { + widget.Limit = 15 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + if !isValidRedditSortType(widget.SortBy) { + widget.SortBy = "hot" + } + + if !isValidRedditTopPeriod(widget.TopPeriod) { + widget.TopPeriod = "day" + } + + if widget.RequestUrlTemplate != "" { + if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") { + return errors.New("no `{REQUEST-URL}` placeholder specified") + } + } + + widget. + withTitle("/r/" + widget.Subreddit). + withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/"). + withCacheDuration(30 * time.Minute) + + return nil +} + +func isValidRedditSortType(sortBy string) bool { + return sortBy == "hot" || + sortBy == "new" || + sortBy == "top" || + sortBy == "rising" +} + +func isValidRedditTopPeriod(period string) bool { + return period == "hour" || + period == "day" || + period == "week" || + period == "month" || + period == "year" || + period == "all" +} + +func (widget *redditWidget) update(ctx context.Context) { + // TODO: refactor, use a struct to pass all of these + posts, err := fetchSubredditPosts( + widget.Subreddit, + widget.SortBy, + widget.TopPeriod, + widget.Search, + widget.CommentsUrlTemplate, + widget.RequestUrlTemplate, + widget.ShowFlairs, + ) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if len(posts) > widget.Limit { + posts = posts[:widget.Limit] + } + + if widget.ExtraSortBy == "engagement" { + posts.calculateEngagement() + posts.sortByEngagement() + } + + widget.Posts = posts +} + +func (widget *redditWidget) Render() template.HTML { + if widget.Style == "horizontal-cards" { + return widget.renderTemplate(widget, redditWidgetHorizontalCardsTemplate) + } + + if widget.Style == "vertical-cards" { + return widget.renderTemplate(widget, redditWidgetVerticalCardsTemplate) + } + + return widget.renderTemplate(widget, forumPostsTemplate) + +} + type subredditResponseJson struct { Data struct { Children []struct { @@ -44,7 +161,7 @@ func templateRedditCommentsURL(template, subreddit, postId, postPath string) str return template } -func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (ForumPosts, error) { +func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (forumPostList, error) { query := url.Values{} var requestUrl string @@ -74,7 +191,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate } // Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests - addBrowserUserAgentHeader(request) + setBrowserUserAgentHeader(request) responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request) if err != nil { @@ -85,7 +202,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate return nil, fmt.Errorf("no posts found") } - posts := make(ForumPosts, 0, len(responseJson.Data.Children)) + posts := make(forumPostList, 0, len(responseJson.Data.Children)) for i := range responseJson.Data.Children { post := &responseJson.Data.Children[i].Data @@ -102,7 +219,7 @@ func FetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink) } - forumPost := ForumPost{ + forumPost := forumPost{ Title: html.UnescapeString(post.Title), DiscussionUrl: commentsUrl, TargetUrlDomain: post.Domain, diff --git a/internal/glance/widget-releases.go b/internal/glance/widget-releases.go new file mode 100644 index 0000000..73b6664 --- /dev/null +++ b/internal/glance/widget-releases.go @@ -0,0 +1,401 @@ +package glance + +import ( + "context" + "errors" + "fmt" + "html/template" + "log/slog" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +var releasesWidgetTemplate = mustParseTemplate("releases.html", "widget-base.html") + +type releasesWidget struct { + widgetBase `yaml:",inline"` + Releases appReleaseList `yaml:"-"` + releaseRequests []*releaseRequest `yaml:"-"` + Repositories []string `yaml:"repositories"` + Token optionalEnvField `yaml:"token"` + GitLabToken optionalEnvField `yaml:"gitlab-token"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + ShowSourceIcon bool `yaml:"show-source-icon"` +} + +func (widget *releasesWidget) initialize() error { + widget.withTitle("Releases").withCacheDuration(2 * time.Hour) + + if widget.Limit <= 0 { + widget.Limit = 10 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + var tokenAsString = widget.Token.String() + var gitLabTokenAsString = widget.GitLabToken.String() + + for _, repository := range widget.Repositories { + parts := strings.SplitN(repository, ":", 2) + var request *releaseRequest + if len(parts) == 1 { + request = &releaseRequest{ + source: releaseSourceGithub, + repository: repository, + } + + if widget.Token != "" { + request.token = &tokenAsString + } + } else if len(parts) == 2 { + if parts[0] == string(releaseSourceGitlab) { + request = &releaseRequest{ + source: releaseSourceGitlab, + repository: parts[1], + } + + if widget.GitLabToken != "" { + request.token = &gitLabTokenAsString + } + } else if parts[0] == string(releaseSourceDockerHub) { + request = &releaseRequest{ + source: releaseSourceDockerHub, + repository: parts[1], + } + } else if parts[0] == string(releaseSourceCodeberg) { + request = &releaseRequest{ + source: releaseSourceCodeberg, + repository: parts[1], + } + } else { + return errors.New("invalid repository source " + parts[0]) + } + } + + widget.releaseRequests = append(widget.releaseRequests, request) + } + + return nil +} + +func (widget *releasesWidget) update(ctx context.Context) { + releases, err := fetchLatestReleases(widget.releaseRequests) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if len(releases) > widget.Limit { + releases = releases[:widget.Limit] + } + + for i := range releases { + releases[i].SourceIconURL = widget.Providers.assetResolver("icons/" + string(releases[i].Source) + ".svg") + } + + widget.Releases = releases +} + +func (widget *releasesWidget) Render() template.HTML { + return widget.renderTemplate(widget, releasesWidgetTemplate) +} + +type releaseSource string + +const ( + releaseSourceCodeberg releaseSource = "codeberg" + releaseSourceGithub releaseSource = "github" + releaseSourceGitlab releaseSource = "gitlab" + releaseSourceDockerHub releaseSource = "dockerhub" +) + +type appRelease struct { + Source releaseSource + SourceIconURL string + Name string + Version string + NotesUrl string + TimeReleased time.Time + Downvotes int +} + +type appReleaseList []appRelease + +func (r appReleaseList) sortByNewest() appReleaseList { + sort.Slice(r, func(i, j int) bool { + return r[i].TimeReleased.After(r[j].TimeReleased) + }) + + return r +} + +type releaseRequest struct { + source releaseSource + repository string + token *string +} + +func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) { + job := newJob(fetchLatestReleaseTask, requests).withWorkers(20) + results, errs, err := workerPoolDo(job) + + if err != nil { + return nil, err + } + + var failed int + + releases := make(appReleaseList, 0, len(requests)) + + for i := range results { + if errs[i] != nil { + failed++ + slog.Error("Failed to fetch release", "source", requests[i].source, "repository", requests[i].repository, "error", errs[i]) + continue + } + + releases = append(releases, *results[i]) + } + + if failed == len(requests) { + return nil, errNoContent + } + + releases.sortByNewest() + + if failed > 0 { + return releases, fmt.Errorf("%w: could not get %d releases", errPartialContent, failed) + } + + return releases, nil +} + +func fetchLatestReleaseTask(request *releaseRequest) (*appRelease, error) { + switch request.source { + case releaseSourceCodeberg: + return fetchLatestCodebergRelease(request) + case releaseSourceGithub: + return fetchLatestGithubRelease(request) + case releaseSourceGitlab: + return fetchLatestGitLabRelease(request) + case releaseSourceDockerHub: + return fetchLatestDockerHubRelease(request) + } + + return nil, errors.New("unsupported source") +} + +type githubReleaseLatestResponseJson struct { + TagName string `json:"tag_name"` + PublishedAt string `json:"published_at"` + HtmlUrl string `json:"html_url"` + Reactions struct { + Downvotes int `json:"-1"` + } `json:"reactions"` +} + +func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.repository), + nil, + ) + + if err != nil { + return nil, err + } + + if request.token != nil { + httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) + } + + response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest) + + if err != nil { + return nil, err + } + + return &appRelease{ + Source: releaseSourceGithub, + Name: request.repository, + Version: normalizeVersionFormat(response.TagName), + NotesUrl: response.HtmlUrl, + TimeReleased: parseRFC3339Time(response.PublishedAt), + Downvotes: response.Reactions.Downvotes, + }, nil +} + +type dockerHubRepositoryTagsResponse struct { + Results []dockerHubRepositoryTagResponse `json:"results"` +} + +type dockerHubRepositoryTagResponse struct { + Name string `json:"name"` + LastPushed string `json:"tag_last_pushed"` +} + +const dockerHubOfficialRepoTagURLFormat = "https://hub.docker.com/_/%s/tags?name=%s" +const dockerHubRepoTagURLFormat = "https://hub.docker.com/r/%s/tags?name=%s" +const dockerHubTagsURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags" +const dockerHubSpecificTagURLFormat = "https://hub.docker.com/v2/namespaces/%s/repositories/%s/tags/%s" + +func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) { + + nameParts := strings.Split(request.repository, "/") + + if len(nameParts) > 2 { + return nil, fmt.Errorf("invalid repository name: %s", request.repository) + } else if len(nameParts) == 1 { + nameParts = []string{"library", nameParts[0]} + } + + tagParts := strings.SplitN(nameParts[1], ":", 2) + + var requestURL string + + if len(tagParts) == 2 { + requestURL = fmt.Sprintf(dockerHubSpecificTagURLFormat, nameParts[0], tagParts[0], tagParts[1]) + } else { + requestURL = fmt.Sprintf(dockerHubTagsURLFormat, nameParts[0], nameParts[1]) + } + + httpRequest, err := http.NewRequest("GET", requestURL, nil) + + if err != nil { + return nil, err + } + + if request.token != nil { + httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) + } + + var tag *dockerHubRepositoryTagResponse + + if len(tagParts) == 1 { + response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest) + + if err != nil { + return nil, err + } + + if len(response.Results) == 0 { + return nil, fmt.Errorf("no tags found for repository: %s", request.repository) + } + + tag = &response.Results[0] + } else { + response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest) + + if err != nil { + return nil, err + } + + tag = &response + } + + var repo string + var displayName string + var notesURL string + + if len(tagParts) == 1 { + repo = nameParts[1] + } else { + repo = tagParts[0] + } + + if nameParts[0] == "library" { + displayName = repo + notesURL = fmt.Sprintf(dockerHubOfficialRepoTagURLFormat, repo, tag.Name) + } else { + displayName = nameParts[0] + "/" + repo + notesURL = fmt.Sprintf(dockerHubRepoTagURLFormat, displayName, tag.Name) + } + + return &appRelease{ + Source: releaseSourceDockerHub, + NotesUrl: notesURL, + Name: displayName, + Version: tag.Name, + TimeReleased: parseRFC3339Time(tag.LastPushed), + }, nil +} + +type gitlabReleaseResponseJson struct { + TagName string `json:"tag_name"` + ReleasedAt string `json:"released_at"` + Links struct { + Self string `json:"self"` + } `json:"_links"` +} + +func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf( + "https://gitlab.com/api/v4/projects/%s/releases/permalink/latest", + url.QueryEscape(request.repository), + ), + nil, + ) + + if err != nil { + return nil, err + } + + if request.token != nil { + httpRequest.Header.Add("PRIVATE-TOKEN", *request.token) + } + + response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest) + + if err != nil { + return nil, err + } + + return &appRelease{ + Source: releaseSourceGitlab, + Name: request.repository, + Version: normalizeVersionFormat(response.TagName), + NotesUrl: response.Links.Self, + TimeReleased: parseRFC3339Time(response.ReleasedAt), + }, nil +} + +type codebergReleaseResponseJson struct { + TagName string `json:"tag_name"` + PublishedAt string `json:"published_at"` + HtmlUrl string `json:"html_url"` +} + +func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) { + httpRequest, err := http.NewRequest( + "GET", + fmt.Sprintf( + "https://codeberg.org/api/v1/repos/%s/releases/latest", + request.repository, + ), + nil, + ) + if err != nil { + return nil, err + } + + response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest) + + if err != nil { + return nil, err + } + return &appRelease{ + Source: releaseSourceCodeberg, + Name: request.repository, + Version: normalizeVersionFormat(response.TagName), + NotesUrl: response.HtmlUrl, + TimeReleased: parseRFC3339Time(response.PublishedAt), + }, nil +} diff --git a/internal/feed/github.go b/internal/glance/widget-repository-overview.go similarity index 54% rename from internal/feed/github.go rename to internal/glance/widget-repository-overview.go index 18487f0..b33e44d 100644 --- a/internal/feed/github.go +++ b/internal/glance/widget-repository-overview.go @@ -1,72 +1,91 @@ -package feed +package glance import ( + "context" "fmt" + "html/template" "net/http" "strings" "sync" "time" ) -type githubReleaseLatestResponseJson struct { - TagName string `json:"tag_name"` - PublishedAt string `json:"published_at"` - HtmlUrl string `json:"html_url"` - Reactions struct { - Downvotes int `json:"-1"` - } `json:"reactions"` +var repositoryWidgetTemplate = mustParseTemplate("repository.html", "widget-base.html") + +type repositoryWidget struct { + widgetBase `yaml:",inline"` + RequestedRepository string `yaml:"repository"` + Token optionalEnvField `yaml:"token"` + PullRequestsLimit int `yaml:"pull-requests-limit"` + IssuesLimit int `yaml:"issues-limit"` + CommitsLimit int `yaml:"commits-limit"` + Repository repository `yaml:"-"` } -func fetchLatestGithubRelease(request *ReleaseRequest) (*AppRelease, error) { - httpRequest, err := http.NewRequest( - "GET", - fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", request.Repository), - nil, +func (widget *repositoryWidget) initialize() error { + widget.withTitle("Repository").withCacheDuration(1 * time.Hour) + + if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 { + widget.PullRequestsLimit = 3 + } + + if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 { + widget.IssuesLimit = 3 + } + + if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 { + widget.CommitsLimit = -1 + } + + return nil +} + +func (widget *repositoryWidget) update(ctx context.Context) { + details, err := fetchRepositoryDetailsFromGithub( + widget.RequestedRepository, + string(widget.Token), + widget.PullRequestsLimit, + widget.IssuesLimit, + widget.CommitsLimit, ) - if err != nil { - return nil, err + if !widget.canContinueUpdateAfterHandlingErr(err) { + return } - if request.Token != nil { - httpRequest.Header.Add("Authorization", "Bearer "+(*request.Token)) - } - - response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest) - - if err != nil { - return nil, err - } - - return &AppRelease{ - Source: ReleaseSourceGithub, - Name: request.Repository, - Version: normalizeVersionFormat(response.TagName), - NotesUrl: response.HtmlUrl, - TimeReleased: parseRFC3339Time(response.PublishedAt), - Downvotes: response.Reactions.Downvotes, - }, nil + widget.Repository = details } -type GithubTicket struct { +func (widget *repositoryWidget) Render() template.HTML { + return widget.renderTemplate(widget, repositoryWidgetTemplate) +} + +type repository struct { + Name string + Stars int + Forks int + OpenPullRequests int + PullRequests []githubTicket + OpenIssues int + Issues []githubTicket + LastCommits int + Commits []githubCommitDetails +} + +type githubTicket struct { Number int CreatedAt time.Time Title string } -type RepositoryDetails struct { - Name string - Stars int - Forks int - OpenPullRequests int - PullRequests []GithubTicket - OpenIssues int - Issues []GithubTicket - LastCommits int - Commits []CommitDetails +type githubCommitDetails struct { + Sha string + Author string + CreatedAt time.Time + Message string } -type githubRepositoryDetailsResponseJson struct { +type githubRepositoryResponseJson struct { Name string `json:"full_name"` Stars int `json:"stargazers_count"` Forks int `json:"forks_count"` @@ -81,13 +100,6 @@ type githubTicketResponseJson struct { } `json:"items"` } -type CommitDetails struct { - Sha string - Author string - CreatedAt time.Time - Message string -} - type gitHubCommitResponseJson struct { Sha string `json:"sha"` Commit struct { @@ -99,15 +111,15 @@ type gitHubCommitResponseJson struct { } `json:"commit"` } -func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs int, maxIssues int, maxCommits int) (RepositoryDetails, error) { - repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repository), nil) +func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, maxIssues int, maxCommits int) (repository, error) { + repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repo), nil) if err != nil { - return RepositoryDetails{}, fmt.Errorf("%w: could not create request with repository: %v", ErrNoContent, err) + return repository{}, fmt.Errorf("%w: could not create request with repository: %v", errNoContent, err) } - PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repository, maxPRs), nil) - issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repository, maxIssues), nil) - CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repository, maxCommits), nil) + PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:pr+is:open+repo:%s&per_page=%d", repo, maxPRs), nil) + issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repo, maxIssues), nil) + CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repo, maxCommits), nil) if token != "" { token = fmt.Sprintf("Bearer %s", token) @@ -117,7 +129,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in CommitsRequest.Header.Add("Authorization", token) } - var detailsResponse githubRepositoryDetailsResponseJson + var repositoryResponse githubRepositoryResponseJson var detailsErr error var PRsResponse githubTicketResponseJson var PRsErr error @@ -130,7 +142,7 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in wg.Add(1) go (func() { defer wg.Done() - detailsResponse, detailsErr = decodeJsonFromRequest[githubRepositoryDetailsResponseJson](defaultClient, repositoryRequest) + repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultClient, repositoryRequest) })() if maxPRs > 0 { @@ -160,28 +172,28 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in wg.Wait() if detailsErr != nil { - return RepositoryDetails{}, fmt.Errorf("%w: could not get repository details: %s", ErrNoContent, detailsErr) + return repository{}, fmt.Errorf("%w: could not get repository details: %s", errNoContent, detailsErr) } - details := RepositoryDetails{ - Name: detailsResponse.Name, - Stars: detailsResponse.Stars, - Forks: detailsResponse.Forks, - PullRequests: make([]GithubTicket, 0, len(PRsResponse.Tickets)), - Issues: make([]GithubTicket, 0, len(issuesResponse.Tickets)), - Commits: make([]CommitDetails, 0, len(commitsResponse)), + details := repository{ + Name: repositoryResponse.Name, + Stars: repositoryResponse.Stars, + Forks: repositoryResponse.Forks, + PullRequests: make([]githubTicket, 0, len(PRsResponse.Tickets)), + Issues: make([]githubTicket, 0, len(issuesResponse.Tickets)), + Commits: make([]githubCommitDetails, 0, len(commitsResponse)), } err = nil if maxPRs > 0 { if PRsErr != nil { - err = fmt.Errorf("%w: could not get PRs: %s", ErrPartialContent, PRsErr) + err = fmt.Errorf("%w: could not get PRs: %s", errPartialContent, PRsErr) } else { details.OpenPullRequests = PRsResponse.Count for i := range PRsResponse.Tickets { - details.PullRequests = append(details.PullRequests, GithubTicket{ + details.PullRequests = append(details.PullRequests, githubTicket{ Number: PRsResponse.Tickets[i].Number, CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt), Title: PRsResponse.Tickets[i].Title, @@ -193,12 +205,12 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in if maxIssues > 0 { if issuesErr != nil { // TODO: fix, overwriting the previous error - err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, issuesErr) + err = fmt.Errorf("%w: could not get issues: %s", errPartialContent, issuesErr) } else { details.OpenIssues = issuesResponse.Count for i := range issuesResponse.Tickets { - details.Issues = append(details.Issues, GithubTicket{ + details.Issues = append(details.Issues, githubTicket{ Number: issuesResponse.Tickets[i].Number, CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt), Title: issuesResponse.Tickets[i].Title, @@ -209,10 +221,10 @@ func FetchRepositoryDetailsFromGithub(repository string, token string, maxPRs in if maxCommits > 0 { if CommitsErr != nil { - err = fmt.Errorf("%w: could not get issues: %s", ErrPartialContent, CommitsErr) + err = fmt.Errorf("%w: could not get commits: %s", errPartialContent, CommitsErr) } else { for i := range commitsResponse { - details.Commits = append(details.Commits, CommitDetails{ + details.Commits = append(details.Commits, githubCommitDetails{ Sha: commitsResponse[i].Sha, Author: commitsResponse[i].Commit.Author.Name, CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date), diff --git a/internal/feed/rss.go b/internal/glance/widget-rss.go similarity index 63% rename from internal/feed/rss.go rename to internal/glance/widget-rss.go index 45cbb14..74371b7 100644 --- a/internal/feed/rss.go +++ b/internal/glance/widget-rss.go @@ -1,8 +1,10 @@ -package feed +package glance import ( + "context" "fmt" "html" + "html/template" "io" "log/slog" "net/http" @@ -16,7 +18,87 @@ import ( gofeedext "github.com/mmcdole/gofeed/extensions" ) -type RSSFeedItem struct { +var ( + rssWidgetTemplate = mustParseTemplate("rss-list.html", "widget-base.html") + rssWidgetDetailedListTemplate = mustParseTemplate("rss-detailed-list.html", "widget-base.html") + rssWidgetHorizontalCardsTemplate = mustParseTemplate("rss-horizontal-cards.html", "widget-base.html") + rssWidgetHorizontalCards2Template = mustParseTemplate("rss-horizontal-cards-2.html", "widget-base.html") +) + +type rssWidget struct { + widgetBase `yaml:",inline"` + FeedRequests []RSSFeedRequest `yaml:"feeds"` + Style string `yaml:"style"` + ThumbnailHeight float64 `yaml:"thumbnail-height"` + CardHeight float64 `yaml:"card-height"` + Items rssFeedItemList `yaml:"-"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + SingleLineTitles bool `yaml:"single-line-titles"` + NoItemsMessage string `yaml:"-"` +} + +func (widget *rssWidget) initialize() error { + widget.withTitle("RSS Feed").withCacheDuration(1 * time.Hour) + + if widget.Limit <= 0 { + widget.Limit = 25 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + if widget.ThumbnailHeight < 0 { + widget.ThumbnailHeight = 0 + } + + if widget.CardHeight < 0 { + widget.CardHeight = 0 + } + + if widget.Style == "detailed-list" { + for i := range widget.FeedRequests { + widget.FeedRequests[i].IsDetailed = true + } + } + + widget.NoItemsMessage = "No items were returned from the feeds." + + return nil +} + +func (widget *rssWidget) update(ctx context.Context) { + items, err := fetchItemsFromRSSFeeds(widget.FeedRequests) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if len(items) > widget.Limit { + items = items[:widget.Limit] + } + + widget.Items = items +} + +func (widget *rssWidget) Render() template.HTML { + if widget.Style == "horizontal-cards" { + return widget.renderTemplate(widget, rssWidgetHorizontalCardsTemplate) + } + + if widget.Style == "horizontal-cards-2" { + return widget.renderTemplate(widget, rssWidgetHorizontalCards2Template) + } + + if widget.Style == "detailed-list" { + return widget.renderTemplate(widget, rssWidgetDetailedListTemplate) + } + + return widget.renderTemplate(widget, rssWidgetTemplate) +} + +type rssFeedItem struct { ChannelName string ChannelURL string Title string @@ -29,7 +111,6 @@ type RSSFeedItem struct { // doesn't cover all cases but works the vast majority of the time var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`) -var sequentialWhitespacePattern = regexp.MustCompile(`\s+`) func sanitizeFeedDescription(description string) string { if description == "" { @@ -67,9 +148,9 @@ type RSSFeedRequest struct { IsDetailed bool `yaml:"-"` } -type RSSFeedItems []RSSFeedItem +type rssFeedItemList []rssFeedItem -func (f RSSFeedItems) SortByNewest() RSSFeedItems { +func (f rssFeedItemList) sortByNewest() rssFeedItemList { sort.Slice(f, func(i, j int) bool { return f[i].PublishedAt.After(f[j].PublishedAt) }) @@ -79,7 +160,7 @@ func (f RSSFeedItems) SortByNewest() RSSFeedItems { var feedParser = gofeed.NewParser() -func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) { +func fetchItemsFromRSSFeedTask(request RSSFeedRequest) ([]rssFeedItem, error) { req, err := http.NewRequest("GET", request.Url, nil) if err != nil { return nil, err @@ -110,12 +191,12 @@ func getItemsFromRSSFeedTask(request RSSFeedRequest) ([]RSSFeedItem, error) { return nil, err } - items := make(RSSFeedItems, 0, len(feed.Items)) + items := make(rssFeedItemList, 0, len(feed.Items)) for i := range feed.Items { item := feed.Items[i] - rssItem := RSSFeedItem{ + rssItem := rssFeedItem{ ChannelURL: feed.Link, } @@ -233,22 +314,22 @@ func findThumbnailInItemExtensions(item *gofeed.Item) string { return recursiveFindThumbnailInExtensions(media) } -func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) { - job := newJob(getItemsFromRSSFeedTask, requests).withWorkers(10) +func fetchItemsFromRSSFeeds(requests []RSSFeedRequest) (rssFeedItemList, error) { + job := newJob(fetchItemsFromRSSFeedTask, requests).withWorkers(10) feeds, errs, err := workerPoolDo(job) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) + return nil, fmt.Errorf("%w: %v", errNoContent, err) } failed := 0 - entries := make(RSSFeedItems, 0, len(feeds)*10) + entries := make(rssFeedItemList, 0, len(feeds)*10) for i := range feeds { if errs[i] != nil { failed++ - slog.Error("failed to get rss feed", "error", errs[i], "url", requests[i].Url) + slog.Error("Failed to get RSS feed", "url", requests[i].Url, "error", errs[i]) continue } @@ -256,13 +337,13 @@ func GetItemsFromRSSFeeds(requests []RSSFeedRequest) (RSSFeedItems, error) { } if failed == len(requests) { - return nil, ErrNoContent + return nil, errNoContent } - entries.SortByNewest() + entries.sortByNewest() if failed > 0 { - return entries, fmt.Errorf("%w: missing %d RSS feeds", ErrPartialContent, failed) + return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed) } return entries, nil diff --git a/internal/widget/search.go b/internal/glance/widget-search.go similarity index 75% rename from internal/widget/search.go rename to internal/glance/widget-search.go index 19ca372..d25064a 100644 --- a/internal/widget/search.go +++ b/internal/glance/widget-search.go @@ -1,20 +1,20 @@ -package widget +package glance import ( "fmt" "html/template" "strings" - - "github.com/glanceapp/glance/internal/assets" ) +var searchWidgetTemplate = mustParseTemplate("search.html", "widget-base.html") + type SearchBang struct { Title string Shortcut string URL string } -type Search struct { +type searchWidget struct { widgetBase `yaml:",inline"` cachedHTML template.HTML `yaml:"-"` SearchEngine string `yaml:"search-engine"` @@ -34,7 +34,7 @@ var searchEngines = map[string]string{ "google": "https://www.google.com/search?q={QUERY}", } -func (widget *Search) Initialize() error { +func (widget *searchWidget) initialize() error { widget.withTitle("Search").withError(nil) if widget.SearchEngine == "" { @@ -49,20 +49,20 @@ func (widget *Search) Initialize() error { for i := range widget.Bangs { if widget.Bangs[i].Shortcut == "" { - return fmt.Errorf("Search bang %d has no shortcut", i+1) + return fmt.Errorf("search bang #%d has no shortcut", i+1) } if widget.Bangs[i].URL == "" { - return fmt.Errorf("Search bang %d has no URL", i+1) + return fmt.Errorf("search bang #%d has no URL", i+1) } widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL) } - widget.cachedHTML = widget.render(widget, assets.SearchTemplate) + widget.cachedHTML = widget.renderTemplate(widget, searchWidgetTemplate) return nil } -func (widget *Search) Render() template.HTML { +func (widget *searchWidget) Render() template.HTML { return widget.cachedHTML } diff --git a/internal/glance/widget-shared.go b/internal/glance/widget-shared.go new file mode 100644 index 0000000..30c4bd3 --- /dev/null +++ b/internal/glance/widget-shared.go @@ -0,0 +1,62 @@ +package glance + +import ( + "math" + "sort" + "time" +) + +const twitchGqlEndpoint = "https://gql.twitch.tv/gql" +const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko" + +type forumPost struct { + Title string + DiscussionUrl string + TargetUrl string + TargetUrlDomain string + ThumbnailUrl string + CommentCount int + Score int + Engagement float64 + TimePosted time.Time + Tags []string + IsCrosspost bool +} + +type forumPostList []forumPost + +const depreciatePostsOlderThanHours = 7 +const maxDepreciation = 0.9 +const maxDepreciationAfterHours = 24 + +func (p forumPostList) calculateEngagement() { + var totalComments int + var totalScore int + + for i := range p { + totalComments += p[i].CommentCount + totalScore += p[i].Score + } + + numberOfPosts := float64(len(p)) + averageComments := float64(totalComments) / numberOfPosts + averageScore := float64(totalScore) / numberOfPosts + + for i := range p { + p[i].Engagement = (float64(p[i].CommentCount)/averageComments + float64(p[i].Score)/averageScore) / 2 + + elapsed := time.Since(p[i].TimePosted) + + if elapsed < time.Hour*depreciatePostsOlderThanHours { + continue + } + + p[i].Engagement *= 1.0 - (math.Max(elapsed.Hours()-depreciatePostsOlderThanHours, maxDepreciationAfterHours)/maxDepreciationAfterHours)*maxDepreciation + } +} + +func (p forumPostList) sortByEngagement() { + sort.Slice(p, func(i, j int) bool { + return p[i].Engagement > p[j].Engagement + }) +} diff --git a/internal/glance/widget-split-column.go b/internal/glance/widget-split-column.go new file mode 100644 index 0000000..71747c9 --- /dev/null +++ b/internal/glance/widget-split-column.go @@ -0,0 +1,45 @@ +package glance + +import ( + "context" + "html/template" + "time" +) + +var splitColumnWidgetTemplate = mustParseTemplate("split-column.html", "widget-base.html") + +type splitColumnWidget struct { + widgetBase `yaml:",inline"` + containerWidgetBase `yaml:",inline"` + MaxColumns int `yaml:"max-columns"` +} + +func (widget *splitColumnWidget) initialize() error { + widget.withError(nil).withTitle("Split Column").setHideHeader(true) + + if err := widget.containerWidgetBase._initializeWidgets(); err != nil { + return err + } + + if widget.MaxColumns < 2 { + widget.MaxColumns = 2 + } + + return nil +} + +func (widget *splitColumnWidget) update(ctx context.Context) { + widget.containerWidgetBase._update(ctx) +} + +func (widget *splitColumnWidget) setProviders(providers *widgetProviders) { + widget.containerWidgetBase._setProviders(providers) +} + +func (widget *splitColumnWidget) requiresUpdate(now *time.Time) bool { + return widget.containerWidgetBase._requiresUpdate(now) +} + +func (widget *splitColumnWidget) Render() template.HTML { + return widget.renderTemplate(widget, splitColumnWidgetTemplate) +} diff --git a/internal/feed/twitch.go b/internal/glance/widget-twitch-channels.go similarity index 62% rename from internal/feed/twitch.go rename to internal/glance/widget-twitch-channels.go index 7e20a52..b52f23e 100644 --- a/internal/feed/twitch.go +++ b/internal/glance/widget-twitch-channels.go @@ -1,30 +1,65 @@ -package feed +package glance import ( + "context" "encoding/json" - "errors" "fmt" + "html/template" "log/slog" "net/http" - "slices" "sort" "strings" "time" ) -type TwitchCategory struct { - Slug string `json:"slug"` - Name string `json:"name"` - AvatarUrl string `json:"avatarURL"` - ViewersCount int `json:"viewersCount"` - Tags []struct { - Name string `json:"tagName"` - } `json:"tags"` - GameReleaseDate string `json:"originalReleaseDate"` - IsNew bool `json:"-"` +var twitchChannelsWidgetTemplate = mustParseTemplate("twitch-channels.html", "widget-base.html") + +type twitchChannelsWidget struct { + widgetBase `yaml:",inline"` + ChannelsRequest []string `yaml:"channels"` + Channels []twitchChannel `yaml:"-"` + CollapseAfter int `yaml:"collapse-after"` + SortBy string `yaml:"sort-by"` } -type TwitchChannel struct { +func (widget *twitchChannelsWidget) initialize() error { + widget. + withTitle("Twitch Channels"). + withTitleURL("https://www.twitch.tv/directory/following"). + withCacheDuration(time.Minute * 10) + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + if widget.SortBy != "viewers" && widget.SortBy != "live" { + widget.SortBy = "viewers" + } + + return nil +} + +func (widget *twitchChannelsWidget) update(ctx context.Context) { + channels, err := fetchChannelsFromTwitch(widget.ChannelsRequest) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.SortBy == "viewers" { + channels.sortByViewers() + } else if widget.SortBy == "live" { + channels.sortByLive() + } + + widget.Channels = channels +} + +func (widget *twitchChannelsWidget) Render() template.HTML { + return widget.renderTemplate(widget, twitchChannelsWidgetTemplate) +} + +type twitchChannel struct { Login string Exists bool Name string @@ -37,15 +72,15 @@ type TwitchChannel struct { ViewersCount int } -type TwitchChannels []TwitchChannel +type twitchChannelList []twitchChannel -func (channels TwitchChannels) SortByViewers() { +func (channels twitchChannelList) sortByViewers() { sort.Slice(channels, func(i, j int) bool { return channels[i].ViewersCount > channels[j].ViewersCount }) } -func (channels TwitchChannels) SortByLive() { +func (channels twitchChannelList) sortByLive() { sort.SliceStable(channels, func(i, j int) bool { return channels[i].IsLive && !channels[j].IsLive }) @@ -84,68 +119,6 @@ type twitchStreamMetadataOperationResponse struct { } `json:"user"` } -type twitchDirectoriesOperationResponse struct { - Data struct { - DirectoriesWithTags struct { - Edges []struct { - Node TwitchCategory `json:"node"` - } `json:"edges"` - } `json:"directoriesWithTags"` - } `json:"data"` -} - -const twitchGqlEndpoint = "https://gql.twitch.tv/gql" -const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko" - -const twitchDirectoriesOperationRequestBody = `[{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}}]` - -func FetchTopGamesFromTwitch(exclude []string, limit int) ([]TwitchCategory, error) { - reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit)) - request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) - request.Header.Add("Client-ID", twitchGqlClientId) - response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request) - - if err != nil { - return nil, err - } - - if len(response) == 0 { - return nil, errors.New("no categories could be retrieved") - } - - edges := (response)[0].Data.DirectoriesWithTags.Edges - categories := make([]TwitchCategory, 0, len(edges)) - - for i := range edges { - if slices.Contains(exclude, edges[i].Node.Slug) { - continue - } - - category := &edges[i].Node - category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1) - - if len(category.Tags) > 2 { - category.Tags = category.Tags[:2] - } - - gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate) - - if err == nil { - if time.Since(gameReleasedDate) < 14*24*time.Hour { - category.IsNew = true - } - } - - categories = append(categories, *category) - } - - if len(categories) > limit { - categories = categories[:limit] - } - - return categories, nil -} - const twitchChannelStatusOperationRequestBody = `[ {"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}}, {"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}} @@ -157,8 +130,8 @@ const twitchChannelStatusOperationRequestBody = `[ // what the limit is for max operations per request and batch operations in // multiple requests if number of channels exceeds allowed limit. -func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) { - result := TwitchChannel{ +func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) { + result := twitchChannel{ Login: strings.ToLower(channel), } @@ -225,7 +198,7 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) { if err == nil { result.LiveSince = startedAt } else { - slog.Warn("failed to parse twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt) + slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt) } } } @@ -233,8 +206,8 @@ func fetchChannelFromTwitchTask(channel string) (TwitchChannel, error) { return result, nil } -func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) { - result := make(TwitchChannels, 0, len(channelLogins)) +func fetchChannelsFromTwitch(channelLogins []string) (twitchChannelList, error) { + result := make(twitchChannelList, 0, len(channelLogins)) job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10) channels, errs, err := workerPoolDo(job) @@ -248,7 +221,7 @@ func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) { for i := range channels { if errs[i] != nil { failed++ - slog.Warn("failed to fetch twitch channel", "channel", channelLogins[i], "error", errs[i]) + slog.Error("Failed to fetch Twitch channel", "channel", channelLogins[i], "error", errs[i]) continue } @@ -256,11 +229,11 @@ func FetchChannelsFromTwitch(channelLogins []string) (TwitchChannels, error) { } if failed == len(channelLogins) { - return result, ErrNoContent + return result, errNoContent } if failed > 0 { - return result, fmt.Errorf("%w: failed to fetch %d channels", ErrPartialContent, failed) + return result, fmt.Errorf("%w: failed to fetch %d channels", errPartialContent, failed) } return result, nil diff --git a/internal/glance/widget-twitch-top-games.go b/internal/glance/widget-twitch-top-games.go new file mode 100644 index 0000000..54f4bdb --- /dev/null +++ b/internal/glance/widget-twitch-top-games.go @@ -0,0 +1,126 @@ +package glance + +import ( + "context" + "errors" + "fmt" + "html/template" + "net/http" + "slices" + "strings" + "time" +) + +var twitchGamesWidgetTemplate = mustParseTemplate("twitch-games-list.html", "widget-base.html") + +type twitchGamesWidget struct { + widgetBase `yaml:",inline"` + Categories []twitchCategory `yaml:"-"` + Exclude []string `yaml:"exclude"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` +} + +func (widget *twitchGamesWidget) initialize() error { + widget. + withTitle("Top games on Twitch"). + withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT"). + withCacheDuration(time.Minute * 10) + + if widget.Limit <= 0 { + widget.Limit = 10 + } + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 5 + } + + return nil +} + +func (widget *twitchGamesWidget) update(ctx context.Context) { + categories, err := fetchTopGamesFromTwitch(widget.Exclude, widget.Limit) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + widget.Categories = categories +} + +func (widget *twitchGamesWidget) Render() template.HTML { + return widget.renderTemplate(widget, twitchGamesWidgetTemplate) +} + +type twitchCategory struct { + Slug string `json:"slug"` + Name string `json:"name"` + AvatarUrl string `json:"avatarURL"` + ViewersCount int `json:"viewersCount"` + Tags []struct { + Name string `json:"tagName"` + } `json:"tags"` + GameReleaseDate string `json:"originalReleaseDate"` + IsNew bool `json:"-"` +} + +type twitchDirectoriesOperationResponse struct { + Data struct { + DirectoriesWithTags struct { + Edges []struct { + Node twitchCategory `json:"node"` + } `json:"edges"` + } `json:"directoriesWithTags"` + } `json:"data"` +} + +const twitchDirectoriesOperationRequestBody = `[ +{"operationName": "BrowsePage_AllDirectories","variables": {"limit": %d,"options": {"sort": "VIEWER_COUNT","tags": []}},"extensions": {"persistedQuery": {"version": 1,"sha256Hash": "2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e"}}} +]` + +func fetchTopGamesFromTwitch(exclude []string, limit int) ([]twitchCategory, error) { + reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit)) + request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) + request.Header.Add("Client-ID", twitchGqlClientId) + response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request) + + if err != nil { + return nil, err + } + + if len(response) == 0 { + return nil, errors.New("no categories could be retrieved") + } + + edges := (response)[0].Data.DirectoriesWithTags.Edges + categories := make([]twitchCategory, 0, len(edges)) + + for i := range edges { + if slices.Contains(exclude, edges[i].Node.Slug) { + continue + } + + category := &edges[i].Node + category.AvatarUrl = strings.Replace(category.AvatarUrl, "285x380", "144x192", 1) + + if len(category.Tags) > 2 { + category.Tags = category.Tags[:2] + } + + gameReleasedDate, err := time.Parse("2006-01-02T15:04:05Z", category.GameReleaseDate) + + if err == nil { + if time.Since(gameReleasedDate) < 14*24*time.Hour { + category.IsNew = true + } + } + + categories = append(categories, *category) + } + + if len(categories) > limit { + categories = categories[:limit] + } + + return categories, nil +} diff --git a/internal/feed/requests.go b/internal/glance/widget-utils.go similarity index 88% rename from internal/feed/requests.go rename to internal/glance/widget-utils.go index 3ce5d9f..3874737 100644 --- a/internal/feed/requests.go +++ b/internal/glance/widget-utils.go @@ -1,10 +1,11 @@ -package feed +package glance import ( "context" "crypto/tls" "encoding/json" "encoding/xml" + "errors" "fmt" "io" "net/http" @@ -12,39 +13,32 @@ import ( "time" ) +var ( + errNoContent = errors.New("failed to retrieve any content") + errPartialContent = errors.New("failed to retrieve some of the content") +) + const defaultClientTimeout = 5 * time.Second var defaultClient = &http.Client{ Timeout: defaultClientTimeout, } -var insecureClientTransport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, -} - var defaultInsecureClient = &http.Client{ - Timeout: defaultClientTimeout, - Transport: insecureClientTransport, + Timeout: defaultClientTimeout, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, } type RequestDoer interface { Do(*http.Request) (*http.Response, error) } -func addBrowserUserAgentHeader(request *http.Request) { +func setBrowserUserAgentHeader(request *http.Request) { request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0") } -func truncateString(s string, maxLen int) string { - asRunes := []rune(s) - - if len(asRunes) > maxLen { - return string(asRunes[:maxLen]) - } - - return s -} - func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) { response, err := client.Do(request) var result T @@ -62,11 +56,13 @@ func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, } if response.StatusCode != http.StatusOK { + truncatedBody, _ := limitStringLength(string(body), 256) + return result, fmt.Errorf( "unexpected status code %d for %s, response: %s", response.StatusCode, request.URL, - truncateString(string(body), 256), + truncatedBody, ) } @@ -103,11 +99,13 @@ func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T, } if response.StatusCode != http.StatusOK { + truncatedBody, _ := limitStringLength(string(body), 256) + return result, fmt.Errorf( "unexpected status code %d for %s, response: %s", response.StatusCode, request.URL, - truncateString(string(body), 256), + truncatedBody, ) } diff --git a/internal/glance/widget-videos.go b/internal/glance/widget-videos.go new file mode 100644 index 0000000..de87e8c --- /dev/null +++ b/internal/glance/widget-videos.go @@ -0,0 +1,189 @@ +package glance + +import ( + "context" + "fmt" + "html/template" + "log/slog" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +var ( + videosWidgetTemplate = mustParseTemplate("videos.html", "widget-base.html", "video-card-contents.html") + videosWidgetGridTemplate = mustParseTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") +) + +type videosWidget struct { + widgetBase `yaml:",inline"` + Videos videoList `yaml:"-"` + VideoUrlTemplate string `yaml:"video-url-template"` + Style string `yaml:"style"` + CollapseAfterRows int `yaml:"collapse-after-rows"` + Channels []string `yaml:"channels"` + Limit int `yaml:"limit"` + IncludeShorts bool `yaml:"include-shorts"` +} + +func (widget *videosWidget) initialize() error { + widget.withTitle("Videos").withCacheDuration(time.Hour) + + if widget.Limit <= 0 { + widget.Limit = 25 + } + + if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 { + widget.CollapseAfterRows = 4 + } + + return nil +} + +func (widget *videosWidget) update(ctx context.Context) { + videos, err := FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if len(videos) > widget.Limit { + videos = videos[:widget.Limit] + } + + widget.Videos = videos +} + +func (widget *videosWidget) Render() template.HTML { + if widget.Style == "grid-cards" { + return widget.renderTemplate(widget, videosWidgetGridTemplate) + } + + return widget.renderTemplate(widget, videosWidgetTemplate) +} + +type youtubeFeedResponseXml struct { + Channel string `xml:"author>name"` + ChannelLink string `xml:"author>uri"` + Videos []struct { + Title string `xml:"title"` + Published string `xml:"published"` + Link struct { + Href string `xml:"href,attr"` + } `xml:"link"` + + Group struct { + Thumbnail struct { + Url string `xml:"url,attr"` + } `xml:"http://search.yahoo.com/mrss/ thumbnail"` + } `xml:"http://search.yahoo.com/mrss/ group"` + } `xml:"entry"` +} + +func parseYoutubeFeedTime(t string) time.Time { + parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t) + + if err != nil { + return time.Now() + } + + return parsedTime +} + +type video struct { + ThumbnailUrl string + Title string + Url string + Author string + AuthorUrl string + TimePosted time.Time +} + +type videoList []video + +func (v videoList) sortByNewest() videoList { + sort.Slice(v, func(i, j int) bool { + return v[i].TimePosted.After(v[j].TimePosted) + }) + + return v +} + +func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, includeShorts bool) (videoList, error) { + requests := make([]*http.Request, 0, len(channelIds)) + + for i := range channelIds { + var feedUrl string + if !includeShorts && strings.HasPrefix(channelIds[i], "UC") { + playlistId := strings.Replace(channelIds[i], "UC", "UULF", 1) + feedUrl = "https://www.youtube.com/feeds/videos.xml?playlist_id=" + playlistId + } else { + feedUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=" + channelIds[i] + } + + request, _ := http.NewRequest("GET", feedUrl, nil) + requests = append(requests, request) + } + + job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30) + + responses, errs, err := workerPoolDo(job) + + if err != nil { + return nil, fmt.Errorf("%w: %v", errNoContent, err) + } + + videos := make(videoList, 0, len(channelIds)*15) + + var failed int + + for i := range responses { + if errs[i] != nil { + failed++ + slog.Error("Failed to fetch youtube feed", "channel", channelIds[i], "error", errs[i]) + continue + } + + response := responses[i] + + for j := range response.Videos { + v := &response.Videos[j] + var videoUrl string + + if videoUrlTemplate == "" { + videoUrl = v.Link.Href + } else { + parsedUrl, err := url.Parse(v.Link.Href) + + if err == nil { + videoUrl = strings.ReplaceAll(videoUrlTemplate, "{VIDEO-ID}", parsedUrl.Query().Get("v")) + } else { + videoUrl = "#" + } + } + + videos = append(videos, video{ + ThumbnailUrl: v.Group.Thumbnail.Url, + Title: v.Title, + Url: videoUrl, + Author: response.Channel, + AuthorUrl: response.ChannelLink + "/videos", + TimePosted: parseYoutubeFeedTime(v.Published), + }) + } + } + + if len(videos) == 0 { + return nil, errNoContent + } + + videos.sortByNewest() + + if failed > 0 { + return videos, fmt.Errorf("%w: missing videos from %d channels", errPartialContent, failed) + } + + return videos, nil +} diff --git a/internal/feed/openmeteo.go b/internal/glance/widget-weather.go similarity index 55% rename from internal/feed/openmeteo.go rename to internal/glance/widget-weather.go index 2bfa8f2..e27ca7d 100644 --- a/internal/feed/openmeteo.go +++ b/internal/glance/widget-weather.go @@ -1,22 +1,107 @@ -package feed +package glance import ( + "context" + "errors" "fmt" + "html/template" "math" "net/http" "net/url" "slices" "strings" "time" - - _ "time/tzdata" ) -type PlacesResponseJson struct { - Results []PlaceJson +var weatherWidgetTemplate = mustParseTemplate("weather.html", "widget-base.html") + +type weatherWidget struct { + widgetBase `yaml:",inline"` + Location string `yaml:"location"` + ShowAreaName bool `yaml:"show-area-name"` + HideLocation bool `yaml:"hide-location"` + HourFormat string `yaml:"hour-format"` + Units string `yaml:"units"` + Place *openMeteoPlaceResponseJson `yaml:"-"` + Weather *weather `yaml:"-"` + TimeLabels [12]string `yaml:"-"` } -type PlaceJson struct { +var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"} +var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"} + +func (widget *weatherWidget) initialize() error { + widget.withTitle("Weather").withCacheOnTheHour() + + if widget.Location == "" { + return fmt.Errorf("location is required") + } + + if widget.HourFormat == "" || widget.HourFormat == "12h" { + widget.TimeLabels = timeLabels12h + } else if widget.HourFormat == "24h" { + widget.TimeLabels = timeLabels24h + } else { + return errors.New("hour-format must be either 12h or 24h") + } + + if widget.Units == "" { + widget.Units = "metric" + } else if widget.Units != "metric" && widget.Units != "imperial" { + return errors.New("units must be either metric or imperial") + } + + return nil +} + +func (widget *weatherWidget) update(ctx context.Context) { + if widget.Place == nil { + place, err := fetchOpenMeteoPlaceFromName(widget.Location) + + if err != nil { + widget.withError(err).scheduleEarlyUpdate() + return + } + + widget.Place = place + } + + weather, err := fetchWeatherForOpenMeteoPlace(widget.Place, widget.Units) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + widget.Weather = weather +} + +func (widget *weatherWidget) Render() template.HTML { + return widget.renderTemplate(widget, weatherWidgetTemplate) +} + +type weather struct { + Temperature int + ApparentTemperature int + WeatherCode int + CurrentColumn int + SunriseColumn int + SunsetColumn int + Columns []weatherColumn +} + +func (w *weather) WeatherCodeAsString() string { + if weatherCode, ok := weatherCodeTable[w.WeatherCode]; ok { + return weatherCode + } + + return "" +} + +type openMeteoPlacesResponseJson struct { + Results []openMeteoPlaceResponseJson +} + +type openMeteoPlaceResponseJson struct { Name string Area string `json:"admin1"` Latitude float64 @@ -26,7 +111,7 @@ type PlaceJson struct { location *time.Location } -type WeatherResponseJson struct { +type openMeteoWeatherResponseJson struct { Daily struct { Sunrise []int64 `json:"sunrise"` Sunset []int64 `json:"sunset"` @@ -82,11 +167,11 @@ func parsePlaceName(name string) (string, string) { return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1]) } -func FetchPlaceFromName(location string) (*PlaceJson, error) { +func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, error) { location, area := parsePlaceName(location) requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location)) request, _ := http.NewRequest("GET", requestUrl, nil) - responseJson, err := decodeJsonFromRequest[PlacesResponseJson](defaultClient, request) + responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultClient, request) if err != nil { return nil, fmt.Errorf("could not fetch places data: %v", err) @@ -96,7 +181,7 @@ func FetchPlaceFromName(location string) (*PlaceJson, error) { return nil, fmt.Errorf("no places found for %s", location) } - var place *PlaceJson + var place *openMeteoPlaceResponseJson if area != "" { area = strings.ToLower(area) @@ -126,12 +211,7 @@ func FetchPlaceFromName(location string) (*PlaceJson, error) { return place, nil } -func barIndexFromHour(h int) int { - return h / 2 -} - -// TODO: bunch of spaget, refactor -func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) { +func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units string) (*weather, error) { query := url.Values{} var temperatureUnit string @@ -153,17 +233,17 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) { requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode() request, _ := http.NewRequest("GET", requestUrl, nil) - responseJson, err := decodeJsonFromRequest[WeatherResponseJson](defaultClient, request) + responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultClient, request) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrNoContent, err) + return nil, fmt.Errorf("%w: %v", errNoContent, err) } now := time.Now().In(place.location) bars := make([]weatherColumn, 0, 24) - currentBar := barIndexFromHour(now.Hour()) - sunriseBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour()) - sunsetBar := barIndexFromHour(time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour()) - 1 + currentBar := now.Hour() / 2 + sunriseBar := (time.Unix(int64(responseJson.Daily.Sunrise[0]), 0).In(place.location).Hour()) / 2 + sunsetBar := (time.Unix(int64(responseJson.Daily.Sunset[0]), 0).In(place.location).Hour() - 1) / 2 if sunsetBar < 0 { sunsetBar = 0 @@ -205,7 +285,7 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) { } } - return &Weather{ + return &weather{ Temperature: int(responseJson.Current.Temperature), ApparentTemperature: int(responseJson.Current.ApparentTemperature), WeatherCode: responseJson.Current.WeatherCode, @@ -215,3 +295,34 @@ func FetchWeatherForPlace(place *PlaceJson, units string) (*Weather, error) { Columns: bars, }, nil } + +var weatherCodeTable = map[int]string{ + 0: "Clear Sky", + 1: "Mainly Clear", + 2: "Partly Cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Rime Fog", + 51: "Drizzle", + 53: "Drizzle", + 55: "Drizzle", + 56: "Drizzle", + 57: "Drizzle", + 61: "Rain", + 63: "Moderate Rain", + 65: "Heavy Rain", + 66: "Freezing Rain", + 67: "Freezing Rain", + 71: "Snow", + 73: "Moderate Snow", + 75: "Heavy Snow", + 77: "Snow Grains", + 80: "Rain", + 81: "Moderate Rain", + 82: "Heavy Rain", + 85: "Snow", + 86: "Snow", + 95: "Thunderstorm", + 96: "Thunderstorm", + 99: "Thunderstorm", +} diff --git a/internal/widget/widget.go b/internal/glance/widget.go similarity index 65% rename from internal/widget/widget.go rename to internal/glance/widget.go index c6c51a7..702c49e 100644 --- a/internal/widget/widget.go +++ b/internal/glance/widget.go @@ -1,4 +1,4 @@ -package widget +package glance import ( "bytes" @@ -12,77 +12,75 @@ import ( "sync/atomic" "time" - "github.com/glanceapp/glance/internal/feed" - "gopkg.in/yaml.v3" ) var uniqueID atomic.Uint64 -func New(widgetType string) (Widget, error) { - var widget Widget +func newWidget(widgetType string) (widget, error) { + var widget widget switch widgetType { case "calendar": - widget = &Calendar{} + widget = &calendarWidget{} case "clock": - widget = &Clock{} + widget = &clockWidget{} case "weather": - widget = &Weather{} + widget = &weatherWidget{} case "bookmarks": - widget = &Bookmarks{} + widget = &bookmarksWidget{} case "iframe": - widget = &IFrame{} + widget = &iframeWidget{} case "html": - widget = &HTML{} + widget = &htmlWidget{} case "hacker-news": - widget = &HackerNews{} + widget = &hackerNewsWidget{} case "releases": - widget = &Releases{} + widget = &releasesWidget{} case "videos": - widget = &Videos{} + widget = &videosWidget{} case "markets", "stocks": - widget = &Markets{} + widget = &marketsWidget{} case "reddit": - widget = &Reddit{} + widget = &redditWidget{} case "rss": - widget = &RSS{} + widget = &rssWidget{} case "monitor": - widget = &Monitor{} + widget = &monitorWidget{} case "twitch-top-games": - widget = &TwitchGames{} + widget = &twitchGamesWidget{} case "twitch-channels": - widget = &TwitchChannels{} + widget = &twitchChannelsWidget{} case "lobsters": - widget = &Lobsters{} + widget = &lobstersWidget{} case "change-detection": - widget = &ChangeDetection{} + widget = &changeDetectionWidget{} case "repository": - widget = &Repository{} + widget = &repositoryWidget{} case "search": - widget = &Search{} + widget = &searchWidget{} case "extension": - widget = &Extension{} + widget = &extensionWidget{} case "group": - widget = &Group{} + widget = &groupWidget{} case "dns-stats": - widget = &DNSStats{} + widget = &dnsStatsWidget{} case "split-column": - widget = &SplitColumn{} + widget = &splitColumnWidget{} case "custom-api": - widget = &CustomApi{} + widget = &customAPIWidget{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) } - widget.SetID(uniqueID.Add(1)) + widget.setID(uniqueID.Add(1)) return widget, nil } -type Widgets []Widget +type widgets []widget -func (w *Widgets) UnmarshalYAML(node *yaml.Node) error { +func (w *widgets) UnmarshalYAML(node *yaml.Node) error { var nodes []yaml.Node if err := node.Decode(&nodes); err != nil { @@ -98,7 +96,7 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error { return err } - widget, err := New(meta.Type) + widget, err := newWidget(meta.Type) if err != nil { return err @@ -114,17 +112,19 @@ func (w *Widgets) UnmarshalYAML(node *yaml.Node) error { return nil } -type Widget interface { - Initialize() error - RequiresUpdate(*time.Time) bool - SetProviders(*Providers) - Update(context.Context) +type widget interface { + // These need to be exported because they get called in templates Render() template.HTML GetType() string - GetID() uint64 - SetID(uint64) - HandleRequest(w http.ResponseWriter, r *http.Request) - SetHideHeader(bool) + + initialize() error + requiresUpdate(*time.Time) bool + setProviders(*widgetProviders) + update(context.Context) + setID(uint64) + id() uint64 + handleRequest(w http.ResponseWriter, r *http.Request) + setHideHeader(bool) } type cacheType int @@ -136,29 +136,29 @@ const ( ) type widgetBase struct { - ID uint64 `yaml:"-"` - Providers *Providers `yaml:"-"` - Type string `yaml:"type"` - Title string `yaml:"title"` - TitleURL string `yaml:"title-url"` - CSSClass string `yaml:"css-class"` - CustomCacheDuration DurationField `yaml:"cache"` - ContentAvailable bool `yaml:"-"` - Error error `yaml:"-"` - Notice error `yaml:"-"` - templateBuffer bytes.Buffer `yaml:"-"` - cacheDuration time.Duration `yaml:"-"` - cacheType cacheType `yaml:"-"` - nextUpdate time.Time `yaml:"-"` - updateRetriedTimes int `yaml:"-"` - HideHeader bool `yaml:"-"` + ID uint64 `yaml:"-"` + Providers *widgetProviders `yaml:"-"` + Type string `yaml:"type"` + Title string `yaml:"title"` + TitleURL string `yaml:"title-url"` + CSSClass string `yaml:"css-class"` + CustomCacheDuration durationField `yaml:"cache"` + ContentAvailable bool `yaml:"-"` + Error error `yaml:"-"` + Notice error `yaml:"-"` + templateBuffer bytes.Buffer `yaml:"-"` + cacheDuration time.Duration `yaml:"-"` + cacheType cacheType `yaml:"-"` + nextUpdate time.Time `yaml:"-"` + updateRetriedTimes int `yaml:"-"` + HideHeader bool `yaml:"-"` } -type Providers struct { - AssetResolver func(string) string +type widgetProviders struct { + assetResolver func(string) string } -func (w *widgetBase) RequiresUpdate(now *time.Time) bool { +func (w *widgetBase) requiresUpdate(now *time.Time) bool { if w.cacheType == cacheTypeInfinite { return false } @@ -170,23 +170,23 @@ func (w *widgetBase) RequiresUpdate(now *time.Time) bool { return now.After(w.nextUpdate) } -func (w *widgetBase) Update(ctx context.Context) { +func (w *widgetBase) update(ctx context.Context) { } -func (w *widgetBase) GetID() uint64 { +func (w *widgetBase) id() uint64 { return w.ID } -func (w *widgetBase) SetID(id uint64) { +func (w *widgetBase) setID(id uint64) { w.ID = id } -func (w *widgetBase) SetHideHeader(value bool) { +func (w *widgetBase) setHideHeader(value bool) { w.HideHeader = value } -func (widget *widgetBase) HandleRequest(w http.ResponseWriter, r *http.Request) { +func (widget *widgetBase) handleRequest(w http.ResponseWriter, r *http.Request) { http.Error(w, "not implemented", http.StatusNotImplemented) } @@ -194,11 +194,11 @@ func (w *widgetBase) GetType() string { return w.Type } -func (w *widgetBase) SetProviders(providers *Providers) { +func (w *widgetBase) setProviders(providers *widgetProviders) { w.Providers = providers } -func (w *widgetBase) render(data any, t *template.Template) template.HTML { +func (w *widgetBase) renderTemplate(data any, t *template.Template) template.HTML { w.templateBuffer.Reset() err := t.Execute(&w.templateBuffer, data) @@ -206,7 +206,7 @@ func (w *widgetBase) render(data any, t *template.Template) template.HTML { w.ContentAvailable = false w.Error = err - slog.Error("failed to render template", "error", err) + slog.Error("Failed to render template", "error", err) // need to immediately re-render with the error, // otherwise risk breaking the page since the widget @@ -215,7 +215,7 @@ func (w *widgetBase) render(data any, t *template.Template) template.HTML { err2 := t.Execute(&w.templateBuffer, data) if err2 != nil { - slog.Error("failed to render error within widget", "error", err2, "initial_error", err) + slog.Error("Failed to render error within widget", "error", err2, "initial_error", err) w.templateBuffer.Reset() // TODO: add some kind of a generic widget error template when the widget // failed to render, and we also failed to re-render the widget with the error @@ -292,7 +292,7 @@ func (w *widgetBase) canContinueUpdateAfterHandlingErr(err error) bool { if err != nil { w.scheduleEarlyUpdate() - if !errors.Is(err, feed.ErrPartialContent) { + if !errors.Is(err, errPartialContent) { w.withError(err) w.withNotice(nil) return false diff --git a/internal/widget/bookmarks.go b/internal/widget/bookmarks.go deleted file mode 100644 index 133fb28..0000000 --- a/internal/widget/bookmarks.go +++ /dev/null @@ -1,34 +0,0 @@ -package widget - -import ( - "html/template" - - "github.com/glanceapp/glance/internal/assets" -) - -type Bookmarks struct { - widgetBase `yaml:",inline"` - cachedHTML template.HTML `yaml:"-"` - Groups []struct { - Title string `yaml:"title"` - Color *HSLColorField `yaml:"color"` - Links []struct { - Title string `yaml:"title"` - URL string `yaml:"url"` - Icon CustomIcon `yaml:"icon"` - SameTab bool `yaml:"same-tab"` - HideArrow bool `yaml:"hide-arrow"` - } `yaml:"links"` - } `yaml:"groups"` -} - -func (widget *Bookmarks) Initialize() error { - widget.withTitle("Bookmarks").withError(nil) - widget.cachedHTML = widget.render(widget, assets.BookmarksTemplate) - - return nil -} - -func (widget *Bookmarks) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/widget/calendar.go b/internal/widget/calendar.go deleted file mode 100644 index 5bfbf37..0000000 --- a/internal/widget/calendar.go +++ /dev/null @@ -1,31 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Calendar struct { - widgetBase `yaml:",inline"` - Calendar *feed.Calendar - StartSunday bool `yaml:"start-sunday"` -} - -func (widget *Calendar) Initialize() error { - widget.withTitle("Calendar").withCacheOnTheHour() - - return nil -} - -func (widget *Calendar) Update(ctx context.Context) { - widget.Calendar = feed.NewCalendar(time.Now(), widget.StartSunday) - widget.withError(nil).scheduleNextUpdate() -} - -func (widget *Calendar) Render() template.HTML { - return widget.render(widget, assets.CalendarTemplate) -} diff --git a/internal/widget/changedetection.go b/internal/widget/changedetection.go deleted file mode 100644 index 26c080a..0000000 --- a/internal/widget/changedetection.go +++ /dev/null @@ -1,66 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type ChangeDetection struct { - widgetBase `yaml:",inline"` - ChangeDetections feed.ChangeDetectionWatches `yaml:"-"` - WatchUUIDs []string `yaml:"watches"` - InstanceURL string `yaml:"instance-url"` - Token OptionalEnvString `yaml:"token"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` -} - -func (widget *ChangeDetection) Initialize() error { - widget.withTitle("Change Detection").withCacheDuration(1 * time.Hour) - - if widget.Limit <= 0 { - widget.Limit = 10 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if widget.InstanceURL == "" { - widget.InstanceURL = "https://www.changedetection.io" - } - - return nil -} - -func (widget *ChangeDetection) Update(ctx context.Context) { - if len(widget.WatchUUIDs) == 0 { - uuids, err := feed.FetchWatchUUIDsFromChangeDetection(widget.InstanceURL, string(widget.Token)) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.WatchUUIDs = uuids - } - - watches, err := feed.FetchWatchesFromChangeDetection(widget.InstanceURL, widget.WatchUUIDs, string(widget.Token)) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if len(watches) > widget.Limit { - watches = watches[:widget.Limit] - } - - widget.ChangeDetections = watches -} - -func (widget *ChangeDetection) Render() template.HTML { - return widget.render(widget, assets.ChangeDetectionTemplate) -} diff --git a/internal/widget/container.go b/internal/widget/container.go deleted file mode 100644 index db07bb7..0000000 --- a/internal/widget/container.go +++ /dev/null @@ -1,48 +0,0 @@ -package widget - -import ( - "context" - "sync" - "time" -) - -type containerWidgetBase struct { - Widgets Widgets `yaml:"widgets"` -} - -func (widget *containerWidgetBase) Update(ctx context.Context) { - var wg sync.WaitGroup - now := time.Now() - - for w := range widget.Widgets { - widget := widget.Widgets[w] - - if !widget.RequiresUpdate(&now) { - continue - } - - wg.Add(1) - go func() { - defer wg.Done() - widget.Update(ctx) - }() - } - - wg.Wait() -} - -func (widget *containerWidgetBase) SetProviders(providers *Providers) { - for i := range widget.Widgets { - widget.Widgets[i].SetProviders(providers) - } -} - -func (widget *containerWidgetBase) RequiresUpdate(now *time.Time) bool { - for i := range widget.Widgets { - if widget.Widgets[i].RequiresUpdate(now) { - return true - } - } - - return false -} diff --git a/internal/widget/custom-api.go b/internal/widget/custom-api.go deleted file mode 100644 index a7a69dd..0000000 --- a/internal/widget/custom-api.go +++ /dev/null @@ -1,70 +0,0 @@ -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 OptionalEnvString `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.String(), nil) - if err != nil { - return err - } - - for key, value := range widget.Headers { - req.Header.Add(key, value.String()) - } - - widget.APIRequest = req - - return nil -} - -func (widget *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/dns-stats.go b/internal/widget/dns-stats.go deleted file mode 100644 index 91757b1..0000000 --- a/internal/widget/dns-stats.go +++ /dev/null @@ -1,77 +0,0 @@ -package widget - -import ( - "context" - "errors" - "html/template" - "strings" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type DNSStats struct { - widgetBase `yaml:",inline"` - - TimeLabels [8]string `yaml:"-"` - Stats *feed.DNSStats `yaml:"-"` - - HourFormat string `yaml:"hour-format"` - Service string `yaml:"service"` - URL OptionalEnvString `yaml:"url"` - Token OptionalEnvString `yaml:"token"` - Username OptionalEnvString `yaml:"username"` - Password OptionalEnvString `yaml:"password"` -} - -func makeDNSTimeLabels(format string) [8]string { - now := time.Now() - var labels [8]string - - for i := 24; i > 0; i -= 3 { - labels[7-(i/3-1)] = strings.ToLower(now.Add(-time.Duration(i) * time.Hour).Format(format)) - } - - return labels -} - -func (widget *DNSStats) Initialize() error { - widget. - withTitle("DNS Stats"). - withTitleURL(string(widget.URL)). - withCacheDuration(10 * time.Minute) - - if widget.Service != "adguard" && widget.Service != "pihole" { - return errors.New("DNS stats service must be either 'adguard' or 'pihole'") - } - - return nil -} - -func (widget *DNSStats) Update(ctx context.Context) { - var stats *feed.DNSStats - var err error - - if widget.Service == "adguard" { - stats, err = feed.FetchAdguardStats(string(widget.URL), string(widget.Username), string(widget.Password)) - } else { - stats, err = feed.FetchPiholeStats(string(widget.URL), string(widget.Token)) - } - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.HourFormat == "24h" { - widget.TimeLabels = makeDNSTimeLabels("15:00") - } else { - widget.TimeLabels = makeDNSTimeLabels("3PM") - } - - widget.Stats = stats -} - -func (widget *DNSStats) Render() template.HTML { - return widget.render(widget, assets.DNSStatsTemplate) -} diff --git a/internal/widget/extension.go b/internal/widget/extension.go deleted file mode 100644 index 4b443f2..0000000 --- a/internal/widget/extension.go +++ /dev/null @@ -1,61 +0,0 @@ -package widget - -import ( - "context" - "errors" - "html/template" - "net/url" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Extension 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 feed.Extension `yaml:"-"` - cachedHTML template.HTML `yaml:"-"` -} - -func (widget *Extension) Initialize() error { - widget.withTitle("Extension").withCacheDuration(time.Minute * 30) - - if widget.URL == "" { - return errors.New("no extension URL specified") - } - - _, err := url.Parse(widget.URL) - - if err != nil { - return err - } - - return nil -} - -func (widget *Extension) Update(ctx context.Context) { - extension, err := feed.FetchExtension(feed.ExtensionRequestOptions{ - URL: widget.URL, - FallbackContentType: widget.FallbackContentType, - Parameters: widget.Parameters, - AllowHtml: widget.AllowHtml, - }) - - widget.canContinueUpdateAfterHandlingErr(err) - - widget.Extension = extension - - if extension.Title != "" { - widget.Title = extension.Title - } - - widget.cachedHTML = widget.render(widget, assets.ExtensionTemplate) -} - -func (widget *Extension) Render() template.HTML { - return widget.cachedHTML -} diff --git a/internal/widget/group.go b/internal/widget/group.go deleted file mode 100644 index 2725aba..0000000 --- a/internal/widget/group.go +++ /dev/null @@ -1,52 +0,0 @@ -package widget - -import ( - "context" - "errors" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" -) - -type Group struct { - widgetBase `yaml:",inline"` - containerWidgetBase `yaml:",inline"` -} - -func (widget *Group) Initialize() error { - widget.withError(nil) - widget.HideHeader = true - - for i := range widget.Widgets { - widget.Widgets[i].SetHideHeader(true) - - if widget.Widgets[i].GetType() == "group" { - return errors.New("nested groups are not supported") - } else if widget.Widgets[i].GetType() == "split-column" { - return errors.New("split columns inside of groups are not supported") - } - - if err := widget.Widgets[i].Initialize(); err != nil { - return err - } - } - - return nil -} - -func (widget *Group) Update(ctx context.Context) { - widget.containerWidgetBase.Update(ctx) -} - -func (widget *Group) SetProviders(providers *Providers) { - widget.containerWidgetBase.SetProviders(providers) -} - -func (widget *Group) RequiresUpdate(now *time.Time) bool { - return widget.containerWidgetBase.RequiresUpdate(now) -} - -func (widget *Group) Render() template.HTML { - return widget.render(widget, assets.GroupTemplate) -} diff --git a/internal/widget/hacker-news.go b/internal/widget/hacker-news.go deleted file mode 100644 index f2db6e3..0000000 --- a/internal/widget/hacker-news.go +++ /dev/null @@ -1,65 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type HackerNews struct { - widgetBase `yaml:",inline"` - Posts feed.ForumPosts `yaml:"-"` - Limit int `yaml:"limit"` - SortBy string `yaml:"sort-by"` - ExtraSortBy string `yaml:"extra-sort-by"` - CollapseAfter int `yaml:"collapse-after"` - CommentsUrlTemplate string `yaml:"comments-url-template"` - ShowThumbnails bool `yaml:"-"` -} - -func (widget *HackerNews) Initialize() error { - widget. - withTitle("Hacker News"). - withTitleURL("https://news.ycombinator.com/"). - withCacheDuration(30 * time.Minute) - - if widget.Limit <= 0 { - widget.Limit = 15 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" { - widget.SortBy = "top" - } - - return nil -} - -func (widget *HackerNews) Update(ctx context.Context) { - posts, err := feed.FetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.ExtraSortBy == "engagement" { - posts.CalculateEngagement() - posts.SortByEngagement() - } - - if widget.Limit < len(posts) { - posts = posts[:widget.Limit] - } - - widget.Posts = posts -} - -func (widget *HackerNews) Render() template.HTML { - return widget.render(widget, assets.ForumPostsTemplate) -} diff --git a/internal/widget/lobsters.go b/internal/widget/lobsters.go deleted file mode 100644 index a783c31..0000000 --- a/internal/widget/lobsters.go +++ /dev/null @@ -1,64 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Lobsters struct { - widgetBase `yaml:",inline"` - Posts feed.ForumPosts `yaml:"-"` - InstanceURL string `yaml:"instance-url"` - CustomURL string `yaml:"custom-url"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` - SortBy string `yaml:"sort-by"` - Tags []string `yaml:"tags"` - ShowThumbnails bool `yaml:"-"` -} - -func (widget *Lobsters) Initialize() error { - widget.withTitle("Lobsters").withCacheDuration(time.Hour) - - if widget.InstanceURL == "" { - widget.withTitleURL("https://lobste.rs") - } else { - widget.withTitleURL(widget.InstanceURL) - } - - if widget.SortBy == "" || (widget.SortBy != "hot" && widget.SortBy != "new") { - widget.SortBy = "hot" - } - - if widget.Limit <= 0 { - widget.Limit = 15 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - return nil -} - -func (widget *Lobsters) Update(ctx context.Context) { - posts, err := feed.FetchLobstersPosts(widget.CustomURL, widget.InstanceURL, widget.SortBy, widget.Tags) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.Limit < len(posts) { - posts = posts[:widget.Limit] - } - - widget.Posts = posts -} - -func (widget *Lobsters) Render() template.HTML { - return widget.render(widget, assets.ForumPostsTemplate) -} diff --git a/internal/widget/markets.go b/internal/widget/markets.go deleted file mode 100644 index 27c431b..0000000 --- a/internal/widget/markets.go +++ /dev/null @@ -1,50 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Markets struct { - widgetBase `yaml:",inline"` - StocksRequests []feed.MarketRequest `yaml:"stocks"` - MarketRequests []feed.MarketRequest `yaml:"markets"` - Sort string `yaml:"sort-by"` - Markets feed.Markets `yaml:"-"` -} - -func (widget *Markets) Initialize() error { - widget.withTitle("Markets").withCacheDuration(time.Hour) - - if len(widget.MarketRequests) == 0 { - widget.MarketRequests = widget.StocksRequests - } - - return nil -} - -func (widget *Markets) Update(ctx context.Context) { - markets, err := feed.FetchMarketsDataFromYahoo(widget.MarketRequests) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.Sort == "absolute-change" { - markets.SortByAbsChange() - } - - if widget.Sort == "change" { - markets.SortByChange() - } - - widget.Markets = markets -} - -func (widget *Markets) Render() template.HTML { - return widget.render(widget, assets.MarketsTemplate) -} diff --git a/internal/widget/monitor.go b/internal/widget/monitor.go deleted file mode 100644 index f769a4e..0000000 --- a/internal/widget/monitor.go +++ /dev/null @@ -1,105 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "slices" - "strconv" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Monitor struct { - widgetBase `yaml:",inline"` - Sites []struct { - *feed.SiteStatusRequest `yaml:",inline"` - Status *feed.SiteStatus `yaml:"-"` - Title string `yaml:"title"` - Icon CustomIcon `yaml:"icon"` - SameTab bool `yaml:"same-tab"` - StatusText string `yaml:"-"` - StatusStyle string `yaml:"-"` - AltStatusCodes []int `yaml:"alt-status-codes"` - } `yaml:"sites"` - Style string `yaml:"style"` - ShowFailingOnly bool `yaml:"show-failing-only"` - HasFailing bool `yaml:"-"` -} - -func (widget *Monitor) Initialize() error { - widget.withTitle("Monitor").withCacheDuration(5 * time.Minute) - - return nil -} - -func (widget *Monitor) Update(ctx context.Context) { - requests := make([]*feed.SiteStatusRequest, len(widget.Sites)) - - for i := range widget.Sites { - requests[i] = widget.Sites[i].SiteStatusRequest - } - - statuses, err := feed.FetchStatusForSites(requests) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.HasFailing = false - - for i := range widget.Sites { - site := &widget.Sites[i] - status := &statuses[i] - site.Status = status - - if !slices.Contains(site.AltStatusCodes, status.Code) && (status.Code >= 400 || status.TimedOut || status.Error != nil) { - widget.HasFailing = true - } - - if !status.TimedOut { - site.StatusText = statusCodeToText(status.Code, site.AltStatusCodes) - site.StatusStyle = statusCodeToStyle(status.Code, site.AltStatusCodes) - } - } -} - -func (widget *Monitor) Render() template.HTML { - if widget.Style == "compact" { - return widget.render(widget, assets.MonitorCompactTemplate) - } - - return widget.render(widget, assets.MonitorTemplate) -} - -func statusCodeToText(status int, altStatusCodes []int) string { - if status == 200 || slices.Contains(altStatusCodes, status) { - return "OK" - } - if status == 404 { - return "Not Found" - } - if status == 403 { - return "Forbidden" - } - if status == 401 { - return "Unauthorized" - } - if status >= 400 { - return "Client Error" - } - if status >= 500 { - return "Server Error" - } - - return strconv.Itoa(status) -} - -func statusCodeToStyle(status int, altStatusCodes []int) string { - if status == 200 || slices.Contains(altStatusCodes, status) { - return "ok" - } - - return "error" -} diff --git a/internal/widget/reddit.go b/internal/widget/reddit.go deleted file mode 100644 index b1ddf0a..0000000 --- a/internal/widget/reddit.go +++ /dev/null @@ -1,121 +0,0 @@ -package widget - -import ( - "context" - "errors" - "html/template" - "strings" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Reddit struct { - widgetBase `yaml:",inline"` - Posts feed.ForumPosts `yaml:"-"` - Subreddit string `yaml:"subreddit"` - Style string `yaml:"style"` - ShowThumbnails bool `yaml:"show-thumbnails"` - ShowFlairs bool `yaml:"show-flairs"` - SortBy string `yaml:"sort-by"` - TopPeriod string `yaml:"top-period"` - Search string `yaml:"search"` - ExtraSortBy string `yaml:"extra-sort-by"` - CommentsUrlTemplate string `yaml:"comments-url-template"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` - RequestUrlTemplate string `yaml:"request-url-template"` -} - -func (widget *Reddit) Initialize() error { - if widget.Subreddit == "" { - return errors.New("no subreddit specified") - } - - if widget.Limit <= 0 { - widget.Limit = 15 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if !isValidRedditSortType(widget.SortBy) { - widget.SortBy = "hot" - } - - if !isValidRedditTopPeriod(widget.TopPeriod) { - widget.TopPeriod = "day" - } - - if widget.RequestUrlTemplate != "" { - if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") { - return errors.New("no `{REQUEST-URL}` placeholder specified") - } - } - - widget. - withTitle("/r/" + widget.Subreddit). - withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/"). - withCacheDuration(30 * time.Minute) - - return nil -} - -func isValidRedditSortType(sortBy string) bool { - return sortBy == "hot" || - sortBy == "new" || - sortBy == "top" || - sortBy == "rising" -} - -func isValidRedditTopPeriod(period string) bool { - return period == "hour" || - period == "day" || - period == "week" || - period == "month" || - period == "year" || - period == "all" -} - -func (widget *Reddit) Update(ctx context.Context) { - // TODO: refactor, use a struct to pass all of these - posts, err := feed.FetchSubredditPosts( - widget.Subreddit, - widget.SortBy, - widget.TopPeriod, - widget.Search, - widget.CommentsUrlTemplate, - widget.RequestUrlTemplate, - widget.ShowFlairs, - ) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if len(posts) > widget.Limit { - posts = posts[:widget.Limit] - } - - if widget.ExtraSortBy == "engagement" { - posts.CalculateEngagement() - posts.SortByEngagement() - } - - widget.Posts = posts -} - -func (widget *Reddit) Render() template.HTML { - if widget.Style == "horizontal-cards" { - return widget.render(widget, assets.RedditCardsHorizontalTemplate) - } - - if widget.Style == "vertical-cards" { - return widget.render(widget, assets.RedditCardsVerticalTemplate) - } - - return widget.render(widget, assets.ForumPostsTemplate) - -} diff --git a/internal/widget/releases.go b/internal/widget/releases.go deleted file mode 100644 index 74b5af7..0000000 --- a/internal/widget/releases.go +++ /dev/null @@ -1,103 +0,0 @@ -package widget - -import ( - "context" - "errors" - "html/template" - "strings" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Releases struct { - widgetBase `yaml:",inline"` - Releases feed.AppReleases `yaml:"-"` - releaseRequests []*feed.ReleaseRequest `yaml:"-"` - Repositories []string `yaml:"repositories"` - Token OptionalEnvString `yaml:"token"` - GitLabToken OptionalEnvString `yaml:"gitlab-token"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` - ShowSourceIcon bool `yaml:"show-source-icon"` -} - -func (widget *Releases) Initialize() error { - widget.withTitle("Releases").withCacheDuration(2 * time.Hour) - - if widget.Limit <= 0 { - widget.Limit = 10 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - var tokenAsString = widget.Token.String() - var gitLabTokenAsString = widget.GitLabToken.String() - - for _, repository := range widget.Repositories { - parts := strings.SplitN(repository, ":", 2) - var request *feed.ReleaseRequest - if len(parts) == 1 { - request = &feed.ReleaseRequest{ - Source: feed.ReleaseSourceGithub, - Repository: repository, - } - - if widget.Token != "" { - request.Token = &tokenAsString - } - } else if len(parts) == 2 { - if parts[0] == string(feed.ReleaseSourceGitlab) { - request = &feed.ReleaseRequest{ - Source: feed.ReleaseSourceGitlab, - Repository: parts[1], - } - - if widget.GitLabToken != "" { - request.Token = &gitLabTokenAsString - } - } else if parts[0] == string(feed.ReleaseSourceDockerHub) { - request = &feed.ReleaseRequest{ - Source: feed.ReleaseSourceDockerHub, - Repository: parts[1], - } - } else if parts[0] == string(feed.ReleaseSourceCodeberg) { - request = &feed.ReleaseRequest{ - Source: feed.ReleaseSourceCodeberg, - Repository: parts[1], - } - } else { - return errors.New("invalid repository source " + parts[0]) - } - } - - widget.releaseRequests = append(widget.releaseRequests, request) - } - - return nil -} - -func (widget *Releases) Update(ctx context.Context) { - releases, err := feed.FetchLatestReleases(widget.releaseRequests) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if len(releases) > widget.Limit { - releases = releases[:widget.Limit] - } - - for i := range releases { - releases[i].SourceIconURL = widget.Providers.AssetResolver("icons/" + string(releases[i].Source) + ".svg") - } - - widget.Releases = releases -} - -func (widget *Releases) Render() template.HTML { - return widget.render(widget, assets.ReleasesTemplate) -} diff --git a/internal/widget/repository-overview.go b/internal/widget/repository-overview.go deleted file mode 100644 index 9d4cab3..0000000 --- a/internal/widget/repository-overview.go +++ /dev/null @@ -1,58 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Repository struct { - widgetBase `yaml:",inline"` - RequestedRepository string `yaml:"repository"` - Token OptionalEnvString `yaml:"token"` - PullRequestsLimit int `yaml:"pull-requests-limit"` - IssuesLimit int `yaml:"issues-limit"` - CommitsLimit int `yaml:"commits-limit"` - RepositoryDetails feed.RepositoryDetails -} - -func (widget *Repository) Initialize() error { - widget.withTitle("Repository").withCacheDuration(1 * time.Hour) - - if widget.PullRequestsLimit == 0 || widget.PullRequestsLimit < -1 { - widget.PullRequestsLimit = 3 - } - - if widget.IssuesLimit == 0 || widget.IssuesLimit < -1 { - widget.IssuesLimit = 3 - } - - if widget.CommitsLimit == 0 || widget.CommitsLimit < -1 { - widget.CommitsLimit = -1 - } - - return nil -} - -func (widget *Repository) Update(ctx context.Context) { - details, err := feed.FetchRepositoryDetailsFromGithub( - widget.RequestedRepository, - string(widget.Token), - widget.PullRequestsLimit, - widget.IssuesLimit, - widget.CommitsLimit, - ) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.RepositoryDetails = details -} - -func (widget *Repository) Render() template.HTML { - return widget.render(widget, assets.RepositoryTemplate) -} diff --git a/internal/widget/rss.go b/internal/widget/rss.go deleted file mode 100644 index 282b150..0000000 --- a/internal/widget/rss.go +++ /dev/null @@ -1,83 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type RSS struct { - widgetBase `yaml:",inline"` - FeedRequests []feed.RSSFeedRequest `yaml:"feeds"` - Style string `yaml:"style"` - ThumbnailHeight float64 `yaml:"thumbnail-height"` - CardHeight float64 `yaml:"card-height"` - Items feed.RSSFeedItems `yaml:"-"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` - SingleLineTitles bool `yaml:"single-line-titles"` - NoItemsMessage string `yaml:"-"` -} - -func (widget *RSS) Initialize() error { - widget.withTitle("RSS Feed").withCacheDuration(1 * time.Hour) - - if widget.Limit <= 0 { - widget.Limit = 25 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if widget.ThumbnailHeight < 0 { - widget.ThumbnailHeight = 0 - } - - if widget.CardHeight < 0 { - widget.CardHeight = 0 - } - - if widget.Style == "detailed-list" { - for i := range widget.FeedRequests { - widget.FeedRequests[i].IsDetailed = true - } - } - - widget.NoItemsMessage = "No items were returned from the feeds." - - return nil -} - -func (widget *RSS) Update(ctx context.Context) { - items, err := feed.GetItemsFromRSSFeeds(widget.FeedRequests) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if len(items) > widget.Limit { - items = items[:widget.Limit] - } - - widget.Items = items -} - -func (widget *RSS) Render() template.HTML { - if widget.Style == "horizontal-cards" { - return widget.render(widget, assets.RSSHorizontalCardsTemplate) - } - - if widget.Style == "horizontal-cards-2" { - return widget.render(widget, assets.RSSHorizontalCards2Template) - } - - if widget.Style == "detailed-list" { - return widget.render(widget, assets.RSSDetailedListTemplate) - } - - return widget.render(widget, assets.RSSListTemplate) -} diff --git a/internal/widget/split-column.go b/internal/widget/split-column.go deleted file mode 100644 index 74cb3d1..0000000 --- a/internal/widget/split-column.go +++ /dev/null @@ -1,47 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" -) - -type SplitColumn struct { - widgetBase `yaml:",inline"` - containerWidgetBase `yaml:",inline"` - MaxColumns int `yaml:"max-columns"` -} - -func (widget *SplitColumn) Initialize() error { - widget.withError(nil).withTitle("Split Column").SetHideHeader(true) - - for i := range widget.Widgets { - if err := widget.Widgets[i].Initialize(); err != nil { - return err - } - } - - if widget.MaxColumns < 2 { - widget.MaxColumns = 2 - } - - return nil -} - -func (widget *SplitColumn) Update(ctx context.Context) { - widget.containerWidgetBase.Update(ctx) -} - -func (widget *SplitColumn) SetProviders(providers *Providers) { - widget.containerWidgetBase.SetProviders(providers) -} - -func (widget *SplitColumn) RequiresUpdate(now *time.Time) bool { - return widget.containerWidgetBase.RequiresUpdate(now) -} - -func (widget *SplitColumn) Render() template.HTML { - return widget.render(widget, assets.SplitColumnTemplate) -} diff --git a/internal/widget/twitch-channels.go b/internal/widget/twitch-channels.go deleted file mode 100644 index b06c986..0000000 --- a/internal/widget/twitch-channels.go +++ /dev/null @@ -1,55 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type TwitchChannels struct { - widgetBase `yaml:",inline"` - ChannelsRequest []string `yaml:"channels"` - Channels []feed.TwitchChannel `yaml:"-"` - CollapseAfter int `yaml:"collapse-after"` - SortBy string `yaml:"sort-by"` -} - -func (widget *TwitchChannels) Initialize() error { - widget. - withTitle("Twitch Channels"). - withTitleURL("https://www.twitch.tv/directory/following"). - withCacheDuration(time.Minute * 10) - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - if widget.SortBy != "viewers" && widget.SortBy != "live" { - widget.SortBy = "viewers" - } - - return nil -} - -func (widget *TwitchChannels) Update(ctx context.Context) { - channels, err := feed.FetchChannelsFromTwitch(widget.ChannelsRequest) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if widget.SortBy == "viewers" { - channels.SortByViewers() - } else if widget.SortBy == "live" { - channels.SortByLive() - } - - widget.Channels = channels -} - -func (widget *TwitchChannels) Render() template.HTML { - return widget.render(widget, assets.TwitchChannelsTemplate) -} diff --git a/internal/widget/twitch-top-games.go b/internal/widget/twitch-top-games.go deleted file mode 100644 index 85933a6..0000000 --- a/internal/widget/twitch-top-games.go +++ /dev/null @@ -1,49 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type TwitchGames struct { - widgetBase `yaml:",inline"` - Categories []feed.TwitchCategory `yaml:"-"` - Exclude []string `yaml:"exclude"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` -} - -func (widget *TwitchGames) Initialize() error { - widget. - withTitle("Top games on Twitch"). - withTitleURL("https://www.twitch.tv/directory?sort=VIEWER_COUNT"). - withCacheDuration(time.Minute * 10) - - if widget.Limit <= 0 { - widget.Limit = 10 - } - - if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { - widget.CollapseAfter = 5 - } - - return nil -} - -func (widget *TwitchGames) Update(ctx context.Context) { - categories, err := feed.FetchTopGamesFromTwitch(widget.Exclude, widget.Limit) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.Categories = categories -} - -func (widget *TwitchGames) Render() template.HTML { - return widget.render(widget, assets.TwitchGamesListTemplate) -} diff --git a/internal/widget/videos.go b/internal/widget/videos.go deleted file mode 100644 index 8943603..0000000 --- a/internal/widget/videos.go +++ /dev/null @@ -1,57 +0,0 @@ -package widget - -import ( - "context" - "html/template" - "time" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Videos struct { - widgetBase `yaml:",inline"` - Videos feed.Videos `yaml:"-"` - VideoUrlTemplate string `yaml:"video-url-template"` - Style string `yaml:"style"` - CollapseAfterRows int `yaml:"collapse-after-rows"` - Channels []string `yaml:"channels"` - Limit int `yaml:"limit"` - IncludeShorts bool `yaml:"include-shorts"` -} - -func (widget *Videos) Initialize() error { - widget.withTitle("Videos").withCacheDuration(time.Hour) - - if widget.Limit <= 0 { - widget.Limit = 25 - } - - if widget.CollapseAfterRows == 0 || widget.CollapseAfterRows < -1 { - widget.CollapseAfterRows = 4 - } - - return nil -} - -func (widget *Videos) Update(ctx context.Context) { - videos, err := feed.FetchYoutubeChannelUploads(widget.Channels, widget.VideoUrlTemplate, widget.IncludeShorts) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - if len(videos) > widget.Limit { - videos = videos[:widget.Limit] - } - - widget.Videos = videos -} - -func (widget *Videos) Render() template.HTML { - if widget.Style == "grid-cards" { - return widget.render(widget, assets.VideosGridTemplate) - } - - return widget.render(widget, assets.VideosTemplate) -} diff --git a/internal/widget/weather.go b/internal/widget/weather.go deleted file mode 100644 index ac207d4..0000000 --- a/internal/widget/weather.go +++ /dev/null @@ -1,74 +0,0 @@ -package widget - -import ( - "context" - "fmt" - "html/template" - - "github.com/glanceapp/glance/internal/assets" - "github.com/glanceapp/glance/internal/feed" -) - -type Weather struct { - widgetBase `yaml:",inline"` - Location string `yaml:"location"` - ShowAreaName bool `yaml:"show-area-name"` - HideLocation bool `yaml:"hide-location"` - HourFormat string `yaml:"hour-format"` - Units string `yaml:"units"` - Place *feed.PlaceJson `yaml:"-"` - Weather *feed.Weather `yaml:"-"` - TimeLabels [12]string `yaml:"-"` -} - -var timeLabels12h = [12]string{"2am", "4am", "6am", "8am", "10am", "12pm", "2pm", "4pm", "6pm", "8pm", "10pm", "12am"} -var timeLabels24h = [12]string{"02:00", "04:00", "06:00", "08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "20:00", "22:00", "00:00"} - -func (widget *Weather) Initialize() error { - widget.withTitle("Weather").withCacheOnTheHour() - - if widget.Location == "" { - return fmt.Errorf("location must be specified for weather widget") - } - - if widget.HourFormat == "" || widget.HourFormat == "12h" { - widget.TimeLabels = timeLabels12h - } else if widget.HourFormat == "24h" { - widget.TimeLabels = timeLabels24h - } else { - return fmt.Errorf("invalid hour format '%s' for weather widget, must be either 12h or 24h", widget.HourFormat) - } - - if widget.Units == "" { - widget.Units = "metric" - } else if widget.Units != "metric" && widget.Units != "imperial" { - return fmt.Errorf("invalid units '%s' for weather, must be either metric or imperial", widget.Units) - } - - return nil -} - -func (widget *Weather) Update(ctx context.Context) { - if widget.Place == nil { - place, err := feed.FetchPlaceFromName(widget.Location) - - if err != nil { - widget.withError(err).scheduleEarlyUpdate() - return - } - - widget.Place = place - } - - weather, err := feed.FetchWeatherForPlace(widget.Place, widget.Units) - - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - widget.Weather = weather -} - -func (widget *Weather) Render() template.HTML { - return widget.render(widget, assets.WeatherTemplate) -} From ebb519e6d807d0f8280ef27466420137c094db83 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:38:16 +0000 Subject: [PATCH 06/26] Capitalize console messages --- internal/glance/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/glance/main.go b/internal/glance/main.go index 0473501..b320d60 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -32,18 +32,18 @@ func Main() int { case cliIntentConfigValidate: contents, _, err := parseYAMLIncludes(options.configPath) if err != nil { - fmt.Printf("could not parse config file: %v\n", err) + fmt.Printf("Could not parse config file: %v\n", err) return 1 } if _, err := newConfigFromYAML(contents); err != nil { - fmt.Printf("config file is invalid: %v\n", err) + fmt.Printf("Config file is invalid: %v\n", err) return 1 } case cliIntentConfigPrint: contents, _, err := parseYAMLIncludes(options.configPath) if err != nil { - fmt.Printf("could not parse config file: %v\n", err) + fmt.Printf("Could not parse config file: %v\n", err) return 1 } From a4185fde07b757d51212b656f693b07a7634e94e Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:41:15 +0000 Subject: [PATCH 07/26] Rename file --- .../{widget-repository-overview.go => widget-repository.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/glance/{widget-repository-overview.go => widget-repository.go} (100%) diff --git a/internal/glance/widget-repository-overview.go b/internal/glance/widget-repository.go similarity index 100% rename from internal/glance/widget-repository-overview.go rename to internal/glance/widget-repository.go From 74e05763f73a44c35ae9d3b9b826439cfcbbee0b Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:45:18 +0000 Subject: [PATCH 08/26] Re-add tzdata --- internal/glance/widget-weather.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/glance/widget-weather.go b/internal/glance/widget-weather.go index e27ca7d..a626a02 100644 --- a/internal/glance/widget-weather.go +++ b/internal/glance/widget-weather.go @@ -11,6 +11,8 @@ import ( "slices" "strings" "time" + + _ "time/tzdata" ) var weatherWidgetTemplate = mustParseTemplate("weather.html", "widget-base.html") From 2dce9b4c48b96145fc2e2f7f1855d9f723f4d64b Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 17:03:52 +0000 Subject: [PATCH 09/26] Listen on all interfaces --- internal/glance/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/glance/main.go b/internal/glance/main.go index b320d60..addb089 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -163,7 +163,7 @@ func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool { }) server := http.Server{ - Addr: "localhost:8080", + Addr: ":8080", Handler: mux, } server.ListenAndServe() From 77a9469ff8c37346ac4801062a8f758bea97525f Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 17:11:07 +0000 Subject: [PATCH 10/26] Add console message about new config location --- internal/glance/main.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/glance/main.go b/internal/glance/main.go index addb089..9f80633 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -154,6 +154,10 @@ func serveUpdateNoticeIfConfigLocationNotMigrated(configPath string) bool { templateFile, _ := templateFS.Open("v0.7-update-notice-page.html") bodyContents, _ := io.ReadAll(templateFile) + // TODO: update - add link + fmt.Println("!!! WARNING !!!") + fmt.Println("The default location of glance.yml in the Docker image has changed starting from v0.7.0, please see for more information.") + mux := http.NewServeMux() mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { From 6165308c23d6b697a8b282797b659cf49d3d509d Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 17:34:25 +0000 Subject: [PATCH 11/26] Fix random overlapping issues after browser update... wtf? --- internal/glance/static/main.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css index a486395..25b4669 100644 --- a/internal/glance/static/main.css +++ b/internal/glance/static/main.css @@ -525,6 +525,7 @@ kbd:active { list-style: none; position: relative; display: flex; + z-index: 1; } .details[open] .summary { @@ -1136,7 +1137,6 @@ details[open] .summary::after { content: ''; position: absolute; inset: 1px 0; - z-index: -1; opacity: 0; background: var(--color-text-base); transition: opacity .2s; @@ -1278,7 +1278,6 @@ details[open] .summary::after { overflow: hidden; mask-image: linear-gradient(0deg, transparent 40%, #000); -webkit-mask-image: linear-gradient(0deg, transparent 40%, #000); - z-index: -1; } .weather-column-rain::before { From 6886716e67a09c9d7d5945569d434e6daa75d0e8 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 18:28:10 +0000 Subject: [PATCH 12/26] Add comment --- internal/glance/config.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/glance/config.go b/internal/glance/config.go index 461fd50..f92082c 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -229,6 +229,11 @@ func configFilesWatcher( // when it gets recreated, in which case we may need to watch the directory for the // creation of that file and then re-add it to the watcher, though that's // a lot of effort for a hopefully rare edge case + + // TODO: update - try and fix this for v0.7.0 + // so, about that "rare edge case"... it's not so rare + // guess what happens when you run `git pull` and a file has changes? + // yeah, it gets removed and re-added ( : case err, isOpen := <-watcher.Errors: if !isOpen { return From 03035d1a2d32eda1ff71e08d5bdde9e939e9995f Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 20:45:33 +0000 Subject: [PATCH 13/26] Move template vars --- internal/glance/glance.go | 6 +++++- internal/glance/templates.go | 8 +------- internal/glance/widget-shared.go | 2 ++ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 8c0068c..1a05476 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -14,7 +14,11 @@ import ( "time" ) -var pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl") +var ( + pageTemplate = mustParseTemplate("page.html", "document.html") + pageContentTemplate = mustParseTemplate("page-content.html") + pageThemeStyleTemplate = mustParseTemplate("theme-style.gotmpl") +) type application struct { Version string diff --git a/internal/glance/templates.go b/internal/glance/templates.go index db14d7e..f3e6158 100644 --- a/internal/glance/templates.go +++ b/internal/glance/templates.go @@ -11,11 +11,7 @@ import ( "golang.org/x/text/message" ) -var ( - pageTemplate = mustParseTemplate("page.html", "document.html") - pageContentTemplate = mustParseTemplate("page-content.html") - forumPostsTemplate = mustParseTemplate("forum-posts.html", "widget-base.html") -) +var intl = message.NewPrinter(language.English) var globalTemplateFunctions = template.FuncMap{ "formatViewerCount": formatViewerCount, @@ -43,8 +39,6 @@ func mustParseTemplate(primary string, dependencies ...string) *template.Templat return t } -var intl = message.NewPrinter(language.English) - func formatViewerCount(count int) string { if count < 1_000 { return strconv.Itoa(count) diff --git a/internal/glance/widget-shared.go b/internal/glance/widget-shared.go index 30c4bd3..45144ac 100644 --- a/internal/glance/widget-shared.go +++ b/internal/glance/widget-shared.go @@ -9,6 +9,8 @@ import ( const twitchGqlEndpoint = "https://gql.twitch.tv/gql" const twitchGqlClientId = "kimne78kx3ncx6brgo4mv6wki5h1ko" +var forumPostsTemplate = mustParseTemplate("forum-posts.html", "widget-base.html") + type forumPost struct { Title string DiscussionUrl string From d19214a3908fa3b8b5f14e3ad3da4c45a0a06ee3 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 20:52:09 +0000 Subject: [PATCH 14/26] Update error messages --- internal/glance/widget-weather.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/glance/widget-weather.go b/internal/glance/widget-weather.go index a626a02..99fb38b 100644 --- a/internal/glance/widget-weather.go +++ b/internal/glance/widget-weather.go @@ -176,7 +176,7 @@ func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultClient, request) if err != nil { - return nil, fmt.Errorf("could not fetch places data: %v", err) + return nil, fmt.Errorf("fetching places data: %v", err) } if len(responseJson.Results) == 0 { @@ -205,7 +205,7 @@ func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, loc, err := time.LoadLocation(place.Timezone) if err != nil { - return nil, fmt.Errorf("could not load location: %v", err) + return nil, fmt.Errorf("loading location: %v", err) } place.location = loc From 2c03316f86c662578fe60a385a2a2986470e9b04 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 21:06:58 +0000 Subject: [PATCH 15/26] Update error --- internal/glance/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/glance/config.go b/internal/glance/config.go index f92082c..b2ebf88 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -267,7 +267,7 @@ func isConfigStateValid(config *config) error { for i := range config.Pages { if config.Pages[i].Title == "" { - return fmt.Errorf("page %d has no title", i+1) + return fmt.Errorf("page %d has no name", i+1) } if config.Pages[i].Width != "" && (config.Pages[i].Width != "wide" && config.Pages[i].Width != "slim") { From a816d1a9138f020791aa81cc89f50d2bbbe86709 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 21:12:10 +0000 Subject: [PATCH 16/26] Update error messages --- internal/glance/config.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/glance/config.go b/internal/glance/config.go index b2ebf88..9568fc7 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -102,12 +102,12 @@ var includePattern = regexp.MustCompile(`(?m)^(\s*)!include:\s*(.+)$`) func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) { mainFileContents, err := os.ReadFile(mainFilePath) if err != nil { - return nil, nil, fmt.Errorf("could not read main YAML file: %w", err) + return nil, nil, fmt.Errorf("reading main YAML file: %w", err) } mainFileAbsPath, err := filepath.Abs(mainFilePath) if err != nil { - return nil, nil, fmt.Errorf("could not get absolute path of main YAML file: %w", err) + return nil, nil, fmt.Errorf("getting absolute path of main YAML file: %w", err) } mainFileDir := filepath.Dir(mainFileAbsPath) @@ -136,7 +136,7 @@ func parseYAMLIncludes(mainFilePath string) ([]byte, map[string]struct{}, error) fileContents, err = os.ReadFile(includeFilePath) if err != nil { - includesLastErr = fmt.Errorf("could not read included file: %w", err) + includesLastErr = fmt.Errorf("reading included file %s: %w", includeFilePath, err) return nil } @@ -160,12 +160,12 @@ func configFilesWatcher( ) (func() error, error) { watcher, err := fsnotify.NewWatcher() if err != nil { - return nil, fmt.Errorf("could not create watcher: %w", err) + return nil, fmt.Errorf("creating watcher: %w", err) } if err = watcher.Add(mainFilePath); err != nil { watcher.Close() - return nil, fmt.Errorf("could not add main file to watcher: %w", err) + return nil, fmt.Errorf("adding main file to watcher: %w", err) } updateWatchedIncludes := func(previousIncludes map[string]struct{}, newIncludes map[string]struct{}) { @@ -192,7 +192,7 @@ func configFilesWatcher( checkForContentChangesBeforeCallback := func() { currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath) if err != nil { - onErr(fmt.Errorf("could not parse main file contents for comparison: %w", err)) + onErr(fmt.Errorf("parsing main file contents for comparison: %w", err)) return } From d6470ae8140c0824ab12c8305d86f9b22209c630 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 21:45:50 +0000 Subject: [PATCH 17/26] Somewhat working fix for config watcher --- internal/glance/config.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/internal/glance/config.go b/internal/glance/config.go index 9568fc7..f69f1a3 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -189,6 +189,9 @@ func configFilesWatcher( updateWatchedIncludes(nil, lastIncludes) + // needed for lastContents and lastIncludes because they get updated in multiple goroutines + mu := sync.Mutex{} + checkForContentChangesBeforeCallback := func() { currentContents, currentIncludes, err := parseYAMLIncludes(mainFilePath) if err != nil { @@ -196,6 +199,9 @@ func configFilesWatcher( return } + mu.Lock() + defer mu.Unlock() + if !bytes.Equal(lastContents, currentContents) { updateWatchedIncludes(lastIncludes, currentIncludes) lastContents, lastIncludes = currentContents, currentIncludes @@ -223,17 +229,13 @@ func configFilesWatcher( } if event.Has(fsnotify.Write) { debouncedCallback() - } - // maybe also handle .Remove event? - // from testing it appears that a removed file will stop triggering .Write events - // when it gets recreated, in which case we may need to watch the directory for the - // creation of that file and then re-add it to the watcher, though that's - // a lot of effort for a hopefully rare edge case + } else if event.Has(fsnotify.Remove) { + mu.Lock() + delete(lastIncludes, event.Name) + mu.Unlock() - // TODO: update - try and fix this for v0.7.0 - // so, about that "rare edge case"... it's not so rare - // guess what happens when you run `git pull` and a file has changes? - // yeah, it gets removed and re-added ( : + debouncedCallback() + } case err, isOpen := <-watcher.Errors: if !isOpen { return From 1d9ae72c81e71b27c892f3515596ec4c2ecb8df7 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Fri, 29 Nov 2024 22:58:05 +0000 Subject: [PATCH 18/26] Rename variables --- internal/glance/widget.go | 56 +++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/internal/glance/widget.go b/internal/glance/widget.go index 702c49e..b046d09 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -15,67 +15,67 @@ import ( "gopkg.in/yaml.v3" ) -var uniqueID atomic.Uint64 +var widgetIDCounter atomic.Uint64 func newWidget(widgetType string) (widget, error) { - var widget widget + var w widget switch widgetType { case "calendar": - widget = &calendarWidget{} + w = &calendarWidget{} case "clock": - widget = &clockWidget{} + w = &clockWidget{} case "weather": - widget = &weatherWidget{} + w = &weatherWidget{} case "bookmarks": - widget = &bookmarksWidget{} + w = &bookmarksWidget{} case "iframe": - widget = &iframeWidget{} + w = &iframeWidget{} case "html": - widget = &htmlWidget{} + w = &htmlWidget{} case "hacker-news": - widget = &hackerNewsWidget{} + w = &hackerNewsWidget{} case "releases": - widget = &releasesWidget{} + w = &releasesWidget{} case "videos": - widget = &videosWidget{} + w = &videosWidget{} case "markets", "stocks": - widget = &marketsWidget{} + w = &marketsWidget{} case "reddit": - widget = &redditWidget{} + w = &redditWidget{} case "rss": - widget = &rssWidget{} + w = &rssWidget{} case "monitor": - widget = &monitorWidget{} + w = &monitorWidget{} case "twitch-top-games": - widget = &twitchGamesWidget{} + w = &twitchGamesWidget{} case "twitch-channels": - widget = &twitchChannelsWidget{} + w = &twitchChannelsWidget{} case "lobsters": - widget = &lobstersWidget{} + w = &lobstersWidget{} case "change-detection": - widget = &changeDetectionWidget{} + w = &changeDetectionWidget{} case "repository": - widget = &repositoryWidget{} + w = &repositoryWidget{} case "search": - widget = &searchWidget{} + w = &searchWidget{} case "extension": - widget = &extensionWidget{} + w = &extensionWidget{} case "group": - widget = &groupWidget{} + w = &groupWidget{} case "dns-stats": - widget = &dnsStatsWidget{} + w = &dnsStatsWidget{} case "split-column": - widget = &splitColumnWidget{} + w = &splitColumnWidget{} case "custom-api": - widget = &customAPIWidget{} + w = &customAPIWidget{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) } - widget.setID(uniqueID.Add(1)) + w.setID(widgetIDCounter.Add(1)) - return widget, nil + return w, nil } type widgets []widget From 02cbb5f8124c9a676c72eea3e3e37a975cf40083 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 30 Nov 2024 10:48:33 +0000 Subject: [PATCH 19/26] Fix CS --- internal/glance/config-fields.go | 1 - internal/glance/glance.go | 3 --- internal/glance/main.go | 1 - internal/glance/utils.go | 1 - internal/glance/widget-changedetection.go | 2 -- internal/glance/widget-clock.go | 4 +--- internal/glance/widget-custom-api.go | 1 - internal/glance/widget-dns-stats.go | 5 ----- internal/glance/widget-extension.go | 8 ++------ internal/glance/widget-hacker-news.go | 3 --- internal/glance/widget-iframe.go | 4 +--- internal/glance/widget-lobsters.go | 3 --- internal/glance/widget-markets.go | 1 - internal/glance/widget-monitor.go | 2 -- internal/glance/widget-reddit.go | 2 -- internal/glance/widget-releases.go | 9 +-------- internal/glance/widget-rss.go | 3 --- internal/glance/widget-twitch-channels.go | 14 ++++---------- internal/glance/widget-twitch-top-games.go | 1 - internal/glance/widget-utils.go | 10 ++-------- internal/glance/widget-videos.go | 2 -- internal/glance/widget-weather.go | 4 ---- internal/glance/widget.go | 2 -- 23 files changed, 11 insertions(+), 75 deletions(-) diff --git a/internal/glance/config-fields.go b/internal/glance/config-fields.go index 16ccc85..527dfe2 100644 --- a/internal/glance/config-fields.go +++ b/internal/glance/config-fields.go @@ -99,7 +99,6 @@ func (d *durationField) UnmarshalYAML(node *yaml.Node) error { } duration, err := strconv.Atoi(matches[1]) - if err != nil { return err } diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 1a05476..daa920c 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -147,7 +147,6 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) var responseBytes bytes.Buffer err := pageTemplate.Execute(&responseBytes, pageData) - if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) @@ -173,7 +172,6 @@ func (a *application) handlePageContentRequest(w http.ResponseWriter, r *http.Re var responseBytes bytes.Buffer err := pageContentTemplate.Execute(&responseBytes, pageData) - if err != nil { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) @@ -193,7 +191,6 @@ func (a *application) handleWidgetRequest(w http.ResponseWriter, r *http.Request widgetValue := r.PathValue("widget") widgetID, err := strconv.ParseUint(widgetValue, 10, 64) - if err != nil { a.handleNotFound(w, r) return diff --git a/internal/glance/main.go b/internal/glance/main.go index 9f80633..35211a9 100644 --- a/internal/glance/main.go +++ b/internal/glance/main.go @@ -12,7 +12,6 @@ var buildVersion = "dev" func Main() int { options, err := parseCliOptions() - if err != nil { fmt.Println(err) return 1 diff --git a/internal/glance/utils.go b/internal/glance/utils.go index 7d621f1..7af45af 100644 --- a/internal/glance/utils.go +++ b/internal/glance/utils.go @@ -25,7 +25,6 @@ func extractDomainFromUrl(u string) string { } parsed, err := url.Parse(u) - if err != nil { return "" } diff --git a/internal/glance/widget-changedetection.go b/internal/glance/widget-changedetection.go index eb5e129..3cc463e 100644 --- a/internal/glance/widget-changedetection.go +++ b/internal/glance/widget-changedetection.go @@ -103,7 +103,6 @@ func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]str } uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request) - if err != nil { return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err) } @@ -139,7 +138,6 @@ func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient) job := newJob(task, requests).withWorkers(15) responses, errs, err := workerPoolDo(job) - if err != nil { return nil, err } diff --git a/internal/glance/widget-clock.go b/internal/glance/widget-clock.go index 0b1688c..c69fc95 100644 --- a/internal/glance/widget-clock.go +++ b/internal/glance/widget-clock.go @@ -33,9 +33,7 @@ func (widget *clockWidget) initialize() error { return errors.New("missing timezone value") } - _, err := time.LoadLocation(widget.Timezones[t].Timezone) - - if err != nil { + if _, err := time.LoadLocation(widget.Timezones[t].Timezone); err != nil { return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err) } } diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index 4f6a1cf..78308e8 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -39,7 +39,6 @@ func (widget *customAPIWidget) initialize() error { } compiledTemplate, err := template.New("").Funcs(customAPITemplateFuncs).Parse(widget.Template) - if err != nil { return fmt.Errorf("parsing template: %w", err) } diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go index 2943208..bdb3efa 100644 --- a/internal/glance/widget-dns-stats.go +++ b/internal/glance/widget-dns-stats.go @@ -114,7 +114,6 @@ func fetchAdguardStats(instanceURL, username, password string) (*dnsStats, error requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats" request, err := http.NewRequest("GET", requestURL, nil) - if err != nil { return nil, err } @@ -122,7 +121,6 @@ func fetchAdguardStats(instanceURL, username, password string) (*dnsStats, error request.SetBasicAuth(username, password) responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request) - if err != nil { return nil, err } @@ -235,7 +233,6 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { temp := make(map[string]int) err := json.Unmarshal(data, &temp) - if err != nil { *p = make(piholeTopBlockedDomains) } else { @@ -254,13 +251,11 @@ func fetchPiholeStats(instanceURL, token string) (*dnsStats, error) { "/admin/api.php?summaryRaw&topItems&overTimeData10mins&auth=" + token request, err := http.NewRequest("GET", requestURL, nil) - if err != nil { return nil, err } responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request) - if err != nil { return nil, err } diff --git a/internal/glance/widget-extension.go b/internal/glance/widget-extension.go index 9c23368..d90c48f 100644 --- a/internal/glance/widget-extension.go +++ b/internal/glance/widget-extension.go @@ -32,10 +32,8 @@ func (widget *extensionWidget) initialize() error { return errors.New("URL is required") } - _, err := url.Parse(widget.URL) - - if err != nil { - return err + if _, err := url.Parse(widget.URL); err != nil { + return fmt.Errorf("parsing URL: %v", err) } return nil @@ -117,7 +115,6 @@ func fetchExtension(options extensionRequestOptions) (extension, error) { request.URL.RawQuery = query.Encode() response, err := http.DefaultClient.Do(request) - if err != nil { slog.Error("Failed fetching extension", "url", options.URL, "error", err) return extension{}, fmt.Errorf("%w: request failed: %w", errNoContent, err) @@ -126,7 +123,6 @@ func fetchExtension(options extensionRequestOptions) (extension, error) { defer response.Body.Close() body, err := io.ReadAll(response.Body) - if err != nil { slog.Error("Failed reading response body of extension", "url", options.URL, "error", err) return extension{}, fmt.Errorf("%w: could not read body: %w", errNoContent, err) diff --git a/internal/glance/widget-hacker-news.go b/internal/glance/widget-hacker-news.go index e8cabca..09d5f89 100644 --- a/internal/glance/widget-hacker-news.go +++ b/internal/glance/widget-hacker-news.go @@ -78,7 +78,6 @@ type hackerNewsPostResponseJson struct { func fetchHackerNewsPostIds(sort string) ([]int, error) { request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil) response, err := decodeJsonFromRequest[[]int](defaultClient, request) - if err != nil { return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent) } @@ -97,7 +96,6 @@ func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (for task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient) job := newJob(task, requests).withWorkers(30) results, errs, err := workerPoolDo(job) - if err != nil { return nil, err } @@ -142,7 +140,6 @@ func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (for func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) { postIds, err := fetchHackerNewsPostIds(sort) - if err != nil { return nil, err } diff --git a/internal/glance/widget-iframe.go b/internal/glance/widget-iframe.go index 0badf00..830b383 100644 --- a/internal/glance/widget-iframe.go +++ b/internal/glance/widget-iframe.go @@ -23,9 +23,7 @@ func (widget *iframeWidget) initialize() error { return errors.New("source is required") } - _, err := url.Parse(widget.Source) - - if err != nil { + if _, err := url.Parse(widget.Source); err != nil { return fmt.Errorf("parsing URL: %v", err) } diff --git a/internal/glance/widget-lobsters.go b/internal/glance/widget-lobsters.go index 625686d..771c8f7 100644 --- a/internal/glance/widget-lobsters.go +++ b/internal/glance/widget-lobsters.go @@ -76,13 +76,11 @@ type lobstersFeedResponseJson []lobstersPostResponseJson func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) { request, err := http.NewRequest("GET", feedUrl, nil) - if err != nil { return nil, err } feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request) - if err != nil { return nil, err } @@ -138,7 +136,6 @@ func fetchLobstersPosts(customURL string, instanceURL string, sortBy string, tag } posts, err := fetchLobstersPostsFromFeed(feedUrl) - if err != nil { return nil, err } diff --git a/internal/glance/widget-markets.go b/internal/glance/widget-markets.go index 481d020..04c68c4 100644 --- a/internal/glance/widget-markets.go +++ b/internal/glance/widget-markets.go @@ -114,7 +114,6 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests) responses, errs, err := workerPoolDo(job) - if err != nil { return nil, fmt.Errorf("%w: %v", errNoContent, err) } diff --git a/internal/glance/widget-monitor.go b/internal/glance/widget-monitor.go index 8d26221..b1f20d6 100644 --- a/internal/glance/widget-monitor.go +++ b/internal/glance/widget-monitor.go @@ -129,7 +129,6 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) { url = statusRequest.URL } request, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { return SiteStatus{ Error: err, @@ -169,7 +168,6 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) { func fetchStatusForSites(requests []*SiteStatusRequest) ([]SiteStatus, error) { job := newJob(fetchSiteStatusTask, requests).withWorkers(20) results, _, err := workerPoolDo(job) - if err != nil { return nil, err } diff --git a/internal/glance/widget-reddit.go b/internal/glance/widget-reddit.go index 84f49cd..406aeaf 100644 --- a/internal/glance/widget-reddit.go +++ b/internal/glance/widget-reddit.go @@ -185,7 +185,6 @@ func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate } request, err := http.NewRequest("GET", requestUrl, nil) - if err != nil { return nil, err } @@ -193,7 +192,6 @@ func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate // Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests setBrowserUserAgentHeader(request) responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request) - if err != nil { return nil, err } diff --git a/internal/glance/widget-releases.go b/internal/glance/widget-releases.go index 73b6664..c661718 100644 --- a/internal/glance/widget-releases.go +++ b/internal/glance/widget-releases.go @@ -144,7 +144,6 @@ type releaseRequest struct { func fetchLatestReleases(requests []*releaseRequest) (appReleaseList, error) { job := newJob(fetchLatestReleaseTask, requests).withWorkers(20) results, errs, err := workerPoolDo(job) - if err != nil { return nil, err } @@ -216,7 +215,6 @@ func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) { } response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest) - if err != nil { return nil, err } @@ -266,7 +264,6 @@ func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) { } httpRequest, err := http.NewRequest("GET", requestURL, nil) - if err != nil { return nil, err } @@ -279,7 +276,6 @@ func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) { if len(tagParts) == 1 { response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest) - if err != nil { return nil, err } @@ -291,7 +287,6 @@ func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) { tag = &response.Results[0] } else { response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest) - if err != nil { return nil, err } @@ -343,7 +338,6 @@ func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) { ), nil, ) - if err != nil { return nil, err } @@ -353,7 +347,6 @@ func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) { } response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest) - if err != nil { return nil, err } @@ -387,10 +380,10 @@ func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) { } response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest) - if err != nil { return nil, err } + return &appRelease{ Source: releaseSourceCodeberg, Name: request.repository, diff --git a/internal/glance/widget-rss.go b/internal/glance/widget-rss.go index 74371b7..ddc63c6 100644 --- a/internal/glance/widget-rss.go +++ b/internal/glance/widget-rss.go @@ -186,7 +186,6 @@ func fetchItemsFromRSSFeedTask(request RSSFeedRequest) ([]rssFeedItem, error) { } feed, err := feedParser.ParseString(string(body)) - if err != nil { return nil, err } @@ -206,7 +205,6 @@ func fetchItemsFromRSSFeedTask(request RSSFeedRequest) ([]rssFeedItem, error) { rssItem.Link = item.Link } else { parsedUrl, err := url.Parse(feed.Link) - if err != nil { parsedUrl, err = url.Parse(request.Url) } @@ -317,7 +315,6 @@ func findThumbnailInItemExtensions(item *gofeed.Item) string { func fetchItemsFromRSSFeeds(requests []RSSFeedRequest) (rssFeedItemList, error) { job := newJob(fetchItemsFromRSSFeedTask, requests).withWorkers(10) feeds, errs, err := workerPoolDo(job) - if err != nil { return nil, fmt.Errorf("%w: %v", errNoContent, err) } diff --git a/internal/glance/widget-twitch-channels.go b/internal/glance/widget-twitch-channels.go index b52f23e..e87fb5a 100644 --- a/internal/glance/widget-twitch-channels.go +++ b/internal/glance/widget-twitch-channels.go @@ -140,7 +140,6 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) { request.Header.Add("Client-ID", twitchGqlClientId) response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultClient, request) - if err != nil { return result, err } @@ -155,16 +154,12 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) { for i := range response { switch response[i].Extensions.OperationName { case "ChannelShell": - err = json.Unmarshal(response[i].Data, &channelShell) - - if err != nil { - return result, fmt.Errorf("failed to unmarshal channel shell: %w", err) + if err = json.Unmarshal(response[i].Data, &channelShell); err != nil { + return result, fmt.Errorf("unmarshalling channel shell: %w", err) } case "StreamMetadata": - err = json.Unmarshal(response[i].Data, &streamMetadata) - - if err != nil { - return result, fmt.Errorf("failed to unmarshal stream metadata: %w", err) + if err = json.Unmarshal(response[i].Data, &streamMetadata); err != nil { + return result, fmt.Errorf("unmarshalling stream metadata: %w", err) } default: return result, fmt.Errorf("unknown operation name: %s", response[i].Extensions.OperationName) @@ -211,7 +206,6 @@ func fetchChannelsFromTwitch(channelLogins []string) (twitchChannelList, error) job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10) channels, errs, err := workerPoolDo(job) - if err != nil { return result, err } diff --git a/internal/glance/widget-twitch-top-games.go b/internal/glance/widget-twitch-top-games.go index 54f4bdb..421e022 100644 --- a/internal/glance/widget-twitch-top-games.go +++ b/internal/glance/widget-twitch-top-games.go @@ -83,7 +83,6 @@ func fetchTopGamesFromTwitch(exclude []string, limit int) ([]twitchCategory, err request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) request.Header.Add("Client-ID", twitchGqlClientId) response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request) - if err != nil { return nil, err } diff --git a/internal/glance/widget-utils.go b/internal/glance/widget-utils.go index 3874737..f7603aa 100644 --- a/internal/glance/widget-utils.go +++ b/internal/glance/widget-utils.go @@ -40,17 +40,15 @@ func setBrowserUserAgentHeader(request *http.Request) { } func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) { - response, err := client.Do(request) var result T + response, err := client.Do(request) if err != nil { return result, err } - defer response.Body.Close() body, err := io.ReadAll(response.Body) - if err != nil { return result, err } @@ -67,7 +65,6 @@ func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, } err = json.Unmarshal(body, &result) - if err != nil { return result, err } @@ -83,17 +80,15 @@ func decodeJsonFromRequestTask[T any](client RequestDoer) func(*http.Request) (T // TODO: tidy up, these are a copy of the above but with a line changed func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T, error) { - response, err := client.Do(request) var result T + response, err := client.Do(request) if err != nil { return result, err } - defer response.Body.Close() body, err := io.ReadAll(response.Body) - if err != nil { return result, err } @@ -110,7 +105,6 @@ func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T, } err = xml.Unmarshal(body, &result) - if err != nil { return result, err } diff --git a/internal/glance/widget-videos.go b/internal/glance/widget-videos.go index de87e8c..79846ff 100644 --- a/internal/glance/widget-videos.go +++ b/internal/glance/widget-videos.go @@ -84,7 +84,6 @@ type youtubeFeedResponseXml struct { func parseYoutubeFeedTime(t string) time.Time { parsedTime, err := time.Parse("2006-01-02T15:04:05-07:00", t) - if err != nil { return time.Now() } @@ -130,7 +129,6 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, in job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30) responses, errs, err := workerPoolDo(job) - if err != nil { return nil, fmt.Errorf("%w: %v", errNoContent, err) } diff --git a/internal/glance/widget-weather.go b/internal/glance/widget-weather.go index 99fb38b..b83085b 100644 --- a/internal/glance/widget-weather.go +++ b/internal/glance/widget-weather.go @@ -59,7 +59,6 @@ func (widget *weatherWidget) initialize() error { func (widget *weatherWidget) update(ctx context.Context) { if widget.Place == nil { place, err := fetchOpenMeteoPlaceFromName(widget.Location) - if err != nil { widget.withError(err).scheduleEarlyUpdate() return @@ -174,7 +173,6 @@ func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location)) request, _ := http.NewRequest("GET", requestUrl, nil) responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultClient, request) - if err != nil { return nil, fmt.Errorf("fetching places data: %v", err) } @@ -203,7 +201,6 @@ func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, } loc, err := time.LoadLocation(place.Timezone) - if err != nil { return nil, fmt.Errorf("loading location: %v", err) } @@ -236,7 +233,6 @@ func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units stri requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode() request, _ := http.NewRequest("GET", requestUrl, nil) responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultClient, request) - if err != nil { return nil, fmt.Errorf("%w: %v", errNoContent, err) } diff --git a/internal/glance/widget.go b/internal/glance/widget.go index b046d09..81cb39c 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -97,7 +97,6 @@ func (w *widgets) UnmarshalYAML(node *yaml.Node) error { } widget, err := newWidget(meta.Type) - if err != nil { return err } @@ -201,7 +200,6 @@ func (w *widgetBase) setProviders(providers *widgetProviders) { func (w *widgetBase) renderTemplate(data any, t *template.Template) template.HTML { w.templateBuffer.Reset() err := t.Execute(&w.templateBuffer, data) - if err != nil { w.ContentAvailable = false w.Error = err From b3e73ce86af02fc0007c3d5d387faf12a9ebf69a Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 30 Nov 2024 10:59:58 +0000 Subject: [PATCH 20/26] Move log outside of func --- internal/glance/embed.go | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/internal/glance/embed.go b/internal/glance/embed.go index 65b1a72..7bb07c9 100644 --- a/internal/glance/embed.go +++ b/internal/glance/embed.go @@ -20,9 +20,17 @@ var _templateFS embed.FS var staticFS, _ = fs.Sub(_staticFS, "static") var templateFS, _ = fs.Sub(_templateFS, "templates") -var staticFSHash = computeFSHash(staticFS) +var staticFSHash = func() string { + hash, err := computeFSHash(staticFS) + if err != nil { + log.Printf("Could not compute static assets cache key: %v", err) + return strconv.FormatInt(time.Now().Unix(), 10) + } -func computeFSHash(files fs.FS) string { + return hash +}() + +func computeFSHash(files fs.FS) (string, error) { hash := md5.New() err := fs.WalkDir(files, ".", func(path string, d fs.DirEntry, err error) error { @@ -46,10 +54,9 @@ func computeFSHash(files fs.FS) string { return nil }) - if err == nil { - return hex.EncodeToString(hash.Sum(nil))[:10] + if err != nil { + return "", err } - log.Printf("Could not compute assets cache: %v", err) - return strconv.FormatInt(time.Now().Unix(), 10) + return hex.EncodeToString(hash.Sum(nil))[:10], nil } From f68e5ae9efbb840f62de8b98804f1ec4d2509660 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 30 Nov 2024 11:16:07 +0000 Subject: [PATCH 21/26] Optimize cache control header assignment in file server --- internal/glance/utils.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/glance/utils.go b/internal/glance/utils.go index 7af45af..9600031 100644 --- a/internal/glance/utils.go +++ b/internal/glance/utils.go @@ -147,10 +147,11 @@ func titleToSlug(s string) string { func fileServerWithCache(fs http.FileSystem, cacheDuration time.Duration) http.Handler { server := http.FileServer(fs) + cacheControlValue := fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds())) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // TODO: fix always setting cache control even if the file doesn't exist - w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(cacheDuration.Seconds()))) + w.Header().Set("Cache-Control", cacheControlValue) server.ServeHTTP(w, r) }) } From 98b4b7330ec2703be8aac979477c42c6738b06b2 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 30 Nov 2024 12:23:57 +0000 Subject: [PATCH 22/26] Further fixes for config file watcher --- internal/glance/config.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/glance/config.go b/internal/glance/config.go index f69f1a3..8322dbf 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -5,6 +5,7 @@ import ( "fmt" "html/template" "log" + "maps" "os" "path/filepath" "regexp" @@ -202,9 +203,13 @@ func configFilesWatcher( mu.Lock() defer mu.Unlock() - if !bytes.Equal(lastContents, currentContents) { + if !maps.Equal(currentIncludes, lastIncludes) { updateWatchedIncludes(lastIncludes, currentIncludes) - lastContents, lastIncludes = currentContents, currentIncludes + lastIncludes = currentIncludes + } + + if !bytes.Equal(lastContents, currentContents) { + lastContents = currentContents onChange(currentContents) } } @@ -230,9 +235,12 @@ func configFilesWatcher( if event.Has(fsnotify.Write) { debouncedCallback() } else if event.Has(fsnotify.Remove) { - mu.Lock() - delete(lastIncludes, event.Name) - mu.Unlock() + func() { + mu.Lock() + defer mu.Unlock() + fileAbsPath, _ := filepath.Abs(event.Name) + delete(lastIncludes, fileAbsPath) + }() debouncedCallback() } From 1785af4749202632daf9e5d1f13e01507f850fd5 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 30 Nov 2024 12:37:56 +0000 Subject: [PATCH 23/26] More config watcher fixes --- internal/glance/config.go | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/internal/glance/config.go b/internal/glance/config.go index 8322dbf..59f5042 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -159,6 +159,14 @@ func configFilesWatcher( onChange func(newContents []byte), onErr func(error), ) (func() error, error) { + mainFileAbsPath, err := filepath.Abs(mainFilePath) + if err != nil { + return nil, fmt.Errorf("getting absolute path of main file: %w", err) + } + + // TODO: refactor, flaky + lastIncludes[mainFileAbsPath] = struct{}{} + watcher, err := fsnotify.NewWatcher() if err != nil { return nil, fmt.Errorf("creating watcher: %w", err) @@ -169,26 +177,26 @@ func configFilesWatcher( return nil, fmt.Errorf("adding main file to watcher: %w", err) } - updateWatchedIncludes := func(previousIncludes map[string]struct{}, newIncludes map[string]struct{}) { - for includePath := range previousIncludes { - if _, ok := newIncludes[includePath]; !ok { - watcher.Remove(includePath) + updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) { + for filePath := range previousWatched { + if _, ok := newWatched[filePath]; !ok { + watcher.Remove(filePath) } } - for includePath := range newIncludes { - if _, ok := previousIncludes[includePath]; !ok { - if err := watcher.Add(includePath); err != nil { + for filePath := range newWatched { + if _, ok := previousWatched[filePath]; !ok { + if err := watcher.Add(filePath); err != nil { log.Printf( - "Could not add included config file to watcher, changes to this file will not trigger a reload. path: %s, error: %v", - includePath, err, + "Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v", + filePath, err, ) } } } } - updateWatchedIncludes(nil, lastIncludes) + updateWatchedFiles(nil, lastIncludes) // needed for lastContents and lastIncludes because they get updated in multiple goroutines mu := sync.Mutex{} @@ -204,7 +212,9 @@ func configFilesWatcher( defer mu.Unlock() if !maps.Equal(currentIncludes, lastIncludes) { - updateWatchedIncludes(lastIncludes, currentIncludes) + // TODO: refactor, flaky + currentIncludes[mainFileAbsPath] = struct{}{} + updateWatchedFiles(lastIncludes, currentIncludes) lastIncludes = currentIncludes } From ffe053ffc59364e9f5d61a8585aef0f324cd449c Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 30 Nov 2024 12:54:19 +0000 Subject: [PATCH 24/26] Even more config watcher fixes --- internal/glance/config.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/internal/glance/config.go b/internal/glance/config.go index 59f5042..0f6b259 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -172,11 +172,6 @@ func configFilesWatcher( return nil, fmt.Errorf("creating watcher: %w", err) } - if err = watcher.Add(mainFilePath); err != nil { - watcher.Close() - return nil, fmt.Errorf("adding main file to watcher: %w", err) - } - updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) { for filePath := range previousWatched { if _, ok := newWatched[filePath]; !ok { @@ -208,12 +203,13 @@ func configFilesWatcher( return } + // TODO: refactor, flaky + currentIncludes[mainFileAbsPath] = struct{}{} + mu.Lock() defer mu.Unlock() if !maps.Equal(currentIncludes, lastIncludes) { - // TODO: refactor, flaky - currentIncludes[mainFileAbsPath] = struct{}{} updateWatchedFiles(lastIncludes, currentIncludes) lastIncludes = currentIncludes } From b5259d1a98b50905918d267675fdc3fc1f1e2061 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 30 Nov 2024 14:24:19 +0000 Subject: [PATCH 25/26] Rename variables & interface --- internal/glance/widget-changedetection.go | 4 ++-- internal/glance/widget-custom-api.go | 2 +- internal/glance/widget-hacker-news.go | 4 ++-- internal/glance/widget-lobsters.go | 2 +- internal/glance/widget-markets.go | 2 +- internal/glance/widget-monitor.go | 4 ++-- internal/glance/widget-reddit.go | 2 +- internal/glance/widget-releases.go | 10 +++++----- internal/glance/widget-repository.go | 8 ++++---- internal/glance/widget-rss.go | 2 +- internal/glance/widget-twitch-channels.go | 2 +- internal/glance/widget-twitch-top-games.go | 2 +- internal/glance/widget-utils.go | 14 +++++++------- internal/glance/widget-videos.go | 2 +- internal/glance/widget-weather.go | 4 ++-- 15 files changed, 32 insertions(+), 32 deletions(-) diff --git a/internal/glance/widget-changedetection.go b/internal/glance/widget-changedetection.go index 3cc463e..ed2fc86 100644 --- a/internal/glance/widget-changedetection.go +++ b/internal/glance/widget-changedetection.go @@ -102,7 +102,7 @@ func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]str request.Header.Add("x-api-key", token) } - uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultClient, request) + uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultHTTPClient, request) if err != nil { return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err) } @@ -135,7 +135,7 @@ func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []str requests[i] = request } - task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultClient) + task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultHTTPClient) job := newJob(task, requests).withWorkers(15) responses, errs, err := workerPoolDo(job) if err != nil { diff --git a/internal/glance/widget-custom-api.go b/internal/glance/widget-custom-api.go index 78308e8..17f3ee8 100644 --- a/internal/glance/widget-custom-api.go +++ b/internal/glance/widget-custom-api.go @@ -75,7 +75,7 @@ func (widget *customAPIWidget) Render() template.HTML { func fetchAndParseCustomAPI(req *http.Request, tmpl *template.Template) (template.HTML, error) { emptyBody := template.HTML("") - resp, err := defaultClient.Do(req) + resp, err := defaultHTTPClient.Do(req) if err != nil { return emptyBody, err } diff --git a/internal/glance/widget-hacker-news.go b/internal/glance/widget-hacker-news.go index 09d5f89..ad00df0 100644 --- a/internal/glance/widget-hacker-news.go +++ b/internal/glance/widget-hacker-news.go @@ -77,7 +77,7 @@ type hackerNewsPostResponseJson struct { func fetchHackerNewsPostIds(sort string) ([]int, error) { request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil) - response, err := decodeJsonFromRequest[[]int](defaultClient, request) + response, err := decodeJsonFromRequest[[]int](defaultHTTPClient, request) if err != nil { return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent) } @@ -93,7 +93,7 @@ func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (for requests[i] = request } - task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient) + task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultHTTPClient) job := newJob(task, requests).withWorkers(30) results, errs, err := workerPoolDo(job) if err != nil { diff --git a/internal/glance/widget-lobsters.go b/internal/glance/widget-lobsters.go index 771c8f7..786d1df 100644 --- a/internal/glance/widget-lobsters.go +++ b/internal/glance/widget-lobsters.go @@ -80,7 +80,7 @@ func fetchLobstersPostsFromFeed(feedUrl string) (forumPostList, error) { return nil, err } - feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultClient, request) + feed, err := decodeJsonFromRequest[lobstersFeedResponseJson](defaultHTTPClient, request) if err != nil { return nil, err } diff --git a/internal/glance/widget-markets.go b/internal/glance/widget-markets.go index 04c68c4..48df6fc 100644 --- a/internal/glance/widget-markets.go +++ b/internal/glance/widget-markets.go @@ -112,7 +112,7 @@ func fetchMarketsDataFromYahoo(marketRequests []marketRequest) (marketList, erro requests = append(requests, request) } - job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests) + job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultHTTPClient), requests) responses, errs, err := workerPoolDo(job) if err != nil { return nil, fmt.Errorf("%w: %v", errNoContent, err) diff --git a/internal/glance/widget-monitor.go b/internal/glance/widget-monitor.go index b1f20d6..09d92ab 100644 --- a/internal/glance/widget-monitor.go +++ b/internal/glance/widget-monitor.go @@ -142,9 +142,9 @@ func fetchSiteStatusTask(statusRequest *SiteStatusRequest) (SiteStatus, error) { var response *http.Response if !statusRequest.AllowInsecure { - response, err = defaultClient.Do(request) + response, err = defaultHTTPClient.Do(request) } else { - response, err = defaultInsecureClient.Do(request) + response, err = defaultInsecureHTTPClient.Do(request) } status := SiteStatus{ResponseTime: time.Since(requestSentAt)} diff --git a/internal/glance/widget-reddit.go b/internal/glance/widget-reddit.go index 406aeaf..2046bd6 100644 --- a/internal/glance/widget-reddit.go +++ b/internal/glance/widget-reddit.go @@ -191,7 +191,7 @@ func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate // Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests setBrowserUserAgentHeader(request) - responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request) + responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultHTTPClient, request) if err != nil { return nil, err } diff --git a/internal/glance/widget-releases.go b/internal/glance/widget-releases.go index c661718..0ac6caa 100644 --- a/internal/glance/widget-releases.go +++ b/internal/glance/widget-releases.go @@ -214,7 +214,7 @@ func fetchLatestGithubRelease(request *releaseRequest) (*appRelease, error) { httpRequest.Header.Add("Authorization", "Bearer "+(*request.token)) } - response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultClient, httpRequest) + response, err := decodeJsonFromRequest[githubReleaseLatestResponseJson](defaultHTTPClient, httpRequest) if err != nil { return nil, err } @@ -275,7 +275,7 @@ func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) { var tag *dockerHubRepositoryTagResponse if len(tagParts) == 1 { - response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultClient, httpRequest) + response, err := decodeJsonFromRequest[dockerHubRepositoryTagsResponse](defaultHTTPClient, httpRequest) if err != nil { return nil, err } @@ -286,7 +286,7 @@ func fetchLatestDockerHubRelease(request *releaseRequest) (*appRelease, error) { tag = &response.Results[0] } else { - response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultClient, httpRequest) + response, err := decodeJsonFromRequest[dockerHubRepositoryTagResponse](defaultHTTPClient, httpRequest) if err != nil { return nil, err } @@ -346,7 +346,7 @@ func fetchLatestGitLabRelease(request *releaseRequest) (*appRelease, error) { httpRequest.Header.Add("PRIVATE-TOKEN", *request.token) } - response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultClient, httpRequest) + response, err := decodeJsonFromRequest[gitlabReleaseResponseJson](defaultHTTPClient, httpRequest) if err != nil { return nil, err } @@ -379,7 +379,7 @@ func fetchLatestCodebergRelease(request *releaseRequest) (*appRelease, error) { return nil, err } - response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultClient, httpRequest) + response, err := decodeJsonFromRequest[codebergReleaseResponseJson](defaultHTTPClient, httpRequest) if err != nil { return nil, err } diff --git a/internal/glance/widget-repository.go b/internal/glance/widget-repository.go index b33e44d..df1e8b7 100644 --- a/internal/glance/widget-repository.go +++ b/internal/glance/widget-repository.go @@ -142,14 +142,14 @@ func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, max wg.Add(1) go (func() { defer wg.Done() - repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultClient, repositoryRequest) + repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultHTTPClient, repositoryRequest) })() if maxPRs > 0 { wg.Add(1) go (func() { defer wg.Done() - PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, PRsRequest) + PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, PRsRequest) })() } @@ -157,7 +157,7 @@ func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, max wg.Add(1) go (func() { defer wg.Done() - issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultClient, issuesRequest) + issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, issuesRequest) })() } @@ -165,7 +165,7 @@ func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, max wg.Add(1) go (func() { defer wg.Done() - commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultClient, CommitsRequest) + commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultHTTPClient, CommitsRequest) })() } diff --git a/internal/glance/widget-rss.go b/internal/glance/widget-rss.go index ddc63c6..fe8a319 100644 --- a/internal/glance/widget-rss.go +++ b/internal/glance/widget-rss.go @@ -170,7 +170,7 @@ func fetchItemsFromRSSFeedTask(request RSSFeedRequest) ([]rssFeedItem, error) { req.Header.Add(key, value) } - resp, err := defaultClient.Do(req) + resp, err := defaultHTTPClient.Do(req) if err != nil { return nil, err } diff --git a/internal/glance/widget-twitch-channels.go b/internal/glance/widget-twitch-channels.go index e87fb5a..f3ab206 100644 --- a/internal/glance/widget-twitch-channels.go +++ b/internal/glance/widget-twitch-channels.go @@ -139,7 +139,7 @@ func fetchChannelFromTwitchTask(channel string) (twitchChannel, error) { request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) request.Header.Add("Client-ID", twitchGqlClientId) - response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultClient, request) + response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultHTTPClient, request) if err != nil { return result, err } diff --git a/internal/glance/widget-twitch-top-games.go b/internal/glance/widget-twitch-top-games.go index 421e022..4235bc9 100644 --- a/internal/glance/widget-twitch-top-games.go +++ b/internal/glance/widget-twitch-top-games.go @@ -82,7 +82,7 @@ func fetchTopGamesFromTwitch(exclude []string, limit int) ([]twitchCategory, err reader := strings.NewReader(fmt.Sprintf(twitchDirectoriesOperationRequestBody, len(exclude)+limit)) request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader) request.Header.Add("Client-ID", twitchGqlClientId) - response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultClient, request) + response, err := decodeJsonFromRequest[[]twitchDirectoriesOperationResponse](defaultHTTPClient, request) if err != nil { return nil, err } diff --git a/internal/glance/widget-utils.go b/internal/glance/widget-utils.go index f7603aa..77a9d5c 100644 --- a/internal/glance/widget-utils.go +++ b/internal/glance/widget-utils.go @@ -20,18 +20,18 @@ var ( const defaultClientTimeout = 5 * time.Second -var defaultClient = &http.Client{ +var defaultHTTPClient = &http.Client{ Timeout: defaultClientTimeout, } -var defaultInsecureClient = &http.Client{ +var defaultInsecureHTTPClient = &http.Client{ Timeout: defaultClientTimeout, Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, } -type RequestDoer interface { +type requestDoer interface { Do(*http.Request) (*http.Response, error) } @@ -39,7 +39,7 @@ func setBrowserUserAgentHeader(request *http.Request) { request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0") } -func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, error) { +func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) { var result T response, err := client.Do(request) @@ -72,14 +72,14 @@ func decodeJsonFromRequest[T any](client RequestDoer, request *http.Request) (T, return result, nil } -func decodeJsonFromRequestTask[T any](client RequestDoer) func(*http.Request) (T, error) { +func decodeJsonFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) { return func(request *http.Request) (T, error) { return decodeJsonFromRequest[T](client, request) } } // TODO: tidy up, these are a copy of the above but with a line changed -func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T, error) { +func decodeXmlFromRequest[T any](client requestDoer, request *http.Request) (T, error) { var result T response, err := client.Do(request) @@ -112,7 +112,7 @@ func decodeXmlFromRequest[T any](client RequestDoer, request *http.Request) (T, return result, nil } -func decodeXmlFromRequestTask[T any](client RequestDoer) func(*http.Request) (T, error) { +func decodeXmlFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) { return func(request *http.Request) (T, error) { return decodeXmlFromRequest[T](client, request) } diff --git a/internal/glance/widget-videos.go b/internal/glance/widget-videos.go index 79846ff..e1f3b14 100644 --- a/internal/glance/widget-videos.go +++ b/internal/glance/widget-videos.go @@ -126,7 +126,7 @@ func FetchYoutubeChannelUploads(channelIds []string, videoUrlTemplate string, in requests = append(requests, request) } - job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultClient), requests).withWorkers(30) + job := newJob(decodeXmlFromRequestTask[youtubeFeedResponseXml](defaultHTTPClient), requests).withWorkers(30) responses, errs, err := workerPoolDo(job) if err != nil { diff --git a/internal/glance/widget-weather.go b/internal/glance/widget-weather.go index b83085b..9d53cd6 100644 --- a/internal/glance/widget-weather.go +++ b/internal/glance/widget-weather.go @@ -172,7 +172,7 @@ func fetchOpenMeteoPlaceFromName(location string) (*openMeteoPlaceResponseJson, location, area := parsePlaceName(location) requestUrl := fmt.Sprintf("https://geocoding-api.open-meteo.com/v1/search?name=%s&count=10&language=en&format=json", url.QueryEscape(location)) request, _ := http.NewRequest("GET", requestUrl, nil) - responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultClient, request) + responseJson, err := decodeJsonFromRequest[openMeteoPlacesResponseJson](defaultHTTPClient, request) if err != nil { return nil, fmt.Errorf("fetching places data: %v", err) } @@ -232,7 +232,7 @@ func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units stri requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode() request, _ := http.NewRequest("GET", requestUrl, nil) - responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultClient, request) + responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultHTTPClient, request) if err != nil { return nil, fmt.Errorf("%w: %v", errNoContent, err) } From d876f4cb3953bbf39e0cb7db12a2ad33e9f57ed5 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Sat, 30 Nov 2024 14:24:56 +0000 Subject: [PATCH 26/26] Add allow-insecure for dns-stats widget --- docs/configuration.md | 4 +++ internal/glance/widget-dns-stats.go | 39 ++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d308918..15d9eac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1343,6 +1343,7 @@ Preview: | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | service | string | no | pihole | +| allow-insecure | bool | no | false | | url | string | yes | | | username | string | when service is `adguard` | | | password | string | when service is `adguard` | | @@ -1352,6 +1353,9 @@ Preview: ##### `service` Either `adguard` or `pihole`. +##### `allow-insecure` +Whether to allow invalid/self-signed certificates when making the request to the service. + ##### `url` The base URL of the service. Can be specified from an environment variable using the syntax `${VARIABLE_NAME}`. diff --git a/internal/glance/widget-dns-stats.go b/internal/glance/widget-dns-stats.go index bdb3efa..8b004b6 100644 --- a/internal/glance/widget-dns-stats.go +++ b/internal/glance/widget-dns-stats.go @@ -20,12 +20,13 @@ type dnsStatsWidget struct { TimeLabels [8]string `yaml:"-"` Stats *dnsStats `yaml:"-"` - HourFormat string `yaml:"hour-format"` - Service string `yaml:"service"` - URL optionalEnvField `yaml:"url"` - Token optionalEnvField `yaml:"token"` - Username optionalEnvField `yaml:"username"` - Password optionalEnvField `yaml:"password"` + HourFormat string `yaml:"hour-format"` + Service string `yaml:"service"` + AllowInsecure bool `yaml:"allow-insecure"` + URL optionalEnvField `yaml:"url"` + Token optionalEnvField `yaml:"token"` + Username optionalEnvField `yaml:"username"` + Password optionalEnvField `yaml:"password"` } func makeDNSWidgetTimeLabels(format string) [8]string { @@ -57,9 +58,9 @@ func (widget *dnsStatsWidget) update(ctx context.Context) { var err error if widget.Service == "adguard" { - stats, err = fetchAdguardStats(string(widget.URL), string(widget.Username), string(widget.Password)) + stats, err = fetchAdguardStats(string(widget.URL), widget.AllowInsecure, string(widget.Username), string(widget.Password)) } else { - stats, err = fetchPiholeStats(string(widget.URL), string(widget.Token)) + stats, err = fetchPiholeStats(string(widget.URL), widget.AllowInsecure, string(widget.Token)) } if !widget.canContinueUpdateAfterHandlingErr(err) { @@ -110,7 +111,7 @@ type adguardStatsResponse struct { TopBlockedDomains []map[string]int `json:"top_blocked_domains"` } -func fetchAdguardStats(instanceURL, username, password string) (*dnsStats, error) { +func fetchAdguardStats(instanceURL string, allowInsecure bool, username, password string) (*dnsStats, error) { requestURL := strings.TrimRight(instanceURL, "/") + "/control/stats" request, err := http.NewRequest("GET", requestURL, nil) @@ -120,7 +121,14 @@ func fetchAdguardStats(instanceURL, username, password string) (*dnsStats, error request.SetBasicAuth(username, password) - responseJson, err := decodeJsonFromRequest[adguardStatsResponse](defaultClient, request) + var client requestDoer + if !allowInsecure { + client = defaultHTTPClient + } else { + client = defaultInsecureHTTPClient + } + + responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, request) if err != nil { return nil, err } @@ -242,7 +250,7 @@ func (p *piholeTopBlockedDomains) UnmarshalJSON(data []byte) error { return nil } -func fetchPiholeStats(instanceURL, token string) (*dnsStats, error) { +func fetchPiholeStats(instanceURL string, allowInsecure bool, token string) (*dnsStats, error) { if token == "" { return nil, errors.New("missing API token") } @@ -255,7 +263,14 @@ func fetchPiholeStats(instanceURL, token string) (*dnsStats, error) { return nil, err } - responseJson, err := decodeJsonFromRequest[piholeStatsResponse](defaultClient, request) + var client requestDoer + if !allowInsecure { + client = defaultHTTPClient + } else { + client = defaultInsecureHTTPClient + } + + responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, request) if err != nil { return nil, err }