Files
sm-sitemap-plugin/controllers/settings.go
Jakub Zych 84cb27a810 feat!: remove the PHP-parity sitemap, tiers, definitions screen and XSL
BREAKING CHANGE: /sitemap.xsl is gone (404), the definitions screen and
the Phase 17 settings tile are replaced by the settings singleton, and
priority, change frequency, priority tiers and the update manifest are
no longer written or read. The config keys golem15.sitemap.cache_seconds
and golem15.sitemap.priority_tiers are removed; site_url, lastmod_ignore
and rebuild_delay_seconds are new.

- sitemap:import and sitemap:import-winter write the additional addresses
  (and site_url, default_locale) instead of the definition
- the golem15_sitemap_definitions table stays in the database, unread
2026-11-04 11:12:51 +01:00

273 lines
9.1 KiB
Go

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"
)
const (
// SettingsControllerID is the cabana controller id of the sitemap
// settings singleton (UI-SPEC C26), linked from the sitemap settings
// tile.
SettingsControllerID = "golem15.sitemap.settings"
// PermissionAccessDefinitions guards the sitemap settings. The code is
// the Phase 17 (and WinterCMS) one, so existing roles keep their access.
PermissionAccessDefinitions = "golem15.sitemap.access_definitions"
)
// 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{}
)