Initial commit

This commit is contained in:
Svilen Markov
2024-04-27 20:10:24 +01:00
commit ec8ba40cf0
100 changed files with 6883 additions and 0 deletions

42
internal/glance/cli.go Normal file
View File

@@ -0,0 +1,42 @@
package glance
import (
"flag"
"os"
)
type CliIntent uint8
const (
CliIntentServe CliIntent = iota
CliIntentCheckConfig = iota
)
type CliOptions struct {
Intent CliIntent
ConfigPath string
}
func ParseCliOptions() (*CliOptions, error) {
flags := flag.NewFlagSet("", flag.ExitOnError)
checkConfig := flags.Bool("check-config", false, "Check whether the config is valid")
configPath := flags.String("config", "glance.yml", "Set config path")
err := flags.Parse(os.Args[1:])
if err != nil {
return nil, err
}
intent := CliIntentServe
if *checkConfig {
intent = CliIntentCheckConfig
}
return &CliOptions{
Intent: intent,
ConfigPath: *configPath,
}, nil
}

79
internal/glance/config.go Normal file
View File

@@ -0,0 +1,79 @@
package glance
import (
"fmt"
"io"
"gopkg.in/yaml.v3"
)
type Config struct {
Server Server `yaml:"server"`
Theme Theme `yaml:"theme"`
Pages []Page `yaml:"pages"`
}
func NewConfigFromYml(contents io.Reader) (*Config, error) {
config := NewConfig()
contentBytes, err := io.ReadAll(contents)
if err != nil {
return nil, err
}
err = yaml.Unmarshal(contentBytes, config)
if err != nil {
return nil, err
}
if err = configIsValid(config); err != nil {
return nil, err
}
return config, nil
}
func NewConfig() *Config {
config := &Config{}
config.Server.Host = ""
config.Server.Port = 8080
return config
}
func configIsValid(config *Config) error {
for i := range config.Pages {
if config.Pages[i].Title == "" {
return fmt.Errorf("Page %d has no title", i+1)
}
if len(config.Pages[i].Columns) == 0 {
return fmt.Errorf("Page %d has no columns", i+1)
}
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))
}
columnSizesCount := make(map[string]int)
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)
}
columnSizesCount[config.Pages[i].Columns[j].Size]++
}
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 nil
}

221
internal/glance/glance.go Normal file
View File

@@ -0,0 +1,221 @@
package glance
import (
"bytes"
"context"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/glanceapp/glance/internal/assets"
"github.com/glanceapp/glance/internal/widget"
)
var buildVersion = "dev"
var sequentialWhitespacePattern = regexp.MustCompile(`\s+`)
type Application struct {
Version string
Config Config
slugToPage map[string]*Page
}
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"`
}
type Server struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
AssetsPath string `yaml:"assets-path"`
StartedAt time.Time `yaml:"-"`
}
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"`
Columns []Column `yaml:"columns"`
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 NewApplication(config *Config) (*Application, error) {
if len(config.Pages) == 0 {
return nil, fmt.Errorf("no pages configured")
}
app := &Application{
Version: buildVersion,
Config: *config,
slugToPage: make(map[string]*Page),
}
app.slugToPage[""] = &config.Pages[0]
for i := range config.Pages {
if config.Pages[i].Slug == "" {
config.Pages[i].Slug = titleToSlug(config.Pages[i].Title)
}
app.slugToPage[config.Pages[i].Slug] = &config.Pages[i]
}
return app, nil
}
func (a *Application) HandlePageRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")]
if !exists {
a.HandleNotFound(w, r)
return
}
pageData := templateData{
Page: page,
App: a,
}
var responseBytes bytes.Buffer
err := assets.PageTemplate.Execute(&responseBytes, pageData)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Write(responseBytes.Bytes())
}
func (a *Application) HandlePageContentRequest(w http.ResponseWriter, r *http.Request) {
page, exists := a.slugToPage[r.PathValue("page")]
if !exists {
a.HandleNotFound(w, r)
return
}
pageData := templateData{
Page: page,
}
page.mu.Lock()
defer page.mu.Unlock()
page.UpdateOutdatedWidgets()
var responseBytes bytes.Buffer
err := assets.PageContentTemplate.Execute(&responseBytes, pageData)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(err.Error()))
return
}
w.Write(responseBytes.Bytes())
}
func (a *Application) HandleNotFound(w http.ResponseWriter, r *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) Serve() 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 /api/pages/{page}/content/{$}", a.HandlePageContentRequest)
mux.Handle("GET /static/{path...}", http.StripPrefix("/static/", FileServerWithCache(http.FS(assets.PublicFS), 2*time.Hour)))
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)
mux.Handle("/assets/{path...}", http.StripPrefix("/assets/", assetsFS))
}
server := http.Server{
Addr: fmt.Sprintf("%s:%d", a.Config.Server.Host, a.Config.Server.Port),
Handler: mux,
}
a.Config.Server.StartedAt = time.Now()
slog.Info("Starting server", "host", a.Config.Server.Host, "port", a.Config.Server.Port)
return server.ListenAndServe()
}

46
internal/glance/main.go Normal file
View File

@@ -0,0 +1,46 @@
package glance
import (
"fmt"
"os"
)
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)
if err != nil {
fmt.Printf("failed creating application: %v\n", err)
return 1
}
if app.Serve() != nil {
fmt.Printf("http server error: %v\n", err)
return 1
}
}
return 0
}