This commit is contained in:
Simon Martens
2025-03-05 16:41:39 +01:00
commit e19fd47c17
88 changed files with 9765 additions and 0 deletions

124
config/config.go Normal file
View File

@@ -0,0 +1,124 @@
package config
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/kelseyhightower/envconfig"
)
const (
DEFAULT_DIR = "cache"
DEFAULT_GIT_CACHE_DIR = "git"
DEFAULT_GND_CACHE_DIR = "gnd"
DEFAULT_GEO_CACHE_DIR = "geo"
DEFAULT_SEARCH_CACHE_DIR = "search"
DEFAULT_IMG_DIR = "data_bilder"
DEFAULT_PORT = "8080"
DEFAULT_ADDR = "localhost"
DEFAULT_HTTPS = false
ENV_PREFIX = "KGPZ"
)
type ConfigProvider struct {
Files []string
*Config
}
type Config struct {
// At least one of these should be set
BaseDIR string `json:"base_dir" envconfig:"BASE_DIR"`
GitURL string `json:"git_url" envconfig:"GIT_URL"`
GitBranch string `json:"git_branch" envconfig:"GIT_BRANCH"`
GITPath string `json:"git_path" envconfig:"GIT_PATH"`
GNDPath string `json:"gnd_path" envconfig:"GND_PATH"`
GeoPath string `json:"geo_path" envconfig:"GEO_PATH"`
WebHookEndpoint string `json:"webhook_endpoint" envconfig:"WEBHOOK_ENDPOINT"`
WebHookSecret string `json:"webhook_secret" envconfig:"WEBHOOK_SECRET"`
Debug bool `json:"debug" envconfig:"DEBUG"`
Watch bool `json:"watch" envconfig:"WATCH"`
LogData bool `json:"log_data" envconfig:"LOG_DATA"`
Address string `json:"address" envconfig:"ADDRESS"`
Port string `json:"port" envconfig:"PORT"`
Https bool `json:"https" envconfig:"HTTPS"`
}
func NewConfigProvider(files []string) *ConfigProvider {
return &ConfigProvider{Files: files}
}
func (c *ConfigProvider) Read() error {
c.Config = &Config{}
for _, file := range c.Files {
c.Config = readSettingsFile(c.Config, file)
}
c.Config = readSettingsEnv(c.Config)
c.Config = readDefaults(c.Config)
return nil
}
func (c *ConfigProvider) Validate() error {
if strings.TrimSpace(c.Config.BaseDIR) == "" {
return fmt.Errorf("Base directory path not set")
}
return nil
}
func readSettingsFile(cfg *Config, path string) *Config {
f, err := os.Open(path)
if err != nil {
return cfg
}
defer f.Close()
dec := json.NewDecoder(f)
err = dec.Decode(cfg)
return cfg
}
func readSettingsEnv(cfg *Config) *Config {
_ = envconfig.Process(ENV_PREFIX, cfg)
return cfg
}
func readDefaults(cfg *Config) *Config {
if strings.TrimSpace(cfg.BaseDIR) == "" {
cfg.BaseDIR = DEFAULT_DIR
}
if strings.TrimSpace(cfg.GITPath) == "" {
cfg.GITPath = DEFAULT_GIT_CACHE_DIR
}
if strings.TrimSpace(cfg.GNDPath) == "" {
cfg.GNDPath = DEFAULT_GND_CACHE_DIR
}
if strings.TrimSpace(cfg.GeoPath) == "" {
cfg.GeoPath = DEFAULT_GEO_CACHE_DIR
}
if strings.TrimSpace(cfg.Address) == "" {
cfg.Address = DEFAULT_ADDR
}
if strings.TrimSpace(cfg.Port) == "" {
cfg.Port = DEFAULT_PORT
}
return cfg
}
func (c *Config) String() string {
json, err := json.Marshal(c)
if err != nil {
return "Config: Error marshalling to JSON"
}
return string(json)
}

23
config/public.go Normal file
View File

@@ -0,0 +1,23 @@
package config
import "sync"
var cp *ConfigProvider
var mu = sync.Mutex{}
func Get() Config {
mu.Lock()
defer mu.Unlock()
if cp == nil {
cp = NewConfigProvider([]string{"config.dev.json", "config.json"})
}
return *cp.Config
}
func Set(config Config) {
mu.Lock()
defer mu.Unlock()
cp = &ConfigProvider{Config: &config}
}

245
git/git.go Normal file
View File

@@ -0,0 +1,245 @@
package providers
import (
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
var InvalidBranchError = errors.New("The currently checked out branch does not match the requested branch. Please checkout the correct branch first.")
var InvalidStateError = errors.New("The GitProvider is not in a valid state. Fix the issues or continue without Git data.")
var NoURLProvidedError = errors.New("No URL provided for GitProvider.")
var NoPathProvidedError = errors.New("No path or branch provided for GitProvider.")
// NOTE: GitProvider does not open any worktree files, it only
// - reads in information from the repo, given a path
// - clones a repo, given an URL & a path
// - pulls a repo, given a path
// In case of success it updates it's state: the commit hash and date. Then it closes the repo.
type GitProvider struct {
mu sync.Mutex
URL string
Path string
Branch string
Commit string
Date time.Time
}
func NewGitProvider(url string, path string, branch string) (*GitProvider, error) {
// TODO: check if directory is empty
// TODO: force clone
if _, err := os.Stat(path); err == nil {
return GitProviderFromPath(path, branch)
}
return GitProviderFromURL(url, path, branch)
}
func GitProviderFromPath(path string, branch string) (*GitProvider, error) {
if branch == "" || path == "" {
return nil, NoPathProvidedError
}
gp := GitProvider{Path: path, Branch: branch}
if err := gp.Read(); err != nil {
return nil, err
}
return &gp, nil
}
func GitProviderFromURL(url string, path string, branch string) (*GitProvider, error) {
if url == "" {
return nil, NoURLProvidedError
}
if branch == "" || path == "" {
return nil, NoPathProvidedError
}
gp := GitProvider{URL: url, Path: path, Branch: branch}
if err := gp.Clone(); err != nil {
return nil, err
}
return &gp, nil
}
// Returs true if the repo was updated remotely, false otherwise
func (g *GitProvider) Pull() (error, bool) {
g.mu.Lock()
defer g.mu.Unlock()
branch := plumbing.NewBranchReferenceName(g.Branch)
repo, err := git.PlainOpen(g.Path)
if err != nil {
return err, false
}
wt, err := repo.Worktree()
if err != nil {
return err, false
}
if err := wt.Pull(&git.PullOptions{
RemoteName: "origin",
ReferenceName: branch,
Progress: os.Stdout,
}); err != nil {
if err == git.NoErrAlreadyUpToDate {
return nil, false
}
return err, false
}
defer wt.Clean(&git.CleanOptions{Dir: true})
oldCommit := g.Commit
if err := g.setValues(repo); err != nil {
return err, false
}
if oldCommit == g.Commit {
return nil, false
}
return nil, true
}
func (g *GitProvider) Clone() error {
if g.URL == "" {
return NoURLProvidedError
}
g.mu.Lock()
defer g.mu.Unlock()
branch := plumbing.NewBranchReferenceName(g.Branch)
repo, err := git.PlainClone(g.Path, false, &git.CloneOptions{
URL: g.URL,
Progress: os.Stdout,
})
if err != nil {
return err
}
wt, err := repo.Worktree()
if err != nil {
return err
}
defer wt.Clean(&git.CleanOptions{Dir: true})
if err := wt.Checkout(&git.CheckoutOptions{
Branch: branch,
Force: true,
}); err != nil {
return err
}
return g.setValues(repo)
}
// Implement String Interface
func (g *GitProvider) String() string {
return fmt.Sprintf("GitProvider\nURL: %s\nPath: %s\nBranch: %s\nCommit: %s\nDate: %s\n", g.URL, g.Path, g.Branch, g.Commit, g.Date)
}
func (g *GitProvider) setValues(repo *git.Repository) error {
log, err := repo.Log(&git.LogOptions{})
if err != nil {
return err
}
defer log.Close()
commit, err := log.Next()
if err != nil {
return err
}
g.Commit = commit.Hash.String()
g.Date = commit.Author.When
return nil
}
func (g *GitProvider) Read() error {
g.mu.Lock()
defer g.mu.Unlock()
repo, err := git.PlainOpen(g.Path)
if err != nil {
return err
}
if err := g.ValidateBranch(repo); err != nil {
branch := plumbing.NewBranchReferenceName(g.Branch)
wt, err := repo.Worktree()
if err != nil {
return err
}
defer wt.Clean(&git.CleanOptions{Dir: true})
if err := wt.Checkout(&git.CheckoutOptions{
Branch: branch,
Force: true,
}); err != nil {
return err
}
if err := g.ValidateBranch(repo); err != nil {
return err
}
}
return g.setValues(repo)
}
func (g *GitProvider) Validate() error {
repo, err := git.PlainOpen(g.Path)
if err != nil {
return err
}
if err := g.ValidateBranch(repo); err != nil {
return err
}
if err := g.ValidateCommit(); err != nil {
return err
}
return nil
}
func (g *GitProvider) ValidateBranch(repo *git.Repository) error {
head, err := repo.Head()
if err != nil {
return err
}
cbranch := head.Name().Short()
if cbranch != g.Branch {
return InvalidBranchError
}
return nil
}
func (g *GitProvider) Wait() {
g.mu.Lock()
defer g.mu.Unlock()
}
func (g *GitProvider) ValidateCommit() error {
if g.Commit == "" || g.Date.IsZero() {
return InvalidStateError
}
return nil
}

32
go.mod Normal file
View File

@@ -0,0 +1,32 @@
module github.com/Theodor-Springmann-Stiftung/lenz-web
go 1.24.0
require (
github.com/go-git/go-git/v5 v5.14.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/yalue/merged_fs v1.3.0
golang.org/x/net v0.36.0
golang.org/x/text v0.22.0
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.5 // indirect
github.com/cloudflare/circl v1.6.0 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

106
go.sum Normal file
View File

@@ -0,0 +1,106 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4=
github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yalue/merged_fs v1.3.0 h1:qCeh9tMPNy/i8cwDsQTJ5bLr6IRxbs6meakNE5O+wyY=
github.com/yalue/merged_fs v1.3.0/go.mod h1:WqqchfVYQyclV2tnR7wtRhBddzBvLVR83Cjw9BKQw0M=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

113
helpers/functions/date.go Normal file
View File

@@ -0,0 +1,113 @@
package functions
import (
"strconv"
"github.com/Theodor-Springmann-Stiftung/lenz-web/helpers/xsdtime"
)
type Month struct {
Full string
Short string
Number string
No int
}
type Weekday struct {
Full string
Short string
No int
}
func (m Month) String() string {
return m.Full
}
func (w Weekday) String() string {
return w.Full
}
var TRANSLM = []Month{
{"NotAvailable", "NA", "0", 0},
{"Januar", "Jan", "1", 1},
{"Februar", "Feb", "2", 2},
{"März", "Mär", "3", 3},
{"April", "Apr", "4", 4},
{"Mai", "Mai", "5", 5},
{"Juni", "Jun", "6", 6},
{"Juli", "Jul", "7", 7},
{"August", "Aug", "8", 8},
{"September", "Sep", "9", 9},
{"Oktober", "Okt", "10", 10},
{"November", "Nov", "11", 11},
{"Dezember", "Dez", "12", 12},
}
var TRANSLD = []Weekday{
{"NotAvailable", "NA", 0},
{"Montag", "Mo", 1},
{"Dienstag", "Di", 2},
{"Mittwoch", "Mi", 3},
{"Donnerstag", "Do", 4},
{"Freitag", "Fr", 5},
{"Samstag", "Sa", 6},
{"Sonntag", "So", 7},
}
func HRDateShort(date string) string {
xsdt, err := xsdtime.New(date)
if err != nil {
return ""
}
t := xsdt.Type()
if t == xsdtime.GYear {
return strconv.Itoa(xsdt.Year)
}
if t == xsdtime.GYearMonth {
return strconv.Itoa(xsdt.Month) + "." + strconv.Itoa(xsdt.Year)
}
if t == xsdtime.Date {
return strconv.Itoa(xsdt.Day) + "." + strconv.Itoa(xsdt.Month) + "." + strconv.Itoa(xsdt.Year)
}
return ""
}
func HRDateYear(date string) string {
xsdt, err := xsdtime.New(date)
if err != nil {
return ""
}
t := xsdt.Type()
if t == xsdtime.GYear {
return strconv.Itoa(xsdt.Year)
}
if t == xsdtime.GYearMonth {
return strconv.Itoa(xsdt.Year)
}
if t == xsdtime.Date {
return strconv.Itoa(xsdt.Year)
}
return ""
}
func MonthName(i int) Month {
if i > 12 || i < 1 {
return TRANSLM[0]
}
return TRANSLM[i]
}
func WeekdayName(i int) Weekday {
if i > 7 || i < 1 {
return TRANSLD[0]
}
return TRANSLD[i]
}

View File

@@ -0,0 +1,93 @@
package functions
import (
"html/template"
"io"
"io/fs"
"path/filepath"
"strings"
"sync"
)
type Embedder struct {
embed_cache sync.Map
fs fs.FS
}
func NewEmbedder(fs fs.FS) *Embedder {
return &Embedder{
fs: fs,
embed_cache: sync.Map{},
}
}
// INFO: We initialize the cache in both functions, which is only valid if both of these get
// called in the same context, eg. when creating a template engine.
func (e *Embedder) EmbedSafe() func(string) template.HTML {
return func(path string) template.HTML {
path = strings.TrimSpace(path)
path = filepath.Clean(path)
val, err := e.getFileData(path)
if err != nil {
return template.HTML("")
}
return template.HTML(val)
}
}
func (e *Embedder) Embed() func(string) string {
return func(path string) string {
path = strings.TrimSpace(path)
path = filepath.Clean(path)
val, err := e.getFileData(path)
if err != nil {
return ""
}
return string(val)
}
}
func (e *Embedder) getFileData(path string) ([]byte, error) {
if val, ok := e.embed_cache.Load(path); ok {
return val.([]byte), nil
}
f, err := e.fs.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
e.embed_cache.Store(path, data)
return data, nil
}
func (e *Embedder) EmbedXSLT() func(string) template.HTML {
return func(path string) template.HTML {
path = strings.TrimSpace(path)
path = filepath.Clean(path)
fn := filepath.Base(path)
ext := filepath.Ext(fn)
fn = fn[:len(fn)-len(ext)]
if (ext != ".xsl" && ext != ".xslt") || ext == "" || fn == "" {
return template.HTML("[ERROR: " + "file is not an XSLT file" + "]")
}
val, err := e.getFileData(path)
if err != nil {
return template.HTML("[ERROR: " + err.Error() + "]")
}
src := "<script id=\"" + fn + "\" type=\"application/xml\">\n" + string(val) + "\n</script>"
return template.HTML(src)
}
}

View File

@@ -0,0 +1,18 @@
package functions
func MapArrayInsert[K comparable, V any](m map[K][]V, k K, v V) {
l, ok := m[k]
if !ok {
m[k] = []V{v}
} else {
m[k] = append(l, v)
}
}
func Keys[K comparable, V any](m map[K]V) []K {
keys := make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

View File

@@ -0,0 +1 @@
package functions

View File

@@ -0,0 +1,12 @@
package functions
import (
"golang.org/x/text/collate"
"golang.org/x/text/language"
)
func Sort(s []string) []string {
c := collate.New(language.German, collate.IgnoreCase)
c.SortStrings(s)
return s
}

View File

@@ -0,0 +1,17 @@
package functions
import "html/template"
func FirstLetter(s string) string {
if len(s) == 0 {
return ""
}
return string(s[:1])
}
func Safe(s string) template.HTML {
if len(s) == 0 {
return ""
}
return template.HTML(s)
}

5
lenz.go Normal file
View File

@@ -0,0 +1,5 @@
package main
func main() {
println("Hello, World!")
}

0
server/server.go Normal file
View File

14
templating/consts.go Normal file
View File

@@ -0,0 +1,14 @@
package templating
var TEMPLATE_FORMATS = []string{".html", ".tmpl", ".gotmpl", ".gotemplate", ".gohtml", ".gohtmltemplate"}
const TEMPLATE_GLOBAL_CONTEXT_NAME = "globals"
const TEMPLATE_ROOT_NAME = "root"
const TEMPLATE_LOCAL_CONTEXT_NAME = "locals"
const TEMPLATE_GLOBAL_PREFIX = "_"
const TEMPLATE_COMPONENT_DIRECTORY = "components"
const TEMPLATE_HEAD = "head"
const TEMPLATE_BODY = "body"
const TEMPLATE_HEADERS = "headers"
const ROOT_LAYOUT_NAME = "root"
const DEFAULT_LAYOUT_NAME = "default"

148
templating/context.go Normal file
View File

@@ -0,0 +1,148 @@
package templating
import (
"html/template"
"io/fs"
"path/filepath"
"slices"
"strings"
)
type TemplateContext struct {
// WARNING: Path is a URL path, NOT a filesystem path
Path string
// WARNING: The keys of these maps are template names, NOT filesystem paths
// The values are FS paths absolute from the root directory of the templates FS
locals map[string]string
globals map[string]string
}
func NewTemplateContext(path string) TemplateContext {
return TemplateContext{
Path: path,
locals: make(map[string]string),
globals: make(map[string]string),
}
}
func (c *TemplateContext) Parse(fsys fs.FS) error {
fspath := PathToFSPath(c.Path)
entries, err := fs.ReadDir(fsys, fspath)
if err != nil {
return NewError(InvalidPathError, c.Path)
}
for _, e := range entries {
if e.IsDir() {
// INFO: components in the components directory can be overwritten
// by components in the base directory down below
// TODO: Maybe allow for subdirectories in the components directory?
if e.Name() == TEMPLATE_COMPONENT_DIRECTORY {
entries, err := fs.ReadDir(fsys, filepath.Join(fspath, e.Name()))
if err != nil {
return NewError(FileAccessError, filepath.Join(fspath, e.Name()))
}
for _, e := range entries {
ext := filepath.Ext(e.Name())
if !slices.Contains(TEMPLATE_FORMATS, ext) {
continue
}
name := strings.TrimSuffix(e.Name(), ext)
if strings.HasPrefix(e.Name(), TEMPLATE_GLOBAL_PREFIX) {
c.globals[name] = filepath.Join(fspath, TEMPLATE_COMPONENT_DIRECTORY, e.Name())
} else {
c.locals[name] = filepath.Join(fspath, TEMPLATE_COMPONENT_DIRECTORY, e.Name())
}
}
continue
}
}
ext := filepath.Ext(e.Name())
if !slices.Contains(TEMPLATE_FORMATS, ext) {
continue
}
name := strings.TrimSuffix(e.Name(), ext)
if strings.HasPrefix(e.Name(), TEMPLATE_GLOBAL_PREFIX) {
c.globals[name] = filepath.Join(fspath, e.Name())
} else {
c.locals[name] = filepath.Join(fspath, e.Name())
}
}
return nil
}
func (c *TemplateContext) SetGlobals(globals map[string]string) error {
// INFO: this allows for overwriting of existing global keys.
// Make sure to call this appopriately before or after Parse(), depending on your use case
for k, v := range globals {
c.globals[k] = v
}
return nil
}
func (c *TemplateContext) Globals() map[string]string {
return c.globals
}
func (c *TemplateContext) Template(fsys fs.FS, funcmap *template.FuncMap) (*template.Template, error) {
// TODO: locals need to be in order: root, head, body
t, err := readTemplates(fsys, nil, c.locals, funcmap)
if err != nil {
return nil, err
}
t, err = readTemplates(fsys, t, c.globals, funcmap)
if err != nil {
return nil, err
}
return t, nil
}
func readTemplates(fsys fs.FS, t *template.Template, paths map[string]string, funcmap *template.FuncMap) (*template.Template, error) {
for k, v := range paths {
text, err := fs.ReadFile(fsys, v)
if err != nil {
return nil, NewError(FileAccessError, v)
}
temp := template.New(k)
if funcmap != nil {
temp.Funcs(*funcmap)
}
temp, err = temp.Parse(string(text))
if err != nil {
return nil, err
}
if t == nil {
t = temp
continue
}
for _, template := range temp.Templates() {
_, err = t.AddParseTree(template.Name(), template.Tree)
if err != nil {
return nil, err
}
}
_, err = t.AddParseTree(temp.Name(), temp.Tree)
if err != nil {
return nil, err
}
}
return t, nil
}

249
templating/engine.go Normal file
View File

@@ -0,0 +1,249 @@
package templating
import (
"html/template"
"io"
"io/fs"
"log"
"net/http"
"sync"
"github.com/Theodor-Springmann-Stiftung/lenz-web/helpers/functions"
"golang.org/x/net/websocket"
)
const (
ASSETS_URL_PREFIX = "/assets"
RELOAD_TEMPLATE = `
<script type="module">
(function () {
let relto = -1;
const scheme = location.protocol === "https:" ? "wss" : "ws";
// Hardcode port 9000 here:
const url = scheme + "://" + location.hostname + ":9000/pb/reload";
function connect() {
const socket = new WebSocket(url);
socket.addEventListener("open", function () {
console.log("Reload socket connected (port 9000).");
});
socket.addEventListener("message", function (evt) {
if (evt.data === "reload") {
console.log("Received reload signal. Reloading...");
if (relto !== -1) clearTimeout(relto);
relto = setTimeout(() => location.reload(), 0);
}
});
socket.addEventListener("close", function () {
console.log("Reload socket closed. Reconnecting in 3 seconds...");
setTimeout(connect, 3000);
});
socket.addEventListener("error", function (err) {
console.error("Reload socket error:", err);
// We'll let onclose handle reconnection.
});
}
// Initiate the first connection attempt.
connect();
})();
</script>
`
)
type Engine struct {
regmu *sync.Mutex
debug bool
ws *WsServer
onceWS sync.Once
// NOTE: LayoutRegistry and TemplateRegistry have their own syncronization & cache and do not require a mutex here
LayoutRegistry *LayoutRegistry
TemplateRegistry *TemplateRegistry
mu *sync.Mutex
FuncMap template.FuncMap
GlobalData map[string]interface{}
}
// INFO: We pass the app here to be able to access the config and other data for functions
// which also means we must reload the engine if the app changes
func NewEngine(layouts, templates *fs.FS) *Engine {
e := Engine{
regmu: &sync.Mutex{},
mu: &sync.Mutex{},
LayoutRegistry: NewLayoutRegistry(*layouts),
TemplateRegistry: NewTemplateRegistry(*templates),
FuncMap: make(template.FuncMap),
GlobalData: make(map[string]interface{}),
}
e.funcs()
return &e
}
func (e *Engine) Debug() {
e.debug = true
e.onceWS.Do(func() {
e.ws = NewWsServer()
go e.startWsServerOnPort9000()
})
}
func (e *Engine) startWsServerOnPort9000() {
// We'll create a basic default mux here and mount /pb/reload
mux := http.NewServeMux()
mux.Handle("/pb/reload", websocket.Handler(e.ws.Handler))
log.Println("[Engine Debug] Starting separate WebSocket server on :9000 for live reload...")
if err := http.ListenAndServe(":9000", mux); err != nil {
log.Println("[Engine Debug] WebSocket server error:", err)
}
}
func (e *Engine) funcs() error {
e.mu.Lock()
e.mu.Unlock()
// Passing HTML
e.AddFunc("Safe", functions.Safe)
// Creating an array or dict (to pass to a template)
// e.AddFunc("Arr", functions.Arr)
// e.AddFunc("Dict", functions.Dict)
// Datatype Functions
// e.AddFunc("HasPrefix", strings.HasPrefix)
// e.AddFunc("Contains", functions.Contains)
// e.AddFunc("Add", functions.Add)
// e.AddFunc("Len", functions.Length)
// String Functions
// e.AddFunc("Lower", functions.Lower)
// e.AddFunc("Upper", functions.Upper)
// e.AddFunc("First", functions.First)
// e.AddFunc("ReplaceSlashParen", functions.ReplaceSlashParen)
// e.AddFunc("ReplaceSlashParenSlash", functions.ReplaceSlashParenSlash)
// e.AddFunc("LinksAnnotation", functions.LinksAnnotation)
//
// Time & Date Functions
// e.AddFunc("Today", functions.Today)
// e.AddFunc("GetMonth", functions.GetMonth)
//
// // TOC
// e.AddFunc("TOCFromHTML", functions.TOCFromHTML)
return nil
}
func (e *Engine) Globals(data map[string]interface{}) {
e.mu.Lock()
defer e.mu.Unlock()
if e.GlobalData == nil {
e.GlobalData = data
} else {
for k, v := range data {
(e.GlobalData)[k] = v
}
}
}
func (e *Engine) Load() {
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
e.LayoutRegistry.Load()
}()
go func() {
defer wg.Done()
e.TemplateRegistry.Load()
}()
wg.Wait()
}
func (e *Engine) Reload() {
e.regmu.Lock()
defer e.regmu.Unlock()
e.LayoutRegistry = e.LayoutRegistry.Reset()
e.TemplateRegistry = e.TemplateRegistry.Reset()
e.Load()
}
func (e *Engine) Refresh() {
if e.debug && e.ws != nil {
e.ws.BroadcastReload()
}
}
// INFO: fn is a function that returns either one value or two values, the second one being an error
func (e *Engine) AddFunc(name string, fn interface{}) {
e.mu.Lock()
defer e.mu.Unlock()
e.FuncMap[name] = fn
}
func (e *Engine) AddFuncs(funcs map[string]interface{}) {
e.mu.Lock()
defer e.mu.Unlock()
for k, v := range funcs {
e.FuncMap[k] = v
}
}
func (e *Engine) Render(out io.Writer, path string, ld map[string]interface{}, layout ...string) error {
gd := e.GlobalData
if ld == nil {
ld = make(map[string]interface{})
}
// INFO: don't pollute the global data space
for k, v := range gd {
_, ok := ld[k]
if !ok {
ld[k] = v
}
}
e.mu.Lock()
defer e.mu.Unlock()
e.regmu.Lock()
defer e.regmu.Unlock()
var l *template.Template
if layout == nil || len(layout) == 0 {
lay, err := e.LayoutRegistry.Default(&e.FuncMap)
if err != nil {
return err
}
l = lay
} else {
lay, err := e.LayoutRegistry.Layout(layout[0], &e.FuncMap)
if err != nil {
return err
}
l = lay
}
lay, err := l.Clone()
if err != nil {
return err
}
err = e.TemplateRegistry.Add(path, lay, &e.FuncMap)
if err != nil {
return err
}
err = lay.Execute(out, ld)
if err != nil {
return err
}
return nil
}

21
templating/errors.go Normal file
View File

@@ -0,0 +1,21 @@
package templating
import "errors"
var InvalidPathError = errors.New("Invalid path. Must be a directory.")
var NoTemplateError = errors.New("No template found for this name")
var InvalidTemplateError = errors.New("invalid template")
var FileAccessError = errors.New("could not stat file or directory")
type FSError[T error] struct {
File string
Err T
}
func NewError[T error](t T, file string) FSError[T] {
return FSError[T]{File: file, Err: t}
}
func (e FSError[T]) Error() string {
return e.Err.Error() + ": " + e.File
}

32
templating/helpers.go Normal file
View File

@@ -0,0 +1,32 @@
package templating
import "strings"
func PathToFSPath(p string) string {
if p == "/" {
return "."
}
p = strings.TrimPrefix(p, "/")
p = strings.TrimSuffix(p, "/")
return p
}
func FSPathToPath(p string) string {
if p == "." {
return "/"
}
p = strings.TrimPrefix(p, ".")
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if !strings.HasSuffix(p, "/") {
p = p + "/"
}
return p
}

View File

@@ -0,0 +1,108 @@
package templating
import (
"html/template"
"io/fs"
"sync"
"github.com/yalue/merged_fs"
)
// TODO: Implement Handler interface, maybe in template? But then template would need to know about the layout registry
// Function signature: func (t *templateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// A static Handler could incoporate both the layout registry and the template registry and serve templates that dont need any data
// INFO: this ist thread-safe and safe to call in a handler or middleware
type LayoutRegistry struct {
layoutsFS fs.FS
once sync.Once
// INFO: Layout & cache keys are template directory names
layouts map[string]TemplateContext
// WARNING: maybe this is too early for caching?
cache sync.Map
}
func NewLayoutRegistry(routes fs.FS) *LayoutRegistry {
return &LayoutRegistry{
layoutsFS: routes,
}
}
// NOTE: Upon registering a new layout dir, we return a new LayoutRegistry
func (r *LayoutRegistry) Register(fs fs.FS) *LayoutRegistry {
return NewLayoutRegistry(merged_fs.MergeMultiple(fs, r.layoutsFS))
}
func (r *LayoutRegistry) Reset() *LayoutRegistry {
return NewLayoutRegistry(r.layoutsFS)
}
func (r *LayoutRegistry) Load() error {
var outer error
r.once.Do(func() {
err := r.load()
if err != nil {
outer = err
}
})
return outer
}
func (r *LayoutRegistry) load() error {
layouts := make(map[string]TemplateContext)
rootcontext := NewTemplateContext(".")
err := rootcontext.Parse(r.layoutsFS)
if err != nil {
return err
}
globals := rootcontext.Globals()
entries, err := fs.ReadDir(r.layoutsFS, ".")
if err != nil {
return NewError(FileAccessError, ".")
}
for _, e := range entries {
if !e.IsDir() || e.Name() == TEMPLATE_COMPONENT_DIRECTORY {
continue
}
url := FSPathToPath(e.Name())
context := NewTemplateContext(url)
context.SetGlobals(globals)
context.Parse(r.layoutsFS)
layouts[e.Name()] = context
}
r.layouts = layouts
return nil
}
func (r *LayoutRegistry) Layout(name string, funcmap *template.FuncMap) (*template.Template, error) {
cached, ok := r.cache.Load(name)
if ok {
return cached.(*template.Template), nil
}
// TODO: What todo on errors?
r.Load()
context, ok := r.layouts[name]
if !ok {
return nil, NewError(NoTemplateError, name)
}
t, err := context.Template(r.layoutsFS, funcmap)
if err != nil {
return nil, err
}
r.cache.Store(name, t)
return t, nil
}
func (r *LayoutRegistry) Default(funcmap *template.FuncMap) (*template.Template, error) {
return r.Layout(DEFAULT_LAYOUT_NAME, funcmap)
}

View File

@@ -0,0 +1,121 @@
package templating
import (
"html/template"
"io/fs"
"os"
"strings"
"sync"
"github.com/yalue/merged_fs"
)
// INFO: this ist thread-safe and safe to call in a handler or middleware
type TemplateRegistry struct {
routesFS fs.FS
once sync.Once
// INFO: Template & cache keys are directory routing paths, with '/' as root
// INFO: we don't need a mutex here since this is set in Load() protected by Once().
templates map[string]TemplateContext
cache sync.Map
}
func NewTemplateRegistry(routes fs.FS) *TemplateRegistry {
return &TemplateRegistry{
routesFS: routes,
}
}
// INFO: This returns a new TemplateRegistry with the new fs added to the existing fs,
// merging with the existing FS, possibly overwriting existing files.
func (r *TemplateRegistry) Register(path string, fs fs.FS) *TemplateRegistry {
return NewTemplateRegistry(merged_fs.MergeMultiple(fs, r.routesFS))
}
func (r *TemplateRegistry) Reset() *TemplateRegistry {
return NewTemplateRegistry(r.routesFS)
}
func (r *TemplateRegistry) Load() error {
var outer error
r.once.Do(func() {
err := r.load()
if err != nil {
outer = err
}
})
return outer
}
// TODO: Throw errors
// TODO: what if there is no template in the directory above?
// What if a certain path is or should uncallable since it has no index or body?
func (r *TemplateRegistry) load() error {
templates := make(map[string]TemplateContext)
fs.WalkDir(r.routesFS, ".", func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() {
return nil
}
url := FSPathToPath(path)
tc := NewTemplateContext(url)
if path != "." {
pathelem := strings.Split(path, string(os.PathSeparator))
pathabove := strings.Join(pathelem[:len(pathelem)-1], string(os.PathSeparator))
pathabove = FSPathToPath(pathabove)
global, ok := templates[pathabove]
if ok {
tc.SetGlobals(global.Globals())
}
}
tc.Parse(r.routesFS)
templates[url] = tc
return nil
})
r.templates = templates
return nil
}
// This function takes a template (typically a layout) and adds all the templates of
// a given directory path to it. This is useful for adding a layout to a template.
func (r *TemplateRegistry) Add(path string, t *template.Template, funcmap *template.FuncMap) error {
temp, ok := r.cache.Load(path)
if !ok {
r.Load()
tc, ok := r.templates[path]
if !ok {
return NewError(NoTemplateError, path)
}
template, err := tc.Template(r.routesFS, funcmap)
if err != nil {
return err
}
r.cache.Store(path, template)
return r.Add(path, t, funcmap)
}
casted := temp.(*template.Template)
for _, st := range casted.Templates() {
_, err := t.AddParseTree(st.Name(), st.Tree)
if err != nil {
return err
}
}
return nil
}
// TODO: get for a specific component
func (r *TemplateRegistry) Get(path string) error {
return nil
}

57
templating/ws.go Normal file
View File

@@ -0,0 +1,57 @@
package templating
import (
"log"
"sync"
"golang.org/x/net/websocket"
)
// WsServer manages all active WebSocket connections so we can broadcast.
type WsServer struct {
mu sync.Mutex
conns map[*websocket.Conn]bool
}
// NewWsServer creates a WsServer.
func NewWsServer() *WsServer {
return &WsServer{
conns: make(map[*websocket.Conn]bool),
}
}
// Handler is invoked for each new WebSocket connection.
func (s *WsServer) Handler(conn *websocket.Conn) {
s.mu.Lock()
s.conns[conn] = true
s.mu.Unlock()
log.Println("[WsServer] Connected:", conn.RemoteAddr())
// Read in a loop until an error (client disconnect).
var msg string
for {
if err := websocket.Message.Receive(conn, &msg); err != nil {
log.Println("[WsServer] Disconnected:", conn.RemoteAddr())
s.mu.Lock()
delete(s.conns, conn)
s.mu.Unlock()
conn.Close()
return
}
}
}
// BroadcastReload sends a "reload" message to all connected clients.
func (s *WsServer) BroadcastReload() {
s.mu.Lock()
defer s.mu.Unlock()
for conn := range s.conns {
err := websocket.Message.Send(conn, "reload")
if err != nil {
log.Println("[WsServer] Broadcast error:", err)
conn.Close()
delete(s.conns, conn)
}
}
}

62
views/.air.toml Normal file
View File

@@ -0,0 +1,62 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
full_bin = "true"
cmd = "npm run build"
delay = 400
exclude_dir = [
"assets",
"node_modules",
"tmp",
"vendor",
"testdata",
"data_git",
"cache_gnd",
"cache_geonames",
"pb_data",
"Almanach-Bilder",
"Static-Bilder",
]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "js", "css", "xsl"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = [""]
rerun = false
rerun_delay = 250
send_interrupt = true
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = true
[proxy]
app_port = 8090
enabled = false
proxy_port = 8081
[screen]
clear_on_rebuild = true
keep_scroll = true

8
views/.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*]
trim_trailing_whitespace = true
charset = utf-8
insert_final_newline = true
end_of_line = lf
indent_style = tab
indent_size = 2
max_line_length = 100

24
views/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

10
views/.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
bracketSameLine: true,
bracketSpacing: true,
whitespaceSensitivity: "ignore",
proseWrap: "always",
bracketLine: true,
useTabs: true,
tabWidth: 2,
"plugins": ["prettier-plugin-go-template"]
}

31
views/embed.go Normal file
View File

@@ -0,0 +1,31 @@
//go:build !dev
// +build !dev
package views
import (
"embed"
"io/fs"
)
//go:embed all:assets
var ui_static embed.FS
var StaticFS = MustSubFS(ui_static, "assets")
//go:embed all:routes
var ui_routes embed.FS
var RoutesFS = MustSubFS(ui_routes, "routes")
//go:embed all:layouts
var ui_layouts embed.FS
var LayoutFS = MustSubFS(ui_layouts, "layouts")
func MustSubFS(fsys fs.FS, dir string) fs.FS {
sub, err := fs.Sub(fsys, dir)
if err != nil {
panic("Could not create SubFS for " + dir)
}
return sub
}

18
views/embed_dev.go Normal file
View File

@@ -0,0 +1,18 @@
//go:build dev
// +build dev
package views
import (
"os"
)
const (
STATIC_FILEPATH = "./views/assets"
ROUTES_FILEPATH = "./views/routes"
LAYOUT_FILEPATH = "./views/layouts"
)
var StaticFS = os.DirFS(STATIC_FILEPATH)
var RoutesFS = os.DirFS(ROUTES_FILEPATH)
var LayoutFS = os.DirFS(LAYOUT_FILEPATH)

View File

@@ -0,0 +1,53 @@
<!doctype html>
<html class="w-full h-full" {{ if .lang }}lang="{{ .lang }}"{{ end }}>
<head>
<meta charset="UTF-8" />
<meta
name="htmx-config"
content='{"defaultSwapStyle":"outerHTML", "scrollBehavior": "instant"}' />
{{ block "head" . }}
<!-- Default Head elements -->
{{ end }}
{{ if .isDev }}
<link rel="icon" href="/assets/logo/dev_favicon.png" />
<meta name="robots" content="noindex" />
{{ else }}
{{ if .url }}
<link rel="canonical" href="{{ .url }}" />
{{ end }}
<link rel="icon" href="/assets/logo/favicon.png" />
{{ end }}
<script src="/assets/js/alpine.min.js" defer></script>
<script src="/assets/js/htmx.min.js" defer></script>
<script src="/assets/js/htmx-response-targets.js" defer></script>
<script src="/assets/js/mark.min.js" defer></script>
<script type="module" src="/assets/scripts.js"></script>
<link href="/assets/css/remixicon.css" rel="stylesheet" />
<link rel="stylesheet" type="text/css" href="/assets/css/fonts.css" />
<link rel="stylesheet" type="text/css" href="/assets/style.css" />
<script type="module">
document.body.addEventListener("htmx:responseError", function (event) {
const config = event.detail.requestConfig;
if (config.boosted) {
document.body.innerHTML = event.detail.xhr.responseText;
const newUrl = event.detail.xhr.responseURL || config.url;
window.history.pushState(null, "", newUrl);
}
});
</script>
</head>
<body class="w-full min-h-full" hx-ext="response-targets" hx-boost="true">
<div class="pb-12">
{{ block "body" . }}
<!-- Default app body... -->
{{ end }}
</div>
</body>
</html>

View File

@@ -0,0 +1,22 @@
{{- $date := Today -}}
<footer class="container-normal pb-1.5 text-base text-gray-800">
<div class="mt-12 pt-3 flex flex-row justify-between">
<div>
<i class="ri-creative-commons-line"></i>
<i class="ri-creative-commons-by-line"></i>
<a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>
<span>&middot;</span>
<span>{{- (GetMonth $date).Name }} {{ $date.Year }}</span>
</div>
<div>
<span>{{- .site.title }} &ndash; ein Projekt der</span>
<a href="https://theodor-springmann-stiftung.de">Theodor Springmann Stiftung</a>
<span>&middot;</span>
<a href="/datenschutz/">Impressum &amp; Datenschutz</a>
<span>&middot;</span>
<i class="ri-code-line"></i>
<a href="https://github.com/Theodor-Springmann-Stiftung/musenalm">Code</a>
</div>
</div>
</footer>

View File

@@ -0,0 +1 @@
MALM

View File

@@ -0,0 +1 @@
{{ $model := . }}

View File

@@ -0,0 +1,83 @@
<!doctype html>
<html class="w-full h-full" {{ if .lang }}lang="{{ .lang }}"{{ end }}>
<head>
<meta charset="UTF-8" />
<meta
name="htmx-config"
content='{"defaultSwapStyle":"outerHTML", "scrollBehavior": "instant"}' />
{{ block "head" . }}
<!-- Default Head elements -->
{{ end }}
{{ if .isDev }}
<link rel="icon" href="/assets/logo/dev_favicon.png" />
<meta name="robots" content="noindex" />
{{ else }}
{{ if .url }}
<link rel="canonical" href="{{ .url }}" />
{{ end }}
<link rel="icon" href="/assets/logo/favicon.png" />
{{ end }}
<script src="/assets/js/alpine.min.js" defer></script>
<script src="/assets/js/htmx.min.js" defer></script>
<script src="/assets/js/htmx-response-targets.js" defer></script>
<script src="/assets/js/mark.min.js" defer></script>
<script type="module" src="/assets/scripts.js"></script>
<link href="/assets/css/remixicon.css" rel="stylesheet" />
<link rel="stylesheet" type="text/css" href="/assets/css/fonts.css" />
<link rel="stylesheet" type="text/css" href="/assets/style.css" />
<script type="module">
document.body.addEventListener("htmx:responseError", function (event) {
const config = event.detail.requestConfig;
if (config.boosted) {
document.body.innerHTML = event.detail.xhr.responseText;
const newUrl = event.detail.xhr.responseURL || config.url;
window.history.pushState(null, "", newUrl);
}
});
</script>
</head>
<body class="w-full text-lg" hx-ext="response-targets" hx-boost="true">
<div class="flex flex-col min-h-screen w-full">
<header class="container-normal pb-0" id="header">
{{ block "_menu" . }}
<!-- Default app menu... -->
{{ end }}
</header>
<main class="">
{{ block "body" . }}
<!-- Default app body... -->
{{ end }}
</main>
{{ block "_footer" . }}
{{ end }}
<scroll-button></scroll-button>
{{ block "scripts" . }}
<!-- Default scripts... -->
{{ end }}
<script type="module">
const hash = window.location.hash;
if (hash) {
const stripped = hash.slice(1);
const element = document.getElementById(stripped);
if (element) {
element.setAttribute("aria-current", "location");
}
}
</script>
</div>
</body>
</html>

34
views/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "caveman_views",
"version": "1.0.0",
"description": "default views for caveman",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"tailwind": "tailwindcss -i transform/site.css -o assets/style.css",
"css": "postcss transform/site.css -o assets/style.css",
"preview": "vite preview"
},
"repository": {
"type": "git",
"url": "github.com/Simon-Martens/pocketcatalog"
},
"keywords": [
"DB",
"htmx",
"frontend"
],
"author": "Simon Martens",
"license": "MIT",
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"daisyui": "^5.0.0-beta.8",
"postcss": "^8.4.47",
"postcss-cli": "^11.0.0",
"prettier": "^3.3.3",
"prettier-plugin-go-template": "^0.0.15",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0"
}
}

5
views/postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

BIN
views/public/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,55 @@
@font-face {
font-family: "Linux Libertine";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/assets/fonts/LinLibertine_R_G.ttf) format("truetype");
}
@font-face {
font-family: "Linux Libertine";
font-style: italic;
font-weight: 500;
font-display: swap;
src: url(/assets/fonts/LinLibertine_RI_G.ttf) format("truetype");
}
@font-face {
font-family: "Linux Libertine";
font-style: normal;
font-weight: bold;
font-display: swap;
src: url(/assets/fonts/LinLibertine_RB_G.ttf) format("truetype");
}
@font-face {
font-family: "Linux Libertine";
font-style: italic;
font-weight: bold;
font-display: swap;
src: url(/assets/fonts/LinLibertine_RBI_G.ttf) format("truetype");
}
@font-face {
font-family: "Linux Biolinum";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/assets/fonts/LinBiolinum_R_G) format("truetype");
}
@font-face {
font-family: "Linux Biolinum";
font-style: italic;
font-weight: 500;
font-display: swap;
src: url(/assets/fonts/LinBiolinum_RI_G) format("truetype");
}
@font-face {
font-family: "Linux Biolinum";
font-style: normal;
font-weight: bold;
font-display: swap;
src: url(/assets/fonts/LinBiolinum_RB_G) format("truetype")
}

655
views/public/css/hint.css Normal file
View File

@@ -0,0 +1,655 @@
/*! Hint.css - v2.7.0 - 2021-10-01
* https://kushagra.dev/lab/hint/
* Copyright (c) 2021 Kushagra Gour */
/*-------------------------------------*\
HINT.css - A CSS tooltip library
\*-------------------------------------*/
/**
* HINT.css is a tooltip library made in pure CSS.
*
* Source: https://github.com/chinchang/hint.css
* Demo: http://kushagragour.in/lab/hint/
*
*/
/**
* source: hint-core.scss
*
* Defines the basic styling for the tooltip.
* Each tooltip is made of 2 parts:
* 1) body (:after)
* 2) arrow (:before)
*
* Classes added:
* 1) hint
*/
[class*="hint--"] {
position: relative;
display: inline-block;
/**
* tooltip arrow
*/
/**
* tooltip body
*/ }
[class*="hint--"]:before, [class*="hint--"]:after {
position: absolute;
-webkit-transform: translate3d(0, 0, 0);
-moz-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
visibility: hidden;
opacity: 0;
z-index: 1000000;
pointer-events: none;
-webkit-transition: 0.3s ease;
-moz-transition: 0.3s ease;
transition: 0.3s ease;
-webkit-transition-delay: 0ms;
-moz-transition-delay: 0ms;
transition-delay: 0ms; }
[class*="hint--"]:hover:before, [class*="hint--"]:hover:after {
visibility: visible;
opacity: 1; }
[class*="hint--"]:hover:before, [class*="hint--"]:hover:after {
-webkit-transition-delay: 100ms;
-moz-transition-delay: 100ms;
transition-delay: 100ms; }
[class*="hint--"]:before {
content: '';
position: absolute;
background: transparent;
border: 6px solid transparent;
z-index: 1000001; }
[class*="hint--"]:after {
background: #383838;
color: white;
padding: 8px 10px;
font-size: 12px;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
line-height: 12px;
white-space: nowrap; }
[class*="hint--"][aria-label]:after {
content: attr(aria-label); }
[class*="hint--"][data-hint]:after {
content: attr(data-hint); }
[aria-label='']:before, [aria-label='']:after,
[data-hint='']:before,
[data-hint='']:after {
display: none !important; }
/**
* source: hint-position.scss
*
* Defines the positoning logic for the tooltips.
*
* Classes added:
* 1) hint--top
* 2) hint--bottom
* 3) hint--left
* 4) hint--right
*/
/**
* set default color for tooltip arrows
*/
.hint--top-left:before {
border-top-color: #383838; }
.hint--top-right:before {
border-top-color: #383838; }
.hint--top:before {
border-top-color: #383838; }
.hint--bottom-left:before {
border-bottom-color: #383838; }
.hint--bottom-right:before {
border-bottom-color: #383838; }
.hint--bottom:before {
border-bottom-color: #383838; }
.hint--left:before {
border-left-color: #383838; }
.hint--right:before {
border-right-color: #383838; }
/**
* top tooltip
*/
.hint--top:before {
margin-bottom: -11px; }
.hint--top:before, .hint--top:after {
bottom: 100%;
left: 50%; }
.hint--top:before {
left: calc(50% - 6px); }
.hint--top:after {
-webkit-transform: translateX(-50%);
-moz-transform: translateX(-50%);
transform: translateX(-50%); }
.hint--top:hover:before {
-webkit-transform: translateY(-8px);
-moz-transform: translateY(-8px);
transform: translateY(-8px); }
.hint--top:hover:after {
-webkit-transform: translateX(-50%) translateY(-8px);
-moz-transform: translateX(-50%) translateY(-8px);
transform: translateX(-50%) translateY(-8px); }
/**
* bottom tooltip
*/
.hint--bottom:before {
margin-top: -11px; }
.hint--bottom:before, .hint--bottom:after {
top: 100%;
left: 50%; }
.hint--bottom:before {
left: calc(50% - 6px); }
.hint--bottom:after {
-webkit-transform: translateX(-50%);
-moz-transform: translateX(-50%);
transform: translateX(-50%); }
.hint--bottom:hover:before {
-webkit-transform: translateY(8px);
-moz-transform: translateY(8px);
transform: translateY(8px); }
.hint--bottom:hover:after {
-webkit-transform: translateX(-50%) translateY(8px);
-moz-transform: translateX(-50%) translateY(8px);
transform: translateX(-50%) translateY(8px); }
/**
* right tooltip
*/
.hint--right:before {
margin-left: -11px;
margin-bottom: -6px; }
.hint--right:after {
margin-bottom: -14px; }
.hint--right:before, .hint--right:after {
left: 100%;
bottom: 50%; }
.hint--right:hover:before {
-webkit-transform: translateX(8px);
-moz-transform: translateX(8px);
transform: translateX(8px); }
.hint--right:hover:after {
-webkit-transform: translateX(8px);
-moz-transform: translateX(8px);
transform: translateX(8px); }
/**
* left tooltip
*/
.hint--left:before {
margin-right: -11px;
margin-bottom: -6px; }
.hint--left:after {
margin-bottom: -14px; }
.hint--left:before, .hint--left:after {
right: 100%;
bottom: 50%; }
.hint--left:hover:before {
-webkit-transform: translateX(-8px);
-moz-transform: translateX(-8px);
transform: translateX(-8px); }
.hint--left:hover:after {
-webkit-transform: translateX(-8px);
-moz-transform: translateX(-8px);
transform: translateX(-8px); }
/**
* top-left tooltip
*/
.hint--top-left:before {
margin-bottom: -11px; }
.hint--top-left:before, .hint--top-left:after {
bottom: 100%;
left: 50%; }
.hint--top-left:before {
left: calc(50% - 6px); }
.hint--top-left:after {
-webkit-transform: translateX(-100%);
-moz-transform: translateX(-100%);
transform: translateX(-100%); }
.hint--top-left:after {
margin-left: 12px; }
.hint--top-left:hover:before {
-webkit-transform: translateY(-8px);
-moz-transform: translateY(-8px);
transform: translateY(-8px); }
.hint--top-left:hover:after {
-webkit-transform: translateX(-100%) translateY(-8px);
-moz-transform: translateX(-100%) translateY(-8px);
transform: translateX(-100%) translateY(-8px); }
/**
* top-right tooltip
*/
.hint--top-right:before {
margin-bottom: -11px; }
.hint--top-right:before, .hint--top-right:after {
bottom: 100%;
left: 50%; }
.hint--top-right:before {
left: calc(50% - 6px); }
.hint--top-right:after {
-webkit-transform: translateX(0);
-moz-transform: translateX(0);
transform: translateX(0); }
.hint--top-right:after {
margin-left: -12px; }
.hint--top-right:hover:before {
-webkit-transform: translateY(-8px);
-moz-transform: translateY(-8px);
transform: translateY(-8px); }
.hint--top-right:hover:after {
-webkit-transform: translateY(-8px);
-moz-transform: translateY(-8px);
transform: translateY(-8px); }
/**
* bottom-left tooltip
*/
.hint--bottom-left:before {
margin-top: -11px; }
.hint--bottom-left:before, .hint--bottom-left:after {
top: 100%;
left: 50%; }
.hint--bottom-left:before {
left: calc(50% - 6px); }
.hint--bottom-left:after {
-webkit-transform: translateX(-100%);
-moz-transform: translateX(-100%);
transform: translateX(-100%); }
.hint--bottom-left:after {
margin-left: 12px; }
.hint--bottom-left:hover:before {
-webkit-transform: translateY(8px);
-moz-transform: translateY(8px);
transform: translateY(8px); }
.hint--bottom-left:hover:after {
-webkit-transform: translateX(-100%) translateY(8px);
-moz-transform: translateX(-100%) translateY(8px);
transform: translateX(-100%) translateY(8px); }
/**
* bottom-right tooltip
*/
.hint--bottom-right:before {
margin-top: -11px; }
.hint--bottom-right:before, .hint--bottom-right:after {
top: 100%;
left: 50%; }
.hint--bottom-right:before {
left: calc(50% - 6px); }
.hint--bottom-right:after {
-webkit-transform: translateX(0);
-moz-transform: translateX(0);
transform: translateX(0); }
.hint--bottom-right:after {
margin-left: -12px; }
.hint--bottom-right:hover:before {
-webkit-transform: translateY(8px);
-moz-transform: translateY(8px);
transform: translateY(8px); }
.hint--bottom-right:hover:after {
-webkit-transform: translateY(8px);
-moz-transform: translateY(8px);
transform: translateY(8px); }
/**
* source: hint-sizes.scss
*
* Defines width restricted tooltips that can span
* across multiple lines.
*
* Classes added:
* 1) hint--small
* 2) hint--medium
* 3) hint--large
*
*/
.hint--small:after,
.hint--medium:after,
.hint--large:after {
white-space: normal;
line-height: 1.4em;
word-wrap: break-word; }
.hint--small:after {
width: 80px; }
.hint--medium:after {
width: 150px; }
.hint--large:after {
width: 300px; }
/**
* source: hint-theme.scss
*
* Defines basic theme for tooltips.
*
*/
[class*="hint--"] {
/**
* tooltip body
*/ }
[class*="hint--"]:after {
text-shadow: 0 -1px 0px black;
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3); }
/**
* source: hint-color-types.scss
*
* Contains tooltips of various types based on color differences.
*
* Classes added:
* 1) hint--error
* 2) hint--warning
* 3) hint--info
* 4) hint--success
*
*/
/**
* Error
*/
.hint--error:after {
background-color: #b34e4d;
text-shadow: 0 -1px 0px #592726; }
.hint--error.hint--top-left:before {
border-top-color: #b34e4d; }
.hint--error.hint--top-right:before {
border-top-color: #b34e4d; }
.hint--error.hint--top:before {
border-top-color: #b34e4d; }
.hint--error.hint--bottom-left:before {
border-bottom-color: #b34e4d; }
.hint--error.hint--bottom-right:before {
border-bottom-color: #b34e4d; }
.hint--error.hint--bottom:before {
border-bottom-color: #b34e4d; }
.hint--error.hint--left:before {
border-left-color: #b34e4d; }
.hint--error.hint--right:before {
border-right-color: #b34e4d; }
/**
* Warning
*/
.hint--warning:after {
background-color: #c09854;
text-shadow: 0 -1px 0px #6c5328; }
.hint--warning.hint--top-left:before {
border-top-color: #c09854; }
.hint--warning.hint--top-right:before {
border-top-color: #c09854; }
.hint--warning.hint--top:before {
border-top-color: #c09854; }
.hint--warning.hint--bottom-left:before {
border-bottom-color: #c09854; }
.hint--warning.hint--bottom-right:before {
border-bottom-color: #c09854; }
.hint--warning.hint--bottom:before {
border-bottom-color: #c09854; }
.hint--warning.hint--left:before {
border-left-color: #c09854; }
.hint--warning.hint--right:before {
border-right-color: #c09854; }
/**
* Info
*/
.hint--info:after {
background-color: #3986ac;
text-shadow: 0 -1px 0px #1a3c4d; }
.hint--info.hint--top-left:before {
border-top-color: #3986ac; }
.hint--info.hint--top-right:before {
border-top-color: #3986ac; }
.hint--info.hint--top:before {
border-top-color: #3986ac; }
.hint--info.hint--bottom-left:before {
border-bottom-color: #3986ac; }
.hint--info.hint--bottom-right:before {
border-bottom-color: #3986ac; }
.hint--info.hint--bottom:before {
border-bottom-color: #3986ac; }
.hint--info.hint--left:before {
border-left-color: #3986ac; }
.hint--info.hint--right:before {
border-right-color: #3986ac; }
/**
* Success
*/
.hint--success:after {
background-color: #458746;
text-shadow: 0 -1px 0px #1a321a; }
.hint--success.hint--top-left:before {
border-top-color: #458746; }
.hint--success.hint--top-right:before {
border-top-color: #458746; }
.hint--success.hint--top:before {
border-top-color: #458746; }
.hint--success.hint--bottom-left:before {
border-bottom-color: #458746; }
.hint--success.hint--bottom-right:before {
border-bottom-color: #458746; }
.hint--success.hint--bottom:before {
border-bottom-color: #458746; }
.hint--success.hint--left:before {
border-left-color: #458746; }
.hint--success.hint--right:before {
border-right-color: #458746; }
/**
* source: hint-always.scss
*
* Defines a persisted tooltip which shows always.
*
* Classes added:
* 1) hint--always
*
*/
.hint--always:after, .hint--always:before {
opacity: 1;
visibility: visible; }
.hint--always.hint--top:before {
-webkit-transform: translateY(-8px);
-moz-transform: translateY(-8px);
transform: translateY(-8px); }
.hint--always.hint--top:after {
-webkit-transform: translateX(-50%) translateY(-8px);
-moz-transform: translateX(-50%) translateY(-8px);
transform: translateX(-50%) translateY(-8px); }
.hint--always.hint--top-left:before {
-webkit-transform: translateY(-8px);
-moz-transform: translateY(-8px);
transform: translateY(-8px); }
.hint--always.hint--top-left:after {
-webkit-transform: translateX(-100%) translateY(-8px);
-moz-transform: translateX(-100%) translateY(-8px);
transform: translateX(-100%) translateY(-8px); }
.hint--always.hint--top-right:before {
-webkit-transform: translateY(-8px);
-moz-transform: translateY(-8px);
transform: translateY(-8px); }
.hint--always.hint--top-right:after {
-webkit-transform: translateY(-8px);
-moz-transform: translateY(-8px);
transform: translateY(-8px); }
.hint--always.hint--bottom:before {
-webkit-transform: translateY(8px);
-moz-transform: translateY(8px);
transform: translateY(8px); }
.hint--always.hint--bottom:after {
-webkit-transform: translateX(-50%) translateY(8px);
-moz-transform: translateX(-50%) translateY(8px);
transform: translateX(-50%) translateY(8px); }
.hint--always.hint--bottom-left:before {
-webkit-transform: translateY(8px);
-moz-transform: translateY(8px);
transform: translateY(8px); }
.hint--always.hint--bottom-left:after {
-webkit-transform: translateX(-100%) translateY(8px);
-moz-transform: translateX(-100%) translateY(8px);
transform: translateX(-100%) translateY(8px); }
.hint--always.hint--bottom-right:before {
-webkit-transform: translateY(8px);
-moz-transform: translateY(8px);
transform: translateY(8px); }
.hint--always.hint--bottom-right:after {
-webkit-transform: translateY(8px);
-moz-transform: translateY(8px);
transform: translateY(8px); }
.hint--always.hint--left:before {
-webkit-transform: translateX(-8px);
-moz-transform: translateX(-8px);
transform: translateX(-8px); }
.hint--always.hint--left:after {
-webkit-transform: translateX(-8px);
-moz-transform: translateX(-8px);
transform: translateX(-8px); }
.hint--always.hint--right:before {
-webkit-transform: translateX(8px);
-moz-transform: translateX(8px);
transform: translateX(8px); }
.hint--always.hint--right:after {
-webkit-transform: translateX(8px);
-moz-transform: translateX(8px);
transform: translateX(8px); }
/**
* source: hint-rounded.scss
*
* Defines rounded corner tooltips.
*
* Classes added:
* 1) hint--rounded
*
*/
.hint--rounded:after {
border-radius: 4px; }
/**
* source: hint-effects.scss
*
* Defines various transition effects for the tooltips.
*
* Classes added:
* 1) hint--no-animate
* 2) hint--bounce
*
*/
.hint--no-animate:before, .hint--no-animate:after {
-webkit-transition-duration: 0ms;
-moz-transition-duration: 0ms;
transition-duration: 0ms; }
.hint--bounce:before, .hint--bounce:after {
-webkit-transition: opacity 0.3s ease, visibility 0.3s ease, -webkit-transform 0.3s cubic-bezier(0.71, 1.7, 0.77, 1.24);
-moz-transition: opacity 0.3s ease, visibility 0.3s ease, -moz-transform 0.3s cubic-bezier(0.71, 1.7, 0.77, 1.24);
transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.3s cubic-bezier(0.71, 1.7, 0.77, 1.24); }
.hint--no-shadow:before, .hint--no-shadow:after {
text-shadow: initial;
box-shadow: initial; }
.hint--no-arrow:before {
display: none; }

5
views/public/css/hint.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

5
views/public/js/alpine.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,97 @@
(function() {
function splitOnWhitespace(trigger) {
return trigger.split(/\s+/)
}
function parseClassOperation(trimmedValue) {
var split = splitOnWhitespace(trimmedValue)
if (split.length > 1) {
var operation = split[0]
var classDef = split[1].trim()
var cssClass
var delay
if (classDef.indexOf(':') > 0) {
var splitCssClass = classDef.split(':')
cssClass = splitCssClass[0]
delay = htmx.parseInterval(splitCssClass[1])
} else {
cssClass = classDef
delay = 100
}
return {
operation,
cssClass,
delay
}
} else {
return null
}
}
function performOperation(elt, classOperation, classList, currentRunTime) {
setTimeout(function() {
elt.classList[classOperation.operation].call(elt.classList, classOperation.cssClass)
}, currentRunTime)
}
function toggleOperation(elt, classOperation, classList, currentRunTime) {
setTimeout(function() {
setInterval(function() {
elt.classList[classOperation.operation].call(elt.classList, classOperation.cssClass)
}, classOperation.delay)
}, currentRunTime)
}
function processClassList(elt, classList) {
var runs = classList.split('&')
for (var i = 0; i < runs.length; i++) {
var run = runs[i]
var currentRunTime = 0
var classOperations = run.split(',')
for (var j = 0; j < classOperations.length; j++) {
var value = classOperations[j]
var trimmedValue = value.trim()
var classOperation = parseClassOperation(trimmedValue)
if (classOperation) {
if (classOperation.operation === 'toggle') {
toggleOperation(elt, classOperation, classList, currentRunTime)
currentRunTime = currentRunTime + classOperation.delay
} else {
currentRunTime = currentRunTime + classOperation.delay
performOperation(elt, classOperation, classList, currentRunTime)
}
}
}
}
}
function maybeProcessClasses(elt) {
if (elt.getAttribute) {
var classList = elt.getAttribute('classes') || elt.getAttribute('data-classes')
if (classList) {
processClassList(elt, classList)
}
}
}
htmx.defineExtension('class-tools', {
onEvent: function(name, evt) {
if (name === 'htmx:afterProcessNode') {
var elt = evt.detail.elt
maybeProcessClasses(elt)
var classList = elt.getAttribute("apply-parent-classes") || elt.getAttribute("data-apply-parent-classes");
if (classList) {
var parent = elt.parentElement;
parent.removeChild(elt);
parent.setAttribute("classes", classList);
maybeProcessClasses(parent);
} else if (elt.querySelectorAll) {
var children = elt.querySelectorAll('[classes], [data-classes]')
for (var i = 0; i < children.length; i++) {
maybeProcessClasses(children[i])
}
}
}
}
})
})()

View File

@@ -0,0 +1,96 @@
htmx.defineExtension('client-side-templates', {
transformResponse: function(text, xhr, elt) {
var mustacheTemplate = htmx.closest(elt, '[mustache-template]')
if (mustacheTemplate) {
var data = JSON.parse(text)
var templateId = mustacheTemplate.getAttribute('mustache-template')
var template = htmx.find('#' + templateId)
if (template) {
return Mustache.render(template.innerHTML, data)
} else {
throw new Error('Unknown mustache template: ' + templateId)
}
}
var mustacheArrayTemplate = htmx.closest(elt, '[mustache-array-template]')
if (mustacheArrayTemplate) {
var data = JSON.parse(text)
var templateId = mustacheArrayTemplate.getAttribute('mustache-array-template')
var template = htmx.find('#' + templateId)
if (template) {
return Mustache.render(template.innerHTML, { data })
} else {
throw new Error('Unknown mustache template: ' + templateId)
}
}
var handlebarsTemplate = htmx.closest(elt, '[handlebars-template]')
if (handlebarsTemplate) {
var data = JSON.parse(text)
var templateId = handlebarsTemplate.getAttribute('handlebars-template')
var templateElement = htmx.find('#' + templateId).innerHTML
var renderTemplate = Handlebars.compile(templateElement)
if (renderTemplate) {
return renderTemplate(data)
} else {
throw new Error('Unknown handlebars template: ' + templateId)
}
}
var handlebarsArrayTemplate = htmx.closest(elt, '[handlebars-array-template]')
if (handlebarsArrayTemplate) {
var data = JSON.parse(text)
var templateId = handlebarsArrayTemplate.getAttribute('handlebars-array-template')
var templateElement = htmx.find('#' + templateId).innerHTML
var renderTemplate = Handlebars.compile(templateElement)
if (renderTemplate) {
return renderTemplate(data)
} else {
throw new Error('Unknown handlebars template: ' + templateId)
}
}
var nunjucksTemplate = htmx.closest(elt, '[nunjucks-template]')
if (nunjucksTemplate) {
var data = JSON.parse(text)
var templateName = nunjucksTemplate.getAttribute('nunjucks-template')
var template = htmx.find('#' + templateName)
if (template) {
return nunjucks.renderString(template.innerHTML, data)
} else {
return nunjucks.render(templateName, data)
}
}
var xsltTemplate = htmx.closest(elt, '[xslt-template]')
if (xsltTemplate) {
var templateId = xsltTemplate.getAttribute('xslt-template')
var template = htmx.find('#' + templateId)
if (template) {
var content = template.innerHTML
? new DOMParser().parseFromString(template.innerHTML, 'application/xml')
: template.contentDocument
var processor = new XSLTProcessor()
processor.importStylesheet(content)
var data = new DOMParser().parseFromString(text, 'application/xml')
var frag = processor.transformToFragment(data, document)
return new XMLSerializer().serializeToString(frag)
} else {
throw new Error('Unknown XSLT template: ' + templateId)
}
}
var nunjucksArrayTemplate = htmx.closest(elt, '[nunjucks-array-template]')
if (nunjucksArrayTemplate) {
var data = JSON.parse(text)
var templateName = nunjucksArrayTemplate.getAttribute('nunjucks-array-template')
var template = htmx.find('#' + templateName)
if (template) {
return nunjucks.renderString(template.innerHTML, { data })
} else {
return nunjucks.render(templateName, { data })
}
}
return text
}
})

View File

@@ -0,0 +1,144 @@
//==========================================================
// head-support.js
//
// An extension to add head tag merging.
//==========================================================
(function(){
var api = null;
function log() {
//console.log(arguments);
}
function mergeHead(newContent, defaultMergeStrategy) {
if (newContent && newContent.indexOf('<head') > -1) {
const htmlDoc = document.createElement("html");
// remove svgs to avoid conflicts
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
// extract head tag
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
// if the head tag exists...
if (headTag) {
var added = []
var removed = []
var preserved = []
var nodesToAppend = []
htmlDoc.innerHTML = headTag;
var newHeadTag = htmlDoc.querySelector("head");
var currentHead = document.head;
if (newHeadTag == null) {
return;
} else {
// put all new head elements into a Map, by their outerHTML
var srcToNewHeadNodes = new Map();
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
}
// determine merge strategy
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
// get the current head
for (const currentHeadElt of currentHead.children) {
// If the current head element is in the map
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (mergeStrategy === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the tremaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
log("to append: ", nodesToAppend);
for (const newNode of nodesToAppend) {
log("adding: ", newNode);
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
log(newElt);
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
currentHead.appendChild(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
currentHead.removeChild(removedElement);
}
}
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
}
}
}
htmx.defineExtension("head-support", {
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef;
htmx.on('htmx:afterSwap', function(evt){
let xhr = evt.detail.xhr;
if (xhr) {
var serverResponse = xhr.response;
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
}
}
})
htmx.on('htmx:historyRestore', function(evt){
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
if (evt.detail.cacheMiss) {
mergeHead(evt.detail.serverResponse, "merge");
} else {
mergeHead(evt.detail.item.head, "merge");
}
}
})
htmx.on('htmx:historyItemCreated', function(evt){
var historyItem = evt.detail.item;
historyItem.head = document.head.outerHTML;
})
}
});
})()

View File

@@ -0,0 +1,130 @@
(function(){
/** @type {import("../htmx").HtmxInternalApi} */
var api;
var attrPrefix = 'hx-target-';
// IE11 doesn't support string.startsWith
function startsWith(str, prefix) {
return str.substring(0, prefix.length) === prefix
}
/**
* @param {HTMLElement} elt
* @param {number} respCode
* @returns {HTMLElement | null}
*/
function getRespCodeTarget(elt, respCodeNumber) {
if (!elt || !respCodeNumber) return null;
var respCode = respCodeNumber.toString();
// '*' is the original syntax, as the obvious character for a wildcard.
// The 'x' alternative was added for maximum compatibility with HTML
// templating engines, due to ambiguity around which characters are
// supported in HTML attributes.
//
// Start with the most specific possible attribute and generalize from
// there.
var attrPossibilities = [
respCode,
respCode.substr(0, 2) + '*',
respCode.substr(0, 2) + 'x',
respCode.substr(0, 1) + '*',
respCode.substr(0, 1) + 'x',
respCode.substr(0, 1) + '**',
respCode.substr(0, 1) + 'xx',
'*',
'x',
'***',
'xxx',
];
if (startsWith(respCode, '4') || startsWith(respCode, '5')) {
attrPossibilities.push('error');
}
for (var i = 0; i < attrPossibilities.length; i++) {
var attr = attrPrefix + attrPossibilities[i];
var attrValue = api.getClosestAttributeValue(elt, attr);
if (attrValue) {
if (attrValue === "this") {
return api.findThisElement(elt, attr);
} else {
return api.querySelectorExt(elt, attrValue);
}
}
}
return null;
}
/** @param {Event} evt */
function handleErrorFlag(evt) {
if (evt.detail.isError) {
if (htmx.config.responseTargetUnsetsError) {
evt.detail.isError = false;
}
} else if (htmx.config.responseTargetSetsError) {
evt.detail.isError = true;
}
}
htmx.defineExtension('response-targets', {
/** @param {import("../htmx").HtmxInternalApi} apiRef */
init: function (apiRef) {
api = apiRef;
if (htmx.config.responseTargetUnsetsError === undefined) {
htmx.config.responseTargetUnsetsError = true;
}
if (htmx.config.responseTargetSetsError === undefined) {
htmx.config.responseTargetSetsError = false;
}
if (htmx.config.responseTargetPrefersExisting === undefined) {
htmx.config.responseTargetPrefersExisting = false;
}
if (htmx.config.responseTargetPrefersRetargetHeader === undefined) {
htmx.config.responseTargetPrefersRetargetHeader = true;
}
},
/**
* @param {string} name
* @param {Event} evt
*/
onEvent: function (name, evt) {
if (name === "htmx:beforeSwap" &&
evt.detail.xhr &&
evt.detail.xhr.status !== 200) {
if (evt.detail.target) {
if (htmx.config.responseTargetPrefersExisting) {
evt.detail.shouldSwap = true;
handleErrorFlag(evt);
return true;
}
if (htmx.config.responseTargetPrefersRetargetHeader &&
evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)) {
evt.detail.shouldSwap = true;
handleErrorFlag(evt);
return true;
}
}
if (!evt.detail.requestConfig) {
return true;
}
var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status);
if (target) {
handleErrorFlag(evt);
evt.detail.shouldSwap = true;
evt.detail.target = target;
}
return true;
}
}
});
})();

1
views/public/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
(function() {
function mergeObjects(obj1, obj2) {
for (var key in obj2) {
if (obj2.hasOwnProperty(key)) {
obj1[key] = obj2[key]
}
}
return obj1
}
htmx.defineExtension('include-vals', {
onEvent: function(name, evt) {
if (name === 'htmx:configRequest') {
var includeValsElt = htmx.closest(evt.detail.elt, '[include-vals],[data-include-vals]')
if (includeValsElt) {
var includeVals = includeValsElt.getAttribute('include-vals') || includeValsElt.getAttribute('data-include-vals')
var valuesToInclude = eval('({' + includeVals + '})')
mergeObjects(evt.detail.parameters, valuesToInclude)
}
}
}
})
})()

View File

@@ -0,0 +1,184 @@
;(function() {
const loadingStatesUndoQueue = []
function loadingStateContainer(target) {
return htmx.closest(target, '[data-loading-states]') || document.body
}
function mayProcessUndoCallback(target, callback) {
if (document.body.contains(target)) {
callback()
}
}
function mayProcessLoadingStateByPath(elt, requestPath) {
const pathElt = htmx.closest(elt, '[data-loading-path]')
if (!pathElt) {
return true
}
return pathElt.getAttribute('data-loading-path') === requestPath
}
function queueLoadingState(sourceElt, targetElt, doCallback, undoCallback) {
const delayElt = htmx.closest(sourceElt, '[data-loading-delay]')
if (delayElt) {
const delayInMilliseconds =
delayElt.getAttribute('data-loading-delay') || 200
const timeout = setTimeout(function() {
doCallback()
loadingStatesUndoQueue.push(function() {
mayProcessUndoCallback(targetElt, undoCallback)
})
}, delayInMilliseconds)
loadingStatesUndoQueue.push(function() {
mayProcessUndoCallback(targetElt, function() { clearTimeout(timeout) })
})
} else {
doCallback()
loadingStatesUndoQueue.push(function() {
mayProcessUndoCallback(targetElt, undoCallback)
})
}
}
function getLoadingStateElts(loadingScope, type, path) {
return Array.from(htmx.findAll(loadingScope, '[' + type + ']')).filter(
function(elt) { return mayProcessLoadingStateByPath(elt, path) }
)
}
function getLoadingTarget(elt) {
if (elt.getAttribute('data-loading-target')) {
return Array.from(
htmx.findAll(elt.getAttribute('data-loading-target'))
)
}
return [elt]
}
htmx.defineExtension('loading-states', {
onEvent: function(name, evt) {
if (name === 'htmx:beforeRequest') {
const container = loadingStateContainer(evt.target)
const loadingStateTypes = [
'data-loading',
'data-loading-class',
'data-loading-class-remove',
'data-loading-disable',
'data-loading-aria-busy'
]
const loadingStateEltsByType = {}
loadingStateTypes.forEach(function(type) {
loadingStateEltsByType[type] = getLoadingStateElts(
container,
type,
evt.detail.pathInfo.requestPath
)
})
loadingStateEltsByType['data-loading'].forEach(function(sourceElt) {
getLoadingTarget(sourceElt).forEach(function(targetElt) {
queueLoadingState(
sourceElt,
targetElt,
function() {
targetElt.style.display =
sourceElt.getAttribute('data-loading') ||
'inline-block'
},
function() { targetElt.style.display = 'none' }
)
})
})
loadingStateEltsByType['data-loading-class'].forEach(
function(sourceElt) {
const classNames = sourceElt
.getAttribute('data-loading-class')
.split(' ')
getLoadingTarget(sourceElt).forEach(function(targetElt) {
queueLoadingState(
sourceElt,
targetElt,
function() {
classNames.forEach(function(className) {
targetElt.classList.add(className)
})
},
function() {
classNames.forEach(function(className) {
targetElt.classList.remove(className)
})
}
)
})
}
)
loadingStateEltsByType['data-loading-class-remove'].forEach(
function(sourceElt) {
const classNames = sourceElt
.getAttribute('data-loading-class-remove')
.split(' ')
getLoadingTarget(sourceElt).forEach(function(targetElt) {
queueLoadingState(
sourceElt,
targetElt,
function() {
classNames.forEach(function(className) {
targetElt.classList.remove(className)
})
},
function() {
classNames.forEach(function(className) {
targetElt.classList.add(className)
})
}
)
})
}
)
loadingStateEltsByType['data-loading-disable'].forEach(
function(sourceElt) {
getLoadingTarget(sourceElt).forEach(function(targetElt) {
queueLoadingState(
sourceElt,
targetElt,
function() { targetElt.disabled = true },
function() { targetElt.disabled = false }
)
})
}
)
loadingStateEltsByType['data-loading-aria-busy'].forEach(
function(sourceElt) {
getLoadingTarget(sourceElt).forEach(function(targetElt) {
queueLoadingState(
sourceElt,
targetElt,
function() { targetElt.setAttribute('aria-busy', 'true') },
function() { targetElt.removeAttribute('aria-busy') }
)
})
}
)
}
if (name === 'htmx:beforeOnLoad') {
while (loadingStatesUndoQueue.length > 0) {
loadingStatesUndoQueue.shift()()
}
}
}
})
})()

13
views/public/js/mark.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,44 @@
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('multi-swap', {
init: function(apiRef) {
api = apiRef
},
isInlineSwap: function(swapStyle) {
return swapStyle.indexOf('multi:') === 0
},
handleSwap: function(swapStyle, target, fragment, settleInfo) {
if (swapStyle.indexOf('multi:') === 0) {
var selectorToSwapStyle = {}
var elements = swapStyle.replace(/^multi\s*:\s*/, '').split(/\s*,\s*/)
elements.forEach(function(element) {
var split = element.split(/\s*:\s*/)
var elementSelector = split[0]
var elementSwapStyle = typeof (split[1]) !== 'undefined' ? split[1] : 'innerHTML'
if (elementSelector.charAt(0) !== '#') {
console.error("HTMX multi-swap: unsupported selector '" + elementSelector + "'. Only ID selectors starting with '#' are supported.")
return
}
selectorToSwapStyle[elementSelector] = elementSwapStyle
})
for (var selector in selectorToSwapStyle) {
var swapStyle = selectorToSwapStyle[selector]
var elementToSwap = fragment.querySelector(selector)
if (elementToSwap) {
api.oobSwap(swapStyle, elementToSwap, settleInfo)
} else {
console.warn("HTMX multi-swap: selector '" + selector + "' not found in source content.")
}
}
return true
}
}
})
})()

View File

@@ -0,0 +1,11 @@
htmx.defineExtension('path-params', {
onEvent: function(name, evt) {
if (name === 'htmx:configRequest') {
evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function(_, param) {
var val = evt.detail.parameters[param]
delete evt.detail.parameters[param]
return val === undefined ? '{' + param + '}' : encodeURIComponent(val)
})
}
}
})

388
views/public/js/preload.js Normal file
View File

@@ -0,0 +1,388 @@
(function() {
/**
* This adds the "preload" extension to htmx. The extension will
* preload the targets of elements with "preload" attribute if:
* - they also have `href`, `hx-get` or `data-hx-get` attributes
* - they are radio buttons, checkboxes, select elements and submit
* buttons of forms with `method="get"` or `hx-get` attributes
* The extension relies on browser cache and for it to work
* server response must include `Cache-Control` header
* e.g. `Cache-Control: private, max-age=60`.
* For more details @see https://htmx.org/extensions/preload/
*/
htmx.defineExtension('preload', {
onEvent: function(name, event) {
// Process preload attributes on `htmx:afterProcessNode`
if (name === 'htmx:afterProcessNode') {
// Initialize all nodes with `preload` attribute
const parent = event.target || event.detail.elt;
const preloadNodes = [
...parent.hasAttribute("preload") ? [parent] : [],
...parent.querySelectorAll("[preload]")]
preloadNodes.forEach(function(node) {
// Initialize the node with the `preload` attribute
init(node)
// Initialize all child elements which has
// `href`, `hx-get` or `data-hx-get` attributes
node.querySelectorAll('[href],[hx-get],[data-hx-get]').forEach(init)
})
return
}
// Intercept HTMX preload requests on `htmx:beforeRequest` and
// send them as XHR requests instead to avoid side-effects,
// such as showing loading indicators while preloading data.
if (name === 'htmx:beforeRequest') {
const requestHeaders = event.detail.requestConfig.headers
if (!("HX-Preloaded" in requestHeaders
&& requestHeaders["HX-Preloaded"] === "true")) {
return
}
event.preventDefault()
// Reuse XHR created by HTMX with replaced callbacks
const xhr = event.detail.xhr
xhr.onload = function() {
processResponse(event.detail.elt, xhr.responseText)
}
xhr.onerror = null
xhr.onabort = null
xhr.ontimeout = null
xhr.send()
}
}
})
/**
* Initialize `node`, set up event handlers based on own or inherited
* `preload` attributes and set `node.preloadState` to `READY`.
*
* `node.preloadState` can have these values:
* - `READY` - event handlers have been set up and node is ready to preload
* - `TIMEOUT` - a triggering event has been fired, but `node` is not
* yet being loaded because some time need to pass first e.g. user
* has to keep hovering over an element for 100ms for preload to start
* - `LOADING` means that `node` is in the process of being preloaded
* - `DONE` means that the preloading process is complete and `node`
* doesn't need a repeated preload (indicated by preload="always")
* @param {Node} node
*/
function init(node) {
// Guarantee that each node is initialized only once
if (node.preloadState !== undefined) {
return
}
if (!isValidNodeForPreloading(node)) {
return
}
// Initialize form element preloading
if (node instanceof HTMLFormElement) {
const form = node
// Only initialize forms with `method="get"` or `hx-get` attributes
if (!((form.hasAttribute('method') && form.method === 'get')
|| form.hasAttribute('hx-get') || form.hasAttribute('hx-data-get'))) {
return
}
for (let i = 0; i < form.elements.length; i++) {
const element = form.elements.item(i);
init(element);
element.labels.forEach(init);
}
return
}
// Process node configuration from preload attribute
let preloadAttr = getClosestAttribute(node, 'preload');
node.preloadAlways = preloadAttr && preloadAttr.includes('always');
if (node.preloadAlways) {
preloadAttr = preloadAttr.replace('always', '').trim();
}
let triggerEventName = preloadAttr || 'mousedown';
// Set up event handlers listening for triggering events
const needsTimeout = triggerEventName === 'mouseover'
node.addEventListener(triggerEventName, getEventHandler(node, needsTimeout))
// Add `touchstart` listener for touchscreen support
// if `mousedown` or `mouseover` is used
if (triggerEventName === 'mousedown' || triggerEventName === 'mouseover') {
node.addEventListener('touchstart', getEventHandler(node))
}
// If `mouseover` is used, set up `mouseout` listener,
// which will abort preloading if user moves mouse outside
// the element in less than 100ms after hovering over it
if (triggerEventName === 'mouseover') {
node.addEventListener('mouseout', function(evt) {
if ((evt.target === node) && (node.preloadState === 'TIMEOUT')) {
node.preloadState = 'READY'
}
})
}
// Mark the node as ready to be preloaded
node.preloadState = 'READY'
// This event can be used to load content immediately
htmx.trigger(node, 'preload:init')
}
/**
* Return event handler which can be called by event listener to start
* the preloading process of `node` with or without a timeout
* @param {Node} node
* @param {boolean=} needsTimeout
* @returns {function(): void}
*/
function getEventHandler(node, needsTimeout = false) {
return function() {
// Do not preload uninitialized nodes, nodes which are in process
// of being preloaded or have been preloaded and don't need repeat
if (node.preloadState !== 'READY') {
return
}
if (needsTimeout) {
node.preloadState = 'TIMEOUT'
const timeoutMs = 100
window.setTimeout(function() {
if (node.preloadState === 'TIMEOUT') {
node.preloadState = 'READY'
load(node)
}
}, timeoutMs)
return
}
load(node)
}
}
/**
* Preload the target of node, which can be:
* - hx-get or data-hx-get attribute
* - href or form action attribute
* @param {Node} node
*/
function load(node) {
// Do not preload uninitialized nodes, nodes which are in process
// of being preloaded or have been preloaded and don't need repeat
if (node.preloadState !== 'READY') {
return
}
node.preloadState = 'LOADING'
// Load nodes with `hx-get` or `data-hx-get` attribute
// Forms don't reach this because only their elements are initialized
const hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get')
if (hxGet) {
sendHxGetRequest(hxGet, node);
return
}
// Load nodes with `href` attribute
const hxBoost = getClosestAttribute(node, "hx-boost") === "true"
if (node.hasAttribute('href')) {
const url = node.getAttribute('href');
if (hxBoost) {
sendHxGetRequest(url, node);
} else {
sendXmlGetRequest(url, node);
}
return
}
// Load form elements
if (isPreloadableFormElement(node)) {
const url = node.form.getAttribute('action')
|| node.form.getAttribute('hx-get')
|| node.form.getAttribute('data-hx-get');
const formData = htmx.values(node.form);
const isStandardForm = !(node.form.getAttribute('hx-get')
|| node.form.getAttribute('data-hx-get')
|| hxBoost);
const sendGetRequest = isStandardForm ? sendXmlGetRequest : sendHxGetRequest
// submit button
if (node.type === 'submit') {
sendGetRequest(url, node.form, formData)
return
}
// select
const inputName = node.name || node.control.name;
if (node.tagName === 'SELECT') {
Array.from(node.options).forEach(option => {
if (option.selected) return;
formData.set(inputName, option.value);
const formDataOrdered = forceFormDataInOrder(node.form, formData);
sendGetRequest(url, node.form, formDataOrdered)
});
return
}
// radio and checkbox
const inputType = node.getAttribute("type") || node.control.getAttribute("type");
const nodeValue = node.value || node.control?.value;
if (inputType === 'radio') {
formData.set(inputName, nodeValue);
} else if (inputType === 'checkbox'){
const inputValues = formData.getAll(inputName);
if (inputValues.includes(nodeValue)) {
formData[inputName] = inputValues.filter(value => value !== nodeValue);
} else {
formData.append(inputName, nodeValue);
}
}
const formDataOrdered = forceFormDataInOrder(node.form, formData);
sendGetRequest(url, node.form, formDataOrdered)
return
}
}
/**
* Force formData values to be in the order of form elements.
* This is useful to apply after alternating formData values
* and before passing them to a HTTP request because cache is
* sensitive to GET parameter order e.g., cached `/link?a=1&b=2`
* will not be used for `/link?b=2&a=1`.
* @param {HTMLFormElement} form
* @param {FormData} formData
* @returns {FormData}
*/
function forceFormDataInOrder(form, formData) {
const formElements = form.elements;
const orderedFormData = new FormData();
for(let i = 0; i < formElements.length; i++) {
const element = formElements.item(i);
if (formData.has(element.name) && element.tagName === 'SELECT') {
orderedFormData.append(
element.name, formData.get(element.name));
continue;
}
if (formData.has(element.name) && formData.getAll(element.name)
.includes(element.value)) {
orderedFormData.append(element.name, element.value);
}
}
return orderedFormData;
}
/**
* Send GET request with `hx-request` headers as if `sourceNode`
* target was loaded. Send alternated values if `formData` is set.
*
* Note that this request is intercepted and sent as XMLHttpRequest.
* It is necessary to use `htmx.ajax` to acquire correct headers which
* HTMX and extensions add based on `sourceNode`. But it cannot be used
* to perform the request due to side-effects e.g. loading indicators.
* @param {string} url
* @param {Node} sourceNode
* @param {FormData=} formData
*/
function sendHxGetRequest(url, sourceNode, formData = undefined) {
htmx.ajax('GET', url, {
source: sourceNode,
values: formData,
headers: {"HX-Preloaded": "true"}
});
}
/**
* Send XML GET request to `url`. Send `formData` as URL params if set.
* @param {string} url
* @param {Node} sourceNode
* @param {FormData=} formData
*/
function sendXmlGetRequest(url, sourceNode, formData = undefined) {
const xhr = new XMLHttpRequest()
if (formData) {
url += '?' + new URLSearchParams(formData.entries()).toString()
}
xhr.open('GET', url);
xhr.setRequestHeader("HX-Preloaded", "true")
xhr.onload = function() { processResponse(sourceNode, xhr.responseText) }
xhr.send()
}
/**
* Process request response by marking node `DONE` to prevent repeated
* requests, except if preload attribute contains `always`,
* and load linked resources (e.g. images) returned in the response
* if `preload-images` attribute is `true`
* @param {Node} node
* @param {string} responseText
*/
function processResponse(node, responseText) {
node.preloadState = node.preloadAlways ? 'READY' : 'DONE'
if (getClosestAttribute(node, 'preload-images') === 'true') {
// Load linked resources
document.createElement('div').innerHTML = responseText
}
}
/**
* Gets attribute value from node or one of its parents
* @param {Node} node
* @param {string} attribute
* @returns { string | undefined }
*/
function getClosestAttribute(node, attribute) {
if (node == undefined) { return undefined }
return node.getAttribute(attribute)
|| node.getAttribute('data-' + attribute)
|| getClosestAttribute(node.parentElement, attribute)
}
/**
* Determines if node is valid for preloading and should be
* initialized by setting up event listeners and handlers
* @param {Node} node
* @returns {boolean}
*/
function isValidNodeForPreloading(node) {
// Add listeners only to nodes which include "GET" transactions
// or preloadable "GET" form elements
const getReqAttrs = ['href', 'hx-get', 'data-hx-get'];
const includesGetRequest = node => getReqAttrs.some(a => node.hasAttribute(a))
|| node.method === 'get';
const isPreloadableGetFormElement = node.form instanceof HTMLFormElement
&& includesGetRequest(node.form)
&& isPreloadableFormElement(node)
if (!includesGetRequest(node) && !isPreloadableGetFormElement) {
return false
}
// Don't preload <input> elements contained in <label>
// to prevent sending two requests. Interaction on <input> in a
// <label><input></input></label> situation activates <label> too.
if (node instanceof HTMLInputElement && node.closest('label')) {
return false
}
return true
}
/**
* Determine if node is a form element which can be preloaded,
* i.e., `radio`, `checkbox`, `select` or `submit` button
* or a `label` of a form element which can be preloaded.
* @param {Node} node
* @returns {boolean}
*/
function isPreloadableFormElement(node) {
if (node instanceof HTMLInputElement || node instanceof HTMLButtonElement) {
const type = node.getAttribute('type');
return ['checkbox', 'radio', 'submit'].includes(type);
}
if (node instanceof HTMLLabelElement) {
return node.control && isPreloadableFormElement(node.control);
}
return node instanceof HTMLSelectElement;
}
})()

471
views/public/js/ws.js Normal file
View File

@@ -0,0 +1,471 @@
/*
WebSockets Extension
============================
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('ws', {
/**
* init is called once, when this extension is first registered.
* @param {import("../htmx").HtmxInternalApi} apiRef
*/
init: function(apiRef) {
// Store reference to internal API
api = apiRef
// Default function for creating new EventSource objects
if (!htmx.createWebSocket) {
htmx.createWebSocket = createWebSocket
}
// Default setting for reconnect delay
if (!htmx.config.wsReconnectDelay) {
htmx.config.wsReconnectDelay = 'full-jitter'
}
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
// Try to close the socket when elements are removed
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
if (internalData.webSocket) {
internalData.webSocket.close()
}
return
// Try to create websockets when elements are processed
case 'htmx:beforeProcessNode':
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
ensureWebSocket(child)
})
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
ensureWebSocketSend(child)
})
}
}
})
function splitOnWhitespace(trigger) {
return trigger.trim().split(/\s+/)
}
function getLegacyWebsocketURL(elt) {
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws')
if (legacySSEValue) {
var values = splitOnWhitespace(legacySSEValue)
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/)
if (value[0] === 'connect') {
return value[1]
}
}
}
}
/**
* ensureWebSocket creates a new WebSocket on the designated element, using
* the element's "ws-connect" attribute.
* @param {HTMLElement} socketElt
* @returns
*/
function ensureWebSocket(socketElt) {
// If the element containing the WebSocket connection no longer exists, then
// do not connect/reconnect the WebSocket.
if (!api.bodyContains(socketElt)) {
return
}
// Get the source straight from the element's value
var wssSource = api.getAttributeValue(socketElt, 'ws-connect')
if (wssSource == null || wssSource === '') {
var legacySource = getLegacyWebsocketURL(socketElt)
if (legacySource == null) {
return
} else {
wssSource = legacySource
}
}
// Guarantee that the wssSource value is a fully qualified URL
if (wssSource.indexOf('/') === 0) {
var base_part = location.hostname + (location.port ? ':' + location.port : '')
if (location.protocol === 'https:') {
wssSource = 'wss://' + base_part + wssSource
} else if (location.protocol === 'http:') {
wssSource = 'ws://' + base_part + wssSource
}
}
var socketWrapper = createWebsocketWrapper(socketElt, function() {
return htmx.createWebSocket(wssSource)
})
socketWrapper.addEventListener('message', function(event) {
if (maybeCloseWebSocketSource(socketElt)) {
return
}
var response = event.data
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
message: response,
socketWrapper: socketWrapper.publicInterface
})) {
return
}
api.withExtensions(socketElt, function(extension) {
response = extension.transformResponse(response, null, socketElt)
})
var settleInfo = api.makeSettleInfo(socketElt)
var fragment = api.makeFragment(response)
if (fragment.children.length) {
var children = Array.from(fragment.children)
for (var i = 0; i < children.length; i++) {
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo)
}
}
api.settleImmediately(settleInfo.tasks)
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface })
})
// Put the WebSocket into the HTML Element's custom data.
api.getInternalData(socketElt).webSocket = socketWrapper
}
/**
* @typedef {Object} WebSocketWrapper
* @property {WebSocket} socket
* @property {Array<{message: string, sendElt: Element}>} messageQueue
* @property {number} retryCount
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
* @property {(message: string, sendElt: Element) => void} send
* @property {(event: string, handler: Function) => void} addEventListener
* @property {() => void} handleQueuedMessages
* @property {() => void} init
* @property {() => void} close
*/
/**
*
* @param socketElt
* @param socketFunc
* @returns {WebSocketWrapper}
*/
function createWebsocketWrapper(socketElt, socketFunc) {
var wrapper = {
socket: null,
messageQueue: [],
retryCount: 0,
/** @type {Object<string, Function[]>} */
events: {},
addEventListener: function(event, handler) {
if (this.socket) {
this.socket.addEventListener(event, handler)
}
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(handler)
},
sendImmediately: function(message, sendElt) {
if (!this.socket) {
api.triggerErrorEvent()
}
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
message,
socketWrapper: this.publicInterface
})) {
this.socket.send(message)
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
message,
socketWrapper: this.publicInterface
})
}
},
send: function(message, sendElt) {
if (this.socket.readyState !== this.socket.OPEN) {
this.messageQueue.push({ message, sendElt })
} else {
this.sendImmediately(message, sendElt)
}
},
handleQueuedMessages: function() {
while (this.messageQueue.length > 0) {
var queuedItem = this.messageQueue[0]
if (this.socket.readyState === this.socket.OPEN) {
this.sendImmediately(queuedItem.message, queuedItem.sendElt)
this.messageQueue.shift()
} else {
break
}
}
},
init: function() {
if (this.socket && this.socket.readyState === this.socket.OPEN) {
// Close discarded socket
this.socket.close()
}
// Create a new WebSocket and event handlers
/** @type {WebSocket} */
var socket = socketFunc()
// The event.type detail is added for interface conformance with the
// other two lifecycle events (open and close) so a single handler method
// can handle them polymorphically, if required.
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } })
this.socket = socket
socket.onopen = function(e) {
wrapper.retryCount = 0
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface })
wrapper.handleQueuedMessages()
}
socket.onclose = function(e) {
// If socket should not be connected, stop further attempts to establish connection
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
var delay = getWebSocketReconnectDelay(wrapper.retryCount)
setTimeout(function() {
wrapper.retryCount += 1
wrapper.init()
}, delay)
}
// Notify client code that connection has been closed. Client code can inspect `event` field
// to determine whether closure has been valid or abnormal
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface })
}
socket.onerror = function(e) {
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper })
maybeCloseWebSocketSource(socketElt)
}
var events = this.events
Object.keys(events).forEach(function(k) {
events[k].forEach(function(e) {
socket.addEventListener(k, e)
})
})
},
close: function() {
this.socket.close()
}
}
wrapper.init()
wrapper.publicInterface = {
send: wrapper.send.bind(wrapper),
sendImmediately: wrapper.sendImmediately.bind(wrapper),
queue: wrapper.messageQueue
}
return wrapper
}
/**
* ensureWebSocketSend attaches trigger handles to elements with
* "ws-send" attribute
* @param {HTMLElement} elt
*/
function ensureWebSocketSend(elt) {
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws')
if (legacyAttribute && legacyAttribute !== 'send') {
return
}
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
processWebSocketSend(webSocketParent, elt)
}
/**
* hasWebSocket function checks if a node has webSocket instance attached
* @param {HTMLElement} node
* @returns {boolean}
*/
function hasWebSocket(node) {
return api.getInternalData(node).webSocket != null
}
/**
* processWebSocketSend adds event listeners to the <form> element so that
* messages can be sent to the WebSocket server when the form is submitted.
* @param {HTMLElement} socketElt
* @param {HTMLElement} sendElt
*/
function processWebSocketSend(socketElt, sendElt) {
var nodeData = api.getInternalData(sendElt)
var triggerSpecs = api.getTriggerSpecs(sendElt)
triggerSpecs.forEach(function(ts) {
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) {
if (maybeCloseWebSocketSource(socketElt)) {
return
}
/** @type {WebSocketWrapper} */
var socketWrapper = api.getInternalData(socketElt).webSocket
var headers = api.getHeaders(sendElt, api.getTarget(sendElt))
var results = api.getInputValues(sendElt, 'post')
var errors = results.errors
var rawParameters = Object.assign({}, results.values)
var expressionVars = api.getExpressionVars(sendElt)
var allParameters = api.mergeObjects(rawParameters, expressionVars)
var filteredParameters = api.filterValues(allParameters, sendElt)
var sendConfig = {
parameters: filteredParameters,
unfilteredParameters: allParameters,
headers,
errors,
triggeringEvent: evt,
messageBody: undefined,
socketWrapper: socketWrapper.publicInterface
}
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
return
}
if (errors && errors.length > 0) {
api.triggerEvent(elt, 'htmx:validation:halted', errors)
return
}
var body = sendConfig.messageBody
if (body === undefined) {
var toSend = Object.assign({}, sendConfig.parameters)
if (sendConfig.headers) { toSend.HEADERS = headers }
body = JSON.stringify(toSend)
}
socketWrapper.send(body, elt)
if (evt && api.shouldCancel(evt, elt)) {
evt.preventDefault()
}
})
})
}
/**
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
* @param {number} retryCount // The number of retries that have already taken place
* @returns {number}
*/
function getWebSocketReconnectDelay(retryCount) {
/** @type {"full-jitter" | ((retryCount:number) => number)} */
var delay = htmx.config.wsReconnectDelay
if (typeof delay === 'function') {
return delay(retryCount)
}
if (delay === 'full-jitter') {
var exp = Math.min(retryCount, 6)
var maxDelay = 1000 * Math.pow(2, exp)
return maxDelay * Math.random()
}
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')
}
/**
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
* returns FALSE.
*
* @param {*} elt
* @returns
*/
function maybeCloseWebSocketSource(elt) {
if (!api.bodyContains(elt)) {
var internalData = api.getInternalData(elt)
if (internalData.webSocket) {
internalData.webSocket.close()
return true
}
return false
}
return false
}
/**
* createWebSocket is the default method for creating new WebSocket objects.
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
*
* @param {string} url
* @returns WebSocket
*/
function createWebSocket(url) {
var sock = new WebSocket(url, [])
sock.binaryType = htmx.config.wsBinaryType
return sock
}
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(elt, attributeName) {
var result = []
// If the parent element also contains the requested attribute, then add it to the results too.
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
result.push(elt)
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
result.push(node)
})
return result
}
/**
* @template T
* @param {T[]} arr
* @param {(T) => void} func
*/
function forEach(arr, func) {
if (arr) {
for (var i = 0; i < arr.length; i++) {
func(arr[i])
}
}
}
})()

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,13 @@
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="html" indent="yes" />
<xsl:template match="title">
<em>
<xsl:apply-templates />
</em>
</xsl:template>
<xsl:template match="year">
<span class="">
<xsl:apply-templates />
</span>
</xsl:template>
</xsl:stylesheet>

0
views/routes/body.gohtml Normal file
View File

0
views/routes/head.gohtml Normal file
View File

1269
views/transform/main.js Normal file

File diff suppressed because it is too large Load Diff

66
views/transform/site.css Normal file
View File

@@ -0,0 +1,66 @@
@import "tailwindcss";
@theme {
--font-script: Rancho, ui-serif;
--font-sans: "Source Sans 3", "Merriweather Sans", ui-sans-serif;
--font-serif: "Merriweather", ui-serif;
--color-background: oklch(0.985 0.001 106.423);
--color-background-darker: oklch(0.97 0.001 106.424);
--color-background-dark: oklch(0.923 0.003 48.717);
--color-border-main: oklch(0.97 0.001 106.424);
--color-border-secondary: oklch(0.923 0.003 48.717);
--color-text: oklch(0.21 0.034 264.665);
--color-text-strong: oklch(0 0 0);
--color-text-muted: oklch(0.373 0.034 259.733);
--color-text-disabled: oklch(0.872 0.01 258.338);
--color-text-subtle: oklch(0.707 0.022 261.325);
--color-accent-blue-500: oklch(0.623 0.214 259.815);
--color-accent-blue-100: oklch(0.932 0.032 255.585);
}
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@utility font-variant-small-caps {
font-variant-caps: small-caps;
}
@layer components {
html {
font-size: 16px;
scroll-behavior: auto !important;
}
@media (max-width: 1280px) {
html {
font-size: 14px;
}
}
@media (max-width: 640px) {
html {
font-size: 12px;
}
}
body {
@apply bg-stone-50;
}
}

16
views/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { resolve } from "path";
import { defineConfig } from "vite";
export default defineConfig({
mode: "production",
build: {
root: resolve(__dirname, ""),
lib: {
entry: "./transform/main.js",
name: "PC-UI",
fileName: "scripts",
cssFileName: "style",
formats: ["es"],
},
outDir: resolve(__dirname, "assets/"),
},
});

27
views/vite.dev.config.js Normal file
View File

@@ -0,0 +1,27 @@
import { resolve } from "path";
import { defineConfig } from "vite";
import tailwindcss from "tailwindcss";
export default defineConfig({
mode: "development",
css: {
postcss: {
plugins: [tailwindcss],
},
},
build: {
root: resolve(__dirname, ""),
// These are dev options only:
minify: false,
emitAssets: true,
lib: {
entry: "./transform/main.js",
name: "PC-UI",
fileName: "scripts",
cssFileName: "style",
formats: ["es"],
},
outDir: resolve(__dirname, "assets/"),
},
});

37
xml/helpers.go Normal file
View File

@@ -0,0 +1,37 @@
package xmlparsing
import (
"encoding/xml"
"io"
"os"
"path/filepath"
)
func UnmarshalFile[T any](filename string, data T) error {
xmlFile, err := os.Open(filename)
if err != nil {
return err
}
defer xmlFile.Close()
byteValue, err := io.ReadAll(xmlFile)
if err != nil {
return err
}
err = xml.Unmarshal(byteValue, &data)
if err != nil {
return err
}
return nil
}
func XMLFilesForPath(path string) ([]string, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil, err
}
matches, err := filepath.Glob(filepath.Join(path, "*.xml"))
return matches, err
}

12
xml/item.go Normal file
View File

@@ -0,0 +1,12 @@
package xmlparsing
type ItemInfo struct {
Source string
Parse ParseMeta
}
// INFO: These are just root elements that hold the data of the XML files.
// They get discarded after a parse.
type XMLRootElement[T any] interface {
Children() []T
}

15
xml/library.go Normal file
View File

@@ -0,0 +1,15 @@
package xmlparsing
import "sync"
type Library struct {
pmux sync.Mutex
Parses []ParseMeta
}
func (l *Library) Latest() ParseMeta {
if len(l.Parses) == 0 {
return ParseMeta{}
}
return l.Parses[len(l.Parses)-1]
}

32
xml/models.go Normal file
View File

@@ -0,0 +1,32 @@
package xmlparsing
import "fmt"
type IXMLItem interface {
fmt.Stringer
// INFO:
// - Keys should be unique
// - Keys[0] has the special meaning of the primary key (for FTS etc.)
Keys() []string
Type() string
}
type ILibrary interface {
Parse(meta ParseMeta) error
}
type ResolvingMap[T IXMLItem] map[string][]Resolved[T]
type ReferenceResolver[T IXMLItem] interface {
References() ResolvingMap[T]
}
type Resolved[T IXMLItem] struct {
Item *T
Reference string
Category string
Cert bool
Conjecture bool
Comment string
MetaData map[string]string
}

49
xml/optionalbool.go Normal file
View File

@@ -0,0 +1,49 @@
package xmlparsing
import (
"encoding/xml"
"strings"
)
type OptionalBool int
const (
Unspecified OptionalBool = iota
True
False
)
func (b *OptionalBool) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var attr struct {
Value string `xml:"value,attr"`
}
if err := d.DecodeElement(&attr, &start); err != nil {
return err
}
switch strings.ToLower(attr.Value) {
case "true":
*b = True
case "false":
*b = False
default:
*b = Unspecified
}
return nil
}
func (b OptionalBool) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if b == Unspecified {
return nil
}
value := "false"
if b == True {
value = "true"
}
type alias struct {
Value string `xml:"value,attr"`
}
return e.EncodeElement(alias{Value: value}, start)
}

48
xml/resolver.go Normal file
View File

@@ -0,0 +1,48 @@
package xmlparsing
// INFO: This is used to resolve references (back-links) between XML items.
import (
"fmt"
"sync"
)
type Resolver[T IXMLItem] struct {
// INFO: map[type][ID]
index map[string]map[string][]Resolved[T]
mu sync.RWMutex
}
func NewResolver[T IXMLItem]() *Resolver[T] {
return &Resolver[T]{index: make(map[string]map[string][]Resolved[T])}
}
func (r *Resolver[T]) Add(typeName, refID string, item Resolved[T]) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.index[typeName]; !exists {
r.index[typeName] = make(map[string][]Resolved[T])
}
r.index[typeName][refID] = append(r.index[typeName][refID], item)
}
func (r *Resolver[T]) Get(typeName, refID string) ([]Resolved[T], error) {
r.mu.RLock()
defer r.mu.RUnlock()
if typeIndex, exists := r.index[typeName]; exists {
if items, ok := typeIndex[refID]; ok {
return items, nil
}
return nil, fmt.Errorf("no references found for refID '%s' of type '%s'", refID, typeName)
}
return nil, fmt.Errorf("no index exists for type '%s'", typeName)
}
func (r *Resolver[T]) Clear() {
r.mu.Lock()
defer r.mu.Unlock()
r.index = make(map[string]map[string][]Resolved[T])
}

200
xml/xmlprovider.go Normal file
View File

@@ -0,0 +1,200 @@
package xmlparsing
import (
"slices"
"sync"
"time"
)
type ParseSource int
const (
SourceUnknown ParseSource = iota
Path
Commit
)
type ParseMeta struct {
Source ParseSource
BaseDir string
Commit string
Date time.Time
FailedPaths []string
}
func (p ParseMeta) Equals(other ParseMeta) bool {
return p.Source == other.Source && p.BaseDir == other.BaseDir && p.Commit == other.Commit && p.Date == other.Date
}
func (p ParseMeta) Failed(path string) bool {
return slices.Contains(p.FailedPaths, path)
}
// An XMLParser is a struct that holds holds serialized XML data of a specific type. It combines multiple parses IF a succeeded parse can not serialize the data from a path.
type XMLParser[T IXMLItem] struct {
// INFO: map is type map[string]*T
Items sync.Map
// INFO: map is type [string]ItemInfo
// It keeps information about parsing status of the items.
Infos sync.Map
// INFO: Resolver is used to resolve references (back-links) between XML items.
Resolver Resolver[T]
mu sync.RWMutex
// TODO: This array is meant to be for iteration purposes, since iteration over the sync.Map is slow.
// It is best for this array to be sorted by key of the corresponding item.
Array []T
}
func NewXMLParser[T IXMLItem]() *XMLParser[T] {
return &XMLParser[T]{Resolver: *NewResolver[T]()}
}
// INFO: To parse sth, we call Prepare, then Serialize, then Cleanup.
// Prepare & Cleanup are called once per parse. Serialize is called for every path.
// and can be called concurretly.
func (p *XMLParser[T]) Prepare() {
p.mu.Lock()
defer p.mu.Unlock()
p.Array = make([]T, 0, len(p.Array))
p.Resolver.Clear()
}
func (p *XMLParser[T]) Serialize(dataholder XMLRootElement[T], path string, latest ParseMeta) error {
if err := UnmarshalFile(path, dataholder); err != nil {
return err
}
newItems := dataholder.Children()
for _, item := range newItems {
// INFO: Mostly it's just one ID, so the double loop is not that bad.
for _, id := range item.Keys() {
p.Infos.Store(id, ItemInfo{Source: path, Parse: latest})
p.Items.Store(id, &item)
}
p.addResolvable(item)
}
p.mu.Lock()
defer p.mu.Unlock()
p.Array = append(p.Array, newItems...)
return nil
}
// INFO: Cleanup is called after all paths have been serialized.
// It deletes all items that have not been parsed in the last commit,
// and whose filepath has not been marked as failed.
func (p *XMLParser[T]) Cleanup(latest ParseMeta) {
todelete := make([]string, 0)
toappend := make([]*T, 0)
p.Infos.Range(func(key, value interface{}) bool {
info := value.(ItemInfo)
if !info.Parse.Equals(latest) {
if !latest.Failed(info.Source) {
todelete = append(todelete, key.(string))
} else {
item, ok := p.Items.Load(key)
if ok {
i := item.(*T)
if !slices.Contains(toappend, i) {
toappend = append(toappend, i)
}
}
}
}
return true
})
for _, key := range todelete {
p.Infos.Delete(key)
p.Items.Delete(key)
}
p.mu.Lock()
defer p.mu.Unlock()
for _, item := range toappend {
p.Array = append(p.Array, *item)
p.addResolvable(*item)
}
slices.SortFunc(p.Array, Sort)
}
func (p *XMLParser[T]) addResolvable(item T) {
// INFO: If the item has a GetReferences method, we add the references to the resolver.
if rr, ok := any(item).(ReferenceResolver[T]); ok {
for name, ids := range rr.References() {
for _, res := range ids {
res.Item = &item
p.Resolver.Add(name, res.Reference, res)
}
}
}
}
func (p *XMLParser[T]) ReverseLookup(item IXMLItem) []Resolved[T] {
// INFO: this runs just once for the first key
ret := make([]Resolved[T], 0)
keys := item.Keys()
for _, key := range keys {
r, err := p.Resolver.Get(item.Type(), key)
if err == nil {
ret = append(ret, r...)
}
}
return ret
}
func (a *XMLParser[T]) String() string {
var s string
for _, item := range a.Array {
s += item.String()
}
return s
}
func (p *XMLParser[T]) Info(id string) ItemInfo {
info, ok := p.Infos.Load(id)
if !ok {
return ItemInfo{}
}
return info.(ItemInfo)
}
func (p *XMLParser[T]) Item(id string) *T {
item, ok := p.Items.Load(id)
if !ok {
return nil
}
i := item.(*T)
return i
}
func (p *XMLParser[T]) Find(fn func(*T) bool) []T {
p.mu.RLock()
defer p.mu.RUnlock()
var items []T
for _, item := range p.Array {
if fn(&item) {
items = append(items, item)
}
}
return items
}
// INFO: These are only reading locks.
func (p *XMLParser[T]) Lock() {
p.mu.RLock()
}
func (p *XMLParser[T]) Unlock() {
p.mu.RUnlock()
}

77
xml/xmlsort.go Normal file
View File

@@ -0,0 +1,77 @@
package xmlparsing
import (
"strconv"
"strings"
)
func Sort[T IXMLItem](i, j T) int {
keys_a := i.Keys()
keys_b := j.Keys()
if len(keys_a) == 0 && len(keys_b) == 0 {
return 0
}
if len(keys_a) == 0 && len(keys_b) > 0 {
return -1
}
if len(keys_a) > 0 && len(keys_b) == 0 {
return 1
}
sort_a := strings.Split(keys_a[0], "-")
sort_b := strings.Split(keys_b[0], "-")
for i, item := range sort_a {
if i >= len(sort_b) {
return 1
}
// INFO: this is a bit lazy since
// - we are comparing bit values not unicode code points
// - the comparison is case sensitive
int_a, err := strconv.Atoi(item)
if err != nil {
if item < sort_b[i] {
return -1
}
if item > sort_b[i] {
return 1
}
continue
}
int_b, err := strconv.Atoi(sort_b[i])
if err != nil {
if item < sort_b[i] {
return -1
}
if item > sort_b[i] {
return 1
}
continue
}
if int_a < int_b {
return -1
}
if int_a > int_b {
return 1
}
}
if len(sort_b) > len(sort_a) {
return -1
}
return 0
}

371
xml/xsdtime.go Normal file
View File

@@ -0,0 +1,371 @@
package xmlparsing
import (
"errors"
"fmt"
"strconv"
"strings"
)
// An implementation of the xsd 1.1 datatypes:
// date, gDay, gMonth, gMonthDay, gYear, gYearMonth.
type XSDDatetype int
type Seperator byte
const (
DEFAULT_YEAR = 0
DEFAULT_DAY = 1
DEFAULT_MONTH = 1
MIN_ALLOWED_NUMBER = 0x30 // 0
MAX_ALLOWED_NUMBER = 0x39 // 9
SIGN = 0x2D // -
SEPERATOR = 0x2D // -
PLUS = 0x2B // +
COLON = 0x3A // :
TIMEZONE = 0x5A // Z
NONE = 0x00 // 0
)
const (
Unknown XSDDatetype = iota
Invalid
Date
GDay
GMonth
GYear
GMonthDay
GYearMonth
)
type XSDDate struct {
base string
Year int
Month int
Day int
hasTimezone bool
hasYear bool
hasMonth bool
hasDay bool
TZH int
TZM int
state XSDDatetype
error bool
// INFO: XSD Date Datatypes typically describe a duration in the value space.
// TimeError bool
// BaseTime time.Time
// BaseDuration time.Duration
}
// Sanity check:
// MONTH DAY + Date: Sanity check Month and Day. Additional checks:
// - Month: 2 - Day < 30
// - Month: 4, 6, 9, 11 - Day < 31
// - Month: 1, 3, 5, 7, 8, 10, 12 - Day < 32
// YEAR + Date: Sanity check Year + February 29. Check zero padding.
// Additional checks:
// - Feb 29 on leap years: y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)
// -> Check last 2 digits: if both are zero, check first two digits.
// Else if last digit is n % 4 == 0, the second to last digit m % 2 == 0
// Else if last digit is n % 4 == 2, the second to last digit m % 2 == 1
// Else its not a leap year.
// - no 0000 Year
//
func New(s string) (XSDDate, error) {
dt := XSDDate{base: s}
err := dt.Parse(s)
return dt, err
}
func (d XSDDate) String() string {
var s string
if d.Year != 0 {
s += fmt.Sprintf("%d", d.Year)
}
if d.Month != 0 {
if d.Year == 0 {
s += "-"
}
s += fmt.Sprintf("-%02d", d.Month)
}
if d.Day != 0 {
if d.Year == 0 && d.Month == 0 {
s += "--"
}
s += fmt.Sprintf("-%02d", d.Day)
}
if d.hasTimezone {
if d.TZH == 0 && d.TZM == 0 {
s += "Z"
} else {
sep := "+"
hint := d.TZH
if hint < 0 {
sep = "-"
hint *= -1
}
h := fmt.Sprintf("%02d", hint)
s += fmt.Sprintf("%v%v:%02d", sep, h, d.TZM)
}
}
return s
}
func (d *XSDDate) UnmarshalText(text []byte) error {
return d.Parse(string(text))
}
func (d XSDDate) MarshalText() ([]byte, error) {
return []byte(d.String()), nil
}
func (xsdd *XSDDate) Parse(s string) error {
s = strings.TrimSpace(s)
xsdd.base = s
// The smallest possible date is 4 chars long
if len(s) < 4 {
return xsdd.parseError("Date too short")
}
// Check for Z, then check for timezone
if len(s) >= 5 && s[len(s)-1] == TIMEZONE {
xsdd.hasTimezone = true
s = s[:len(s)-1]
} else if len(s) >= 10 {
err := xsdd.parseTimezone(s[len(s)-6:])
if err == nil {
s = s[:len(s)-6]
}
}
// Year
if s[1] != SEPERATOR {
i := 3
for ; i < len(s); i++ {
if s[i] < MIN_ALLOWED_NUMBER || s[i] > MAX_ALLOWED_NUMBER {
break
}
}
yint, err := strconv.Atoi(s[:i])
if err != nil {
return xsdd.parseError(fmt.Sprintf("Invalid year: %v", s[:i]))
}
xsdd.Year = yint
xsdd.hasYear = true
if i == len(s) {
return nil
}
s = s[i+1:]
} else {
s = s[2:]
}
// Left are 02 (Month), -02 (Day), 02-02 (Date)
if s[0] != SEPERATOR {
mstr := s[:2]
mint, err := strconv.Atoi(mstr)
if err != nil {
return xsdd.parseError(fmt.Sprintf("Invalid month: %v", mstr))
}
xsdd.Month = mint
xsdd.hasMonth = true
s = s[2:]
if len(s) == 0 {
return nil
} else if len(s) != 3 || s[0] != SEPERATOR {
return xsdd.parseError(fmt.Sprintf("Invalid date ending: %v", s))
}
}
s = s[1:]
// Left is 02 Day
dint, err := strconv.Atoi(s)
if err != nil {
return xsdd.parseError(fmt.Sprintf("Invalid day: %v", s))
}
// INFO: We do not check len here, it is handled above
xsdd.Day = dint
xsdd.hasDay = true
return nil
}
var WD_CALC_MATRIX = []int{0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4}
func (xsdd XSDDate) Weekday() int {
y := xsdd.Year
if xsdd.Month < 3 {
y--
}
return (y + y/4 - y/100 + y/400 + WD_CALC_MATRIX[xsdd.Month-1] + xsdd.Day) % 7
}
func (xsdd XSDDate) Base() string {
return xsdd.base
}
func (xsdd XSDDate) Type() XSDDatetype {
if xsdd.state == Unknown {
_ = xsdd.Validate()
}
return xsdd.state
}
func (xsdd *XSDDate) Validate() bool {
if xsdd.error {
xsdd.state = Invalid
return false
}
xsdd.state = xsdd.inferState()
if xsdd.state != Invalid {
return true
}
return false
}
func (xsdd *XSDDate) parseError(s string) error {
xsdd.error = true
xsdd.state = Invalid
return errors.New(s)
}
func (xsdd *XSDDate) parseTimezone(s string) error {
// INFO: We assume the check for 'Z' has already been done
if len(s) != 6 || s[3] != COLON || (s[0] != PLUS && s[0] != SIGN) {
return fmt.Errorf("Invalid timezone")
}
h, err := strconv.Atoi(s[:3])
if err != nil {
return fmt.Errorf("Invalid hour: %v", s[:3])
}
m, err := strconv.Atoi(s[4:])
if err != nil {
return fmt.Errorf("Invalid minute: %v", s[4:])
}
xsdd.hasTimezone = true
xsdd.TZH = h
xsdd.TZM = m
return nil
}
func (xsdd XSDDate) inferState() XSDDatetype {
if xsdd.hasYear && xsdd.hasMonth && xsdd.hasDay {
if !validDayMonthYear(xsdd.Year, xsdd.Month, xsdd.Day) {
return Invalid
}
return Date
} else if xsdd.hasYear && xsdd.hasMonth {
if !validMonth(xsdd.Month) || !validYear(xsdd.Year) {
return Invalid
}
return GYearMonth
} else if xsdd.hasMonth && xsdd.hasDay {
if !validDayMonth(xsdd.Day, xsdd.Month) {
return Invalid
}
return GMonthDay
} else if xsdd.hasYear {
if !validYear(xsdd.Year) {
return Invalid
}
return GYear
} else if xsdd.hasMonth {
if !validMonth(xsdd.Month) {
return Invalid
}
return GMonth
} else if xsdd.hasDay {
if !validDay(xsdd.Day) {
return Invalid
}
return GDay
}
return Invalid
}
func validDay(i int) bool {
if i < 1 || i > 31 {
return false
}
return true
}
func validMonth(i int) bool {
if i < 1 || i > 12 {
return false
}
return true
}
func validYear(i int) bool {
if i == 0 {
return false
}
return true
}
func validDayMonth(d int, m int) bool {
if !validDay(d) || !validMonth(m) {
return false
}
if m == 2 {
if d > 29 {
return false
}
} else if m == 4 || m == 6 || m == 9 || m == 11 {
if d > 30 {
return false
}
}
return true
}
func validDayMonthYear(y int, m int, d int) bool {
if !validDay(d) || !validMonth(m) || !validYear(y) {
return false
}
if m == 2 {
if d == 29 {
if y%4 == 0 && (y%100 != 0 || y%400 == 0) {
return true
}
return false
}
}
return true
}

69
xml/xsdtime_test.go Normal file
View File

@@ -0,0 +1,69 @@
package xmlparsing
import "testing"
type Test struct {
Input string
Output XSDDate
Type XSDDatetype
}
var tests = []Test{
{"2006-01-02", XSDDate{Year: 2006, Month: 1, Day: 2}, GYear},
{"-1222-01-02", XSDDate{Year: -1222, Month: 1, Day: 2}, Date},
{"-2777", XSDDate{Year: -2777}, GYear},
{"1988-12:30", XSDDate{Year: 1988, hasTimezone: true, TZH: -12, TZM: 30}, GYear},
{"--03+05:00", XSDDate{Month: 3, hasTimezone: true, TZH: 5, TZM: 0}, GMonth},
{"---29", XSDDate{Day: 29}, GDay},
{"-1234567-12Z", XSDDate{Year: -1234567, Month: 12, hasTimezone: true, TZH: 0, TZM: 0}, GYearMonth},
{"-1234567-12+05:00", XSDDate{Year: -1234567, Month: 12, hasTimezone: true, TZH: 5, TZM: 0}, GYearMonth},
{"--12-31", XSDDate{Month: 12, Day: 31}, GMonthDay},
}
func TestParse(t *testing.T) {
for _, test := range tests {
dt, err := New(test.Input)
if err != nil {
t.Errorf("Error parsing %v: %v", test.Input, err)
continue
}
if dt.Year != test.Output.Year {
t.Errorf("Year mismatch for %v: expected %v, got %v", test.Input, test.Output.Year, dt.Year)
}
if dt.Month != test.Output.Month {
t.Errorf("Month mismatch for %v: expected %v, got %v", test.Input, test.Output.Month, dt.Month)
}
if dt.Day != test.Output.Day {
t.Errorf("Day mismatch for %v: expected %v, got %v", test.Input, test.Output.Day, dt.Day)
}
if dt.hasTimezone != test.Output.hasTimezone {
t.Errorf("Timezone mismatch for %v: expected %v, got %v", test.Input, test.Output.hasTimezone, dt.hasTimezone)
}
if dt.TZH != test.Output.TZH {
t.Errorf("Timezone mismatch for %v: expected %v, got %v", test.Input, test.Output.TZH, dt.TZH)
}
if dt.TZM != test.Output.TZM {
t.Errorf("Timezone mismatch for %v: expected %v, got %v", test.Input, test.Output.TZM, dt.TZM)
}
}
}
func TestString(t *testing.T) {
for _, test := range tests {
dt, err := New(test.Input)
if err != nil {
t.Errorf("Error parsing %v: %v", test.Input, err)
continue
}
if dt.String() != test.Input {
t.Errorf("String mismatch for %v: expected %v, got %v", test.Input, test.Input, dt.String())
}
}
}

19
xmlmodels/common.go Normal file
View File

@@ -0,0 +1,19 @@
package xmlmodels
import xmlparsing "github.com/Theodor-Springmann-Stiftung/lenz-web/xml"
type RefElement struct {
Reference int `xml:"ref,attr"`
Text string `xml:",chardata"`
Cert string `xml:"cert,attr"`
}
type Date struct {
When xmlparsing.XSDDate `xml:"when,attr"`
NotBefore xmlparsing.XSDDate `xml:"notBefore,attr"`
NotAfter xmlparsing.XSDDate `xml:"notAfter,attr"`
From xmlparsing.XSDDate `xml:"from,attr"`
To xmlparsing.XSDDate `xml:"to,attr"`
Cert string `xml:"cert,attr"`
Text string `xml:",chardata"`
}

12
xmlmodels/data.go Normal file
View File

@@ -0,0 +1,12 @@
package xmlmodels
const (
AGENTREF = "AgentRef"
PERSONREF = "PersonRef"
LOCATIONREF = "LocationRef"
APPREF = "AppRef"
META = "Meta"
LETTER = "Letter"
TRADITION = "Tradition"
)

16
xmlmodels/letter.go Normal file
View File

@@ -0,0 +1,16 @@
package xmlmodels
import "encoding/xml"
type Letter struct {
XMLName xml.Name `xml:"letterText"`
Letter int `xml:"letter,attr"`
Pages []Page `xml:"page"`
Hands []RefElement `xml:"hand"`
Content string `xml:",innerxml"`
}
type Page struct {
XMLName xml.Name `xml:"page"`
Index int `xml:"index,attr"`
}

23
xmlmodels/meta.go Normal file
View File

@@ -0,0 +1,23 @@
package xmlmodels
import (
"encoding/xml"
xmlparsing "github.com/Theodor-Springmann-Stiftung/lenz-web/xml"
)
type Meta struct {
XMLName xml.Name `xml:"letterDesc"`
Letter int `xml:"letter,attr"`
HasOriginal xmlparsing.OptionalBool `xml:"hasOriginal"`
IsProofread xmlparsing.OptionalBool `xml:"isProofread"`
IsDraft xmlparsing.OptionalBool `xml:"isDraft"`
Sent []Action `xml:"sent"`
Recieved []Action `xml:"recieved"`
}
type Action struct {
Dates []Date `xml:"date,attr"`
Places []RefElement `xml:"place"`
Persons []RefElement `xml:"person"`
}

22
xmlmodels/references.go Normal file
View File

@@ -0,0 +1,22 @@
package xmlmodels
type PersonDef struct {
Index int `xml:"index,attr"`
Name string `xml:"name,attr"`
Ref string `xml:"ref,attr"`
FirstName string `xml:"vorname,attr"`
LastName string `xml:"nachname,attr"`
Comment string `xml:"komm,attr"`
}
type LocationDef struct {
Index int `xml:"index,attr"`
Name string `xml:"name,attr"`
Ref string `xml:"ref,attr"`
}
type AppDef struct {
Index int `xml:"index,attr"`
Name string `xml:"name,attr"`
Category string `xml:"category,attr"`
}

41
xmlmodels/roots.go Normal file
View File

@@ -0,0 +1,41 @@
package xmlmodels
import "encoding/xml"
type MetaRoot struct {
XMLName xml.Name `xml:"opus"`
Metas []Meta `xml:"letterDesc"`
}
func (m MetaRoot) Children() []Meta {
return m.Metas
}
type DefinitionsRoot struct {
XMLName xml.Name `xml:"definitions"`
Persons PersonDefs `xml:"personDefs"`
Locations LocationDefs `xml:"locationDefs"`
Apparatuses AppDefs `xml:"appDefs"`
}
type PersonDefs struct {
Persons []PersonDef `xml:"personDef"`
}
type LocationDefs struct {
Locations []LocationDef `xml:"locationDef"`
}
type AppDefs struct {
Apps []AppDef `xml:"appDef"`
}
type TraditionsRoot struct {
XMLName xml.Name `xml:"traditions"`
Traditions []Tradition `xml:"tradition"`
}
type DocumentsRoot struct {
XMLName xml.Name `xml:"document"`
Documents []Letter `xml:"letterText"`
}

14
xmlmodels/traditions.go Normal file
View File

@@ -0,0 +1,14 @@
package xmlmodels
import "encoding/xml"
type Tradition struct {
XMLName xml.Name `xml:"letterTradition"`
Letter int `xml:"letter,attr"`
Apps []App `xml:"app"`
}
type App struct {
Reference int `xml:"ref,attr"`
Content string `xml:",innerxml"`
}