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

66
classes/dispatch.go Normal file
View File

@@ -0,0 +1,66 @@
package classes
import (
"context"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"gorm.io/gorm"
)
// RebuildLabelKey is the phrase key of the rebuild job's label.
const RebuildLabelKey = "golem15.sitemap::lang.jobs.rebuild"
// buildingWindow bounds the "rebuilding now" state of the settings screen:
// a request older than this without a finished rebuild (no worker runs)
// is no longer shown as running.
const buildingWindow = time.Hour
// RequestRebuild records that a rebuild was asked for (the settings
// screen's files.building state) and dispatches the rebuild job once db's
// transaction commits, so the job reads what the transaction wrote. A
// queue error is logged and never fails the caller.
func RequestRebuild(ctx context.Context, app *backpack.App, db *gorm.DB) error {
if app == nil || db == nil {
return ErrNoDatabase
}
if err := db.Exec(`UPDATE golem15_sitemap_settings SET rebuild_requested_at = ? WHERE id = 1`, time.Now().UTC()).Error; err != nil {
return err
}
lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) {
if _, err := DispatchRebuild(ctx, app); err != nil {
pluginLog.Warn("golem15.sitemap: queue rebuild", "error", err)
}
})
return nil
}
// DispatchRebuild dispatches the rebuild job now, with a summer_jobs row
// labelled in the request locale, and returns the row id.
func DispatchRebuild(ctx context.Context, app *backpack.App) (uint, error) {
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
return 0, ErrNoDatabase
}
m, err := conga.From(app)
if err != nil {
return 0, err
}
label := RebuildLabelKey
if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
label = tr.Get(ctx, RebuildLabelKey, nil)
}
return m.Dispatch(ctx, db.WithContext(ctx), RebuildArgs{}, conga.DispatchOpts{Label: label})
}
// Building reports whether a rebuild asked for from the admin has not
// finished yet (within buildingWindow).
func Building(requested, built *time.Time, now time.Time) bool {
if requested == nil || now.Sub(*requested) > buildingWindow {
return false
}
return built == nil || built.Before(*requested)
}

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"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/lagoon"
@@ -124,8 +125,12 @@ func Rebuild(ctx context.Context, app *backpack.App) (RebuildResult, error) {
if err != nil {
return res, err
}
types, err := LoadTypeSettings(db)
if err != nil {
return res, err
}
reg, _ := app.Lookup[*signpost.Registry]()
found, err := collect(ctx, reg, settings)
found, err := collect(ctx, reg, settings, types)
if err != nil {
return res, err
}
@@ -145,8 +150,10 @@ func Rebuild(ctx context.Context, app *backpack.App) (RebuildResult, error) {
}
// collect walks the content types page by page and adds the additional
// addresses.
func collect(ctx context.Context, reg *signpost.Registry, settings models.Settings) (collected, error) {
// addresses. The records of a type switched off in the settings are kept
// in the table with the reason contract.ReasonType, so the settings screen
// counts them.
func collect(ctx context.Context, reg *signpost.Registry, settings models.Settings, types map[string]models.TypeSetting) (collected, error) {
out := collected{unpublished: map[slotKey]bool{}}
seen := map[slotKey]bool{}
add := func(w wanted) {
@@ -159,7 +166,11 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
unprefixed := models.Text(settings.DefaultLocale)
if reg != nil {
for _, typ := range reg.ContentTypes() {
slug := DefaultSlug(typ.Key)
cfg := EffectiveType(typ, types)
reason := ""
if !cfg.Include {
reason = contract.ReasonType
}
after := ""
for {
page, err := reg.Records(ctx, typ.Key, after, recordsPage, unprefixed)
@@ -177,10 +188,11 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
}
add(wanted{
key: slotKey{typ.Key, rec.ID, l.Locale},
grp: slug,
grp: cfg.Slug,
url: l.URL,
title: l.Title,
firstMod: rec.LastMod,
reason: reason,
})
}
}
@@ -196,7 +208,7 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
if !models.AllowedAddress(u) {
continue
}
mod := ParseDate(e.LastMod)
mod := models.ParseDate(e.LastMod)
add(wanted{
key: slotKey{models.ExtraTypeKey, ExtraRecordID(u), ""},
grp: models.ExtraGroup,
@@ -209,23 +221,6 @@ func collect(ctx context.Context, reg *signpost.Registry, settings models.Settin
return out, nil
}
// ParseDate reads a date as the admin's date picker or an import sends it
// (RFC 3339, "2006-01-02 15:04:05" or "2006-01-02", UTC); nil when empty or
// unreadable.
func ParseDate(s string) *time.Time {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
for _, layout := range []string{time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02"} {
if t, err := time.Parse(layout, s); err == nil {
t = t.UTC()
return &t
}
}
return nil
}
// apply writes the found addresses into the slot table and numbers the
// files, inside the rebuild's transaction.
func apply(tx *gorm.DB, found collected, settings models.Settings, site string) (RebuildResult, error) {

54
classes/types.go Normal file
View File

@@ -0,0 +1,54 @@
package classes
import (
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// ReservedSlugs are file name parts no content type may use: the image
// and News sitemaps own them.
var ReservedSlugs = map[string]bool{"images": true, "news": true}
// TypeConfig is a content type's effective sitemap setting: its key and
// label, whether its records are listed, its group slug and its HTML
// sitemap headings (nil when none was saved).
type TypeConfig struct {
Key string
Label string
Include bool
Slug string
Titles map[string]string
}
// LoadTypeSettings reads the saved content type settings by type key.
func LoadTypeSettings(db *gorm.DB) (map[string]models.TypeSetting, error) {
var rows []models.TypeSetting
if err := db.Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]models.TypeSetting, len(rows))
for _, r := range rows {
out[r.TypeKey] = r
}
return out, nil
}
// EffectiveType is the setting of typ: the saved row, else listed under
// DefaultSlug. A saved slug that is not a ValidSlug falls back to the
// default.
func EffectiveType(typ pact.MenuItemType, rows map[string]models.TypeSetting) TypeConfig {
cfg := TypeConfig{Key: typ.Key, Label: typ.Label, Include: true, Slug: DefaultSlug(typ.Key)}
row, ok := rows[typ.Key]
if !ok {
return cfg
}
cfg.Include = row.Include
if ValidSlug(row.Slug) {
cfg.Slug = row.Slug
}
if titles := row.Titles.Get(); len(titles) > 0 {
cfg.Titles = titles
}
return cfg
}