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 “” (<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 }