feat: URL rules and the settings singleton
This commit is contained in:
98
controllers/admin_registry.go
Normal file
98
controllers/admin_registry.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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, 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},
|
||||
rulesAdminController{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}
|
||||
}
|
||||
90
controllers/rules.go
Normal file
90
controllers/rules.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// rulesAdminController lists and edits URL rules (D-02, UI-SPEC C7).
|
||||
type rulesAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (rulesAdminController) ID() string { return RulesControllerID }
|
||||
func (rulesAdminController) ModelName() string { return `Golem15\SEO\Models\Rule` }
|
||||
func (rulesAdminController) ConfigDir() string { return "controllers/rules" }
|
||||
func (rulesAdminController) NewRecord() any { return &models.Rule{} }
|
||||
func (rulesAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionManage}
|
||||
}
|
||||
|
||||
// FormBeforeCreate trims the address, normalises the match and refuses an
|
||||
// invalid or taken address.
|
||||
func (c rulesAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
return c.check(ctx, model)
|
||||
}
|
||||
|
||||
// FormBeforeUpdate checks an update like a create.
|
||||
func (c rulesAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
return c.check(ctx, model)
|
||||
}
|
||||
|
||||
func (c rulesAdminController) check(ctx context.Context, model any) error {
|
||||
r, ok := model.(*models.Rule)
|
||||
if !ok || r == nil {
|
||||
return nil
|
||||
}
|
||||
r.URL = strings.TrimSpace(r.URL)
|
||||
r.Match = models.NormalizeMatch(r.Match)
|
||||
db, err := database(ctx, c.app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return problemsError(ctx, translator(c.app), r.Problems(db))
|
||||
}
|
||||
|
||||
// ListExtendRecords fills the overrides column: the translated labels of
|
||||
// the properties each rule sets, comma-joined.
|
||||
func (c rulesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
|
||||
tr := translator(c.app)
|
||||
for _, record := range records {
|
||||
r, ok := record.(*models.Rule)
|
||||
if !ok || r == nil {
|
||||
continue
|
||||
}
|
||||
var labels []string
|
||||
for _, p := range models.RuleProperties {
|
||||
if strings.TrimSpace(r.Value(p.Column)) != "" {
|
||||
labels = append(labels, Phrase(ctx, tr, p.Label, nil))
|
||||
}
|
||||
}
|
||||
r.Overrides = strings.Join(labels, ", ")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilterOptions serves the match filter's two choices.
|
||||
func (rulesAdminController) FilterOptions(scope string) []pact.Option {
|
||||
if scope != models.FilterMatch {
|
||||
return nil
|
||||
}
|
||||
return []pact.Option{
|
||||
{Value: models.MatchExact, Label: "golem15.seo::lang.rules.match_exact"},
|
||||
{Value: models.MatchPattern, Label: "golem15.seo::lang.rules.match_pattern"},
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = rulesAdminController{}
|
||||
_ pact.AdminPermissioned = rulesAdminController{}
|
||||
_ pact.AdminRecordSource = rulesAdminController{}
|
||||
_ pact.FormBeforeCreate = rulesAdminController{}
|
||||
_ pact.FormBeforeUpdate = rulesAdminController{}
|
||||
_ pact.ListExtendRecords = rulesAdminController{}
|
||||
_ pact.FilterOptions = rulesAdminController{}
|
||||
)
|
||||
7
controllers/rules/config_filter.yaml
Normal file
7
controllers/rules/config_filter.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
# The match filter (UI-SPEC C7): exact addresses or patterns.
|
||||
scopes:
|
||||
match:
|
||||
label: golem15.seo::lang.rules.match
|
||||
modelClass: Golem15\SEO\Models\Rule
|
||||
nameFrom: match
|
||||
scope: FilterMatch
|
||||
18
controllers/rules/config_form.yaml
Normal file
18
controllers/rules/config_form.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# URL rule form (UI-SPEC C7).
|
||||
name: golem15.seo::lang.rules.name
|
||||
form: ~/plugins/golem15/seo/models/rule/fields.yaml
|
||||
modelClass: Golem15\SEO\Models\Rule
|
||||
defaultRedirect: golem15/seo/rules
|
||||
|
||||
create:
|
||||
redirect: golem15/seo/rules/update/:id
|
||||
redirectClose: golem15/seo/rules
|
||||
|
||||
update:
|
||||
redirect: golem15/seo/rules
|
||||
redirectClose: golem15/seo/rules
|
||||
|
||||
messages:
|
||||
create: golem15.seo::lang.rules.new
|
||||
update: golem15.seo::lang.rules.name
|
||||
deleteConfirm: golem15.seo::lang.rules.delete_confirm
|
||||
23
controllers/rules/config_list.yaml
Normal file
23
controllers/rules/config_list.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
# URL rules list (UI-SPEC C7).
|
||||
list: ~/plugins/golem15/seo/models/rule/columns.yaml
|
||||
modelClass: Golem15\SEO\Models\Rule
|
||||
title: golem15.seo::lang.rules.list_title
|
||||
recordUrl: golem15/seo/rules/update/:id
|
||||
noRecordsMessage: golem15.seo::lang.rules.list_empty
|
||||
recordsPerPage: 20
|
||||
showCheckboxes: true
|
||||
showSetup: true
|
||||
filter: config_filter.yaml
|
||||
|
||||
defaultSort:
|
||||
column: url
|
||||
direction: asc
|
||||
|
||||
toolbar:
|
||||
buttons: [create, delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
messages:
|
||||
empty: golem15.seo::lang.rules.list_empty
|
||||
deleteConfirm: golem15.seo::lang.rules.delete_selected_confirm
|
||||
99
controllers/settings.go
Normal file
99
controllers/settings.go
Normal file
@@ -0,0 +1,99 @@
|
||||
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/pact"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// settingsAdminController edits the SEO settings singleton (D-104), linked
|
||||
// from the SEO settings tile. Its per-content-type tabs are added by the
|
||||
// plugin's own form extension on this controller.
|
||||
type settingsAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (settingsAdminController) ID() string { return SettingsControllerID }
|
||||
func (settingsAdminController) ModelName() string { return `Golem15\SEO\Models\Settings` }
|
||||
func (settingsAdminController) ConfigDir() string { return "controllers/settings" }
|
||||
func (settingsAdminController) NewRecord() any { return &models.Settings{} }
|
||||
func (settingsAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionManage}
|
||||
}
|
||||
|
||||
// SingletonRecord returns settings row 1, creating it with the defaults
|
||||
// when the migration's row is gone (first or create, safe against a
|
||||
// concurrent first request through ON CONFLICT DO NOTHING).
|
||||
func (settingsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("golem15.seo: database is not configured")
|
||||
}
|
||||
var rows []models.Settings
|
||||
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
return &rows[0], nil
|
||||
}
|
||||
row := models.DefaultSettings()
|
||||
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
|
||||
return nil, errors.New("golem15.seo: settings row is missing")
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
// FormBeforeUpdate refuses templates with unknown placeholders and
|
||||
// malformed or duplicate verification file names.
|
||||
func (c settingsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
||||
s, ok := model.(*models.Settings)
|
||||
if !ok || s == nil {
|
||||
return nil
|
||||
}
|
||||
return problemsError(ctx, translator(c.app), s.Problems())
|
||||
}
|
||||
|
||||
// FormBeforeCreate checks a created row like an update.
|
||||
func (c settingsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
||||
return c.FormBeforeUpdate(ctx, model)
|
||||
}
|
||||
|
||||
// stagingView is the view model of the staging partial.
|
||||
type stagingView struct {
|
||||
Staging bool
|
||||
}
|
||||
|
||||
// PartialData serves the staging callout: shown outside production, where
|
||||
// every page is noindex whatever the settings say (D-22).
|
||||
func (c settingsAdminController) PartialData(_ context.Context, name string, _ any) (any, error) {
|
||||
if name != "staging" {
|
||||
return nil, nil
|
||||
}
|
||||
return stagingView{Staging: IsStaging(appOf(c.app))}, nil
|
||||
}
|
||||
|
||||
// IsStaging reports whether the application runs outside production. It
|
||||
// depends on the environment only (D-22): no setting can change it.
|
||||
func IsStaging(app *backpack.App) bool {
|
||||
if app == nil || app.Config == nil {
|
||||
return true
|
||||
}
|
||||
return app.Config.Environment() != "production"
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = settingsAdminController{}
|
||||
_ pact.AdminPermissioned = settingsAdminController{}
|
||||
_ pact.AdminRecordSource = settingsAdminController{}
|
||||
_ pact.AdminSingleton = settingsAdminController{}
|
||||
_ pact.AdminPartialData = settingsAdminController{}
|
||||
_ pact.FormBeforeCreate = settingsAdminController{}
|
||||
_ pact.FormBeforeUpdate = settingsAdminController{}
|
||||
)
|
||||
6
controllers/settings/_staging.htm
Normal file
6
controllers/settings/_staging.htm
Normal file
@@ -0,0 +1,6 @@
|
||||
{{- if .Data.Staging -}}
|
||||
<div class="summer-callout summer-callout--warning" role="status">
|
||||
<p class="summer-callout__title">{{ trans "golem15.seo::lang.robots.staging_title" }}</p>
|
||||
<p class="summer-callout__text">{{ trans "golem15.seo::lang.status.staging" }}</p>
|
||||
</div>
|
||||
{{- end -}}
|
||||
19
controllers/settings/config_form.yaml
Normal file
19
controllers/settings/config_form.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
# ===================================
|
||||
# Form Behavior Config
|
||||
# ===================================
|
||||
#
|
||||
# The SEO settings singleton (D-104), linked from the SEO settings tile.
|
||||
# The plugin's form extension adds one tab per content type after General.
|
||||
|
||||
name: golem15.seo::lang.settings.label
|
||||
form: ~/plugins/golem15/seo/models/settings/fields.yaml
|
||||
modelClass: Golem15\SEO\Models\Settings
|
||||
defaultRedirect: system/settings
|
||||
|
||||
update:
|
||||
redirect: golem15/seo/settings
|
||||
redirectClose: system/settings
|
||||
|
||||
messages:
|
||||
update: golem15.seo::lang.settings.label
|
||||
saved: backend::lang.messages.form.saved
|
||||
Reference in New Issue
Block a user