Files
sm-sitemap-plugin/extensions_settings.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

218 lines
7.2 KiB
Go

package sitemap
import (
"context"
"strings"
"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/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/signpost"
"github.com/goccy/go-yaml"
"gorm.io/gorm"
)
// The settings form's tab keys: the Content types tab follows Files; the
// HTML sitemap tab's address field is added after html_enabled.
const (
TabFiles = "golem15.sitemap::lang.settings.tab_files"
TabTypes = "golem15.sitemap::lang.settings.tab_types"
TabHTML = "golem15.sitemap::lang.settings.tab_html"
)
// htmlPathField is the HTML sitemap address field: translatable, stored
// as a locale map in html_paths by the plugin's form extension (the
// settings row has no translation writer of its own).
const htmlPathField = "html_path"
// AdminActionExtensions extends the plugin's own settings singleton: the
// Content types tab (one block per signpost content type, in registration
// order; none without content types) and the HTML sitemap address.
func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
var types []pact.MenuItemType
if reg := p.registry(); reg != nil {
types = reg.ContentTypes()
}
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 extension (UI-SPEC
// C26): per type, the include switch (labelled with the type's label), the
// file name and the HTML sitemap heading on the Content types tab, then
// the HTML sitemap address after html_enabled.
func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) {
fields := yaml.MapSlice{}
for _, typ := range types {
base := classes.TypeFieldBase(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},
}},
)
}
fields = append(fields, yaml.MapItem{Key: htmlPathField, Value: yaml.MapSlice{
{Key: "label", Value: "golem15.sitemap::lang.settings.html_path"},
{Key: "comment", Value: "golem15.sitemap::lang.settings.html_path_comment"},
{Key: "type", Value: "mltext"},
{Key: "span", Value: "left"},
{Key: "tab", Value: TabHTML},
{Key: "after", Value: "html_enabled"},
}})
doc := yaml.MapSlice{}
if len(types) > 0 {
doc = append(doc, yaml.MapItem{Key: "tabs", Value: []yaml.MapSlice{{{Key: "name", Value: TabTypes}, {Key: "after", Value: TabFiles}}}})
}
return yaml.Marshal(append(doc, yaml.MapItem{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, and the HTML sitemap address (html_paths).
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 := classes.TypeFieldBase(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
}
settings, err := classes.LoadSettings(in.DB.WithContext(ctx))
if err != nil {
return nil, err
}
out[htmlPathField] = settings.EffectiveHTMLPaths()
return out, nil
},
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
if err := saveTypeSettings(ctx, in, types); err != nil {
return pact.AdminFormSaveResult{}, err
}
return pact.AdminFormSaveResult{}, saveHTMLPaths(in)
},
}
}
// saveTypeSettings stores the submitted Content types values
// (classes.SaveTypeSettings).
func saveTypeSettings(_ context.Context, in pact.AdminFormSave, types []pact.MenuItemType) error {
refusals, err := classes.SaveTypeSettings(in.DB, types, in.Values)
if err != nil {
return err
}
if len(refusals) > 0 {
return pact.AdminFormErrors(refusals)
}
return nil
}
// saveHTMLPaths stores a submitted HTML sitemap address: the locales sent
// replace theirs in html_paths (a blank one removes it), the others keep
// theirs. An address that models.ValidHTMLPath refuses is refused with
// errors.html_path.
func saveHTMLPaths(in pact.AdminFormSave) error {
raw, sent := in.Values[htmlPathField]
if !sent {
return nil
}
paths := map[string]string{}
switch v := raw.(type) {
case map[string]string:
paths = v
case map[string]any:
for k, s := range v {
text, _ := s.(string)
paths[k] = text
}
}
merged, problem := classes.MergeHTMLPaths(in.DB, paths)
if problem {
return pact.AdminFormErrors{htmlPathField: {models.HTMLPathProblem}}
}
return classes.StoreHTMLPaths(in.DB, merged)
}
// 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)