feat: sitemap settings singleton with content types and additional addresses

- one settings tile opening the golem15.sitemap.settings singleton
- Files tab (site address, file size, S1 files partial), Additional
  addresses repeater, Content types tab built from signpost at boot
- golem15_sitemap_types; a type switched off leaves the files (reason type)
- a save validates (errors.*) and dispatches a rebuild after commit
This commit is contained in:
Jakub Zych
2026-11-04 11:06:29 +01:00
parent 3359f1a196
commit 3ba70bb69f
20 changed files with 1304 additions and 337 deletions

View File

@@ -2,6 +2,7 @@ package models
import (
"net/url"
"strconv"
"strings"
"time"
"unicode"
@@ -9,7 +10,6 @@ import (
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// errFrontendURL is the phrase key of the frontend_url refusal.
@@ -95,28 +95,62 @@ func (s Settings) Extra() []ExtraURL { return s.ExtraURLs.Get() }
func (Settings) TableName() string { return "golem15_sitemap_settings" }
// Fillable lists only the settings columns.
// Fillable lists the columns the settings form edits.
func (Settings) Fillable() []string {
return []string{
"frontend_url", "frontend_default_locale", "updates_manifest_path",
"enable_priority_tiers", PriorityHomepage, PriorityServices,
PriorityBlog, PriorityCategories, PriorityLegal,
}
return []string{"site_url", "file_size", "extra_urls"}
}
// Rules is empty: the PHP settings form validated nothing. The one check of
// the port, on frontend_url, is in BeforeValidate.
func (Settings) Rules() map[string]string { return map[string]string{} }
// BeforeValidate is the lagoon.HasBeforeValidate hook cabana calls on a
// settings save, after the fill and before the row is written: a
// frontend_url that AllowedFrontendURL refuses is a validation error on
// frontend_url, so the save answers 422 and the row is left as it was.
func (s Settings) BeforeValidate(*gorm.DB) error {
if AllowedFrontendURL(Text(s.FrontendURL)) {
// The phrase keys of the other settings refusals.
const (
errFileSize = "golem15.sitemap::lang.errors.file_size"
errURLScheme = "golem15.sitemap::lang.errors.url_scheme"
errLastmod = "golem15.sitemap::lang.errors.lastmod"
)
// Validate answers every problem Problems finds as a validation error on
// its field; the settings controller calls it before a save, so the save
// answers 422 and the row is left as it was.
func (s Settings) Validate() error {
problems := s.Problems()
if len(problems) == 0 {
return nil
}
return &cabana.ValidationError{Details: map[string]any{"frontend_url": []string{errFrontendURL}}}
details := make(map[string]any, len(problems))
for field, keys := range problems {
details[field] = keys
}
return &cabana.ValidationError{Details: details}
}
// Problems lists the settings' refusals by field (UI-SPEC C26): a site
// address that AllowedFrontendURL refuses (site_url and the Phase 17
// frontend_url), a file size outside 1..MaxFileSize and an additional
// address that is not a path or an http(s) URL (extra_urls.<i>.url) or
// whose lastmod is not a date (extra_urls.<i>.lastmod).
func (s Settings) Problems() map[string][]string {
out := map[string][]string{}
if !AllowedFrontendURL(Text(s.SiteURL)) {
out["site_url"] = []string{errFrontendURL}
}
if !AllowedFrontendURL(Text(s.FrontendURL)) {
out["frontend_url"] = []string{errFrontendURL}
}
if s.FileSize < 1 || s.FileSize > MaxFileSize {
out["file_size"] = []string{errFileSize}
}
for i, e := range s.Extra() {
if !AllowedAddress(strings.TrimSpace(e.URL)) {
out["extra_urls."+strconv.Itoa(i)+".url"] = []string{errURLScheme}
}
if mod := strings.TrimSpace(e.LastMod); mod != "" && !ValidDate(mod) {
out["extra_urls."+strconv.Itoa(i)+".lastmod"] = []string{errLastmod}
}
}
return out
}
// AllowedFrontendURL reports whether u may be the frontend_url setting: the
@@ -180,3 +214,26 @@ func (s Settings) Priority(key string) string {
func init() {
Register(Settings{})
}
// dateLayouts are the lastmod forms an additional address may carry.
var dateLayouts = []string{time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02"}
// ParseDate reads a lastmod as the settings form or an import gives it
// (YYYY-MM-DD, "YYYY-MM-DD hh:mm:ss" or RFC 3339; UTC unless it names a
// zone); nil when empty or unreadable.
func ParseDate(s string) *time.Time {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
for _, layout := range dateLayouts {
if t, err := time.Parse(layout, s); err == nil {
t = t.UTC()
return &t
}
}
return nil
}
// ValidDate reports whether s is a lastmod ParseDate reads.
func ValidDate(s string) bool { return ParseDate(s) != nil }