299 lines
8.6 KiB
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
|
|
}
|