Files
sm-seo-plugin/classes/entries.go
Jakub Zych 17da132b00 feat: graph validation and custom JSON-LD
- 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.
2026-11-04 14:33:10 +01:00

340 lines
9.8 KiB
Go

package classes
import (
"context"
"fmt"
"sort"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// The entry columns the SEO tab edits, in form order. Each one is a field
// named FieldPrefix+column in the SEO tab.
var EntryColumns = []string{
"title", "description", "keyphrase", "canonical", "robots",
"og_title", "og_description", "og_image",
}
// SharedColumn is the column whose value is one per record, shared by
// every locale row (Q-5): the social image.
const SharedColumn = "og_image"
// DataColumns are the SEO tab values stored in an entry's data (P33-23):
// the primary parent and the structured data value. Like the social image
// they are one per record, written on every locale row.
var DataColumns = []string{DataPrimaryParent, DataSchema}
// sharedColumns are every value shared by the locale rows of a record.
var sharedColumns = append([]string{SharedColumn}, DataColumns...)
// SharedData returns a record's shared data value: the first non-empty
// value in locale order.
func SharedData(entries map[string]models.Entry, key string) string {
locales := make([]string, 0, len(entries))
for locale := range entries {
locales = append(locales, locale)
}
sort.Strings(locales)
for _, locale := range locales {
if v := entryData(entries[locale], key); v != "" {
return v
}
}
return ""
}
// SharedValue returns a record's shared social image: the first non-empty
// value in locale order.
func SharedValue(entries map[string]models.Entry) string {
locales := make([]string, 0, len(entries))
for locale := range entries {
locales = append(locales, locale)
}
sort.Strings(locales)
for _, locale := range locales {
if v := entries[locale].OGImage; v != "" {
return v
}
}
return ""
}
// FieldPrefix is the prefix of the SEO tab's field names (seo_title, ...),
// so they never collide with an owner's fields.
const FieldPrefix = "seo_"
// entryValue reads one editable column of e.
func entryValue(e models.Entry, column string) string {
switch column {
case "title":
return e.Title
case "description":
return e.Description
case "keyphrase":
return e.Keyphrase
case "canonical":
return e.Canonical
case "robots":
return e.Robots
case "og_title":
return e.OGTitle
case "og_description":
return e.OGDescription
case "og_image":
return e.OGImage
case DataPrimaryParent, DataSchema:
return entryData(e, column)
}
return ""
}
// setEntryValue writes one editable column of e; an unknown column is
// ignored.
func setEntryValue(e *models.Entry, column, value string) {
switch column {
case "title":
e.Title = value
case "description":
e.Description = value
case "keyphrase":
e.Keyphrase = value
case "canonical":
e.Canonical = value
case "robots":
e.Robots = value
case "og_title":
e.OGTitle = value
case "og_description":
e.OGDescription = value
case "og_image":
e.OGImage = value
case DataPrimaryParent, DataSchema:
data := models.JSONMap{}
for k, v := range e.Data {
data[k] = v
}
if value == "" {
delete(data, column)
} else {
data[column] = value
}
e.Data = data
}
}
// EntryValue is the exported reader of one editable column.
func EntryValue(e models.Entry, column string) string { return entryValue(e, column) }
// LoadEntries returns a record's entries keyed by locale.
func LoadEntries(db *gorm.DB, modelType, recordID string) (map[string]models.Entry, error) {
var rows []models.Entry
if err := db.Where("model_type = ? AND record_id = ?", modelType, recordID).Order("locale ASC").Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]models.Entry, len(rows))
for _, row := range rows {
out[row.Locale] = row
}
return out, nil
}
// LoadEntry returns a record's entry in locale; found is false when there is
// none.
func LoadEntry(db *gorm.DB, modelType, recordID, locale string) (models.Entry, bool, error) {
var rows []models.Entry
if err := db.Where("model_type = ? AND record_id = ? AND locale = ?", modelType, recordID, locale).Limit(1).Find(&rows).Error; err != nil {
return models.Entry{}, false, err
}
if len(rows) == 0 {
return models.Entry{}, false, nil
}
return rows[0], true, nil
}
// RecordValues is a set of SEO tab values for one record: column ->
// locale -> value. A column or locale that is absent is left as stored.
type RecordValues map[string]map[string]string
// RecordWrite is one write of a record's SEO values through the service.
type RecordWrite struct {
// Type is the record's content type (it carries the descriptor).
Type pact.MenuItemType
// ID is the record's primary key as text.
ID string
// Values are the submitted values; nil writes none and only refreshes
// the URL snapshot of existing entries.
Values RecordValues
}
// SaveRecord writes a record's SEO values inside db (the caller's
// transaction): each locale that carries a value gets its entry created or
// updated, and every written entry takes the record's current URL in that
// locale (signpost Describe, read through the same transaction). It returns
// the entries before and after the write, keyed by locale, for history.
func (s *Service) SaveRecord(ctx context.Context, db *gorm.DB, in RecordWrite) (before, after map[string]models.Entry, err error) {
if in.Type.Content == nil {
return nil, nil, fmt.Errorf("golem15.seo: type %q has no content descriptor", in.Type.Key)
}
modelType := in.Type.Content.Model
existing, err := LoadEntries(db, modelType, in.ID)
if err != nil {
return nil, nil, err
}
before = make(map[string]models.Entry, len(existing))
for locale, e := range existing {
before[locale] = e
}
described := s.recordLocales(ctx, in.Type.Key, in.ID)
values := in.Values
for _, column := range sharedColumns {
shared, ok := values[column]
if !ok {
continue
}
// One value for every locale of the record (Q-5).
v := ""
for _, text := range shared {
v = text
}
all := map[string]string{}
for locale := range existing {
all[locale] = v
}
for locale := range described {
all[locale] = v
}
copied := RecordValues{}
for c, perLocale := range values {
copied[c] = perLocale
}
copied[column] = all
values = copied
}
touched := map[string]models.Entry{}
for column, perLocale := range values {
for locale, value := range perLocale {
e, ok := touched[locale]
if !ok {
if e, ok = existing[locale]; !ok {
e = models.Entry{ModelType: modelType, RecordID: in.ID, Locale: locale}
}
}
setEntryValue(&e, column, strings.TrimSpace(value))
touched[locale] = e
}
}
after = make(map[string]models.Entry, len(existing))
for locale, e := range existing {
after[locale] = e
}
for locale, e := range touched {
_, stored := existing[locale]
if !stored && entryEmpty(e) {
continue
}
e.TypeKey = in.Type.Key
if l, ok := described[locale]; ok {
e.URL = l.URL
}
e.EffectiveTitle, e.EffectiveDescription = s.effectiveOf(ctx, db, in.Type.Key, e, described[locale], locale)
if e.ID == 0 {
if err := db.Create(&e).Error; err != nil {
return nil, nil, err
}
} else if err := db.Save(&e).Error; err != nil {
return nil, nil, err
}
after[locale] = e
var old map[string]any
if stored {
old = entryValues(existing[locale])
}
if err := s.Record(ctx, db, Change{
Kind: models.SubjectEntry,
SubjectID: EntrySubject(modelType, in.ID, locale),
What: s.recordWhat(in.Type, in.ID, described[locale], locale),
Before: old,
After: entryValues(e),
}); err != nil {
return nil, nil, err
}
}
return before, after, nil
}
// effectiveOf computes an entry's effective title and description cache.
func (s *Service) effectiveOf(ctx context.Context, db *gorm.DB, key string, e models.Entry, l pact.ContentLocale, locale string) (string, string) {
settings, err := LoadSettings(db)
if err != nil {
return e.EffectiveTitle, e.EffectiveDescription
}
ts, err := LoadTypeSetting(db, key)
if err != nil {
return e.EffectiveTitle, e.EffectiveDescription
}
site := s.translatedSetting(ctx, db, &settings, "site_name", settings.SiteName, locale)
fallback := s.translatedSetting(ctx, db, &settings, "default_description", settings.DefaultDescription, locale)
return EffectiveValues(e, l, settings, ts, site, fallback)
}
// recordWhat is the history description of a record's SEO in a locale:
// "SEO of <type> “<title>” (<locale>)".
func (s *Service) recordWhat(typ pact.MenuItemType, id string, l pact.ContentLocale, locale string) string {
title := l.Title
if strings.TrimSpace(title) == "" {
title = "#" + id
}
return s.Phrase("golem15.seo::lang.history.what_record", map[string]string{
"type": s.Phrase(typ.Label, nil),
"title": title,
"locale": locale,
})
}
// entryEmpty reports whether no editable column of e carries a value.
func entryEmpty(e models.Entry) bool {
for _, column := range EntryColumns {
if entryValue(e, column) != "" {
return false
}
}
return len(e.Data) == 0
}
// recordLocales describes the record per locale through its content
// descriptor; an error or a missing registry answers none.
func (s *Service) recordLocales(ctx context.Context, key, id string) map[string]pact.ContentLocale {
out := map[string]pact.ContentLocale{}
reg := s.Registry()
if reg == nil {
return out
}
rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale())
if err != nil || rec == nil {
return out
}
for _, l := range rec.Locales {
out[l.Locale] = l
}
return out
}
// RecordValuesOf returns the stored values of a record for the SEO tab:
// column -> locale -> value.
func RecordValuesOf(entries map[string]models.Entry) RecordValues {
out := RecordValues{}
for _, column := range EntryColumns {
perLocale := map[string]string{}
for locale, e := range entries {
if v := entryValue(e, column); v != "" {
perLocale[locale] = v
}
}
out[column] = perLocale
}
return out
}