mirror of
https://github.com/Theodor-Springmann-Stiftung/lenz-web.git
synced 2025-10-29 01:05:32 +00:00
Init
This commit is contained in:
124
config/config.go
Normal file
124
config/config.go
Normal 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
23
config/public.go
Normal 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
245
git/git.go
Normal 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
32
go.mod
Normal 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
106
go.sum
Normal 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
113
helpers/functions/date.go
Normal 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]
|
||||
}
|
||||
93
helpers/functions/embedding.go
Normal file
93
helpers/functions/embedding.go
Normal 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)
|
||||
}
|
||||
}
|
||||
18
helpers/functions/iterables.go
Normal file
18
helpers/functions/iterables.go
Normal 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
|
||||
}
|
||||
1
helpers/functions/slices.go
Normal file
1
helpers/functions/slices.go
Normal file
@@ -0,0 +1 @@
|
||||
package functions
|
||||
12
helpers/functions/sorting.go
Normal file
12
helpers/functions/sorting.go
Normal 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
|
||||
}
|
||||
17
helpers/functions/string.go
Normal file
17
helpers/functions/string.go
Normal 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
5
lenz.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package main
|
||||
|
||||
func main() {
|
||||
println("Hello, World!")
|
||||
}
|
||||
0
server/server.go
Normal file
0
server/server.go
Normal file
14
templating/consts.go
Normal file
14
templating/consts.go
Normal 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
148
templating/context.go
Normal 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
249
templating/engine.go
Normal 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
21
templating/errors.go
Normal 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
32
templating/helpers.go
Normal 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
|
||||
}
|
||||
108
templating/layout_registry.go
Normal file
108
templating/layout_registry.go
Normal 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)
|
||||
}
|
||||
121
templating/template_registry.go
Normal file
121
templating/template_registry.go
Normal 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
57
templating/ws.go
Normal 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
62
views/.air.toml
Normal 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
8
views/.editorconfig
Normal 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
24
views/.gitignore
vendored
Normal 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
10
views/.prettierrc
Normal 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
31
views/embed.go
Normal 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
18
views/embed_dev.go
Normal 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)
|
||||
53
views/layouts/blank/root.gohtml
Normal file
53
views/layouts/blank/root.gohtml
Normal 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>
|
||||
22
views/layouts/components/_footer.gohtml
Normal file
22
views/layouts/components/_footer.gohtml
Normal 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>·</span>
|
||||
<span>{{- (GetMonth $date).Name }} {{ $date.Year }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span>{{- .site.title }} – ein Projekt der</span>
|
||||
<a href="https://theodor-springmann-stiftung.de">Theodor Springmann Stiftung</a>
|
||||
<span>·</span>
|
||||
<a href="/datenschutz/">Impressum & Datenschutz</a>
|
||||
<span>·</span>
|
||||
<i class="ri-code-line"></i>
|
||||
<a href="https://github.com/Theodor-Springmann-Stiftung/musenalm">Code</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
1
views/layouts/components/_header.gohtml
Normal file
1
views/layouts/components/_header.gohtml
Normal file
@@ -0,0 +1 @@
|
||||
MALM
|
||||
1
views/layouts/components/_menu.gohtml
Normal file
1
views/layouts/components/_menu.gohtml
Normal file
@@ -0,0 +1 @@
|
||||
{{ $model := . }}
|
||||
83
views/layouts/default/root.gohtml
Normal file
83
views/layouts/default/root.gohtml
Normal 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
34
views/package.json
Normal 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
5
views/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
BIN
views/public/bg.jpg
Normal file
BIN
views/public/bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
55
views/public/css/fonts.css
Normal file
55
views/public/css/fonts.css
Normal 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
655
views/public/css/hint.css
Normal 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
5
views/public/css/hint.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3104
views/public/css/remixicon.css
Normal file
3104
views/public/css/remixicon.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
views/public/fonts/LinBiolinum_RB_G.ttf
Normal file
BIN
views/public/fonts/LinBiolinum_RB_G.ttf
Normal file
Binary file not shown.
BIN
views/public/fonts/LinBiolinum_RI_G.ttf
Normal file
BIN
views/public/fonts/LinBiolinum_RI_G.ttf
Normal file
Binary file not shown.
BIN
views/public/fonts/LinBiolinum_R_G.ttf
Normal file
BIN
views/public/fonts/LinBiolinum_R_G.ttf
Normal file
Binary file not shown.
BIN
views/public/fonts/LinLibertine_DR_G.ttf
Normal file
BIN
views/public/fonts/LinLibertine_DR_G.ttf
Normal file
Binary file not shown.
BIN
views/public/fonts/LinLibertine_RBI_G.ttf
Normal file
BIN
views/public/fonts/LinLibertine_RBI_G.ttf
Normal file
Binary file not shown.
BIN
views/public/fonts/LinLibertine_RB_G.ttf
Normal file
BIN
views/public/fonts/LinLibertine_RB_G.ttf
Normal file
Binary file not shown.
BIN
views/public/fonts/LinLibertine_RI_G.ttf
Normal file
BIN
views/public/fonts/LinLibertine_RI_G.ttf
Normal file
Binary file not shown.
BIN
views/public/fonts/LinLibertine_RZI_G.ttf
Normal file
BIN
views/public/fonts/LinLibertine_RZI_G.ttf
Normal file
Binary file not shown.
BIN
views/public/fonts/LinLibertine_RZ_G.ttf
Normal file
BIN
views/public/fonts/LinLibertine_RZ_G.ttf
Normal file
Binary file not shown.
BIN
views/public/fonts/LinLibertine_R_G.ttf
Normal file
BIN
views/public/fonts/LinLibertine_R_G.ttf
Normal file
Binary file not shown.
5
views/public/js/alpine.min.js
vendored
Normal file
5
views/public/js/alpine.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
97
views/public/js/class-tools.js
Normal file
97
views/public/js/class-tools.js
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
96
views/public/js/client-side-templates.js
Normal file
96
views/public/js/client-side-templates.js
Normal 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
|
||||
}
|
||||
})
|
||||
144
views/public/js/head-support.js
Normal file
144
views/public/js/head-support.js
Normal 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;
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
})()
|
||||
130
views/public/js/htmx-response-targets.js
Normal file
130
views/public/js/htmx-response-targets.js
Normal 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
1
views/public/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
23
views/public/js/include-vals.js
Normal file
23
views/public/js/include-vals.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
184
views/public/js/loading-states.js
Normal file
184
views/public/js/loading-states.js
Normal 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
13
views/public/js/mark.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
44
views/public/js/multi-swap.js
Normal file
44
views/public/js/multi-swap.js
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
11
views/public/js/path-params.js
Normal file
11
views/public/js/path-params.js
Normal 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
388
views/public/js/preload.js
Normal 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
471
views/public/js/ws.js
Normal 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
BIN
views/public/logo/dev_favicon.png
Normal file
BIN
views/public/logo/dev_favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
views/public/logo/favicon.png
Normal file
BIN
views/public/logo/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
13
views/public/xslt/transform-citation.xsl
Normal file
13
views/public/xslt/transform-citation.xsl
Normal 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
0
views/routes/body.gohtml
Normal file
0
views/routes/head.gohtml
Normal file
0
views/routes/head.gohtml
Normal file
1269
views/transform/main.js
Normal 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
66
views/transform/site.css
Normal 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
16
views/vite.config.js
Normal 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
27
views/vite.dev.config.js
Normal 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
37
xml/helpers.go
Normal 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
12
xml/item.go
Normal 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
15
xml/library.go
Normal 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
32
xml/models.go
Normal 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
49
xml/optionalbool.go
Normal 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
48
xml/resolver.go
Normal 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
200
xml/xmlprovider.go
Normal 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
77
xml/xmlsort.go
Normal 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
371
xml/xsdtime.go
Normal 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
69
xml/xsdtime_test.go
Normal 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
19
xmlmodels/common.go
Normal 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
12
xmlmodels/data.go
Normal 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
16
xmlmodels/letter.go
Normal 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
23
xmlmodels/meta.go
Normal 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
22
xmlmodels/references.go
Normal 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
41
xmlmodels/roots.go
Normal 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
14
xmlmodels/traditions.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user