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 }