214 lines
6.0 KiB
Go
214 lines
6.0 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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",
|
|
}
|
|
|
|
// 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
|
|
}
|
|
urls := s.recordURLs(ctx, in.Type.Key, in.ID)
|
|
touched := map[string]models.Entry{}
|
|
for column, perLocale := range in.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 u, ok := urls[locale]; ok {
|
|
e.URL = u
|
|
}
|
|
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
|
|
}
|
|
return before, after, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// recordURLs returns the record's public path per locale through its
|
|
// content descriptor; an error or a missing registry answers none.
|
|
func (s *Service) recordURLs(ctx context.Context, key, id string) map[string]string {
|
|
out := map[string]string{}
|
|
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.URL
|
|
}
|
|
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
|
|
}
|