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

@@ -11,5 +11,6 @@ import (
func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
definitionsAdminController{app: app},
settingsAdminController{app: app},
}
}

266
controllers/settings.go Normal file
View File

@@ -0,0 +1,266 @@
package controllers
import (
"context"
"errors"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
// SettingsControllerID is the cabana controller id of the sitemap settings
// singleton (UI-SPEC C26), linked from the sitemap settings tile.
const SettingsControllerID = "golem15.sitemap.settings"
// The reasons the files partial counts as "left out", in display order
// (P33-11). A deleted or unpublished address is a hole, not left out.
var leftOutReasons = []string{contract.ReasonNoindex, contract.ReasonCanonical, contract.ReasonRedirect, contract.ReasonType}
// settingsAdminController edits the sitemap settings singleton: Files,
// Content types (added by the plugin's own form extension) and Additional
// addresses. A save dispatches a rebuild.
type settingsAdminController struct {
app func() *backpack.App
}
func (settingsAdminController) ID() string { return SettingsControllerID }
func (settingsAdminController) ModelName() string { return `Golem15\Sitemap\Models\Settings` }
func (settingsAdminController) ConfigDir() string { return "controllers/settings" }
func (settingsAdminController) NewRecord() any { return &models.Settings{} }
func (settingsAdminController) RequiredPermissions() []string {
return []string{PermissionAccessDefinitions}
}
// SingletonRecord returns settings row 1, creating it with the defaults
// when the migration's row is gone (safe against a concurrent first
// request through ON CONFLICT DO NOTHING).
func (settingsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
if db == nil {
return nil, errors.New("golem15.sitemap: database is not configured")
}
var rows []models.Settings
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) > 0 {
return &rows[0], nil
}
if err := db.Exec(`INSERT INTO golem15_sitemap_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`).Error; err != nil {
return nil, err
}
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return nil, errors.New("golem15.sitemap: settings row is missing")
}
return &rows[0], nil
}
// FormBeforeUpdate refuses the settings Problems finds (UI-SPEC C26:
// errors.frontend_url on site_url, errors.file_size, errors.url_scheme and
// errors.lastmod on an additional address).
func (settingsAdminController) FormBeforeUpdate(_ context.Context, model any) error {
s, ok := model.(*models.Settings)
if !ok || s == nil {
return nil
}
return s.Validate()
}
// FormBeforeCreate checks a created row like an update.
func (c settingsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
return c.FormBeforeUpdate(ctx, model)
}
// FormAfterUpdate dispatches a rebuild once the save commits (C26: saving
// the form rebuilds the files now). A queue error is logged and never fails
// the save.
func (c settingsAdminController) FormAfterUpdate(ctx context.Context, _ any) error {
app := appOf(c.app)
tx, err := database(ctx, c.app)
if err != nil || app == nil {
return nil
}
return classes.RequestRebuild(ctx, app, tx)
}
// FormAfterCreate dispatches a rebuild like an update.
func (c settingsAdminController) FormAfterCreate(ctx context.Context, model any) error {
return c.FormAfterUpdate(ctx, model)
}
// filesView is the view model of the S1 partial sitemap_files.
type filesView struct {
Building bool
Empty bool
Files []fileRow
LeftOut []reasonRow
Note string
}
// fileRow is one row of the S1 files table: the index first, then each
// file. URL is the backend route that serves it.
type fileRow struct {
Name string
URL string
Count int
LastMod string
}
// reasonRow is one row of the S1 left-out table.
type reasonRow struct {
Label string
Count int
}
// PartialData serves the S1 files partial: the index and its files with
// their address counts and newest lastmod, the left-out counts by reason
// and the index note; files.empty before the first build and
// files.building while a rebuild asked for from the admin runs.
func (c settingsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) {
if name != "sitemap_files" {
return nil, nil
}
app := appOf(c.app)
db, err := database(ctx, c.app)
if err != nil {
return nil, err
}
settings, err := classes.LoadSettings(db)
if err != nil {
return nil, err
}
site := classes.SiteURL(app, settings)
view := filesView{Building: classes.Building(settings.RebuildRequestedAt, settings.BuiltAt, time.Now())}
var files []struct {
Grp string `gorm:"column:grp"`
FileNo int `gorm:"column:file_no"`
N int `gorm:"column:n"`
LastMod *time.Time `gorm:"column:lastmod"`
}
if err := db.Raw(`SELECT grp, file_no, COUNT(*) AS n, MAX(lastmod) AS lastmod FROM golem15_sitemap_urls
WHERE excluded_reason = '' AND file_no IS NOT NULL
GROUP BY grp, file_no ORDER BY grp, file_no`).Scan(&files).Error; err != nil {
return nil, err
}
// The partial kit links only root-relative addresses, so a file links
// to the backend route that serves it; the note names the public index.
link := func(name string) string {
if name == "sitemap.xml" {
return "/sitemap.xml"
}
return "/_sitemap/files/" + name
}
if len(files) == 0 {
view.Empty = true
} else {
index := fileRow{Name: "sitemap.xml", URL: link("sitemap.xml")}
var newest *time.Time
for _, f := range files {
index.Count += f.N
if f.LastMod != nil && (newest == nil || f.LastMod.After(*newest)) {
newest = f.LastMod
}
name := classes.FileName(f.Grp, f.FileNo)
view.Files = append(view.Files, fileRow{Name: name, URL: link(name), Count: f.N, LastMod: formatTime(f.LastMod)})
}
index.LastMod = formatTime(newest)
view.Files = append([]fileRow{index}, view.Files...)
}
var counts []struct {
Reason string `gorm:"column:excluded_reason"`
N int `gorm:"column:n"`
}
if err := db.Raw(`SELECT excluded_reason, COUNT(*) AS n FROM golem15_sitemap_urls
WHERE excluded_reason NOT IN ('', ?, ?) GROUP BY excluded_reason`, models.ReasonDeleted, models.ReasonUnpublished).Scan(&counts).Error; err != nil {
return nil, err
}
byReason := map[string]int{}
for _, row := range counts {
byReason[row.Reason] = row.N
}
tr := translator(c.app)
for _, reason := range leftOutReasons {
if n := byReason[reason]; n > 0 {
view.LeftOut = append(view.LeftOut, reasonRow{Label: Phrase(ctx, tr, "golem15.sitemap::lang.files.reason_"+reason, nil), Count: n})
delete(byReason, reason)
}
}
for reason, n := range byReason {
view.LeftOut = append(view.LeftOut, reasonRow{Label: reason, Count: n})
}
indexURL := "/sitemap.xml"
if site != "" {
indexURL = site + "/sitemap.xml"
}
view.Note = Phrase(ctx, tr, "golem15.sitemap::lang.files.index_note", map[string]string{"url": indexURL})
return view, nil
}
// formatTime renders a lastmod for the files table, "" when unknown.
func formatTime(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format("2006-01-02 15:04 UTC")
}
// appOf calls the lazy app accessor.
func appOf(app func() *backpack.App) *backpack.App {
if app == nil {
return nil
}
return app()
}
// translator returns the application's translator, nil when none.
func translator(app func() *backpack.App) *phrasebook.Translator {
a := appOf(app)
if a == nil {
return nil
}
tr, _ := a.Lookup[*phrasebook.Translator]()
return tr
}
// database returns the write transaction of ctx, else the app database.
func database(ctx context.Context, app func() *backpack.App) (*gorm.DB, error) {
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
return tx, nil
}
a := appOf(app)
if a == nil {
return nil, errors.New("golem15.sitemap: app is not booted")
}
db, ok := a.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("golem15.sitemap: database is not configured")
}
return db.WithContext(ctx), nil
}
// Phrase translates key with params in the request locale; without a
// translator it returns the key.
func Phrase(ctx context.Context, tr *phrasebook.Translator, key string, params map[string]string) string {
if tr == nil {
return key
}
return tr.Get(ctx, key, params)
}
var (
_ pact.AdminController = settingsAdminController{}
_ pact.AdminPermissioned = settingsAdminController{}
_ pact.AdminRecordSource = settingsAdminController{}
_ pact.AdminSingleton = settingsAdminController{}
_ pact.AdminPartialData = settingsAdminController{}
_ pact.FormBeforeCreate = settingsAdminController{}
_ pact.FormBeforeUpdate = settingsAdminController{}
_ pact.FormAfterCreate = settingsAdminController{}
_ pact.FormAfterUpdate = settingsAdminController{}
)

View File

@@ -0,0 +1,42 @@
{{- if .Data.Building -}}
<div class="summer-callout" role="status">
<p class="summer-callout__text">{{ trans "golem15.sitemap::lang.files.building" }}</p>
</div>
{{- end -}}
{{- if .Data.Empty -}}
<div class="summer-callout" role="status">
<p class="summer-callout__text">{{ trans "golem15.sitemap::lang.files.empty" }}</p>
</div>
{{- else -}}
<section>
<h4>{{ trans "golem15.sitemap::lang.files.label" }}</h4>
<div class="summer-table-wrap">
<table class="summer-table">
<thead><tr><th>{{ trans "golem15.sitemap::lang.files.file" }}</th><th class="summer-table__num">{{ trans "golem15.sitemap::lang.files.file_urls" }}</th><th>{{ trans "golem15.sitemap::lang.files.file_lastmod" }}</th></tr></thead>
<tbody>
{{- range .Data.Files -}}
<tr><td>{{ if .URL }}<a href="{{ .URL }}"><code>{{ .Name }}</code></a>{{ else }}<code>{{ .Name }}</code>{{ end }}</td><td class="summer-table__num">{{ .Count }}</td><td>{{ .LastMod }}</td></tr>
{{- end -}}
</tbody>
</table>
</div>
</section>
{{- end -}}
{{- if .Data.LeftOut -}}
<section>
<h4>{{ trans "golem15.sitemap::lang.files.left_out" }}</h4>
<div class="summer-table-wrap">
<table class="summer-table">
<thead><tr><th>{{ trans "golem15.sitemap::lang.files.left_out_reason" }}</th><th class="summer-table__num">{{ trans "golem15.sitemap::lang.files.left_out_count" }}</th></tr></thead>
<tbody>
{{- range .Data.LeftOut -}}
<tr><td>{{ .Label }}</td><td class="summer-table__num">{{ .Count }}</td></tr>
{{- end -}}
</tbody>
</table>
</div>
</section>
{{- end -}}
<div class="summer-callout" role="note">
<p class="summer-callout__text">{{ .Data.Note }}</p>
</div>

View File

@@ -0,0 +1,20 @@
# ===================================
# Form Behavior Config
# ===================================
#
# The sitemap settings singleton (UI-SPEC C26), linked from the sitemap
# settings tile. The plugin's form extension adds the Content types tab
# after Files.
name: golem15.sitemap::lang.settings.label
form: ~/plugins/golem15/sitemap/models/settings/fields.yaml
modelClass: Golem15\Sitemap\Models\Settings
defaultRedirect: system/settings
update:
redirect: golem15/sitemap/settings
redirectClose: system/settings
messages:
update: golem15.sitemap::lang.settings.label
saved: backend::lang.messages.form.saved