Files
sm-sitemap-plugin/classes/typesettings.go
Jakub Zych 2a05516036 feat: sitemap management API and MCP tools
- GET and PUT /_sitemap/api/v1/settings and POST /_sitemap/api/v1/rebuild
  resolve the administrator from a backend token, a personal API token or
  the MCP dispatch and require the sitemap permission (401/403 otherwise);
  bucket sitemap-api
- PUT changes the fields sent with the settings screen's checks (422
  validation_failed, nothing stored), merges the HTML sitemap addresses,
  writes content types, generates the IndexNow key and queues a rebuild
- GET answers the settings, the content types, the index files with their
  counts, the left-out counts, the IndexNow state and the News source
  values
- MCP tools sitemap_get_settings, sitemap_update_settings and
  sitemap_rebuild bound to those routes, listed with the permission
- the content type settings write and the file listing move to classes,
  shared by the form, S1, the index and the API; S1 and the index now list
  the image and News files
2026-11-04 12:38:35 +01:00

146 lines
4.0 KiB
Go

package classes
import (
"fmt"
"regexp"
"strings"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The phrase keys of the content type 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_]`)
// TypeFieldBase is the prefix of a content type's settings fields:
// type_<key>_, where every character of the key outside [a-z0-9_] becomes
// "_".
func TypeFieldBase(key string) string {
return "type_" + typeFieldUnsafe.ReplaceAllString(strings.ToLower(key), "_") + "_"
}
// SaveTypeSettings stores submitted content type settings: values holds
// TypeFieldBase(key)+"include" (a switch), +"slug" and +"title" (a locale
// map) per type; a type or value not sent keeps what is stored. 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. Refusals are by field name; nothing is written
// while there are any.
func SaveTypeSettings(db *gorm.DB, types []pact.MenuItemType, values map[string]any) (map[string][]string, error) {
rows, err := LoadTypeSettings(db)
if err != nil {
return nil, err
}
refusals := map[string][]string{}
next := make([]models.TypeSetting, 0, len(types))
slugOwner := map[string]string{}
for _, typ := range types {
base := TypeFieldBase(typ.Key)
cfg := EffectiveType(typ, rows)
row, ok := rows[typ.Key]
if !ok {
row = models.TypeSetting{TypeKey: typ.Key, Include: true}
}
if raw, sent := values[base+"include"]; sent {
row.Include = truthy(raw)
}
slug := cfg.Slug
if raw, sent := values[base+"slug"]; sent {
slug = strings.TrimSpace(stringOf(raw))
if slug == "" {
slug = DefaultSlug(typ.Key)
} else if !ValidSlug(slug) {
refusals[base+"slug"] = append(refusals[base+"slug"], errSlug)
}
}
if 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 == DefaultSlug(typ.Key) {
row.Slug = ""
} else {
row.Slug = slug
}
if raw, sent := 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, nil
}
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 := 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 nil, err
}
}
return nil, 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 float64:
return b != 0
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)
}