- PUT /_seo/api/v1/seo writes to the record behind a URL in its locale, else to an exact URL rule (force_rule writes a rule); the answer says where (D-11) - management routes resolve the backend administrator from a token or the MCP dispatch and require golem15.seo.manage (D-10, D-15) - MCP tool set_url_seo; history records via mcp - classes.WithTx, FindOwner, SetURLSEO, SaveRule and DeleteRule
194 lines
6.5 KiB
Go
194 lines
6.5 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Where a change addressed by URL is written (D-11).
|
|
const (
|
|
WrittenToRecord = "record"
|
|
WrittenToRule = "rule"
|
|
)
|
|
|
|
// Owner is the owner of a URL's SEO (D-11, P-25): the record behind the
|
|
// URL in the matched locale, or an exact URL rule. URL is the address in
|
|
// the site's form (normalised path with its query); RuleID is the rule's
|
|
// id once it exists. Published is true for a published record.
|
|
type Owner struct {
|
|
WrittenTo string `json:"written_to"`
|
|
URL string `json:"url"`
|
|
Type string `json:"type,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Locale string `json:"locale,omitempty"`
|
|
RuleID uint `json:"rule_id,omitempty"`
|
|
Published bool `json:"-"`
|
|
}
|
|
|
|
// FindOwner resolves the owner of rawURL (a path or an absolute URL of the
|
|
// site) inside db: the URL is normalised with the site's address rules,
|
|
// then the signpost patterns (MatchPath) name the record behind it. An
|
|
// exact match without a query is the record's SEO in the matched locale;
|
|
// anything else, or forceRule, is an exact URL rule on the normalised
|
|
// address. ErrBadURL refuses an input that is not a path or http(s) URL.
|
|
func (s *Service) FindOwner(ctx context.Context, db *gorm.DB, rawURL string, forceRule bool) (Owner, pact.MenuItemType, error) {
|
|
path, _, err := parseTarget(rawURL)
|
|
if err != nil {
|
|
return Owner{}, pact.MenuItemType{}, err
|
|
}
|
|
settings, err := LoadSettings(db)
|
|
if err != nil {
|
|
return Owner{}, pact.MenuItemType{}, err
|
|
}
|
|
norm, _ := Normalize(&url.URL{Path: path, RawQuery: rawQueryOf(rawURL)}, NormalizeOptionsOf(settings))
|
|
address := joinQuery(norm.Path, norm.RawQuery)
|
|
if !forceRule && norm.RawQuery == "" {
|
|
if reg := s.Registry(); reg != nil {
|
|
unprefixed := s.UnprefixedLocale()
|
|
key, match, err := reg.MatchPath(ctx, trimSlash(norm.Path), unprefixed)
|
|
if err != nil {
|
|
return Owner{}, pact.MenuItemType{}, err
|
|
}
|
|
if match != nil && match.Exact {
|
|
typ, ok := reg.ContentType(key)
|
|
if ok && typ.Content != nil {
|
|
owner := Owner{WrittenTo: WrittenToRecord, URL: address, Type: key, ID: match.ID, Locale: match.Locale}
|
|
if rec, err := reg.Describe(ctx, key, match.ID, unprefixed); err == nil && rec != nil {
|
|
owner.Published = rec.Published
|
|
}
|
|
return owner, typ, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
owner := Owner{WrittenTo: WrittenToRule, URL: address}
|
|
rule, found, err := ExactRule(db, address)
|
|
if err != nil {
|
|
return Owner{}, pact.MenuItemType{}, err
|
|
}
|
|
if found {
|
|
owner.RuleID = rule.ID
|
|
}
|
|
return owner, pact.MenuItemType{}, nil
|
|
}
|
|
|
|
// ExactRule returns the exact URL rule of address; found is false when
|
|
// there is none.
|
|
func ExactRule(db *gorm.DB, address string) (models.Rule, bool, error) {
|
|
var rows []models.Rule
|
|
if err := db.Where("url = ? AND match = ?", address, models.MatchExact).Limit(1).Find(&rows).Error; err != nil {
|
|
return models.Rule{}, false, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return models.Rule{}, false, nil
|
|
}
|
|
return rows[0], true, nil
|
|
}
|
|
|
|
// URLSEOWrite is one change of a URL's SEO (D-11): the address, the
|
|
// locale of a record's entry (empty takes the locale the URL matched),
|
|
// the values to write (column -> value; an absent column is left as it
|
|
// is, an empty value inherits), and ForceRule to write a URL rule even
|
|
// when a record is behind the URL.
|
|
type URLSEOWrite struct {
|
|
URL string
|
|
Locale string
|
|
Values map[string]string
|
|
ForceRule bool
|
|
}
|
|
|
|
// URLSEOResult is where a URL change was written and the stored values
|
|
// of the owner after the write.
|
|
type URLSEOResult struct {
|
|
Owner
|
|
Values map[string]string `json:"values"`
|
|
}
|
|
|
|
// SetURLSEO writes in.Values to the owner of in.URL inside tx (D-11): the
|
|
// record's SEO entry when a record is behind the URL, else an exact URL
|
|
// rule, created when missing. Robots values are brought to the C5 grammar,
|
|
// a canonical must be a path or an http(s) URL, and the focus keyphrase
|
|
// belongs to records only; problems are refused with a *ProblemsError.
|
|
// History records the write with the via of ctx.
|
|
func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (URLSEOResult, error) {
|
|
owner, typ, err := s.FindOwner(ctx, tx, in.URL, in.ForceRule)
|
|
if err != nil {
|
|
return URLSEOResult{}, err
|
|
}
|
|
values := map[string]string{}
|
|
problems := map[string][]models.Problem{}
|
|
known := map[string]bool{}
|
|
for _, column := range EntryColumns {
|
|
known[column] = true
|
|
}
|
|
for column, raw := range in.Values {
|
|
v := strings.TrimSpace(raw)
|
|
switch {
|
|
case !known[column]:
|
|
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.field_unknown"})
|
|
continue
|
|
case column == "keyphrase" && owner.WrittenTo == WrittenToRule && v != "":
|
|
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.keyphrase_record_only"})
|
|
continue
|
|
case column == "robots" && v != "":
|
|
normal, err := NormalizeRobots(v)
|
|
if err != nil {
|
|
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
|
|
continue
|
|
}
|
|
v = normal
|
|
case column == "canonical" && v != "" && !ValidURLRef(v):
|
|
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.canonical_invalid"})
|
|
continue
|
|
}
|
|
values[column] = v
|
|
}
|
|
if len(problems) > 0 {
|
|
return URLSEOResult{}, &ProblemsError{Problems: problems}
|
|
}
|
|
if owner.WrittenTo == WrittenToRecord {
|
|
locale := owner.Locale
|
|
if l := strings.TrimSpace(in.Locale); l != "" {
|
|
locale = l
|
|
}
|
|
owner.Locale = locale
|
|
write := RecordValues{}
|
|
for column, v := range values {
|
|
write[column] = map[string]string{locale: v}
|
|
}
|
|
_, after, err := s.SaveRecord(ctx, tx, RecordWrite{Type: typ, ID: owner.ID, Values: write})
|
|
if err != nil {
|
|
return URLSEOResult{}, err
|
|
}
|
|
out := map[string]string{}
|
|
e := after[locale]
|
|
for _, column := range EntryColumns {
|
|
out[column] = EntryValue(e, column)
|
|
}
|
|
return URLSEOResult{Owner: owner, Values: out}, nil
|
|
}
|
|
rule := models.Rule{URL: owner.URL, Match: models.MatchExact}
|
|
if owner.RuleID != 0 {
|
|
if rule, err = LoadRule(tx, owner.RuleID); err != nil {
|
|
return URLSEOResult{}, err
|
|
}
|
|
}
|
|
for column, v := range values {
|
|
SetRuleValue(&rule, column, v)
|
|
}
|
|
if err := s.SaveRule(ctx, tx, &rule); err != nil {
|
|
return URLSEOResult{}, err
|
|
}
|
|
owner.RuleID = rule.ID
|
|
out := map[string]string{}
|
|
for _, p := range models.RuleProperties {
|
|
out[p.Column] = rule.Value(p.Column)
|
|
}
|
|
return URLSEOResult{Owner: owner, Values: out}, nil
|
|
}
|