Merge latest changes

This commit is contained in:
Svilen Markov
2024-12-03 13:59:20 +00:00
131 changed files with 4277 additions and 3827 deletions

View File

@@ -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
}

View File

@@ -0,0 +1,228 @@
package glance
import (
"fmt"
"html/template"
"os"
"regexp"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
var hslColorFieldPattern = regexp.MustCompile(`^(?:hsla?\()?(\d{1,3})(?: |,)+(\d{1,3})%?(?: |,)+(\d{1,3})%?\)?$`)
const (
hslHueMax = 360
hslSaturationMax = 100
hslLightnessMax = 100
)
type hslColorField struct {
Hue uint16
Saturation uint8
Lightness uint8
}
func (c *hslColorField) String() string {
return fmt.Sprintf("hsl(%d, %d%%, %d%%)", c.Hue, c.Saturation, c.Lightness)
}
func (c *hslColorField) AsCSSValue() template.CSS {
return template.CSS(c.String())
}
func (c *hslColorField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
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)
}
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)
}
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)
}
c.Hue = uint16(hue)
c.Saturation = uint8(saturation)
c.Lightness = uint8(lightness)
return nil
}
var durationFieldPattern = regexp.MustCompile(`^(\d+)(s|m|h|d)$`)
type durationField time.Duration
func (d *durationField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
matches := durationFieldPattern.FindStringSubmatch(value)
if len(matches) != 3 {
return fmt.Errorf("invalid duration format: %s", value)
}
duration, err := strconv.Atoi(matches[1])
if err != nil {
return err
}
switch matches[2] {
case "s":
*d = durationField(time.Duration(duration) * time.Second)
case "m":
*d = durationField(time.Duration(duration) * time.Minute)
case "h":
*d = durationField(time.Duration(duration) * time.Hour)
case "d":
*d = durationField(time.Duration(duration) * 24 * time.Hour)
}
return nil
}
var optionalEnvFieldPattern = regexp.MustCompile(`(^|.)\$\{([A-Z_]+)\}`)
type optionalEnvField string
func (f *optionalEnvField) UnmarshalYAML(node *yaml.Node) error {
var value string
err := node.Decode(&value)
if err != nil {
return err
}
replaced := optionalEnvFieldPattern.ReplaceAllStringFunc(value, func(match string) string {
if err != nil {
return ""
}
groups := optionalEnvFieldPattern.FindStringSubmatch(match)
if len(groups) != 3 {
return match
}
prefix, key := groups[1], groups[2]
if prefix == `\` {
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 ""
}
return prefix + value
})
if err != nil {
return err
}
*f = optionalEnvField(replaced)
return nil
}
func (f *optionalEnvField) String() string {
return string(*f)
}
type customIconField struct {
URL string
IsFlatIcon bool
// TODO: along with whether the icon is flat, we also need to know
// whether the icon is black or white by default in order to properly
// invert the color based on the theme being light or dark
}
func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
prefix, icon, found := strings.Cut(value, ":")
if !found {
i.URL = url
return nil
}
switch prefix {
case "si":
i.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
i.IsFlatIcon = true
case "di":
// syntax: di:<icon_name>[.svg|.png]
// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
// otherwise, specify the extension of either .svg or .png to use either of the CDN offerings
// any other extension will be interpreted as .svg
basename, ext, found := strings.Cut(icon, ".")
if !found {
ext = "svg"
basename = icon
}
if ext != "svg" && ext != "png" {
ext = "svg"
}
i.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext
default:
i.URL = url
}
return nil
}
func (i *CustomIcon) UnmarshalYAML(node *yaml.Node) error {
var value string
if err := node.Decode(&value); err != nil {
return err
}
return i.FromURL(value)
}

View File

@@ -1,43 +1,91 @@
package glance
import (
"bytes"
"fmt"
"io"
"html/template"
"log"
"maps"
"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 NewConfigFromYml(contents io.Reader) (*Config, error) {
config := NewConfig()
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:"-"`
}
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
}
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])
}
}
}
@@ -46,36 +94,213 @@ func NewConfigFromYml(contents io.Reader) (*Config, error) {
return config, nil
}
func NewConfig() *Config {
config := &Config{}
config.Server.Host = ""
config.Server.Port = 8080
return config
func formatWidgetInitError(err error, w widget) error {
return fmt.Errorf("%s widget: %v", w.GetType(), err)
}
func configIsValid(config *Config) error {
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("reading main YAML file: %w", err)
}
mainFileAbsPath, err := filepath.Abs(mainFilePath)
if err != nil {
return nil, nil, fmt.Errorf("getting 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("reading included file %s: %w", includeFilePath, err)
return nil
}
includes[includeFilePath] = struct{}{}
return []byte(prefixStringLines(indent, string(fileContents)))
})
if includesLastErr != nil {
return nil, nil, includesLastErr
}
return mainFileContents, includes, nil
}
func configFilesWatcher(
mainFilePath string,
lastContents []byte,
lastIncludes map[string]struct{},
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)
}
updateWatchedFiles := func(previousWatched map[string]struct{}, newWatched map[string]struct{}) {
for filePath := range previousWatched {
if _, ok := newWatched[filePath]; !ok {
watcher.Remove(filePath)
}
}
for filePath := range newWatched {
if _, ok := previousWatched[filePath]; !ok {
if err := watcher.Add(filePath); err != nil {
log.Printf(
"Could not add file to watcher, changes to this file will not trigger a reload. path: %s, error: %v",
filePath, err,
)
}
}
}
}
updateWatchedFiles(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 {
onErr(fmt.Errorf("parsing main file contents for comparison: %w", err))
return
}
// TODO: refactor, flaky
currentIncludes[mainFileAbsPath] = struct{}{}
mu.Lock()
defer mu.Unlock()
if !maps.Equal(currentIncludes, lastIncludes) {
updateWatchedFiles(lastIncludes, currentIncludes)
lastIncludes = currentIncludes
}
if !bytes.Equal(lastContents, currentContents) {
lastContents = currentContents
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()
} else if event.Has(fsnotify.Remove) {
func() {
mu.Lock()
defer mu.Unlock()
fileAbsPath, _ := filepath.Abs(event.Name)
delete(lastIncludes, fileAbsPath)
}()
debouncedCallback()
}
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)
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") {
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)
}
}
@@ -83,7 +308,7 @@ func configIsValid(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]++
@@ -92,7 +317,7 @@ func configIsValid(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)
}
}

205
internal/glance/diagnose.go Normal file
View File

@@ -0,0 +1,205 @@
package glance
import (
"context"
"fmt"
"io"
"net"
"net/http"
"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 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
}

62
internal/glance/embed.go Normal file
View File

@@ -0,0 +1,62 @@
package glance
import (
"crypto/md5"
"embed"
"encoding/hex"
"io"
"io/fs"
"log"
"strconv"
"time"
)
//go:embed static
var _staticFS embed.FS
//go:embed templates
var _templateFS embed.FS
var staticFS, _ = fs.Sub(_staticFS, "static")
var templateFS, _ = fs.Sub(_templateFS, "templates")
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)
}
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 {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
file, err := files.Open(path)
if err != nil {
return err
}
if _, err := io.Copy(hash, file); err != nil {
return err
}
return nil
})
if err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil))[:10], nil
}

View File

@@ -5,140 +5,48 @@ import (
"context"
"fmt"
"html/template"
"log/slog"
"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 (
pageTemplate = mustParseTemplate("page.html", "document.html")
pageContentTemplate = mustParseTemplate("page-content.html")
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, error) {
if len(config.Pages) == 0 {
return nil, fmt.Errorf("no pages configured")
}
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 {
@@ -160,9 +68,9 @@ func NewApplication(config *Config) (*Application, error) {
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)
}
}
}
@@ -170,35 +78,75 @@ func NewApplication(config *Config) (*Application, error) {
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, 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)
w.Write([]byte(err.Error()))
@@ -208,25 +156,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)
w.Write([]byte(err.Error()))
@@ -236,74 +181,58 @@ 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) 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()
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, 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)
assetsFS := FileServerWithCache(http.Dir(a.Config.Server.AssetsPath), 2*time.Hour)
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 +241,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
}

View File

@@ -2,45 +2,174 @@ package glance
import (
"fmt"
"io"
"log"
"net/http"
"os"
)
func Main() int {
options, err := ParseCliOptions()
var buildVersion = "dev"
func Main() int {
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:
// remove in v0.10.0
if serveUpdateNoticeIfConfigLocationNotMigrated(options.configPath) {
return 1
}
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("Could not 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("Could not 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, reloading...")
}
config, err := newConfigFromYAML(newContents)
if err != nil {
log.Printf("Config has errors: %v", err)
if !hadValidConfigOnStartup {
close(exitChannel)
}
return
} else if !hadValidConfigOnStartup {
hadValidConfigOnStartup = true
}
app, err := newApplication(config)
if err != nil {
log.Printf("Failed to create application: %v", err)
return
}
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("parsing config: %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("validating config file: %w", err)
}
app, err := newApplication(config)
if err != nil {
return fmt.Errorf("creating application: %w", err)
}
startServer, _ := app.server()
if err := startServer(); err != nil {
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)
// 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 <link> 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) {
w.WriteHeader(http.StatusServiceUnavailable)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(bodyContents))
})
server := http.Server{
Addr: ":8080",
Handler: mux,
}
server.ListenAndServe()
return true
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Binary file not shown.

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.955.49A12 12 0 0 0 0 12.49a12 12 0 0 0 1.832 6.373L11.838 5.928a.187.14 0 0 1 .324 0l10.006 12.935A12 12 0 0 0 24 12.49a12 12 0 0 0-12-12 12 12 0 0 0-.045 0zm.375 6.467l4.416 16.553a12 12 0 0 0 5.137-4.213z"/></svg>

After

Width:  |  Height:  |  Size: 300 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>

After

Width:  |  Height:  |  Size: 802 B

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"/></svg>

After

Width:  |  Height:  |  Size: 553 B

View File

@@ -0,0 +1,653 @@
import { setupPopovers } from './popover.js';
import { setupMasonries } from './masonry.js';
import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js';
async function fetchPageContent(pageData) {
// TODO: handle non 200 status codes/time outs
// TODO: add retries
const response = await fetch(`${pageData.baseURL}/api/pages/${pageData.slug}/content/`);
const content = await response.text();
return content;
}
function setupCarousels() {
const carouselElements = document.getElementsByClassName("carousel-container");
if (carouselElements.length == 0) {
return;
}
for (let i = 0; i < carouselElements.length; i++) {
const carousel = carouselElements[i];
carousel.classList.add("show-right-cutoff");
const itemsContainer = carousel.getElementsByClassName("carousel-items-container")[0];
const determineSideCutoffs = () => {
if (itemsContainer.scrollLeft != 0) {
carousel.classList.add("show-left-cutoff");
} else {
carousel.classList.remove("show-left-cutoff");
}
if (Math.ceil(itemsContainer.scrollLeft) + itemsContainer.clientWidth < itemsContainer.scrollWidth) {
carousel.classList.add("show-right-cutoff");
} else {
carousel.classList.remove("show-right-cutoff");
}
}
const determineSideCutoffsRateLimited = throttledDebounce(determineSideCutoffs, 20, 100);
itemsContainer.addEventListener("scroll", determineSideCutoffsRateLimited);
window.addEventListener("resize", determineSideCutoffsRateLimited);
afterContentReady(determineSideCutoffs);
}
}
const minuteInSeconds = 60;
const hourInSeconds = minuteInSeconds * 60;
const dayInSeconds = hourInSeconds * 24;
const monthInSeconds = dayInSeconds * 30;
const yearInSeconds = monthInSeconds * 12;
function relativeTimeSince(timestamp) {
const delta = Math.round((Date.now() / 1000) - timestamp);
if (delta < minuteInSeconds) {
return "1m";
}
if (delta < hourInSeconds) {
return Math.floor(delta / minuteInSeconds) + "m";
}
if (delta < dayInSeconds) {
return Math.floor(delta / hourInSeconds) + "h";
}
if (delta < monthInSeconds) {
return Math.floor(delta / dayInSeconds) + "d";
}
if (delta < yearInSeconds) {
return Math.floor(delta / monthInSeconds) + "mo";
}
return Math.floor(delta / yearInSeconds) + "y";
}
function updateRelativeTimeForElements(elements)
{
for (let i = 0; i < elements.length; i++)
{
const element = elements[i];
const timestamp = element.dataset.dynamicRelativeTime;
if (timestamp === undefined)
continue
element.textContent = relativeTimeSince(timestamp);
}
}
function setupSearchBoxes() {
const searchWidgets = document.getElementsByClassName("search");
if (searchWidgets.length == 0) {
return;
}
for (let i = 0; i < searchWidgets.length; i++) {
const widget = searchWidgets[i];
const defaultSearchUrl = widget.dataset.defaultSearchUrl;
const newTab = widget.dataset.newTab === "true";
const inputElement = widget.getElementsByClassName("search-input")[0];
const bangElement = widget.getElementsByClassName("search-bang")[0];
const bangs = widget.querySelectorAll(".search-bangs > input");
const bangsMap = {};
const kbdElement = widget.getElementsByTagName("kbd")[0];
let currentBang = null;
let lastQuery = "";
for (let j = 0; j < bangs.length; j++) {
const bang = bangs[j];
bangsMap[bang.dataset.shortcut] = bang;
}
const handleKeyDown = (event) => {
if (event.key == "Escape") {
inputElement.blur();
return;
}
if (event.key == "Enter") {
const input = inputElement.value.trim();
let query;
let searchUrlTemplate;
if (currentBang != null) {
query = input.slice(currentBang.dataset.shortcut.length + 1);
searchUrlTemplate = currentBang.dataset.url;
} else {
query = input;
searchUrlTemplate = defaultSearchUrl;
}
if (query.length == 0 && currentBang == null) {
return;
}
const url = searchUrlTemplate.replace("!QUERY!", encodeURIComponent(query));
if (newTab && !event.ctrlKey || !newTab && event.ctrlKey) {
window.open(url, '_blank').focus();
} else {
window.location.href = url;
}
lastQuery = query;
inputElement.value = "";
return;
}
if (event.key == "ArrowUp" && lastQuery.length > 0) {
inputElement.value = lastQuery;
return;
}
};
const changeCurrentBang = (bang) => {
currentBang = bang;
bangElement.textContent = bang != null ? bang.dataset.title : "";
}
const handleInput = (event) => {
const value = event.target.value.trim();
if (value in bangsMap) {
changeCurrentBang(bangsMap[value]);
return;
}
const words = value.split(" ");
if (words.length >= 2 && words[0] in bangsMap) {
changeCurrentBang(bangsMap[words[0]]);
return;
}
changeCurrentBang(null);
};
inputElement.addEventListener("focus", () => {
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("input", handleInput);
});
inputElement.addEventListener("blur", () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("input", handleInput);
});
document.addEventListener("keydown", (event) => {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
if (event.key != "s") return;
inputElement.focus();
event.preventDefault();
});
kbdElement.addEventListener("mousedown", () => {
requestAnimationFrame(() => inputElement.focus());
});
}
}
function setupDynamicRelativeTime() {
const elements = document.querySelectorAll("[data-dynamic-relative-time]");
const updateInterval = 60 * 1000;
let lastUpdateTime = Date.now();
updateRelativeTimeForElements(elements);
const updateElementsAndTimestamp = () => {
updateRelativeTimeForElements(elements);
lastUpdateTime = Date.now();
};
const scheduleRepeatingUpdate = () => setInterval(updateElementsAndTimestamp, updateInterval);
if (document.hidden === undefined) {
scheduleRepeatingUpdate();
return;
}
let timeout = scheduleRepeatingUpdate();
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
clearTimeout(timeout);
return;
}
const delta = Date.now() - lastUpdateTime;
if (delta >= updateInterval) {
updateElementsAndTimestamp();
timeout = scheduleRepeatingUpdate();
return;
}
timeout = setTimeout(() => {
updateElementsAndTimestamp();
timeout = scheduleRepeatingUpdate();
}, updateInterval - delta);
});
}
function setupGroups() {
const groups = document.getElementsByClassName("widget-type-group");
if (groups.length == 0) {
return;
}
for (let g = 0; g < groups.length; g++) {
const group = groups[g];
const titles = group.getElementsByClassName("widget-header")[0].children;
const tabs = group.getElementsByClassName("widget-group-contents")[0].children;
let current = 0;
for (let t = 0; t < titles.length; t++) {
const title = titles[t];
if (title.dataset.titleUrl !== undefined) {
title.addEventListener("mousedown", (event) => {
if (event.button != 1) {
return;
}
openURLInNewTab(title.dataset.titleUrl, false);
event.preventDefault();
});
}
title.addEventListener("click", () => {
if (t == current) {
if (title.dataset.titleUrl !== undefined) {
openURLInNewTab(title.dataset.titleUrl);
}
return;
}
for (let i = 0; i < titles.length; i++) {
titles[i].classList.remove("widget-group-title-current");
tabs[i].classList.remove("widget-group-content-current");
}
if (current < t) {
tabs[t].dataset.direction = "right";
} else {
tabs[t].dataset.direction = "left";
}
current = t;
title.classList.add("widget-group-title-current");
tabs[t].classList.add("widget-group-content-current");
});
}
}
}
function setupLazyImages() {
const images = document.querySelectorAll("img[loading=lazy]");
if (images.length == 0) {
return;
}
function imageFinishedTransition(image) {
image.classList.add("finished-transition");
}
afterContentReady(() => {
setTimeout(() => {
for (let i = 0; i < images.length; i++) {
const image = images[i];
if (image.complete) {
image.classList.add("cached");
setTimeout(() => imageFinishedTransition(image), 1);
} else {
// TODO: also handle error event
image.addEventListener("load", () => {
image.classList.add("loaded");
setTimeout(() => imageFinishedTransition(image), 400);
});
}
}
}, 1);
});
}
function attachExpandToggleButton(collapsibleContainer) {
const showMoreText = "Show more";
const showLessText = "Show less";
let expanded = false;
const button = document.createElement("button");
const icon = document.createElement("span");
icon.classList.add("expand-toggle-button-icon");
const textNode = document.createTextNode(showMoreText);
button.classList.add("expand-toggle-button");
button.append(textNode, icon);
button.addEventListener("click", () => {
expanded = !expanded;
if (expanded) {
collapsibleContainer.classList.add("container-expanded");
button.classList.add("container-expanded");
textNode.nodeValue = showLessText;
return;
}
const topBefore = button.getClientRects()[0].top;
collapsibleContainer.classList.remove("container-expanded");
button.classList.remove("container-expanded");
textNode.nodeValue = showMoreText;
const topAfter = button.getClientRects()[0].top;
if (topAfter > 0)
return;
window.scrollBy({
top: topAfter - topBefore,
behavior: "instant"
});
});
collapsibleContainer.after(button);
return button;
};
function setupCollapsibleLists() {
const collapsibleLists = document.querySelectorAll(".list.collapsible-container");
if (collapsibleLists.length == 0) {
return;
}
for (let i = 0; i < collapsibleLists.length; i++) {
const list = collapsibleLists[i];
if (list.dataset.collapseAfter === undefined) {
continue;
}
const collapseAfter = parseInt(list.dataset.collapseAfter);
if (collapseAfter == -1) {
continue;
}
if (list.children.length <= collapseAfter) {
continue;
}
attachExpandToggleButton(list);
for (let c = collapseAfter; c < list.children.length; c++) {
const child = list.children[c];
child.classList.add("collapsible-item");
child.style.animationDelay = ((c - collapseAfter) * 20).toString() + "ms";
}
}
}
function setupCollapsibleGrids() {
const collapsibleGridElements = document.querySelectorAll(".cards-grid.collapsible-container");
if (collapsibleGridElements.length == 0) {
return;
}
for (let i = 0; i < collapsibleGridElements.length; i++) {
const gridElement = collapsibleGridElements[i];
if (gridElement.dataset.collapseAfterRows === undefined) {
continue;
}
const collapseAfterRows = parseInt(gridElement.dataset.collapseAfterRows);
if (collapseAfterRows == -1) {
continue;
}
const getCardsPerRow = () => {
return parseInt(getComputedStyle(gridElement).getPropertyValue('--cards-per-row'));
};
const button = attachExpandToggleButton(gridElement);
let cardsPerRow;
const resolveCollapsibleItems = () => {
const hideItemsAfterIndex = cardsPerRow * collapseAfterRows;
if (hideItemsAfterIndex >= gridElement.children.length) {
button.style.display = "none";
} else {
button.style.removeProperty("display");
}
let row = 0;
for (let i = 0; i < gridElement.children.length; i++) {
const child = gridElement.children[i];
if (i >= hideItemsAfterIndex) {
child.classList.add("collapsible-item");
child.style.animationDelay = (row * 40).toString() + "ms";
if (i % cardsPerRow + 1 == cardsPerRow) {
row++;
}
} else {
child.classList.remove("collapsible-item");
child.style.removeProperty("animation-delay");
}
}
};
const observer = new ResizeObserver(() => {
if (!isElementVisible(gridElement)) {
return;
}
const newCardsPerRow = getCardsPerRow();
if (cardsPerRow == newCardsPerRow) {
return;
}
cardsPerRow = newCardsPerRow;
resolveCollapsibleItems();
});
afterContentReady(() => observer.observe(gridElement));
}
}
const contentReadyCallbacks = [];
function afterContentReady(callback) {
contentReadyCallbacks.push(callback);
}
const weekDayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
function makeSettableTimeElement(element, hourFormat) {
const fragment = document.createDocumentFragment();
const hour = document.createElement('span');
const minute = document.createElement('span');
const amPm = document.createElement('span');
fragment.append(hour, document.createTextNode(':'), minute);
if (hourFormat == '12h') {
fragment.append(document.createTextNode(' '), amPm);
}
element.append(fragment);
return (date) => {
const hours = date.getHours();
if (hourFormat == '12h') {
amPm.textContent = hours < 12 ? 'AM' : 'PM';
hour.textContent = hours % 12 || 12;
} else {
hour.textContent = hours < 10 ? '0' + hours : hours;
}
const minutes = date.getMinutes();
minute.textContent = minutes < 10 ? '0' + minutes : minutes;
};
};
function timeInZone(now, zone) {
let timeInZone;
try {
timeInZone = new Date(now.toLocaleString('en-US', { timeZone: zone }));
} catch (e) {
// TODO: indicate to the user that this is an invalid timezone
console.error(e);
timeInZone = now
}
const diffInMinutes = Math.round((timeInZone.getTime() - now.getTime()) / 1000 / 60);
return { time: timeInZone, diffInMinutes: diffInMinutes };
}
function zoneDiffText(diffInMinutes) {
if (diffInMinutes == 0) {
return "";
}
const sign = diffInMinutes < 0 ? "-" : "+";
const signText = diffInMinutes < 0 ? "behind" : "ahead";
diffInMinutes = Math.abs(diffInMinutes);
const hours = Math.floor(diffInMinutes / 60);
const minutes = diffInMinutes % 60;
const hourSuffix = hours == 1 ? "" : "s";
if (minutes == 0) {
return { text: `${sign}${hours}h`, title: `${hours} hour${hourSuffix} ${signText}` };
}
if (hours == 0) {
return { text: `${sign}${minutes}m`, title: `${minutes} minutes ${signText}` };
}
return { text: `${sign}${hours}h~`, title: `${hours} hour${hourSuffix} and ${minutes} minutes ${signText}` };
}
function setupClocks() {
const clocks = document.getElementsByClassName('clock');
if (clocks.length == 0) {
return;
}
const updateCallbacks = [];
for (var i = 0; i < clocks.length; i++) {
const clock = clocks[i];
const hourFormat = clock.dataset.hourFormat;
const localTimeContainer = clock.querySelector('[data-local-time]');
const localDateElement = localTimeContainer.querySelector('[data-date]');
const localWeekdayElement = localTimeContainer.querySelector('[data-weekday]');
const localYearElement = localTimeContainer.querySelector('[data-year]');
const timeZoneContainers = clock.querySelectorAll('[data-time-in-zone]');
const setLocalTime = makeSettableTimeElement(
localTimeContainer.querySelector('[data-time]'),
hourFormat
);
updateCallbacks.push((now) => {
setLocalTime(now);
localDateElement.textContent = now.getDate() + ' ' + monthNames[now.getMonth()];
localWeekdayElement.textContent = weekDayNames[now.getDay()];
localYearElement.textContent = now.getFullYear();
});
for (var z = 0; z < timeZoneContainers.length; z++) {
const timeZoneContainer = timeZoneContainers[z];
const diffElement = timeZoneContainer.querySelector('[data-time-diff]');
const setZoneTime = makeSettableTimeElement(
timeZoneContainer.querySelector('[data-time]'),
hourFormat
);
updateCallbacks.push((now) => {
const { time, diffInMinutes } = timeInZone(now, timeZoneContainer.dataset.timeInZone);
setZoneTime(time);
const { text, title } = zoneDiffText(diffInMinutes);
diffElement.textContent = text;
diffElement.title = title;
});
}
}
const updateClocks = () => {
const now = new Date();
for (var i = 0; i < updateCallbacks.length; i++)
updateCallbacks[i](now);
setTimeout(updateClocks, (60 - now.getSeconds()) * 1000);
};
updateClocks();
}
async function setupPage() {
const pageElement = document.getElementById("page");
const pageContentElement = document.getElementById("page-content");
const pageContent = await fetchPageContent(pageData);
pageContentElement.innerHTML = pageContent;
try {
setupPopovers();
setupClocks()
setupCarousels();
setupSearchBoxes();
setupCollapsibleLists();
setupCollapsibleGrids();
setupGroups();
setupMasonries();
setupDynamicRelativeTime();
setupLazyImages();
} finally {
pageElement.classList.add("content-ready");
for (let i = 0; i < contentReadyCallbacks.length; i++) {
contentReadyCallbacks[i]();
}
setTimeout(() => {
document.body.classList.add("page-columns-transitioned");
}, 300);
}
}
setupPage();

View File

@@ -0,0 +1,53 @@
import { clamp } from "./utils.js";
export function setupMasonries() {
const masonryContainers = document.getElementsByClassName("masonry");
for (let i = 0; i < masonryContainers.length; i++) {
const container = masonryContainers[i];
const options = {
minColumnWidth: container.dataset.minColumnWidth || 330,
maxColumns: container.dataset.maxColumns || 6,
};
const items = Array.from(container.children);
let previousColumnsCount = 0;
const render = function() {
const columnsCount = clamp(
Math.floor(container.offsetWidth / options.minColumnWidth),
1,
Math.min(options.maxColumns, items.length)
);
if (columnsCount === previousColumnsCount) {
return;
} else {
container.textContent = "";
previousColumnsCount = columnsCount;
}
const columnsFragment = document.createDocumentFragment();
for (let i = 0; i < columnsCount; i++) {
const column = document.createElement("div");
column.className = "masonry-column";
columnsFragment.append(column);
}
// poor man's masonry
// TODO: add an option that allows placing items in the
// shortest column instead of iterating the columns in order
for (let i = 0; i < items.length; i++) {
columnsFragment.children[i % columnsCount].appendChild(items[i]);
}
container.append(columnsFragment);
};
const observer = new ResizeObserver(() => requestAnimationFrame(render));
observer.observe(container);
}
}

View File

@@ -0,0 +1,185 @@
const defaultShowDelayMs = 200;
const defaultHideDelayMs = 500;
const defaultMaxWidth = "300px";
const defaultDistanceFromTarget = "0px"
const htmlContentSelector = "[data-popover-html]";
let activeTarget = null;
let pendingTarget = null;
let cleanupOnHidePopover = null;
let togglePopoverTimeout = null;
const containerElement = document.createElement("div");
const containerComputedStyle = getComputedStyle(containerElement);
containerElement.addEventListener("mouseenter", clearTogglePopoverTimeout);
containerElement.addEventListener("mouseleave", handleMouseLeave);
containerElement.classList.add("popover-container");
const frameElement = document.createElement("div");
frameElement.classList.add("popover-frame");
const contentElement = document.createElement("div");
contentElement.classList.add("popover-content");
frameElement.append(contentElement);
containerElement.append(frameElement);
document.body.append(containerElement);
const observer = new ResizeObserver(repositionContainer);
function handleMouseEnter(event) {
clearTogglePopoverTimeout();
const target = event.target;
pendingTarget = target;
const showDelay = target.dataset.popoverShowDelay || defaultShowDelayMs;
if (activeTarget !== null) {
if (activeTarget !== target) {
hidePopover();
requestAnimationFrame(() => requestAnimationFrame(showPopover));
}
return;
}
togglePopoverTimeout = setTimeout(showPopover, showDelay);
}
function handleMouseLeave(event) {
clearTogglePopoverTimeout();
const target = activeTarget || event.target;
togglePopoverTimeout = setTimeout(hidePopover, target.dataset.popoverHideDelay || defaultHideDelayMs);
}
function clearTogglePopoverTimeout() {
clearTimeout(togglePopoverTimeout);
}
function showPopover() {
if (pendingTarget === null) return;
activeTarget = pendingTarget;
pendingTarget = null;
const popoverType = activeTarget.dataset.popoverType;
if (popoverType === "text") {
const text = activeTarget.dataset.popoverText;
if (text === undefined || text === "") return;
contentElement.textContent = text;
} else if (popoverType === "html") {
const htmlContent = activeTarget.querySelector(htmlContentSelector);
if (htmlContent === null) return;
/**
* The reason for all of the below shenanigans is that I want to preserve
* all attached event listeners of the original HTML content. This is so I don't have to
* re-setup events for things like lazy images, they'd just work as expected.
*/
const placeholder = document.createComment("");
htmlContent.replaceWith(placeholder);
contentElement.replaceChildren(htmlContent);
htmlContent.removeAttribute("data-popover-html");
cleanupOnHidePopover = () => {
htmlContent.setAttribute("data-popover-html", "");
placeholder.replaceWith(htmlContent);
placeholder.remove();
};
} else {
return;
}
const contentMaxWidth = activeTarget.dataset.popoverMaxWidth || defaultMaxWidth;
if (activeTarget.dataset.popoverTextAlign !== undefined) {
contentElement.style.textAlign = activeTarget.dataset.popoverTextAlign;
} else {
contentElement.style.removeProperty("text-align");
}
contentElement.style.maxWidth = contentMaxWidth;
containerElement.style.display = "block";
activeTarget.classList.add("popover-active");
document.addEventListener("keydown", handleHidePopoverOnEscape);
window.addEventListener("resize", repositionContainer);
observer.observe(containerElement);
}
function repositionContainer() {
const targetBounds = activeTarget.dataset.popoverAnchor !== undefined
? activeTarget.querySelector(activeTarget.dataset.popoverAnchor).getBoundingClientRect()
: activeTarget.getBoundingClientRect();
const containerBounds = containerElement.getBoundingClientRect();
const containerInlinePadding = parseInt(containerComputedStyle.getPropertyValue("padding-inline"));
const targetBoundsWidthOffset = targetBounds.width * (activeTarget.dataset.popoverTargetOffset || 0.5);
const position = activeTarget.dataset.popoverPosition || "below";
const popoverOffest = activeTarget.dataset.popoverOffset || 0.5;
const left = Math.round(targetBounds.left + targetBoundsWidthOffset - (containerBounds.width * popoverOffest));
if (left < 0) {
containerElement.style.left = 0;
containerElement.style.removeProperty("right");
containerElement.style.setProperty("--triangle-offset", targetBounds.left - containerInlinePadding + targetBoundsWidthOffset + "px");
} else if (left + containerBounds.width > window.innerWidth) {
containerElement.style.removeProperty("left");
containerElement.style.right = 0;
containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + "px");
} else {
containerElement.style.removeProperty("right");
containerElement.style.left = left + "px";
containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + "px");
}
const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget;
const topWhenAbove = targetBounds.top + window.scrollY - containerBounds.height;
const topWhenBelow = targetBounds.top + window.scrollY + targetBounds.height;
if (
position === "above" && topWhenAbove > window.scrollY ||
(position === "below" && topWhenBelow + containerBounds.height > window.scrollY + window.innerHeight)
) {
containerElement.classList.add("position-above");
frameElement.style.removeProperty("margin-top");
frameElement.style.marginBottom = distanceFromTarget;
containerElement.style.top = topWhenAbove + "px";
} else {
containerElement.classList.remove("position-above");
frameElement.style.removeProperty("margin-bottom");
frameElement.style.marginTop = distanceFromTarget;
containerElement.style.top = topWhenBelow + "px";
}
}
function hidePopover() {
if (activeTarget === null) return;
activeTarget.classList.remove("popover-active");
containerElement.style.display = "none";
document.removeEventListener("keydown", handleHidePopoverOnEscape);
window.removeEventListener("resize", repositionContainer);
observer.unobserve(containerElement);
if (cleanupOnHidePopover !== null) {
cleanupOnHidePopover();
cleanupOnHidePopover = null;
}
activeTarget = null;
}
function handleHidePopoverOnEscape(event) {
if (event.key === "Escape") {
hidePopover();
}
}
export function setupPopovers() {
const targets = document.querySelectorAll("[data-popover-type]");
for (let i = 0; i < targets.length; i++) {
const target = targets[i];
target.addEventListener("mouseenter", handleMouseEnter);
target.addEventListener("mouseleave", handleMouseLeave);
}
}

View File

@@ -0,0 +1,38 @@
export function throttledDebounce(callback, maxDebounceTimes, debounceDelay) {
let debounceTimeout;
let timesDebounced = 0;
return function () {
if (timesDebounced == maxDebounceTimes) {
clearTimeout(debounceTimeout);
timesDebounced = 0;
callback();
return;
}
clearTimeout(debounceTimeout);
timesDebounced++;
debounceTimeout = setTimeout(() => {
timesDebounced = 0;
callback();
}, debounceDelay);
};
};
export function isElementVisible(element) {
return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}
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');
if (focus && newWindow != null) newWindow.focus();
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"name": "Glance",
"display": "standalone",
"background_color": "#151519",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "app-icon.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View File

@@ -0,0 +1,56 @@
package glance
import (
"fmt"
"html/template"
"math"
"strconv"
"time"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
var intl = message.NewPrinter(language.English)
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
}
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)
}

View File

@@ -0,0 +1,23 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="dynamic-columns list-gap-24 list-with-separator">
{{ range .Groups }}
<div class="bookmarks-group"{{ if .Color }} style="--bookmarks-group-color: {{ .Color.AsCSSValue }}"{{ end }}>
{{ if ne .Title "" }}<div class="bookmarks-group-title size-h3 margin-bottom-3">{{ .Title }}</div>{{ end }}
<ul class="list list-gap-2">
{{ range .Links }}
<li class="flex items-center gap-10">
{{ if ne "" .Icon.URL }}
<div class="bookmarks-icon-container">
<img class="bookmarks-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
</div>
{{ end }}
<a href="{{ .URL }}" class="bookmarks-link {{ if .HideArrow }}bookmarks-link-no-arrow {{ end }}color-highlight size-h4" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
</li>
{{ end }}
</ul>
</div>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,34 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="widget-small-content-bounds">
<div class="flex justify-between items-center">
<div class="color-highlight size-h1">{{ .Calendar.CurrentMonthName }}</div>
<ul class="list-horizontal-text color-highlight size-h4">
<li>Week {{ .Calendar.CurrentWeekNumber }}</li>
<li>{{ .Calendar.CurrentYear }}</li>
</ul>
</div>
<div class="flex flex-wrap size-h6 margin-top-10 color-subdue">
{{ if .StartSunday }}
<div class="calendar-day">Su</div>
{{ end }}
<div class="calendar-day">Mo</div>
<div class="calendar-day">Tu</div>
<div class="calendar-day">We</div>
<div class="calendar-day">Th</div>
<div class="calendar-day">Fr</div>
<div class="calendar-day">Sa</div>
{{ if not .StartSunday }}
<div class="calendar-day">Su</div>
{{ end }}
</div>
<div class="flex flex-wrap">
{{ range .Calendar.Days }}
<div class="calendar-day{{ if eq . $.Calendar.CurrentDay }} calendar-day-today{{ end }}">{{ . }}</div>
{{ end }}
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,17 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .ChangeDetections }}
<li>
<a class="size-h4 block text-truncate color-highlight" href="{{ .URL }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .LastChanged }}></li>
<li class="shrink min-width-0"><a class="visited-indicator" href="{{ .DiffURL }}" target="_blank" rel="noreferrer">diff:{{ .PreviousHash }}</a></li>
</ul>
</li>
{{ else }}
<li>No watches configured</li>
{{ end}}
</ul>
{{ end }}

View File

@@ -0,0 +1,30 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="clock" data-hour-format="{{ .HourFormat }}">
<div class="flex justify-between items-center" data-local-time>
<div>
<div class="color-highlight size-h1" data-date></div>
<div data-year></div>
</div>
<div class="text-right">
<div class="clock-time size-h1" data-time></div>
<div data-weekday></div>
</div>
</div>
{{ if gt (len .Timezones) 0 }}
<hr class="margin-block-10">
<ul class="list list-gap-4">
{{ range .Timezones }}
<li class="flex items-center gap-15" data-time-in-zone="{{ .Timezone }}">
<div class="grow min-width-0">
<div class="text-truncate">{{ if ne .Label "" }}{{ .Label }}{{ else }}{{ .Timezone }}{{ end }}</div>
</div>
<div class="color-subdue" data-time-diff></div>
<div class="size-h4 clock-time shrink-0 text-right" data-time></div>
</li>
{{ end }}
</ul>
{{ end }}
</div>
{{ end }}

View File

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

View File

@@ -0,0 +1,85 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="widget-small-content-bounds dns-stats">
<div class="flex text-center justify-between dns-stats-totals">
<div>
<div class="color-highlight size-h3">{{ .Stats.TotalQueries | formatNumber }}</div>
<div class="size-h6">QUERIES</div>
</div>
<div>
<div class="color-highlight size-h3">{{ .Stats.BlockedPercent }}%</div>
<div class="size-h6">BLOCKED</div>
</div>
{{ if gt .Stats.ResponseTime 0 }}
<div>
<div class="color-highlight size-h3">{{ .Stats.ResponseTime | formatNumber }}ms</div>
<div class="size-h6">LATENCY</div>
</div>
{{ else }}
<div class="cursor-help" data-popover-type="text" data-popover-text="Total number of blocked domains from all adlists" data-popover-max-width="200px" data-popover-text-align="center">
<div class="color-highlight size-h3">{{ .Stats.DomainsBlocked | formatViewerCount }}</div>
<div class="size-h6">DOMAINS</div>
</div>
{{ end }}
</div>
<div class="dns-stats-graph margin-top-15">
<div class="dns-stats-graph-gridlines-container">
<svg class="dns-stats-graph-gridlines" shape-rendering="crispEdges" viewBox="0 0 1 100" preserveAspectRatio="none">
<g stroke="var(--color-graph-gridlines)" stroke-width="1">
<line x1="0" y1="1" x2="1" y2="1" vector-effect="non-scaling-stroke" />
<line x1="0" y1="25" x2="1" y2="25" vector-effect="non-scaling-stroke" />
<line x1="0" y1="50" x2="1" y2="50" vector-effect="non-scaling-stroke" />
<line x1="0" y1="75" x2="1" y2="75" vector-effect="non-scaling-stroke" />
<line x1="0" y1="99" x2="1" y2="99" vector-effect="non-scaling-stroke" stroke="var(--color-progress-bar-border)"/>
</g>
</svg>
</div>
<div class="dns-stats-graph-columns">
{{ range $i, $column := .Stats.Series }}
<div class="dns-stats-graph-column" data-popover-type="html" data-popover-position="above" data-popover-show-delay="500">
<div data-popover-html>
<div class="flex text-center justify-between gap-25">
<div>
<div class="color-highlight size-h3">{{ $column.Queries | formatNumber }}</div>
<div class="size-h6">QUERIES</div>
</div>
<div>
<div class="color-highlight size-h3">{{ $column.PercentBlocked }}%</div>
<div class="size-h6">BLOCKED</div>
</div>
</div>
</div>
{{ if gt $column.PercentTotal 0}}
<div class="dns-stats-graph-bar" style="--bar-height: {{ $column.PercentTotal }}">
{{ if ne $column.Queries $column.Blocked }}
<div class="queries"></div>
{{ end }}
{{ if or (gt $column.Blocked 0) (and (lt $column.PercentTotal 15) (lt $column.PercentBlocked 10)) }}
<div class="blocked" style="flex-basis: {{ $column.PercentBlocked }}%"></div>
{{ end }}
</div>
{{ end }}
<div class="dns-stats-graph-time">{{ index $.TimeLabels $i }}</div>
</div>
{{ end }}
</div>
</div>
{{ if .Stats.TopBlockedDomains }}
<details class="details margin-top-40">
<summary class="summary">Top blocked domains</summary>
<ul class="list list-gap-4 list-with-transition size-h5">
{{ range .Stats.TopBlockedDomains }}
<li class="flex justify-between">
<div class="text-truncate rtl">{{ .Domain }}</div>
<div class="text-right" style="width: 4rem;"><span class="color-highlight">{{ .PercentBlocked }}</span>%</div>
</li>
{{ end }}
</ul>
</details>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,44 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="dynamic-columns list-gap-20 list-with-separator">
{{ range .Containers }}
<div class="docker-container flex items-center gap-15">
{{ template "container" . }}
</div>
{{ end }}
</div>
{{ end }}
{{ define "container" }}
{{ if .Icon.URL }}
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ end }}
<div class="min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
<div class="text-truncate" title="{{ .Image }}">{{ .Image }}</div>
<ul class="size-h6 color-subdue list-horizontal-text">
<li>{{ .StatusShort }}</li>
<li>{{ .StatusFull }}</li>
</ul>
</div>
{{ if eq .StatusStyle "success" }}
<div class="docker-container-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
</svg>
</div>
{{ else if eq .StatusStyle "warning" }}
<div class="docker-container-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
</svg>
</div>
{{ else }}
<div class="docker-container-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
</svg>
</div>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html {{ block "document-root-attrs" . }}{{ end }} lang="en" id="top">
<head>
{{ block "document-head-before" . }}{{ end }}
<title>{{ block "document-title" . }}{{ end }}</title>
<meta charset="UTF-8">
<meta name="color-scheme" content="dark">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Glance">
<meta name="theme-color" content="{{ if ne nil .App.Config.Theme.BackgroundColor }}{{ .App.Config.Theme.BackgroundColor }}{{ else }}hsl(240, 8%, 9%){{ end }}">
<link rel="apple-touch-icon" sizes="512x512" href="{{ .App.AssetPath "app-icon.png" }}">
<link rel="manifest" href="{{ .App.AssetPath "manifest.json" }}">
<link rel="icon" type="image/png" href="{{ .App.Config.Branding.FaviconURL }}" />
<link rel="stylesheet" href="{{ .App.AssetPath "main.css" }}">
<script type="module" src="{{ .App.AssetPath "js/main.js" }}"></script>
{{ block "document-head-after" . }}{{ end }}
</head>
<body>
{{ template "document-body" . }}
</body>
</html>

View File

@@ -0,0 +1,5 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ .Extension.Content }}
{{ end }}

View File

@@ -0,0 +1,49 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Posts }}
<li>
<div class="flex gap-10 row-reverse-on-mobile thumbnail-parent">
{{ if $.ShowThumbnails }}
{{ if .IsCrosspost }}
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
{{ else if ne .ThumbnailUrl "" }}
<img class="forum-post-list-thumbnail thumbnail" src="{{ .ThumbnailUrl }}" alt="" loading="lazy">
{{ else if ne "" .TargetUrl }}
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
{{ else }}
<svg class="forum-post-list-thumbnail hide-on-mobile" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-9 -8 40 40" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" />
</svg>
{{ end }}
{{ end }}
<div class="grow min-width-0">
<a href="{{ .DiscussionUrl }}" class="size-title-dynamic color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
{{ if gt (len .Tags) 0 }}
<div class="inline-block forum-post-tags-container">
<ul class="attachments">
{{ range .Tags }}
<li>{{ . }}</li>
{{ end }}
</ul>
</div>
{{ end }}
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li>
<li>{{ .CommentCount | formatNumber }} comments</li>
{{ if ne "" .TargetUrl }}
<li class="min-width-0"><a class="visited-indicator text-truncate block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a></li>
{{ end }}
</ul>
</div>
</div>
</li>
{{ end }}
</ul>
{{ end }}

View File

@@ -0,0 +1,20 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="widget-group-header">
<div class="widget-header gap-20">
{{ range $i, $widget := .Widgets }}
<button class="widget-group-title{{ if eq $i 0 }} widget-group-title-current{{ end }}"{{ if ne "" .TitleURL }} data-title-url="{{ .TitleURL }}"{{ end }}>{{ $widget.Title }}</button>
{{ end }}
</div>
</div>
<div class="widget-group-contents">
{{ range $i, $widget := .Widgets }}
<div class="widget-group-content{{ if eq $i 0 }} widget-group-content-current{{ end }}">{{ .Render }}</div>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,7 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<iframe src="{{ .Source }}" width="100%" height="{{ .Height }}px" frameborder="0"></iframe>
{{ end }}

View File

@@ -0,0 +1,25 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="dynamic-columns list-gap-20 list-with-separator">
{{ range .Markets }}
<div class="flex items-center gap-15">
<div class="min-width-0">
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
<div title="{{ .Name }}" class="text-truncate">{{ .Name }}</div>
</div>
<a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="market-chart shrink-0" viewBox="0 0 100 50">
<polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
</svg>
</a>
<div class="market-values shrink-0">
<div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
<div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
</div>
</div>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,39 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ if not (and .ShowFailingOnly (not .HasFailing)) }}
<ul class="dynamic-columns list-gap-8">
{{ range .Sites }}
{{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }}{{ continue }}{{ end }}
<div class="flex items-center gap-12">
{{ template "site" . }}
</div>
{{ end }}
</ul>
{{ else }}
<div class="flex items-center justify-center gap-10 padding-block-5">
<p>All sites are online</p>
<svg class="shrink-0" style="width: 1.7rem;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
</svg>
</div>
{{ end }}
{{ end }}
{{ define "site" }}
<a class="size-title-dynamic color-highlight text-truncate block grow" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
{{ if not .Status.TimedOut }}<div>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</div>{{ end }}
{{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon-compact" title="{{ .Status.Code }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
</svg>
</div>
{{ else }}
<div class="monitor-site-status-icon-compact" title="{{ if .Status.Error }}{{ .Status.Error }}{{ else }}{{ .Status.Code }}{{ end }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
</svg>
</div>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,53 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{ if not (and .ShowFailingOnly (not .HasFailing)) }}
<ul class="dynamic-columns list-gap-20 list-with-separator">
{{ range .Sites }}
{{ if and $.ShowFailingOnly (eq .StatusStyle "ok" ) }} {{ continue }} {{ end }}
<div class="monitor-site flex items-center gap-15">
{{ template "site" . }}
</div>
{{ end }}
</ul>
{{ else }}
<div class="flex items-center justify-center gap-10 padding-block-5">
<p>All sites are online</p>
<svg class="shrink-0" style="width: 1.7rem;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
</svg>
</div>
{{ end }}
{{ end }}
{{ define "site" }}
{{ if .Icon.URL }}
<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
{{ end }}
<div class="min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text">
{{ if not .Status.Error }}
<li title="{{ .Status.Code }}">{{ .StatusText }}</li>
<li>{{ .Status.ResponseTime.Milliseconds | formatNumber }}ms</li>
{{ else if .Status.TimedOut }}
<li class="color-negative">Timed Out</li>
{{ else }}
<li class="color-negative" title="{{ .Status.Error }}">ERROR</li>
{{ end }}
</ul>
</div>
{{ if eq .StatusStyle "ok" }}
<div class="monitor-site-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
<path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
</svg>
</div>
{{ else }}
<div class="monitor-site-status-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
</svg>
</div>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,13 @@
{{ if .Page.ShowMobileHeader }}
<div class="mobile-reachability-header">{{ .Page.Title }}</div>
{{ end }}
<div class="page-columns">
{{ range .Page.Columns }}
<div class="page-column page-column-{{ .Size }}">
{{ range .Widgets }}
{{ .Render }}
{{ end }}
</div>
{{ end }}
</div>

View File

@@ -0,0 +1,83 @@
{{ template "document.html" . }}
{{ define "document-title" }}{{ .Page.Title }}{{ end }}
{{ define "document-head-before" }}
<script>
const pageData = {
slug: "{{ .Page.Slug }}",
baseURL: "{{ .App.Config.Server.BaseURL }}",
};
</script>
{{ end }}
{{ 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" }}
{{ .App.ParsedThemeStyle }}
{{ if ne "" .App.Config.Theme.CustomCSSFile }}
<link rel="stylesheet" href="{{ .App.Config.Theme.CustomCSSFile }}?v={{ .App.Config.Server.StartedAt.Unix }}">
{{ end }}
{{ if ne "" .App.Config.Document.Head }}{{ .App.Config.Document.Head }}{{ end }}
{{ end }}
{{ define "navigation-links" }}
{{ range .App.Config.Pages }}
<a href="{{ $.App.Config.Server.BaseURL }}/{{ .Slug }}" class="nav-item{{ if eq .Slug $.Page.Slug }} nav-item-current{{ end }}">{{ .Title }}</a>
{{ end }}
{{ end }}
{{ define "document-body" }}
<div class="flex flex-column height-100">
{{ if not .Page.HideDesktopNavigation }}
<div class="header-container content-bounds">
<div class="header flex padding-inline-widget widget-content-frame">
<!-- TODO: Replace G with actual logo, first need an actual logo -->
<div class="logo">{{ if ne "" .App.Config.Branding.LogoURL }}<img src="{{ .App.Config.Branding.LogoURL }}" alt="">{{ else if ne "" .App.Config.Branding.LogoText }}{{ .App.Config.Branding.LogoText }}{{ else }}G{{ end }}</div>
<div class="nav flex grow">
{{ template "navigation-links" . }}
</div>
</div>
</div>
{{ end }}
<div class="mobile-navigation">
<div class="mobile-navigation-icons">
<a class="mobile-navigation-label" href="#top"></a>
{{ range $i, $column := .Page.Columns }}
<label class="mobile-navigation-label"><input type="radio" class="mobile-navigation-input" name="column" value="{{ $i }}" autocomplete="off"{{ if eq $i $.Page.PrimaryColumnIndex }} checked{{ end }}><div class="mobile-navigation-pill"></div></label>
{{ end }}
<label class="mobile-navigation-label"><input type="checkbox" class="mobile-navigation-page-links-input" autocomplete="on"{{ if .Page.ExpandMobilePageNavigation }} checked{{ end }}><div class="hamburger-icon"></div></label>
</div>
<div class="mobile-navigation-page-links">
{{ template "navigation-links" . }}
</div>
</div>
<div class="content-bounds grow">
<div class="page" id="page">
<div class="page-content" id="page-content"></div>
<div class="page-loading-container">
<!-- TODO: add a bigger/better loading indicator -->
<div class="loading-icon"></div>
</div>
</div>
</div>
{{ if not .App.Config.Branding.HideFooter }}
<div class="footer flex items-center flex-column">
{{ if eq "" .App.Config.Branding.CustomFooter }}
<div>
<a class="size-h3" href="https://github.com/glanceapp/glance" target="_blank" rel="noreferrer">Glance</a> {{ if ne "dev" .App.Version }}<a class="visited-indicator" title="Release notes" href="https://github.com/glanceapp/glance/releases/tag/{{ .App.Version }}" target="_blank" rel="noreferrer">{{ .App.Version }}</a>{{ else }}({{ .App.Version }}){{ end }}
</div>
{{ else }}
{{ .App.Config.Branding.CustomFooter }}
{{ end }}
</div>
{{ end }}
<div class="mobile-navigation-offset"></div>
</div>
{{ end }}

View File

@@ -0,0 +1,31 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container">
{{ range .Posts }}
<div class="card widget-content-frame relative">
{{ if ne "" .ThumbnailUrl }}
<div class="reddit-card-thumbnail-container">
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
</div>
{{ end }}
<div class="padding-widget flex flex-column grow relative">
{{ if ne "" .TargetUrl }}
<a class="color-highlight size-h5 text-truncate visited-indicator" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
{{ else }}
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
{{ end }}
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text margin-top-7">
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li>
</ul>
</div>
</div>
{{ end }}
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,29 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="cards-vertical">
{{ range .Posts }}
<div class="widget-content-frame relative">
{{ if ne "" .ThumbnailUrl }}
<div class="reddit-card-thumbnail-container">
<img class="reddit-card-thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
</div>
{{ end }}
<div class="padding-widget relative">
{{ if ne "" .TargetUrl }}
<a class="color-highlight size-h5 text-truncate visited-indicator block" href="{{ .TargetUrl }}" target="_blank" rel="noreferrer">{{ .TargetUrlDomain }}</a>
{{ else }}
<div class="color-highlight size-h5 text-truncate">/r/{{ $.Subreddit }}</div>
{{ end }}
<a href="{{ .DiscussionUrl }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-7" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text margin-top-7">
<li {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li>{{ .Score | formatNumber }} points</li>
</ul>
</div>
</div>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,23 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-10 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Releases }}
<li>
<div class="flex items-center gap-10">
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="{{ .NotesUrl }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if $.ShowSourceIcon }}
<img class="flat-icon release-source-icon" src="{{ .SourceIconURL }}" alt="" loading="lazy">
{{ end }}
</div>
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .TimeReleased }}></li>
<li>{{ .Version }}</li>
{{ if gt .Downvotes 3 }}
<li>{{ .Downvotes | formatNumber }} ⚠</li>
{{ end }}
</ul>
</li>
{{ end }}
</ul>
{{ end }}

View File

@@ -0,0 +1,61 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<a class="size-h4 color-highlight" href="https://github.com/{{ $.Repository.Name }}" target="_blank" rel="noreferrer">{{ .Repository.Name }}</a>
<ul class="list-horizontal-text">
<li>{{ .Repository.Stars | formatNumber }} stars</li>
<li>{{ .Repository.Forks | formatNumber }} forks</li>
</ul>
{{ if gt (len .Repository.Commits) 0 }}
<hr class="margin-block-8">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/commits" target="_blank" rel="noreferrer">Last {{ .CommitsLimit }} commits</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.Commits }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .Repository.Commits }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Author }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/commit/{{ .Sha }}">{{ .Message }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .Repository.PullRequests) 0 }}
<hr class="margin-block-8">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/pulls" target="_blank" rel="noreferrer">Open pull requests ({{ .Repository.OpenPullRequests | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.PullRequests }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .Repository.PullRequests }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/pull/{{ .Number }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ if gt (len .Repository.Issues) 0 }}
<hr class="margin-block-10">
<a class="text-compact" href="https://github.com/{{ $.Repository.Name }}/issues" target="_blank" rel="noreferrer">Open issues ({{ .Repository.OpenIssues | formatNumber }} total)</a>
<div class="flex gap-7 size-h5 margin-top-3">
<ul class="list list-gap-2">
{{ range .Repository.Issues }}
<li {{ dynamicRelativeTimeAttrs .CreatedAt }}></li>
{{ end }}
</ul>
<ul class="list list-gap-2 min-width-0">
{{ range .Repository.Issues }}
<li><a class="color-primary-if-not-visited text-truncate block" title="{{ .Title }}" target="_blank" rel="noreferrer" href="https://github.com/{{ $.Repository.Name }}/issues/{{ .Number }}">{{ .Title }}</a></li>
{{ end }}
</ul>
</div>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,40 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-24 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Items }}
<li class="flex gap-15 items-start row-reverse-on-mobile thumbnail-parent">
<div class="thumbnail-container rss-detailed-thumbnail">
{{ if ne "" .ImageURL }}
<img class="thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
<svg class="scale-half hide-on-mobile" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
{{ end }}
</div>
<div class="grow min-width-0">
<a class="size-h3 color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap">
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0">
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
</li>
</ul>
{{ if ne "" .Description }}
<p class="rss-detailed-description text-truncate-2-lines margin-top-10">{{ .Description }}</p>
{{ end }}
{{ if gt (len .Categories) 0 }}
<ul class="attachments margin-top-10">
{{ range .Categories }}
<li>{{ . }}</li>
{{ end }}
</ul>
{{ end }}
</div>
</li>
{{ else }}
<li>{{ .NoItemsMessage }}</li>
{{ end }}
</ul>
{{ end }}

View File

@@ -0,0 +1,32 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
{{ if gt (len .Items) 0 }}
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .CardHeight }} style="--rss-card-height: {{ .CardHeight }}rem;"{{ end }}>
{{ range .Items }}
<div class="card rss-card-2 widget-content-frame thumbnail-parent">
{{ if ne "" .ImageURL }}
<img class="rss-card-2-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
<svg class="rss-card-2-image" style="transform: scale(0.35) translateY(-25%)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
{{ end }}
<div class="rss-card-2-content padding-inline-widget">
<a href="{{ .Link }}" title="{{ .Title }}" class="block text-truncate color-primary-if-not-visited" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-5">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
</ul>
</div>
</div>
{{ end }}
</div>
</div>
{{ else }}
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,32 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
{{ if gt (len .Items) 0 }}
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container"{{ if ne 0.0 .ThumbnailHeight }} style="--rss-thumbnail-height: {{ .ThumbnailHeight }}rem;"{{ end }}>
{{ range .Items }}
<div class="card widget-content-frame thumbnail-parent">
{{ if ne "" .ImageURL }}
<img class="rss-card-image thumbnail" loading="lazy" src="{{ .ImageURL }}" alt="">
{{ else }}
<svg class="rss-card-image" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="var(--color-text-subdue)">
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
</svg>
{{ end }}
<div class="margin-bottom-widget padding-inline-widget flex flex-column grow">
<a href="{{ .Link }}" title="{{ .Title }}" class="text-truncate-3-lines color-primary-if-not-visited margin-top-10 margin-bottom-auto" target="_blank" rel="noreferrer">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0 text-truncate">{{ .ChannelName }}</li>
</ul>
</div>
</div>
{{ end }}
</div>
</div>
{{ else }}
<div class="widget-content-frame padding-widget">{{ .NoItemsMessage }}</div>
{{ end }}
{{ end }}

View File

@@ -0,0 +1,19 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container{{ if .SingleLineTitles }} single-line-titles{{ end }}" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Items }}
<li>
<a class="title size-title-dynamic color-primary-if-not-visited" href="{{ .Link }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap">
<li {{ dynamicRelativeTimeAttrs .PublishedAt }}></li>
<li class="min-width-0">
<a class="block text-truncate" href="{{ .ChannelURL }}" target="_blank" rel="noreferrer">{{ .ChannelName }}</a>
</li>
</ul>
</li>
{{ else }}
<li>{{ .NoItemsMessage }}</li>
{{ end }}
</ul>
{{ end }}

View File

@@ -0,0 +1,24 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="search widget-content-frame padding-inline-widget flex gap-15 items-center" data-default-search-url="{{ .SearchEngine }}" data-new-tab="{{ .NewTab }}">
<div class="search-bangs">
{{ range .Bangs }}
<input type="hidden" data-shortcut="{{ .Shortcut }}" data-title="{{ .Title }}" data-url="{{ .URL }}">
{{ end }}
</div>
<div class="search-icon-container">
<svg class="search-icon" stroke="var(--color-text-subdue)" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<input class="search-input" type="text" placeholder="Type here to search…" autocomplete="off"{{ if .Autofocus }} autofocus{{ end }}>
<div class="search-bang"></div>
<kbd class="hide-on-mobile" title="Press [S] to focus the search input">S</kbd>
</div>
{{ end }}

View File

@@ -0,0 +1,11 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="masonry" data-max-columns="{{ .MaxColumns }}">
{{ range .Widgets }}
{{ .Render }}
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,14 @@
<style>
:root {
{{ if .BackgroundColor }}
--bgh: {{ .BackgroundColor.Hue }};
--bgs: {{ .BackgroundColor.Saturation }}%;
--bgl: {{ .BackgroundColor.Lightness }}%;
{{ end }}
{{ if ne 0.0 .ContrastMultiplier }}--cm: {{ .ContrastMultiplier }};{{ end }}
{{ if ne 0.0 .TextSaturationMultiplier }}--tsm: {{ .TextSaturationMultiplier }};{{ end }}
{{ if .PrimaryColor }}--color-primary: {{ .PrimaryColor.AsCSSValue }};{{ end }}
{{ if .PositiveColor }}--color-positive: {{ .PositiveColor.AsCSSValue }};{{ end }}
{{ if .NegativeColor }}--color-negative: {{ .NegativeColor.AsCSSValue }};{{ end }}
}
</style>

View File

@@ -0,0 +1,47 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Channels }}
<li>
<div class="{{ if .IsLive }}twitch-channel-live {{ end }}flex gap-10 items-start thumbnail-parent">
<div class="twitch-channel-avatar-container"{{ if .IsLive }} data-popover-type="html" data-popover-position="above" data-popover-margin="0.15rem" data-popover-offset="0.2"{{ end }}>
{{ if .IsLive }}
<div data-popover-html>
<img class="twitch-stream-preview" src="https://static-cdn.jtvnw.net/previews-ttv/live_user_{{ .Login }}-440x248.jpg" loading="lazy" alt="">
<p class="margin-top-10 color-highlight text-truncate-3-lines">{{ .StreamTitle }}</p>
</div>
{{ end }}
{{ if .Exists }}
<a href="https://twitch.tv/{{ .Login }}" target="_blank" rel="noreferrer">
<img class="twitch-channel-avatar thumbnail" src="{{ .AvatarUrl }}" alt="" loading="lazy">
</a>
{{ else }}
<svg class="twitch-channel-avatar thumbnail" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
{{ end }}
</div>
<div class="min-width-0">
<a href="https://twitch.tv/{{ .Login }}" class="size-h3{{ if .IsLive }} color-highlight{{ end }} block text-truncate" target="_blank" rel="noreferrer">{{ .Name }}</a>
{{ if .Exists }}
{{ if .IsLive }}
{{ if .Category }}
<a class="text-truncate block" href="https://www.twitch.tv/directory/category/{{ .CategorySlug }}" target="_blank" rel="noreferrer">{{ .Category }}</a>
{{ end }}
<ul class="list-horizontal-text">
<li {{ dynamicRelativeTimeAttrs .LiveSince }}></li>
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
</ul>
{{ else }}
<div>Offline</div>
{{ end }}
{{ else }}
<div class="color-negative">Not found</div>
{{ end }}
</div>
</div>
</li>
{{ end }}
</ul>
{{ end }}

View File

@@ -0,0 +1,31 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
{{ range .Categories }}
<li class="twitch-category thumbnail-parent">
<div class="flex gap-10 items-start">
<img class="twitch-category-thumbnail thumbnail" loading="lazy" src="{{ .AvatarUrl }}" alt="">
<div class="min-width-0">
<a class="size-h3 color-highlight text-truncate block" href="https://www.twitch.tv/directory/category/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Name }}</a>
<ul class="list-horizontal-text">
<li>{{ .ViewersCount | formatViewerCount }} viewers</li>
{{ if .IsNew }}
<li class="color-primary">NEW</li>
{{ end }}
</ul>
<ul class="list-horizontal-text flex-nowrap">
{{ range $i, $tag := .Tags }}
{{ if eq $i 0 }}
<li class="shrink-0">{{ $tag.Name }}</li>
{{ else }}
<li class="text-truncate min-width-0">{{ $tag.Name }}</li>
{{ end }}
{{ end }}
</ul>
</div>
</div>
</li>
{{ end }}
</ul>
{{ end }}

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="static/main.css">
<title>Update notice</title>
<style>
body {
display: flex;
align-items: center;
justify-content: center;
}
.content-bounds {
max-width: 700px;
margin-top: -10rem;
}
.comfy-line-height {
line-height: 1.9;
}
</style>
</head>
<body>
<!-- TODO: update - add links -->
<div class="content-bounds color-highlight">
<p class="uppercase size-h5 color-negative padding-inline-widget">UPDATE NOTICE</p>
<div class="widget-content-frame margin-top-10 padding-widget">
<p class="comfy-line-height">
The default location of glance.yml in the Docker image has
changed since v0.7.0, please see the <a class="color-primary" href="#">migration guide</a>
for instructions or visit the <a class="color-primary" href="#">release notes</a>
to find out more about why this change was necessary. Sorry for the inconvenience.
</p>
<p class="margin-top-15 color-base">Migration should take around 5 minutes.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
{{ define "video-card-contents" }}
<img class="video-thumbnail thumbnail" loading="lazy" src="{{ .ThumbnailUrl }}" alt="">
<div class="margin-top-10 margin-bottom-widget flex flex-column grow padding-inline-widget">
<a class="video-title color-primary-if-not-visited" href="{{ .Url }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
<ul class="list-horizontal-text flex-nowrap margin-top-7">
<li class="shrink-0" {{ dynamicRelativeTimeAttrs .TimePosted }}></li>
<li class="min-width-0">
<a class="block text-truncate" href="{{ .AuthorUrl }}" target="_blank" rel="noreferrer">{{ .Author }}</a>
</li>
</ul>
</div>
{{ end }}

View File

@@ -0,0 +1,13 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="cards-grid collapsible-container" data-collapse-after-rows="{{ .CollapseAfterRows }}">
{{ range .Videos }}
<div class="card widget-content-frame thumbnail-parent">
{{ template "video-card-contents" . }}
</div>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,15 @@
{{ template "widget-base.html" . }}
{{ define "widget-content-classes" }}widget-content-frameless{{ end }}
{{ define "widget-content" }}
<div class="carousel-container">
<div class="cards-horizontal carousel-items-container">
{{ range .Videos }}
<div class="card widget-content-frame thumbnail-parent">
{{ template "video-card-contents" . }}
</div>
{{ end }}
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,31 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="widget-small-content-bounds">
<div class="size-h2 color-highlight text-center">{{ .Weather.WeatherCodeAsString }}</div>
<div class="size-h4 text-center">Feels like {{ .Weather.ApparentTemperature }}°{{ if eq .Units "metric" }}C{{ else }}F{{ end }}</div>
<div class="weather-columns flex margin-top-15 justify-center">
{{ range $i, $column := .Weather.Columns }}
<div class="weather-column{{ if eq $i $.Weather.CurrentColumn }} weather-column-current{{ end }}">
{{ if $column.HasPrecipitation }}
<div class="weather-column-rain"></div>
{{ end }}
{{ if and (ge $i $.Weather.SunriseColumn) (le $i $.Weather.SunsetColumn ) }}
<div class="weather-column-daylight{{ if eq $i $.Weather.SunriseColumn }} weather-column-daylight-sunrise{{ else if eq $i $.Weather.SunsetColumn }} weather-column-daylight-sunset{{ end }}"></div>
{{ end }}
<div class="weather-column-value{{ if lt $column.Temperature 0 }} weather-column-value-negative{{ end }}">{{ $column.Temperature | absInt }}</div>
<div class="weather-bar" style='--weather-bar-height: {{ printf "%.2f" $column.Scale }}'></div>
<div class="weather-column-time">{{ index $.TimeLabels $i }}</div>
</div>
{{ end }}
</div>
{{ if not .HideLocation }}
<div class="flex items-center justify-center margin-top-15 gap-7 size-h5">
<div class="location-icon"></div>
<div class="text-truncate">{{ .Place.Name }},{{ if .ShowAreaName }} {{ .Place.Area }},{{ end }} {{ .Place.Country }}</div>
</div>
{{ end }}
</div>
{{ end }}

View File

@@ -0,0 +1,23 @@
<div class="widget widget-type-{{ .GetType }}{{ if ne "" .CSSClass }} {{ .CSSClass }}{{ end }}">
{{ if not .HideHeader}}
<div class="widget-header">
{{ if ne "" .TitleURL}}<a href="{{ .TitleURL }}" target="_blank" rel="noreferrer" class="uppercase">{{ .Title }}</a>{{ else }}<div class="uppercase">{{ .Title }}</div>{{ end }}
{{ if and .Error .ContentAvailable }}
<div class="notice-icon notice-icon-major" title="{{ .Error }}"></div>
{{ else if .Notice }}
<div class="notice-icon notice-icon-minor" title="{{ .Notice }}"></div>
{{ end }}
</div>
{{ end }}
<div class="widget-content{{ if .ContentAvailable }} {{ block "widget-content-classes" . }}{{ end }}{{ end }}">
{{ if .ContentAvailable }}
{{ block "widget-content" . }}{{ end }}
{{ else }}
<div class="widget-error-header">
<div class="color-negative size-h3">ERROR</div>
<div class="widget-error-icon"></div>
</div>
<p class="break-all">{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ end }}</p>
{{ end}}
</div>
</div>

168
internal/glance/utils.go Normal file
View File

@@ -0,0 +1,168 @@
package glance
import (
"bytes"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"regexp"
"slices"
"strings"
"time"
)
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
func percentChange(current, previous float64) float64 {
return (current/previous - 1) * 100
}
func extractDomainFromUrl(u string) string {
if u == "" {
return ""
}
parsed, err := url.Parse(u)
if err != nil {
return ""
}
return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
}
func svgPolylineCoordsFromYValues(width float64, height float64, values []float64) string {
if len(values) < 2 {
return ""
}
verticalPadding := height * 0.02
height -= verticalPadding * 2
coordinates := make([]string, len(values))
distanceBetweenPoints := width / float64(len(values)-1)
min := slices.Min(values)
max := slices.Max(values)
for i := range values {
coordinates[i] = fmt.Sprintf(
"%.2f,%.2f",
float64(i)*distanceBetweenPoints,
((max-values[i])/(max-min))*height+verticalPadding,
)
}
return strings.Join(coordinates, " ")
}
func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T {
if len(values) == 0 {
return values
}
for i := range values {
if values[i] != 0 {
continue
}
c := make([]T, 0, len(values)-1)
for i := range values {
if values[i] != 0 {
c = append(c, values[i])
}
}
return c
}
return values
}
var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`)
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)
if len(asRunes) > max {
return string(asRunes[:max]), true
}
return s, false
}
func parseRFC3339Time(t string) time.Time {
parsed, err := time.Parse(time.RFC3339, t)
if err != nil {
return time.Now()
}
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))
if len(version) > 0 && version[0] != 'v' {
return "v" + version
}
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)
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", cacheControlValue)
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
}

View File

@@ -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
}

View File

@@ -0,0 +1,86 @@
package glance
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 {
year, week := now.ISOWeek()
weekday := now.Weekday()
if !startSunday {
weekday = (weekday + 6) % 7 // Shift Monday to 0
}
currentMonthDays := daysInMonth(now.Month(), year)
var previousMonthDays int
if previousMonthNumber := now.Month() - 1; previousMonthNumber < 1 {
previousMonthDays = daysInMonth(12, year-1)
} else {
previousMonthDays = daysInMonth(previousMonthNumber, year)
}
startDaysFrom := now.Day() - int(weekday) - 7
days := make([]int, 21)
for i := 0; i < 21; i++ {
day := startDaysFrom + i
if day < 1 {
day = previousMonthDays + day
} else if day > currentMonthDays {
day = day - currentMonthDays
}
days[i] = day
}
return &calendar{
CurrentDay: now.Day(),
CurrentWeekNumber: week,
CurrentMonthName: now.Month().String(),
CurrentYear: year,
Days: days,
}
}
func daysInMonth(m time.Month, year int) int {
return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day()
}

View File

@@ -0,0 +1,197 @@
package glance
import (
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"sort"
"strings"
"time"
)
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
DiffURL string
PreviousHash string
}
type changeDetectionWatchList []changeDetectionWatch
func (r changeDetectionWatchList) sortByNewest() changeDetectionWatchList {
sort.Slice(r, func(i, j int) bool {
return r[i].LastChanged.After(r[j].LastChanged)
})
return r
}
type changeDetectionResponseJson struct {
Title string `json:"title"`
URL string `json:"url"`
LastChanged int64 `json:"last_changed"`
DateCreated int64 `json:"date_created"`
PreviousHash string `json:"previous_md5"`
}
func fetchWatchUUIDsFromChangeDetection(instanceURL string, token string) ([]string, error) {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch", instanceURL), nil)
if token != "" {
request.Header.Add("x-api-key", token)
}
uuidsMap, err := decodeJsonFromRequest[map[string]struct{}](defaultHTTPClient, request)
if err != nil {
return nil, fmt.Errorf("could not fetch list of watch UUIDs: %v", err)
}
uuids := make([]string, 0, len(uuidsMap))
for uuid := range uuidsMap {
uuids = append(uuids, uuid)
}
return uuids, nil
}
func fetchWatchesFromChangeDetection(instanceURL string, requestedWatchIDs []string, token string) (changeDetectionWatchList, error) {
watches := make(changeDetectionWatchList, 0, len(requestedWatchIDs))
if len(requestedWatchIDs) == 0 {
return watches, nil
}
requests := make([]*http.Request, len(requestedWatchIDs))
for i, repository := range requestedWatchIDs {
request, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/watch/%s", instanceURL, repository), nil)
if token != "" {
request.Header.Add("x-api-key", token)
}
requests[i] = request
}
task := decodeJsonFromRequestTask[changeDetectionResponseJson](defaultHTTPClient)
job := newJob(task, requests).withWorkers(15)
responses, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
var failed int
for i := range responses {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch or parse change detection watch", "url", requests[i].URL, "error", errs[i])
continue
}
watchJson := responses[i]
watch := changeDetectionWatch{
URL: watchJson.URL,
DiffURL: fmt.Sprintf("%s/diff/%s?from_version=%d", instanceURL, requestedWatchIDs[i], watchJson.LastChanged-1),
}
if watchJson.LastChanged == 0 {
watch.LastChanged = time.Unix(watchJson.DateCreated, 0)
} else {
watch.LastChanged = time.Unix(watchJson.LastChanged, 0)
}
if watchJson.Title != "" {
watch.Title = watchJson.Title
} else {
watch.Title = strings.TrimPrefix(strings.Trim(stripURLScheme(watchJson.URL), "/"), "www.")
}
if watchJson.PreviousHash != "" {
var hashLength = 8
if len(watchJson.PreviousHash) < hashLength {
hashLength = len(watchJson.PreviousHash)
}
watch.PreviousHash = watchJson.PreviousHash[0:hashLength]
}
watches = append(watches, watch)
}
if len(watches) == 0 {
return nil, errNoContent
}
watches.sortByNewest()
if failed > 0 {
return watches, fmt.Errorf("%w: could not get %d watches", errPartialContent, failed)
}
return watches, nil
}

View File

@@ -0,0 +1,48 @@
package glance
import (
"errors"
"fmt"
"html/template"
"time"
)
var clockWidgetTemplate = mustParseTemplate("clock.html", "widget-base.html")
type clockWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
HourFormat string `yaml:"hour-format"`
Timezones []struct {
Timezone string `yaml:"timezone"`
Label string `yaml:"label"`
} `yaml:"timezones"`
}
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("hour-format must be either 12h or 24h")
}
for t := range widget.Timezones {
if widget.Timezones[t].Timezone == "" {
return errors.New("missing timezone value")
}
if _, err := time.LoadLocation(widget.Timezones[t].Timezone); err != nil {
return fmt.Errorf("invalid timezone '%s': %v", widget.Timezones[t].Timezone, err)
}
}
widget.cachedHTML = widget.renderTemplate(widget, clockWidgetTemplate)
return nil
}
func (widget *clockWidget) Render() template.HTML {
return widget.cachedHTML
}

View File

@@ -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
}

View File

@@ -0,0 +1,208 @@
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 := defaultHTTPClient.Do(req)
if err != nil {
return emptyBody, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return emptyBody, err
}
body := string(bodyBytes)
if !gjson.Valid(body) {
truncatedBody, isTruncated := limitStringLength(body, 100)
if isTruncated {
truncatedBody += "... <truncated>"
}
slog.Error("Invalid response JSON in custom API widget", "url", req.URL.String(), "body", truncatedBody)
return emptyBody, errors.New("invalid response JSON")
}
var templateBuffer bytes.Buffer
data := CustomAPITemplateData{
JSON: decoratedGJSONResult{gjson.Parse(body)},
Response: resp,
}
err = tmpl.Execute(&templateBuffer, &data)
if err != nil {
return emptyBody, err
}
return template.HTML(templateBuffer.String()), nil
}
type decoratedGJSONResult struct {
gjson.Result
}
type CustomAPITemplateData struct {
JSON decoratedGJSONResult
Response *http.Response
}
func gJsonResultArrayToDecoratedResultArray(results []gjson.Result) []decoratedGJSONResult {
decoratedResults := make([]decoratedGJSONResult, len(results))
for i, result := range results {
decoratedResults[i] = decoratedGJSONResult{result}
}
return decoratedResults
}
func (r *decoratedGJSONResult) Array(key string) []decoratedGJSONResult {
if key == "" {
return gJsonResultArrayToDecoratedResultArray(r.Result.Array())
}
return gJsonResultArrayToDecoratedResultArray(r.Get(key).Array())
}
func (r *decoratedGJSONResult) String(key string) string {
if key == "" {
return r.Result.String()
}
return r.Get(key).String()
}
func (r *decoratedGJSONResult) Int(key string) int64 {
if key == "" {
return r.Result.Int()
}
return r.Get(key).Int()
}
func (r *decoratedGJSONResult) Float(key string) float64 {
if key == "" {
return r.Result.Float()
}
return r.Get(key).Float()
}
func (r *decoratedGJSONResult) Bool(key string) bool {
if key == "" {
return r.Result.Bool()
}
return r.Get(key).Bool()
}
var customAPITemplateFuncs = func() template.FuncMap {
funcs := template.FuncMap{
"toFloat": func(a int64) float64 {
return float64(a)
},
"toInt": func(a float64) int64 {
return int64(a)
},
"mathexpr": func(left float64, op string, right float64) float64 {
if right == 0 {
return 0
}
switch op {
case "+":
return left + right
case "-":
return left - right
case "*":
return left * right
case "/":
return left / right
default:
return 0
}
},
}
for key, value := range globalTemplateFunctions {
funcs[key] = value
}
return funcs
}()

View File

@@ -0,0 +1,352 @@
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"`
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 {
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), widget.AllowInsecure, string(widget.Username), string(widget.Password))
} else {
stats, err = fetchPiholeStats(string(widget.URL), widget.AllowInsecure, 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 string, allowInsecure bool, 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)
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
}
responseJson, err := decodeJsonFromRequest[adguardStatsResponse](client, 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 string, allowInsecure bool, 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
}
var client requestDoer
if !allowInsecure {
client = defaultHTTPClient
} else {
client = defaultInsecureHTTPClient
}
responseJson, err := decodeJsonFromRequest[piholeStatsResponse](client, 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
}

View File

@@ -0,0 +1,152 @@
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")
}
if _, err := url.Parse(widget.URL); err != nil {
return fmt.Errorf("parsing URL: %v", 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
}

View File

@@ -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)
}

View File

@@ -0,0 +1,152 @@
package glance
import (
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"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"`
Title string `json:"title"`
TargetUrl string `json:"url,omitempty"`
CommentCount int `json:"descendants"`
TimePosted int64 `json:"time"`
}
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](defaultHTTPClient, request)
if err != nil {
return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent)
}
return response, nil
}
func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) {
requests := make([]*http.Request, len(postIds))
for i, id := range postIds {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
requests[i] = request
}
task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultHTTPClient)
job := newJob(task, requests).withWorkers(30)
results, errs, err := workerPoolDo(job)
if err != nil {
return nil, err
}
posts := make(forumPostList, 0, len(postIds))
for i := range results {
if errs[i] != nil {
slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
continue
}
var commentsUrl string
if commentsUrlTemplate == "" {
commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
} else {
commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
}
posts = append(posts, forumPost{
Title: results[i].Title,
DiscussionUrl: commentsUrl,
TargetUrl: results[i].TargetUrl,
TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
CommentCount: results[i].CommentCount,
Score: results[i].Score,
TimePosted: time.Unix(results[i].TimePosted, 0),
})
}
if len(posts) == 0 {
return nil, errNoContent
}
if len(posts) != len(postIds) {
return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent)
}
return posts, nil
}
func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) {
postIds, err := fetchHackerNewsPostIds(sort)
if err != nil {
return nil, err
}
if len(postIds) > limit {
postIds = postIds[:limit]
}
return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
}

View File

@@ -0,0 +1,20 @@
package glance
import (
"html/template"
)
type htmlWidget struct {
widgetBase `yaml:",inline"`
Source template.HTML `yaml:"source"`
}
func (widget *htmlWidget) initialize() error {
widget.withTitle("").withError(nil)
return nil
}
func (widget *htmlWidget) Render() template.HTML {
return widget.Source
}

View File

@@ -0,0 +1,43 @@
package glance
import (
"errors"
"fmt"
"html/template"
"net/url"
)
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 *iframeWidget) initialize() error {
widget.withTitle("IFrame").withError(nil)
if widget.Source == "" {
return errors.New("source is required")
}
if _, err := url.Parse(widget.Source); err != nil {
return fmt.Errorf("parsing URL: %v", err)
}
if widget.Height == 50 {
widget.Height = 300
} else if widget.Height < 50 {
widget.Height = 50
}
widget.cachedHTML = widget.renderTemplate(widget, iframeWidgetTemplate)
return nil
}
func (widget *iframeWidget) Render() template.HTML {
return widget.cachedHTML
}

View File

@@ -0,0 +1,144 @@
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](defaultHTTPClient, 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
}

View File

@@ -0,0 +1,205 @@
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](defaultHTTPClient), 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": "₱",
}

View File

@@ -0,0 +1,176 @@
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 = defaultHTTPClient.Do(request)
} else {
response, err = defaultInsecureHTTPClient.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
}

View File

@@ -0,0 +1,261 @@
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 {
Data struct {
Id string `json:"id"`
Title string `json:"title"`
Upvotes int `json:"ups"`
Url string `json:"url"`
Time float64 `json:"created"`
CommentsCount int `json:"num_comments"`
Domain string `json:"domain"`
Permalink string `json:"permalink"`
Stickied bool `json:"stickied"`
Pinned bool `json:"pinned"`
IsSelf bool `json:"is_self"`
Thumbnail string `json:"thumbnail"`
Flair string `json:"link_flair_text"`
ParentList []struct {
Id string `json:"id"`
Subreddit string `json:"subreddit"`
Permalink string `json:"permalink"`
} `json:"crosspost_parent_list"`
} `json:"data"`
} `json:"children"`
} `json:"data"`
}
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
template = strings.ReplaceAll(template, "{POST-ID}", postId)
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
return template
}
func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (forumPostList, error) {
query := url.Values{}
var requestUrl string
if search != "" {
query.Set("q", search+" subreddit:"+subreddit)
query.Set("sort", sort)
}
if sort == "top" {
query.Set("t", topPeriod)
}
if search != "" {
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
} else {
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
}
if requestUrlTemplate != "" {
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
}
request, err := http.NewRequest("GET", requestUrl, nil)
if err != nil {
return nil, err
}
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
setBrowserUserAgentHeader(request)
responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultHTTPClient, request)
if err != nil {
return nil, err
}
if len(responseJson.Data.Children) == 0 {
return nil, fmt.Errorf("no posts found")
}
posts := make(forumPostList, 0, len(responseJson.Data.Children))
for i := range responseJson.Data.Children {
post := &responseJson.Data.Children[i].Data
if post.Stickied || post.Pinned {
continue
}
var commentsUrl string
if commentsUrlTemplate == "" {
commentsUrl = "https://www.reddit.com" + post.Permalink
} else {
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
}
forumPost := forumPost{
Title: html.UnescapeString(post.Title),
DiscussionUrl: commentsUrl,
TargetUrlDomain: post.Domain,
CommentCount: post.CommentsCount,
Score: post.Upvotes,
TimePosted: time.Unix(int64(post.Time), 0),
}
if post.Thumbnail != "" && post.Thumbnail != "self" && post.Thumbnail != "default" {
forumPost.ThumbnailUrl = post.Thumbnail
}
if !post.IsSelf {
forumPost.TargetUrl = post.Url
}
if showFlairs && post.Flair != "" {
forumPost.Tags = append(forumPost.Tags, post.Flair)
}
if len(post.ParentList) > 0 {
forumPost.IsCrosspost = true
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
if commentsUrlTemplate == "" {
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
} else {
forumPost.TargetUrl = templateRedditCommentsURL(
commentsUrlTemplate,
post.ParentList[0].Subreddit,
post.ParentList[0].Id,
post.ParentList[0].Permalink,
)
}
}
posts = append(posts, forumPost)
}
return posts, nil
}

View File

@@ -0,0 +1,394 @@
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](defaultHTTPClient, 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](defaultHTTPClient, 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](defaultHTTPClient, 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](defaultHTTPClient, 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](defaultHTTPClient, 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
}

View File

@@ -0,0 +1,238 @@
package glance
import (
"context"
"fmt"
"html/template"
"net/http"
"strings"
"sync"
"time"
)
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 (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 !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.Repository = details
}
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 githubCommitDetails struct {
Sha string
Author string
CreatedAt time.Time
Message string
}
type githubRepositoryResponseJson struct {
Name string `json:"full_name"`
Stars int `json:"stargazers_count"`
Forks int `json:"forks_count"`
}
type githubTicketResponseJson struct {
Count int `json:"total_count"`
Tickets []struct {
Number int `json:"number"`
CreatedAt string `json:"created_at"`
Title string `json:"title"`
} `json:"items"`
}
type gitHubCommitResponseJson struct {
Sha string `json:"sha"`
Commit struct {
Author struct {
Name string `json:"name"`
Date string `json:"date"`
} `json:"author"`
Message string `json:"message"`
} `json:"commit"`
}
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 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", 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)
repositoryRequest.Header.Add("Authorization", token)
PRsRequest.Header.Add("Authorization", token)
issuesRequest.Header.Add("Authorization", token)
CommitsRequest.Header.Add("Authorization", token)
}
var repositoryResponse githubRepositoryResponseJson
var detailsErr error
var PRsResponse githubTicketResponseJson
var PRsErr error
var issuesResponse githubTicketResponseJson
var issuesErr error
var commitsResponse []gitHubCommitResponseJson
var CommitsErr error
var wg sync.WaitGroup
wg.Add(1)
go (func() {
defer wg.Done()
repositoryResponse, detailsErr = decodeJsonFromRequest[githubRepositoryResponseJson](defaultHTTPClient, repositoryRequest)
})()
if maxPRs > 0 {
wg.Add(1)
go (func() {
defer wg.Done()
PRsResponse, PRsErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, PRsRequest)
})()
}
if maxIssues > 0 {
wg.Add(1)
go (func() {
defer wg.Done()
issuesResponse, issuesErr = decodeJsonFromRequest[githubTicketResponseJson](defaultHTTPClient, issuesRequest)
})()
}
if maxCommits > 0 {
wg.Add(1)
go (func() {
defer wg.Done()
commitsResponse, CommitsErr = decodeJsonFromRequest[[]gitHubCommitResponseJson](defaultHTTPClient, CommitsRequest)
})()
}
wg.Wait()
if detailsErr != nil {
return repository{}, fmt.Errorf("%w: could not get repository details: %s", errNoContent, detailsErr)
}
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)
} else {
details.OpenPullRequests = PRsResponse.Count
for i := range PRsResponse.Tickets {
details.PullRequests = append(details.PullRequests, githubTicket{
Number: PRsResponse.Tickets[i].Number,
CreatedAt: parseRFC3339Time(PRsResponse.Tickets[i].CreatedAt),
Title: PRsResponse.Tickets[i].Title,
})
}
}
}
if maxIssues > 0 {
if issuesErr != nil {
// TODO: fix, overwriting the previous error
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{
Number: issuesResponse.Tickets[i].Number,
CreatedAt: parseRFC3339Time(issuesResponse.Tickets[i].CreatedAt),
Title: issuesResponse.Tickets[i].Title,
})
}
}
}
if maxCommits > 0 {
if CommitsErr != nil {
err = fmt.Errorf("%w: could not get commits: %s", errPartialContent, CommitsErr)
} else {
for i := range commitsResponse {
details.Commits = append(details.Commits, githubCommitDetails{
Sha: commitsResponse[i].Sha,
Author: commitsResponse[i].Commit.Author.Name,
CreatedAt: parseRFC3339Time(commitsResponse[i].Commit.Author.Date),
Message: strings.SplitN(commitsResponse[i].Commit.Message, "\n\n", 2)[0],
})
}
}
}
return details, err
}

View File

@@ -0,0 +1,347 @@
package glance
import (
"context"
"fmt"
"html"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/mmcdole/gofeed"
gofeedext "github.com/mmcdole/gofeed/extensions"
)
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
Link string
ImageURL string
Categories []string
Description string
PublishedAt time.Time
}
// doesn't cover all cases but works the vast majority of the time
var htmlTagsWithAttributesPattern = regexp.MustCompile(`<\/?[a-zA-Z0-9-]+ *(?:[a-zA-Z-]+=(?:"|').*?(?:"|') ?)* *\/?>`)
func sanitizeFeedDescription(description string) string {
if description == "" {
return ""
}
description = strings.ReplaceAll(description, "\n", " ")
description = htmlTagsWithAttributesPattern.ReplaceAllString(description, "")
description = sequentialWhitespacePattern.ReplaceAllString(description, " ")
description = strings.TrimSpace(description)
description = html.UnescapeString(description)
return description
}
func shortenFeedDescriptionLen(description string, maxLen int) string {
description, _ = limitStringLength(description, 1000)
description = sanitizeFeedDescription(description)
description, limited := limitStringLength(description, maxLen)
if limited {
description += "…"
}
return description
}
type RSSFeedRequest struct {
Url string `yaml:"url"`
Title string `yaml:"title"`
HideCategories bool `yaml:"hide-categories"`
HideDescription bool `yaml:"hide-description"`
ItemLinkPrefix string `yaml:"item-link-prefix"`
Headers map[string]string `yaml:"headers"`
IsDetailed bool `yaml:"-"`
}
type rssFeedItemList []rssFeedItem
func (f rssFeedItemList) sortByNewest() rssFeedItemList {
sort.Slice(f, func(i, j int) bool {
return f[i].PublishedAt.After(f[j].PublishedAt)
})
return f
}
var feedParser = gofeed.NewParser()
func fetchItemsFromRSSFeedTask(request RSSFeedRequest) ([]rssFeedItem, error) {
req, err := http.NewRequest("GET", request.Url, nil)
if err != nil {
return nil, err
}
for key, value := range request.Headers {
req.Header.Add(key, value)
}
resp, err := defaultHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.Url)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
feed, err := feedParser.ParseString(string(body))
if err != nil {
return nil, err
}
items := make(rssFeedItemList, 0, len(feed.Items))
for i := range feed.Items {
item := feed.Items[i]
rssItem := rssFeedItem{
ChannelURL: feed.Link,
}
if request.ItemLinkPrefix != "" {
rssItem.Link = request.ItemLinkPrefix + item.Link
} else if strings.HasPrefix(item.Link, "http://") || strings.HasPrefix(item.Link, "https://") {
rssItem.Link = item.Link
} else {
parsedUrl, err := url.Parse(feed.Link)
if err != nil {
parsedUrl, err = url.Parse(request.Url)
}
if err == nil {
var link string
if len(item.Link) > 0 && item.Link[0] == '/' {
link = item.Link
} else {
link = "/" + item.Link
}
rssItem.Link = parsedUrl.Scheme + "://" + parsedUrl.Host + link
}
}
if item.Title != "" {
rssItem.Title = item.Title
} else {
rssItem.Title = shortenFeedDescriptionLen(item.Description, 100)
}
if request.IsDetailed {
if !request.HideDescription && item.Description != "" && item.Title != "" {
rssItem.Description = shortenFeedDescriptionLen(item.Description, 200)
}
if !request.HideCategories {
var categories = make([]string, 0, 6)
for _, category := range item.Categories {
if len(categories) == 6 {
break
}
if len(category) == 0 || len(category) > 30 {
continue
}
categories = append(categories, category)
}
rssItem.Categories = categories
}
}
if request.Title != "" {
rssItem.ChannelName = request.Title
} else {
rssItem.ChannelName = feed.Title
}
if item.Image != nil {
rssItem.ImageURL = item.Image.URL
} else if url := findThumbnailInItemExtensions(item); url != "" {
rssItem.ImageURL = url
} else if feed.Image != nil {
if len(feed.Image.URL) > 0 && feed.Image.URL[0] == '/' {
rssItem.ImageURL = strings.TrimRight(feed.Link, "/") + feed.Image.URL
} else {
rssItem.ImageURL = feed.Image.URL
}
}
if item.PublishedParsed != nil {
rssItem.PublishedAt = *item.PublishedParsed
} else {
rssItem.PublishedAt = time.Now()
}
items = append(items, rssItem)
}
return items, nil
}
func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string {
for _, exts := range extensions {
for _, ext := range exts {
if ext.Name == "thumbnail" || ext.Name == "image" {
if url, ok := ext.Attrs["url"]; ok {
return url
}
}
if ext.Children != nil {
if url := recursiveFindThumbnailInExtensions(ext.Children); url != "" {
return url
}
}
}
}
return ""
}
func findThumbnailInItemExtensions(item *gofeed.Item) string {
media, ok := item.Extensions["media"]
if !ok {
return ""
}
return recursiveFindThumbnailInExtensions(media)
}
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)
}
failed := 0
entries := make(rssFeedItemList, 0, len(feeds)*10)
for i := range feeds {
if errs[i] != nil {
failed++
slog.Error("Failed to get RSS feed", "url", requests[i].Url, "error", errs[i])
continue
}
entries = append(entries, feeds[i]...)
}
if failed == len(requests) {
return nil, errNoContent
}
entries.sortByNewest()
if failed > 0 {
return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed)
}
return entries, nil
}

View File

@@ -0,0 +1,68 @@
package glance
import (
"fmt"
"html/template"
"strings"
)
var searchWidgetTemplate = mustParseTemplate("search.html", "widget-base.html")
type SearchBang struct {
Title string
Shortcut string
URL string
}
type searchWidget struct {
widgetBase `yaml:",inline"`
cachedHTML template.HTML `yaml:"-"`
SearchEngine string `yaml:"search-engine"`
Bangs []SearchBang `yaml:"bangs"`
NewTab bool `yaml:"new-tab"`
Autofocus bool `yaml:"autofocus"`
}
func convertSearchUrl(url string) string {
// Go's template is being stubborn and continues to escape the curlies in the
// URL regardless of what the type of the variable is so this is my way around it
return strings.ReplaceAll(url, "{QUERY}", "!QUERY!")
}
var searchEngines = map[string]string{
"duckduckgo": "https://duckduckgo.com/?q={QUERY}",
"google": "https://www.google.com/search?q={QUERY}",
}
func (widget *searchWidget) initialize() error {
widget.withTitle("Search").withError(nil)
if widget.SearchEngine == "" {
widget.SearchEngine = "duckduckgo"
}
if url, ok := searchEngines[widget.SearchEngine]; ok {
widget.SearchEngine = url
}
widget.SearchEngine = convertSearchUrl(widget.SearchEngine)
for i := range widget.Bangs {
if widget.Bangs[i].Shortcut == "" {
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)
}
widget.Bangs[i].URL = convertSearchUrl(widget.Bangs[i].URL)
}
widget.cachedHTML = widget.renderTemplate(widget, searchWidgetTemplate)
return nil
}
func (widget *searchWidget) Render() template.HTML {
return widget.cachedHTML
}

View File

@@ -0,0 +1,64 @@
package glance
import (
"math"
"sort"
"time"
)
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
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
})
}

View File

@@ -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)
}

View File

@@ -0,0 +1,234 @@
package glance
import (
"context"
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"sort"
"strings"
"time"
)
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"`
}
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
StreamTitle string
AvatarUrl string
IsLive bool
LiveSince time.Time
Category string
CategorySlug string
ViewersCount int
}
type twitchChannelList []twitchChannel
func (channels twitchChannelList) sortByViewers() {
sort.Slice(channels, func(i, j int) bool {
return channels[i].ViewersCount > channels[j].ViewersCount
})
}
func (channels twitchChannelList) sortByLive() {
sort.SliceStable(channels, func(i, j int) bool {
return channels[i].IsLive && !channels[j].IsLive
})
}
type twitchOperationResponse struct {
Data json.RawMessage
Extensions struct {
OperationName string `json:"operationName"`
}
}
type twitchChannelShellOperationResponse struct {
UserOrError struct {
Type string `json:"__typename"`
DisplayName string `json:"displayName"`
ProfileImageUrl string `json:"profileImageURL"`
Stream *struct {
ViewersCount int `json:"viewersCount"`
}
} `json:"userOrError"`
}
type twitchStreamMetadataOperationResponse struct {
UserOrNull *struct {
Stream *struct {
StartedAt string `json:"createdAt"`
Game *struct {
Slug string `json:"slug"`
Name string `json:"name"`
} `json:"game"`
} `json:"stream"`
LastBroadcast *struct {
Title string `json:"title"`
}
} `json:"user"`
}
const twitchChannelStatusOperationRequestBody = `[
{"operationName":"ChannelShell","variables":{"login":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"}}},
{"operationName":"StreamMetadata","variables":{"channelLogin":"%s"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"676ee2f834ede42eb4514cdb432b3134fefc12590080c9a2c9bb44a2a4a63266"}}}
]`
// TODO: rework
// The operations for multiple channels can all be sent in a single request
// rather than sending a separate request for each channel. Need to figure out
// 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{
Login: strings.ToLower(channel),
}
reader := strings.NewReader(fmt.Sprintf(twitchChannelStatusOperationRequestBody, channel, channel))
request, _ := http.NewRequest("POST", twitchGqlEndpoint, reader)
request.Header.Add("Client-ID", twitchGqlClientId)
response, err := decodeJsonFromRequest[[]twitchOperationResponse](defaultHTTPClient, request)
if err != nil {
return result, err
}
if len(response) != 2 {
return result, fmt.Errorf("expected 2 operation responses, got %d", len(response))
}
var channelShell twitchChannelShellOperationResponse
var streamMetadata twitchStreamMetadataOperationResponse
for i := range response {
switch response[i].Extensions.OperationName {
case "ChannelShell":
if err = json.Unmarshal(response[i].Data, &channelShell); err != nil {
return result, fmt.Errorf("unmarshalling channel shell: %w", err)
}
case "StreamMetadata":
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)
}
}
if channelShell.UserOrError.Type != "User" {
result.Name = result.Login
return result, nil
}
result.Exists = true
result.Name = channelShell.UserOrError.DisplayName
result.AvatarUrl = channelShell.UserOrError.ProfileImageUrl
if channelShell.UserOrError.Stream != nil {
result.IsLive = true
result.ViewersCount = channelShell.UserOrError.Stream.ViewersCount
if streamMetadata.UserOrNull != nil && streamMetadata.UserOrNull.Stream != nil {
if streamMetadata.UserOrNull.LastBroadcast != nil {
result.StreamTitle = streamMetadata.UserOrNull.LastBroadcast.Title
}
if streamMetadata.UserOrNull.Stream.Game != nil {
result.Category = streamMetadata.UserOrNull.Stream.Game.Name
result.CategorySlug = streamMetadata.UserOrNull.Stream.Game.Slug
}
startedAt, err := time.Parse("2006-01-02T15:04:05Z", streamMetadata.UserOrNull.Stream.StartedAt)
if err == nil {
result.LiveSince = startedAt
} else {
slog.Warn("Failed to parse Twitch stream started at", "error", err, "started_at", streamMetadata.UserOrNull.Stream.StartedAt)
}
}
}
return result, nil
}
func fetchChannelsFromTwitch(channelLogins []string) (twitchChannelList, error) {
result := make(twitchChannelList, 0, len(channelLogins))
job := newJob(fetchChannelFromTwitchTask, channelLogins).withWorkers(10)
channels, errs, err := workerPoolDo(job)
if err != nil {
return result, err
}
var failed int
for i := range channels {
if errs[i] != nil {
failed++
slog.Error("Failed to fetch Twitch channel", "channel", channelLogins[i], "error", errs[i])
continue
}
result = append(result, channels[i])
}
if failed == len(channelLogins) {
return result, errNoContent
}
if failed > 0 {
return result, fmt.Errorf("%w: failed to fetch %d channels", errPartialContent, failed)
}
return result, nil
}

View File

@@ -0,0 +1,125 @@
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](defaultHTTPClient, 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
}

View File

@@ -0,0 +1,219 @@
package glance
import (
"context"
"crypto/tls"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"sync"
"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 defaultHTTPClient = &http.Client{
Timeout: defaultClientTimeout,
}
var defaultInsecureHTTPClient = &http.Client{
Timeout: defaultClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
type requestDoer interface {
Do(*http.Request) (*http.Response, error)
}
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) {
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
}
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,
truncatedBody,
)
}
err = json.Unmarshal(body, &result)
if err != nil {
return result, err
}
return result, nil
}
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) {
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
}
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,
truncatedBody,
)
}
err = xml.Unmarshal(body, &result)
if err != nil {
return result, err
}
return result, nil
}
func decodeXmlFromRequestTask[T any](client requestDoer) func(*http.Request) (T, error) {
return func(request *http.Request) (T, error) {
return decodeXmlFromRequest[T](client, request)
}
}
type workerPoolTask[I any, O any] struct {
index int
input I
output O
err error
}
type workerPoolJob[I any, O any] struct {
data []I
workers int
task func(I) (O, error)
ctx context.Context
}
const defaultNumWorkers = 10
func (job *workerPoolJob[I, O]) withWorkers(workers int) *workerPoolJob[I, O] {
if workers == 0 {
job.workers = defaultNumWorkers
} else if workers > len(job.data) {
job.workers = len(job.data)
} else {
job.workers = workers
}
return job
}
// func (job *workerPoolJob[I, O]) withContext(ctx context.Context) *workerPoolJob[I, O] {
// if ctx != nil {
// job.ctx = ctx
// }
// return job
// }
func newJob[I any, O any](task func(I) (O, error), data []I) *workerPoolJob[I, O] {
return &workerPoolJob[I, O]{
workers: defaultNumWorkers,
task: task,
data: data,
ctx: context.Background(),
}
}
func workerPoolDo[I any, O any](job *workerPoolJob[I, O]) ([]O, []error, error) {
results := make([]O, len(job.data))
errs := make([]error, len(job.data))
if len(job.data) == 0 {
return results, errs, nil
}
tasksQueue := make(chan *workerPoolTask[I, O])
resultsQueue := make(chan *workerPoolTask[I, O])
var wg sync.WaitGroup
for range job.workers {
wg.Add(1)
go func() {
defer wg.Done()
for t := range tasksQueue {
t.output, t.err = job.task(t.input)
resultsQueue <- t
}
}()
}
var err error
go func() {
loop:
for i := range job.data {
select {
default:
tasksQueue <- &workerPoolTask[I, O]{
index: i,
input: job.data[i],
}
case <-job.ctx.Done():
err = job.ctx.Err()
break loop
}
}
close(tasksQueue)
wg.Wait()
close(resultsQueue)
}()
for task := range resultsQueue {
errs[task.index] = task.err
results[task.index] = task.output
}
return results, errs, err
}

View File

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

View File

@@ -0,0 +1,326 @@
package glance
import (
"context"
"errors"
"fmt"
"html/template"
"math"
"net/http"
"net/url"
"slices"
"strings"
"time"
_ "time/tzdata"
)
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:"-"`
}
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
Longitude float64
Timezone string
Country string
location *time.Location
}
type openMeteoWeatherResponseJson struct {
Daily struct {
Sunrise []int64 `json:"sunrise"`
Sunset []int64 `json:"sunset"`
} `json:"daily"`
Hourly struct {
Temperature []float64 `json:"temperature_2m"`
PrecipitationProbability []int `json:"precipitation_probability"`
} `json:"hourly"`
Current struct {
Temperature float64 `json:"temperature_2m"`
ApparentTemperature float64 `json:"apparent_temperature"`
WeatherCode int `json:"weather_code"`
} `json:"current"`
}
type weatherColumn struct {
Temperature int
Scale float64
HasPrecipitation bool
}
var commonCountryAbbreviations = map[string]string{
"US": "United States",
"USA": "United States",
"UK": "United Kingdom",
}
func expandCountryAbbreviations(name string) string {
if expanded, ok := commonCountryAbbreviations[strings.TrimSpace(name)]; ok {
return expanded
}
return name
}
// Separates the location that Open Meteo accepts from the administrative area
// which can then be used to filter to the correct place after the list of places
// has been retrieved. Also expands abbreviations since Open Meteo does not accept
// country names like "US", "USA" and "UK"
func parsePlaceName(name string) (string, string) {
parts := strings.Split(name, ",")
if len(parts) == 1 {
return name, ""
}
if len(parts) == 2 {
return parts[0] + ", " + expandCountryAbbreviations(parts[1]), ""
}
return parts[0] + ", " + expandCountryAbbreviations(parts[2]), strings.TrimSpace(parts[1])
}
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[openMeteoPlacesResponseJson](defaultHTTPClient, request)
if err != nil {
return nil, fmt.Errorf("fetching places data: %v", err)
}
if len(responseJson.Results) == 0 {
return nil, fmt.Errorf("no places found for %s", location)
}
var place *openMeteoPlaceResponseJson
if area != "" {
area = strings.ToLower(area)
for i := range responseJson.Results {
if strings.ToLower(responseJson.Results[i].Area) == area {
place = &responseJson.Results[i]
break
}
}
if place == nil {
return nil, fmt.Errorf("no place found for %s in %s", location, area)
}
} else {
place = &responseJson.Results[0]
}
loc, err := time.LoadLocation(place.Timezone)
if err != nil {
return nil, fmt.Errorf("loading location: %v", err)
}
place.location = loc
return place, nil
}
func fetchWeatherForOpenMeteoPlace(place *openMeteoPlaceResponseJson, units string) (*weather, error) {
query := url.Values{}
var temperatureUnit string
if units == "imperial" {
temperatureUnit = "fahrenheit"
} else {
temperatureUnit = "celsius"
}
query.Add("latitude", fmt.Sprintf("%f", place.Latitude))
query.Add("longitude", fmt.Sprintf("%f", place.Longitude))
query.Add("timeformat", "unixtime")
query.Add("timezone", place.Timezone)
query.Add("forecast_days", "1")
query.Add("current", "temperature_2m,apparent_temperature,weather_code")
query.Add("hourly", "temperature_2m,precipitation_probability")
query.Add("daily", "sunrise,sunset")
query.Add("temperature_unit", temperatureUnit)
requestUrl := "https://api.open-meteo.com/v1/forecast?" + query.Encode()
request, _ := http.NewRequest("GET", requestUrl, nil)
responseJson, err := decodeJsonFromRequest[openMeteoWeatherResponseJson](defaultHTTPClient, request)
if err != nil {
return nil, fmt.Errorf("%w: %v", errNoContent, err)
}
now := time.Now().In(place.location)
bars := make([]weatherColumn, 0, 24)
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
}
if len(responseJson.Hourly.Temperature) == 24 {
temperatures := make([]int, 12)
precipitations := make([]bool, 12)
t := responseJson.Hourly.Temperature
p := responseJson.Hourly.PrecipitationProbability
for i := 0; i < 24; i += 2 {
if i/2 == currentBar {
temperatures[i/2] = int(responseJson.Current.Temperature)
} else {
temperatures[i/2] = int(math.Round((t[i] + t[i+1]) / 2))
}
precipitations[i/2] = (p[i]+p[i+1])/2 > 75
}
minT := slices.Min(temperatures)
maxT := slices.Max(temperatures)
temperaturesRange := float64(maxT - minT)
for i := 0; i < 12; i++ {
bars = append(bars, weatherColumn{
Temperature: temperatures[i],
HasPrecipitation: precipitations[i],
})
if temperaturesRange > 0 {
bars[i].Scale = float64(temperatures[i]-minT) / temperaturesRange
} else {
bars[i].Scale = 1
}
}
}
return &weather{
Temperature: int(responseJson.Current.Temperature),
ApparentTemperature: int(responseJson.Current.ApparentTemperature),
WeatherCode: responseJson.Current.WeatherCode,
CurrentColumn: currentBar,
SunriseColumn: sunriseBar,
SunsetColumn: sunsetBar,
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",
}

352
internal/glance/widget.go Normal file
View File

@@ -0,0 +1,352 @@
package glance
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"math"
"net/http"
"sync/atomic"
"time"
"gopkg.in/yaml.v3"
)
var widgetIDCounter atomic.Uint64
func newWidget(widgetType string) (widget, error) {
var w widget
switch widgetType {
case "calendar":
w = &calendarWidget{}
case "clock":
w = &clockWidget{}
case "weather":
w = &weatherWidget{}
case "bookmarks":
w = &bookmarksWidget{}
case "iframe":
w = &iframeWidget{}
case "html":
w = &htmlWidget{}
case "hacker-news":
w = &hackerNewsWidget{}
case "releases":
w = &releasesWidget{}
case "videos":
w = &videosWidget{}
case "markets", "stocks":
w = &marketsWidget{}
case "reddit":
w = &redditWidget{}
case "rss":
w = &rssWidget{}
case "monitor":
w = &monitorWidget{}
case "twitch-top-games":
w = &twitchGamesWidget{}
case "twitch-channels":
w = &twitchChannelsWidget{}
case "lobsters":
w = &lobstersWidget{}
case "change-detection":
w = &changeDetectionWidget{}
case "repository":
w = &repositoryWidget{}
case "search":
w = &searchWidget{}
case "extension":
w = &extensionWidget{}
case "group":
w = &groupWidget{}
case "dns-stats":
w = &dnsStatsWidget{}
case "split-column":
w = &splitColumnWidget{}
case "custom-api":
w = &customAPIWidget{}
case "docker":
w = &dockerContainersWidget{}
default:
return nil, fmt.Errorf("unknown widget type: %s", widgetType)
}
w.setID(widgetIDCounter.Add(1))
return w, nil
}
type widgets []widget
func (w *widgets) UnmarshalYAML(node *yaml.Node) error {
var nodes []yaml.Node
if err := node.Decode(&nodes); err != nil {
return err
}
for _, node := range nodes {
meta := struct {
Type string `yaml:"type"`
}{}
if err := node.Decode(&meta); err != nil {
return err
}
widget, err := newWidget(meta.Type)
if err != nil {
return err
}
if err = node.Decode(widget); err != nil {
return err
}
*w = append(*w, widget)
}
return nil
}
type widget interface {
// These need to be exported because they get called in templates
Render() template.HTML
GetType() string
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
const (
cacheTypeInfinite cacheType = iota
cacheTypeDuration
cacheTypeOnTheHour
)
type widgetBase struct {
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 widgetProviders struct {
assetResolver func(string) string
}
func (w *widgetBase) requiresUpdate(now *time.Time) bool {
if w.cacheType == cacheTypeInfinite {
return false
}
if w.nextUpdate.IsZero() {
return true
}
return now.After(w.nextUpdate)
}
func (w *widgetBase) update(ctx context.Context) {
}
func (w *widgetBase) id() uint64 {
return w.ID
}
func (w *widgetBase) setID(id uint64) {
w.ID = id
}
func (w *widgetBase) setHideHeader(value bool) {
w.HideHeader = value
}
func (widget *widgetBase) handleRequest(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not implemented", http.StatusNotImplemented)
}
func (w *widgetBase) GetType() string {
return w.Type
}
func (w *widgetBase) setProviders(providers *widgetProviders) {
w.Providers = providers
}
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
slog.Error("Failed to render template", "error", err)
// need to immediately re-render with the error,
// otherwise risk breaking the page since the widget
// will likely be partially rendered with tags not closed.
w.templateBuffer.Reset()
err2 := t.Execute(&w.templateBuffer, data)
if err2 != nil {
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
}
}
return template.HTML(w.templateBuffer.String())
}
func (w *widgetBase) withTitle(title string) *widgetBase {
if w.Title == "" {
w.Title = title
}
return w
}
func (w *widgetBase) withTitleURL(titleURL string) *widgetBase {
if w.TitleURL == "" {
w.TitleURL = titleURL
}
return w
}
func (w *widgetBase) withCacheDuration(duration time.Duration) *widgetBase {
w.cacheType = cacheTypeDuration
if duration == -1 || w.CustomCacheDuration == 0 {
w.cacheDuration = duration
} else {
w.cacheDuration = time.Duration(w.CustomCacheDuration)
}
return w
}
func (w *widgetBase) withCacheOnTheHour() *widgetBase {
w.cacheType = cacheTypeOnTheHour
return w
}
func (w *widgetBase) withNotice(err error) *widgetBase {
w.Notice = err
return w
}
func (w *widgetBase) withError(err error) *widgetBase {
if err == nil && !w.ContentAvailable {
w.ContentAvailable = true
}
w.Error = err
return w
}
func (w *widgetBase) canContinueUpdateAfterHandlingErr(err error) bool {
// TODO: needs covering more edge cases.
// if there's partial content and we update early there's a chance
// the early update returns even less content than the initial update.
// need some kind of mechanism that tells us whether we should update early
// or not depending on the number of things that failed during the initial
// and subsequent update and how they failed - ie whether it was server
// error (like gateway timeout, do retry early) or client error (like
// hitting a rate limit, don't retry early). will require reworking a
// good amount of code in the feed package and probably having a custom
// error type that holds more information because screw wrapping errors.
// alternatively have a resource cache and only refetch the failed resources,
// then rebuild the widget.
if err != nil {
w.scheduleEarlyUpdate()
if !errors.Is(err, errPartialContent) {
w.withError(err)
w.withNotice(nil)
return false
}
w.withError(nil)
w.withNotice(err)
return true
}
w.withNotice(nil)
w.withError(nil)
w.scheduleNextUpdate()
return true
}
func (w *widgetBase) getNextUpdateTime() time.Time {
now := time.Now()
if w.cacheType == cacheTypeDuration {
return now.Add(w.cacheDuration)
}
if w.cacheType == cacheTypeOnTheHour {
return now.Add(time.Duration(
((60-now.Minute())*60)-now.Second(),
) * time.Second)
}
return time.Time{}
}
func (w *widgetBase) scheduleNextUpdate() *widgetBase {
w.nextUpdate = w.getNextUpdateTime()
w.updateRetriedTimes = 0
return w
}
func (w *widgetBase) scheduleEarlyUpdate() *widgetBase {
w.updateRetriedTimes++
if w.updateRetriedTimes > 5 {
w.updateRetriedTimes = 5
}
nextEarlyUpdate := time.Now().Add(time.Duration(math.Pow(float64(w.updateRetriedTimes), 2)) * time.Minute)
nextUsualUpdate := w.getNextUpdateTime()
if nextEarlyUpdate.After(nextUsualUpdate) {
w.nextUpdate = nextUsualUpdate
} else {
w.nextUpdate = nextEarlyUpdate
}
return w
}