feat: redirects screen

- golem15.seo.redirects list and form (UI-SPEC C8): chain rows marked,
  status, match, origin and chain filters, the navigation item lands here
- a field-less extension on the controller refuses a loop with
  redirects.loop on target, answers the chain notice and records history
- the status dropdown posts text, read through RedirectStatus
This commit is contained in:
2026-10-27 11:30:56 +01:00
parent 34a65b846e
commit 163b0aaf98
15 changed files with 677 additions and 24 deletions

View File

@@ -14,7 +14,7 @@ import (
)
// PermissionManage is the one SEO permission (D-15): the SEO tab, URL
// rules, settings and history.
// rules, redirects, the 404 log, robots.txt, settings and history.
const PermissionManage = "golem15.seo.manage"
// The admin controller IDs.
@@ -29,6 +29,7 @@ const (
func AdminControllers(app func() *backpack.App) []pact.AdminController {
return []pact.AdminController{
settingsAdminController{app: app},
redirectsAdminController{app: app},
rulesAdminController{app: app},
historyAdminController{app: app},
}

192
controllers/redirects.go Normal file
View File

@@ -0,0 +1,192 @@
package controllers
import (
"context"
"strconv"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"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"
)
// RedirectsControllerID is the redirects screen (UI-SPEC C8).
const RedirectsControllerID = "golem15.seo.redirects"
// redirectsAdminController lists and edits redirects (D-16 to D-19). The
// loop refusal, the chain notice and history come from the plugin's own
// field-less form extension on this controller, which runs inside the
// save transaction once the row is written.
type redirectsAdminController struct {
app func() *backpack.App
}
func (redirectsAdminController) ID() string { return RedirectsControllerID }
func (redirectsAdminController) ModelName() string { return `Golem15\SEO\Models\Redirect` }
func (redirectsAdminController) ConfigDir() string { return "controllers/redirects" }
func (redirectsAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// NewRecord is a redirect with the form's defaults, so a save that omits
// an option keeps its default.
func (redirectsAdminController) NewRecord() any {
r := models.NewRedirect()
return &r
}
// FormBeforeCreate normalises the redirect and refuses an invalid source,
// target or regex, and a source and match another redirect has.
func (c redirectsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
return c.check(ctx, model)
}
// FormBeforeUpdate checks an update like a create.
func (c redirectsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
return c.check(ctx, model)
}
func (c redirectsAdminController) check(ctx context.Context, model any) error {
r, ok := model.(*models.Redirect)
if !ok || r == nil {
return nil
}
r.Normalize()
db, err := database(ctx, c.app)
if err != nil {
return err
}
return problemsError(ctx, translator(c.app), r.Problems(db))
}
// FormAfterDelete records the deleted redirect in history inside the
// delete's transaction.
func (c redirectsAdminController) FormAfterDelete(ctx context.Context, model any) error {
r, ok := model.(*models.Redirect)
if !ok || r == nil {
return nil
}
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return err
}
db, err := database(ctx, c.app)
if err != nil {
return err
}
return svc.Record(ctx, db, classes.Change{
Kind: models.SubjectRedirect,
SubjectID: strconv.FormatUint(uint64(r.ID), 10),
What: svc.Phrase("golem15.seo::lang.history.what_redirect", map[string]string{"url": r.Source}),
Before: classes.RedirectValues(*r),
})
}
// ListRowStates marks a chain (UI-SPEC C8): a redirect whose target is
// another redirect's source, labelled redirects.state_chain.
func (redirectsAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
var targets []string
for _, record := range records {
if r, ok := record.(*models.Redirect); ok && r != nil && r.Target != "" {
targets = append(targets, r.Target)
}
}
sources := map[string]bool{}
if len(targets) > 0 {
var rows []string
if err := db.WithContext(ctx).Model(&models.Redirect{}).Where("source IN ?", targets).Pluck("source", &rows).Error; err != nil {
return nil, err
}
for _, s := range rows {
sources[s] = true
}
}
out := make([][]pact.RowState, len(records))
for i, record := range records {
if r, ok := record.(*models.Redirect); ok && r != nil && r.Target != "" && sources[r.Target] {
out[i] = []pact.RowState{pact.RowStateNegative}
}
}
return out, nil
}
// matchLabels and originLabels are the phrase keys of the match kinds and
// origins.
var (
matchLabels = map[string]string{
models.RedirectExact: "golem15.seo::lang.redirects.match_exact",
models.RedirectPattern: "golem15.seo::lang.redirects.match_pattern",
models.RedirectRegex: "golem15.seo::lang.redirects.match_regex",
}
originLabels = map[string]string{
models.OriginManual: "golem15.seo::lang.redirects.origin_manual",
models.OriginSlug: "golem15.seo::lang.redirects.origin_slug",
models.OriginImport: "golem15.seo::lang.redirects.origin_import",
models.OriginMCP: "golem15.seo::lang.redirects.origin_mcp",
models.OriginNotFound: "golem15.seo::lang.redirects.origin_notfound",
}
)
// FilterOptions serves the choices of the status, match, origin and chain
// filters.
func (redirectsAdminController) FilterOptions(scope string) []pact.Option {
switch scope {
case models.FilterRedirectStatus:
return []pact.Option{
{Value: "301", Label: "golem15.seo::lang.redirects.status_301"},
{Value: "302", Label: "golem15.seo::lang.redirects.status_302"},
{Value: "410", Label: "golem15.seo::lang.redirects.status_410"},
}
case models.FilterRedirectMatch:
out := make([]pact.Option, 0, len(matchLabels))
for _, m := range []string{models.RedirectExact, models.RedirectPattern, models.RedirectRegex} {
out = append(out, pact.Option{Value: m, Label: matchLabels[m]})
}
return out
case models.FilterRedirectOrigin:
out := make([]pact.Option, 0, len(models.RedirectOrigins))
for _, o := range models.RedirectOrigins {
out = append(out, pact.Option{Value: o, Label: originLabels[o]})
}
return out
case models.FilterRedirectChain:
return []pact.Option{
{Value: models.ChainOnly, Label: "golem15.seo::lang.redirects.filter_chain_only"},
{Value: models.ChainHide, Label: "golem15.seo::lang.redirects.filter_chain_hide"},
}
}
return nil
}
// ListExtendRecords shows the match and the origin of the list's rows as
// their labels in the request locale. The rows are list values only and
// never saved; the columns still sort by the stored values.
func (c redirectsAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
tr := translator(c.app)
for _, record := range records {
r, ok := record.(*models.Redirect)
if !ok || r == nil {
continue
}
if key, ok := matchLabels[r.Match]; ok {
r.Match = Phrase(ctx, tr, key, nil)
}
if key, ok := originLabels[r.Origin]; ok {
r.Origin = Phrase(ctx, tr, key, nil)
}
}
return nil
}
var (
_ pact.AdminController = redirectsAdminController{}
_ pact.AdminPermissioned = redirectsAdminController{}
_ pact.AdminRecordSource = redirectsAdminController{}
_ pact.FormBeforeCreate = redirectsAdminController{}
_ pact.FormBeforeUpdate = redirectsAdminController{}
_ pact.FormAfterDelete = redirectsAdminController{}
_ pact.ListRowStates = redirectsAdminController{}
_ pact.FilterOptions = redirectsAdminController{}
_ pact.ListExtendRecords = redirectsAdminController{}
)

View File

@@ -0,0 +1,22 @@
# Redirects filters (UI-SPEC C8): status, match, origin and chains.
scopes:
status:
label: golem15.seo::lang.redirects.status
modelClass: Golem15\SEO\Models\Redirect
nameFrom: status
scope: FilterRedirectStatus
match:
label: golem15.seo::lang.redirects.match
modelClass: Golem15\SEO\Models\Redirect
nameFrom: match
scope: FilterRedirectMatch
origin:
label: golem15.seo::lang.redirects.origin
modelClass: Golem15\SEO\Models\Redirect
nameFrom: origin
scope: FilterRedirectOrigin
chain:
label: golem15.seo::lang.redirects.filter_chain
modelClass: Golem15\SEO\Models\Redirect
nameFrom: target
scope: FilterRedirectChain

View File

@@ -0,0 +1,18 @@
# Redirect form (UI-SPEC C8).
name: golem15.seo::lang.redirects.name
form: ~/plugins/golem15/seo/models/redirect/fields.yaml
modelClass: Golem15\SEO\Models\Redirect
defaultRedirect: golem15/seo/redirects
create:
redirect: golem15/seo/redirects/update/:id
redirectClose: golem15/seo/redirects
update:
redirect: golem15/seo/redirects
redirectClose: golem15/seo/redirects
messages:
create: golem15.seo::lang.redirects.new
update: golem15.seo::lang.redirects.name
deleteConfirm: golem15.seo::lang.redirects.delete_confirm

View File

@@ -0,0 +1,25 @@
# Redirects list (UI-SPEC C8): newest change first, chains marked.
list: ~/plugins/golem15/seo/models/redirect/columns.yaml
modelClass: Golem15\SEO\Models\Redirect
title: golem15.seo::lang.redirects.list_title
recordUrl: golem15/seo/redirects/update/:id
noRecordsMessage: golem15.seo::lang.redirects.list_empty
recordsPerPage: 20
showCheckboxes: true
showSetup: true
filter: config_filter.yaml
defaultSort:
column: updated_at
direction: desc
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
create: golem15.seo::lang.redirects.new
empty: golem15.seo::lang.redirects.list_empty
deleteConfirm: golem15.seo::lang.redirects.delete_selected_confirm
rowStateNegative: golem15.seo::lang.redirects.state_chain