Files
sm-seo-plugin/controllers/admin_registry.go
Jakub Zych 9e802ec850 feat: page registry with its admin screen and signpost type
- golem15_seo_pages (migration 202610280002): the site's own frontend pages
  with an address and a name per locale, kind, origin, parent, content date
  and note; settings routes_reported_at
- SEO > Pages (golem15.seo.pages) after URL rules: list with kind, origin
  and missing filters and the missing row state, form with the P9 partial;
  the plugin's form extension saves the address and name per locale and
  refuses url_required, url_invalid, url_taken and url_owned
- menu item type seo-page with a content descriptor, so each page gets
  the SEO tab, resolves ok and is listed in the sitemaps grouped by kind
  (pages, offers, services, landing-pages, titled per locale)
- management API GET/POST /_seo/api/v1/pages, PUT/DELETE /pages/{id} and
  the MCP tools seo_list_pages, seo_create_page, seo_update_page,
  seo_delete_page (in this commit because the operations parity test maps
  every Pages operation to its tool)
2026-11-04 13:20:45 +01:00

105 lines
3.0 KiB
Go

// Package controllers holds the SEO plugin's admin controllers.
package controllers
import (
"context"
"errors"
"git.golem15.com/golem15/sm-seo-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"
)
// PermissionManage is the one SEO permission (D-15): the SEO tab, URL
// rules, redirects, the 404 log, robots.txt, settings and history.
const PermissionManage = "golem15.seo.manage"
// The admin controller IDs.
const (
SettingsControllerID = "golem15.seo.settings"
RulesControllerID = "golem15.seo.rules"
)
// AdminControllers is the plugin's admin controller list. app returns the
// booted application; it is asked on every call, so a controller never
// keeps a database handle of its own.
func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
settingsAdminController{app: app},
redirectsAdminController{app: app},
notFoundAdminController{app: app},
rulesAdminController{app: app},
pagesAdminController{app: app},
robotsAdminController{app: app},
historyAdminController{app: app},
transferAdminController{app: app},
}
}
// appOf returns the booted application, nil before boot.
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.seo: app is not booted")
}
db, ok := a.Lookup[*gorm.DB]()
if !ok || db == nil {
return nil, errors.New("golem15.seo: 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)
}
// problemsError turns field problems into a 422 with the messages
// translated in the request locale; nil when there are none.
func problemsError(ctx context.Context, tr *phrasebook.Translator, problems map[string][]models.Problem) error {
if len(problems) == 0 {
return nil
}
details := make(map[string]any, len(problems))
for field, list := range problems {
msgs := make([]string, 0, len(list))
for _, p := range list {
if len(p.Params) == 0 {
msgs = append(msgs, p.Key)
continue
}
msgs = append(msgs, Phrase(ctx, tr, p.Key, p.Params))
}
details[field] = msgs
}
return &cabana.ValidationError{Details: details}
}