- seo_schema (seoschema, E8) with the C17 server rules: invalid JSON with line and column, non-objects, 16 KB, depth 8, a literal <script and a type outside the kind's list refused; the value re-serialised - a custom node with a generated @id merges into it (schema.merged save notice), other nodes are appended to the graph - seo_primary_parent (seoparent) for article types; both values stored in the entry data on every locale row - the seo_graph widget action (E9 data), P1 primary_gone and the P2 schema type and breadcrumb trail rows - GET /_seo/api/v1/graph and MCP seo_get_graph; seo_set_url_seo and apply_batch accept primary_parent and schema The graph tool lands with the validation: the operations parity test binds the seo_graph widget action to its tool.
233 lines
8.0 KiB
Go
233 lines
8.0 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
|
|
"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 := range DataColumns {
|
|
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
|
|
case column == "og_image" && !ValidImageURL(v):
|
|
problems[column] = append(problems[column], models.Problem{Key: errImageURL})
|
|
continue
|
|
case (column == DataPrimaryParent || column == DataSchema) && owner.WrittenTo != WrittenToRecord:
|
|
if v != "" {
|
|
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.record_only"})
|
|
}
|
|
continue
|
|
case column == DataPrimaryParent && v != "":
|
|
found := false
|
|
for _, opt := range s.ParentOptions(ctx, typ.Key, owner.ID, "") {
|
|
found = found || opt.Value == v
|
|
}
|
|
if !found {
|
|
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
|
|
continue
|
|
}
|
|
case column == DataSchema:
|
|
stored, _, err := schema.Normalize(typ.Content.Kind, pageVariant(tx, typ.Key, owner.ID), v)
|
|
if err != nil {
|
|
problems[column] = append(problems[column], SchemaProblem(typ.Content.Kind, err))
|
|
continue
|
|
}
|
|
v = stored
|
|
}
|
|
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 != "" && l != locale {
|
|
// Another locale must be one the record describes (WR-18): an
|
|
// entry in any other would never be read by the resolver or the
|
|
// SEO tab.
|
|
if _, known := s.recordLocales(ctx, typ.Key, owner.ID)[l]; !known {
|
|
return URLSEOResult{}, &ProblemsError{Problems: map[string][]models.Problem{
|
|
"locale": {{Key: "golem15.seo::lang.api.locale_unknown", Params: map[string]string{"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)
|
|
}
|
|
for _, column := range DataColumns {
|
|
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
|
|
}
|