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

@@ -1,8 +1,10 @@
package models
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"time"
@@ -16,11 +18,25 @@ const (
RedirectRegex = "regex"
)
// RedirectStatus is a redirect's HTTP status. The admin form's dropdown
// posts it as text ("301"), which UnmarshalText reads.
type RedirectStatus int
// UnmarshalText reads a status written as text.
func (s *RedirectStatus) UnmarshalText(b []byte) error {
n, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err != nil {
return fmt.Errorf("models: redirect status %q is not a number", b)
}
*s = RedirectStatus(n)
return nil
}
// The statuses of a redirect (D-16).
const (
Status301 = 301
Status302 = 302
Status410 = 410
Status301 RedirectStatus = 301
Status302 RedirectStatus = 302
Status410 RedirectStatus = 410
)
// The query string options of a redirect (D-17).
@@ -46,18 +62,18 @@ const (
// trailing slash; Query ignores the request's query string or passes it to
// the target. Origin records who created the rule.
type Redirect struct {
ID uint `gorm:"column:id;primaryKey"`
Source string `gorm:"column:source"`
Match string `gorm:"column:match"`
Target string `gorm:"column:target"`
Status int `gorm:"column:status"`
Query string `gorm:"column:query"`
IgnoreCase bool `gorm:"column:ignore_case"`
TrailingSlash bool `gorm:"column:trailing_slash"`
Origin string `gorm:"column:origin"`
Note string `gorm:"column:note"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
ID uint `gorm:"column:id;primaryKey"`
Source string `gorm:"column:source"`
Match string `gorm:"column:match"`
Target string `gorm:"column:target"`
Status RedirectStatus `gorm:"column:status"`
Query string `gorm:"column:query"`
IgnoreCase bool `gorm:"column:ignore_case"`
TrailingSlash bool `gorm:"column:trailing_slash"`
Origin string `gorm:"column:origin"`
Note string `gorm:"column:note"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
}
func (Redirect) TableName() string { return "golem15_seo_redirects" }
@@ -189,3 +205,63 @@ func regexError(err error) string {
func init() {
Register(Redirect{})
}
// RedirectOrigins are the origins in filter order.
var RedirectOrigins = []string{OriginManual, OriginSlug, OriginImport, OriginMCP, OriginNotFound}
// The redirects list's filter scopes and the chain filter's values.
const (
FilterRedirectStatus = "FilterRedirectStatus"
FilterRedirectMatch = "FilterRedirectMatch"
FilterRedirectOrigin = "FilterRedirectOrigin"
FilterRedirectChain = "FilterRedirectChain"
ChainOnly = "only"
ChainHide = "hide"
)
// FilterScopes lists the redirects list's filter scopes.
func (Redirect) FilterScopes() []string {
return []string{FilterRedirectStatus, FilterRedirectMatch, FilterRedirectOrigin, FilterRedirectChain}
}
// chainSQL is true for a redirect whose target is another redirect's
// source.
const chainSQL = `EXISTS (SELECT 1 FROM golem15_seo_redirects AS next WHERE next.source = golem15_seo_redirects.target AND next.id <> golem15_seo_redirects.id)`
// FilterScope narrows the redirects list; an unknown value matches
// nothing.
func (Redirect) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil {
return db
}
s, _ := value.(string)
switch name {
case FilterRedirectStatus:
n, err := strconv.Atoi(s)
if err != nil || (n != 301 && n != 302 && n != 410) {
return db.Where("1 = 0")
}
return db.Where("golem15_seo_redirects.status = ?", n)
case FilterRedirectMatch:
if s != RedirectExact && s != RedirectPattern && s != RedirectRegex {
return db.Where("1 = 0")
}
return db.Where("golem15_seo_redirects.match = ?", s)
case FilterRedirectOrigin:
for _, o := range RedirectOrigins {
if o == s {
return db.Where("golem15_seo_redirects.origin = ?", s)
}
}
return db.Where("1 = 0")
case FilterRedirectChain:
switch s {
case ChainOnly:
return db.Where(chainSQL)
case ChainHide:
return db.Where("NOT " + chainSQL)
}
return db.Where("1 = 0")
}
return db
}

View File

@@ -0,0 +1,33 @@
# Redirects list columns (UI-SPEC C8). match and origin show their labels
# (the controller's ListExtendRecords) and sort by the stored values.
columns:
source:
label: golem15.seo::lang.redirects.source
type: text
searchable: true
sortable: true
target:
label: golem15.seo::lang.redirects.target
type: text
searchable: true
status:
label: golem15.seo::lang.redirects.status
type: text
sortable: true
match:
label: golem15.seo::lang.redirects.match
type: text
sortable: true
origin:
label: golem15.seo::lang.redirects.origin
type: text
sortable: true
updated_at:
label: golem15.seo::lang.redirects.updated
type: datetime
sortable: true
note:
label: golem15.seo::lang.redirects.note
type: text
searchable: true
invisible: true

View File

@@ -0,0 +1,63 @@
# Redirect form (UI-SPEC C8). The loop refusal and the chain notice land on
# target; they come from the plugin's own form extension on the redirects
# controller.
fields:
source:
label: golem15.seo::lang.redirects.source
comment: golem15.seo::lang.redirects.source_comment
type: text
span: left
required: true
match:
label: golem15.seo::lang.redirects.match
type: dropdown
span: right
default: exact
options:
exact: golem15.seo::lang.redirects.match_exact
pattern: golem15.seo::lang.redirects.match_pattern
regex: golem15.seo::lang.redirects.match_regex
target:
label: golem15.seo::lang.redirects.target
comment: golem15.seo::lang.redirects.target_comment
type: text
span: full
status:
label: golem15.seo::lang.redirects.status
type: dropdown
span: left
default: "301"
options:
"301": golem15.seo::lang.redirects.status_301
"302": golem15.seo::lang.redirects.status_302
"410": golem15.seo::lang.redirects.status_410
query:
label: golem15.seo::lang.redirects.query
type: dropdown
span: right
default: ignore
options:
ignore: golem15.seo::lang.redirects.query_ignore
pass: golem15.seo::lang.redirects.query_pass
ignore_case:
label: golem15.seo::lang.redirects.ignore_case
type: checkbox
span: left
default: false
trailing_slash:
label: golem15.seo::lang.redirects.trailing_slash
type: checkbox
span: right
default: true
note:
label: golem15.seo::lang.redirects.note
type: textarea
size: tiny
span: full