Files
sm-sitemap-plugin/extensions_settings.go
Jakub Zych 3ba70bb69f 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
2026-11-04 11:06:29 +01:00

288 lines
8.6 KiB
Go

package sitemap
import (
"context"
"fmt"
"regexp"
"strings"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/controllers"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/signpost"
"github.com/goccy/go-yaml"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The settings form's tab keys: the Content types tab follows Files.
const (
TabFiles = "golem15.sitemap::lang.settings.tab_files"
TabTypes = "golem15.sitemap::lang.settings.tab_types"
)
// The phrase keys of the Content types refusals (UI-SPEC C26).
const (
errSlug = "golem15.sitemap::lang.errors.slug"
errSlugTaken = "golem15.sitemap::lang.errors.slug_taken"
)
var typeFieldUnsafe = regexp.MustCompile(`[^a-z0-9_]`)
// typeFieldKey turns a content type key into the part of a field name:
// every character outside [a-z0-9_] becomes "_".
func typeFieldKey(key string) string {
return typeFieldUnsafe.ReplaceAllString(strings.ToLower(key), "_")
}
// AdminActionExtensions adds the Content types tab to the plugin's own
// settings singleton: one block per signpost content type, in registration
// order. Nothing is added when no content type is registered.
func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
reg := p.registry()
if reg == nil {
return nil
}
types := reg.ContentTypes()
if len(types) == 0 {
return nil
}
form := p.typeSettingsTab(types)
if form == nil {
return nil
}
return []pact.AdminActionExtension{{Controller: controllers.SettingsControllerID, Form: form}}
}
func (p *Plugin) registry() *signpost.Registry {
if p == nil || p.app == nil {
return nil
}
reg, _ := p.app.Lookup[*signpost.Registry]()
return reg
}
// typeSettingsDocument is the fields document of the Content types tab
// (UI-SPEC C26): per type, the include switch (labelled with the type's
// label), the file name and the HTML sitemap heading.
func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) {
fields := yaml.MapSlice{}
for _, typ := range types {
base := "type_" + typeFieldKey(typ.Key) + "_"
label := typ.Label
if strings.TrimSpace(label) == "" {
label = typ.Key
}
fields = append(fields,
yaml.MapItem{Key: base + "include", Value: yaml.MapSlice{
{Key: "label", Value: label},
{Key: "comment", Value: "golem15.sitemap::lang.settings.type_include"},
{Key: "type", Value: "switch"},
{Key: "span", Value: "left"},
{Key: "tab", Value: TabTypes},
}},
yaml.MapItem{Key: base + "slug", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.sitemap::lang.settings.type_slug"},
{Key: "comment", Value: "golem15.sitemap::lang.settings.type_slug_comment"},
{Key: "type", Value: "text"},
{Key: "span", Value: "right"},
{Key: "tab", Value: TabTypes},
}},
yaml.MapItem{Key: base + "title", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.sitemap::lang.settings.type_title"},
{Key: "type", Value: "mltext"},
{Key: "span", Value: "full"},
{Key: "tab", Value: TabTypes},
}},
)
}
return yaml.Marshal(yaml.MapSlice{
{Key: "tabs", Value: []yaml.MapSlice{{{Key: "name", Value: TabTypes}, {Key: "after", Value: TabFiles}}}},
{Key: "fields", Value: fields},
})
}
// typeSettingsTab is the plugin's form extension on its own settings
// controller: the Content types tab, loaded from and saved to
// golem15_sitemap_types.
func (p *Plugin) typeSettingsTab(types []pact.MenuItemType) *pact.AdminFormExtension {
doc, err := typeSettingsDocument(types)
if err != nil {
pluginLog.Error("golem15.sitemap: content types tab", "error", err)
return nil
}
return &pact.AdminFormExtension{
FieldsYAML: doc,
Permissions: []string{controllers.PermissionAccessDefinitions},
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
rows, err := classes.LoadTypeSettings(in.DB.WithContext(ctx))
if err != nil {
return nil, err
}
def := p.defaultLocale(ctx, in.DB)
tr := p.translator()
out := map[string]any{}
for _, typ := range types {
cfg := classes.EffectiveType(typ, rows)
base := "type_" + typeFieldKey(typ.Key) + "_"
out[base+"include"] = cfg.Include
out[base+"slug"] = cfg.Slug
titles := cfg.Titles
if len(titles) == 0 {
titles = map[string]string{def: controllers.Phrase(ctx, tr, typ.Label, nil)}
}
out[base+"title"] = titles
}
return out, nil
},
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
return pact.AdminFormSaveResult{}, saveTypeSettings(ctx, in, types)
},
}
}
// saveTypeSettings stores the submitted Content types values. A slug
// outside [a-z0-9-] is refused with errors.slug, one that another type (or
// the image and News sitemaps) uses with errors.slug_taken; an empty slug
// is the type's default.
func saveTypeSettings(ctx context.Context, in pact.AdminFormSave, types []pact.MenuItemType) error {
rows, err := classes.LoadTypeSettings(in.DB)
if err != nil {
return err
}
refusals := pact.AdminFormErrors{}
next := make([]models.TypeSetting, 0, len(types))
slugOwner := map[string]string{}
for _, typ := range types {
base := "type_" + typeFieldKey(typ.Key) + "_"
cfg := classes.EffectiveType(typ, rows)
row, ok := rows[typ.Key]
if !ok {
row = models.TypeSetting{TypeKey: typ.Key, Include: true}
}
if raw, sent := in.Values[base+"include"]; sent {
row.Include = truthy(raw)
}
slug := cfg.Slug
if raw, sent := in.Values[base+"slug"]; sent {
slug = strings.TrimSpace(stringOf(raw))
if slug == "" {
slug = classes.DefaultSlug(typ.Key)
} else if !classes.ValidSlug(slug) {
refusals[base+"slug"] = append(refusals[base+"slug"], errSlug)
}
}
if classes.ReservedSlugs[slug] {
refusals[base+"slug"] = append(refusals[base+"slug"], errSlugTaken)
} else if owner, taken := slugOwner[slug]; taken && owner != typ.Key {
refusals[base+"slug"] = append(refusals[base+"slug"], errSlugTaken)
}
slugOwner[slug] = typ.Key
if slug == classes.DefaultSlug(typ.Key) {
row.Slug = ""
} else {
row.Slug = slug
}
if raw, sent := in.Values[base+"title"]; sent {
row.Titles = lagoon.Jsonable[map[string]string]{Data: titlesOf(raw), Valid: true}
}
next = append(next, row)
}
if len(refusals) > 0 {
return refusals
}
now := time.Now().UTC()
for _, row := range next {
row.UpdatedAt = now
if !row.Titles.Valid {
row.Titles = lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}
}
if err := in.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "type_key"}},
DoUpdates: clause.AssignmentColumns([]string{"include", "slug", "titles", "updated_at"}),
}).Create(&row).Error; err != nil {
return err
}
}
return nil
}
// titlesOf reads a submitted translatable value (a map of locale to text);
// blank texts are dropped.
func titlesOf(v any) map[string]string {
out := map[string]string{}
switch m := v.(type) {
case map[string]string:
for k, s := range m {
if strings.TrimSpace(s) != "" {
out[k] = strings.TrimSpace(s)
}
}
case map[string]any:
for k, s := range m {
if text, ok := s.(string); ok && strings.TrimSpace(text) != "" {
out[k] = strings.TrimSpace(text)
}
}
}
return out
}
// truthy reads a submitted switch value.
func truthy(v any) bool {
switch b := v.(type) {
case bool:
return b
case string:
return b == "1" || strings.EqualFold(b, "true") || strings.EqualFold(b, "on")
case fmt.Stringer:
return b.String() == "1"
}
return false
}
// stringOf reads a submitted text value.
func stringOf(v any) string {
switch s := v.(type) {
case string:
return s
case nil:
return ""
}
return fmt.Sprint(v)
}
// translator returns the application's translator, nil when none.
func (p *Plugin) translator() *phrasebook.Translator {
if p == nil || p.app == nil {
return nil
}
tr, _ := p.app.Lookup[*phrasebook.Translator]()
return tr
}
// defaultLocale is the site's default content locale: the translation
// writer's default, else app.locale, else en.
func (p *Plugin) defaultLocale(ctx context.Context, db *gorm.DB) string {
if p != nil && p.app != nil {
if w, ok := p.app.Lookup[cabana.TranslationWriter](); ok && w != nil {
if code, err := w.DefaultLocale(ctx, db); err == nil && code != "" {
return code
}
}
if p.app.Config != nil {
if code := strings.TrimSpace(p.app.Config.String("app.locale")); code != "" {
return code
}
}
}
return "en"
}
var _ pact.HasAdminActionExtensions = (*Plugin)(nil)