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:
@@ -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
|
||||
}
|
||||
|
||||
33
models/redirect/columns.yaml
Normal file
33
models/redirect/columns.yaml
Normal 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
|
||||
63
models/redirect/fields.yaml
Normal file
63
models/redirect/fields.yaml
Normal 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
|
||||
Reference in New Issue
Block a user