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:
@@ -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
266
controllers/settings.go
Normal 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{}
|
||||
)
|
||||
42
controllers/settings/_sitemap_files.htm
Normal file
42
controllers/settings/_sitemap_files.htm
Normal 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>
|
||||
20
controllers/settings/config_form.yaml
Normal file
20
controllers/settings/config_form.yaml
Normal 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
|
||||
Reference in New Issue
Block a user