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:
@@ -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
192
controllers/redirects.go
Normal 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{}
|
||||
)
|
||||
22
controllers/redirects/config_filter.yaml
Normal file
22
controllers/redirects/config_filter.yaml
Normal 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
|
||||
18
controllers/redirects/config_form.yaml
Normal file
18
controllers/redirects/config_form.yaml
Normal 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
|
||||
25
controllers/redirects/config_list.yaml
Normal file
25
controllers/redirects/config_list.yaml
Normal 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
|
||||
Reference in New Issue
Block a user