Files
sm-seo-plugin/classes/entries.go

299 lines
8.6 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"
// 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
}
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
}
}
// 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
if shared, ok := values[SharedColumn]; ok {
// 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 column, perLocale := range values {
copied[column] = perLocale
}
copied[SharedColumn] = 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
}