- 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
146 lines
4.0 KiB
Go
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)
|
|
}
|