package classes import ( "context" "fmt" "strings" "git.golem15.com/golem15/sm-seo-plugin/models" "git.golem15.com/golem15/summercms/modules/conga" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gorm.io/gorm" ) // GenerateKind is the River kind of the entry generation job. const GenerateKind = "golem15.seo.generate_entries" // generatePage is the page size of the generation job. const generatePage = 200 // GenerateEntriesArgs are the arguments of the entry generation job (D-94, // the mpseo GenerateSeoEntriesJob): Type limits it to one content type // key; empty runs every content type. type GenerateEntriesArgs struct { Type string `json:"type"` } // Kind is GenerateKind. func (GenerateEntriesArgs) Kind() string { return GenerateKind } // GenerateResult counts what one generation run did. type GenerateResult struct { Created int Updated int } // GenerateEntries walks the published records of every content type (or // of typeKey) through their descriptors and makes sure each locale with an // address has an entry: a missing one is created with the URL, an existing // one gets its URL, type and effective title and description cache // refreshed. Editor values are never written. Each page of records is one // transaction. func (s *Service) GenerateEntries(ctx context.Context, typeKey string) (GenerateResult, error) { var res GenerateResult reg := s.Registry() if reg == nil { return res, nil } db, err := s.DB(ctx) if err != nil { return res, err } var types []pact.MenuItemType for _, typ := range reg.ContentTypes() { if typeKey == "" || typ.Key == typeKey { types = append(types, typ) } } if typeKey != "" && len(types) == 0 { return res, fmt.Errorf("golem15.seo: content type %q is not registered", typeKey) } settings, err := LoadSettings(db) if err != nil { return res, err } for _, typ := range types { ts, err := LoadTypeSetting(db, typ.Key) if err != nil { return res, err } after := "" for { page, err := reg.Records(ctx, typ.Key, after, generatePage, s.UnprefixedLocale()) if err != nil { return res, err } if err := db.Transaction(func(tx *gorm.DB) error { for _, rec := range page.Records { if !rec.Published { continue } for _, l := range rec.Locales { if strings.TrimSpace(l.URL) == "" { continue } created, err := s.refreshEntry(ctx, tx, typ, rec.ID, l, settings, ts) if err != nil { return err } if created { res.Created++ } else { res.Updated++ } } } return nil }); err != nil { return res, err } if page.Next == "" || page.Next == after || len(page.Records) == 0 { break } after = page.Next } } return res, nil } // refreshEntry creates or refreshes the entry of one record in one // locale; created is true for a new row. The job only sees published // records, so an entry whose URL differs from the record's address gets // the address change (with its 301), never a silent overwrite. func (s *Service) refreshEntry(ctx context.Context, tx *gorm.DB, typ pact.MenuItemType, id string, l pact.ContentLocale, settings models.Settings, ts models.TypeSetting) (bool, error) { entry, found, err := LoadEntry(tx, typ.Content.Model, id, l.Locale) if err != nil { return false, err } site := s.translatedSetting(ctx, tx, &settings, "site_name", settings.SiteName, l.Locale) fallback := s.translatedSetting(ctx, tx, &settings, "default_description", settings.DefaultDescription, l.Locale) title, description := EffectiveValues(entry, l, settings, ts, site, fallback) if !found { row := models.Entry{ ModelType: typ.Content.Model, RecordID: id, Locale: l.Locale, TypeKey: typ.Key, URL: l.URL, EffectiveTitle: title, EffectiveDescription: description, } return true, tx.Create(&row).Error } updates := map[string]any{ "type_key": typ.Key, "effective_title": title, "effective_description": description, } switch { case entry.URL == "": updates["url"] = l.URL case entry.URL != l.URL: // The published record's address changed and no RecordSaved // listener has seen it yet (WR-19): apply it like the listener // would, so its 301 is not lost; AddressChanged moves the entry's // URL. if _, err := s.AddressChanged(ctx, tx, AddressChange{ TypeKey: typ.Key, ModelType: typ.Content.Model, RecordID: id, Locale: l.Locale, Old: entry.URL, New: l.URL, }); err != nil { return false, err } } return false, tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(updates).Error } // EffectiveValues are a record's effective title and description without // URL rules (the cache duplicate checks read): the record's values, else // the type or general template on the content, else the content's own, // else the settings. func EffectiveValues(entry models.Entry, l pact.ContentLocale, settings models.Settings, ts models.TypeSetting, site, fallback string) (string, string) { vars := TemplateVars{Title: l.Title, Description: l.Description, Site: site} tpl := ts.TitleTemplate if strings.TrimSpace(tpl) == "" { tpl = settings.TitleTemplate } title, _ := pick(candidate{entry.Title, SourceRecord}, candidate{RenderTemplate(tpl, vars), SourceTemplate}, candidate{l.Title, SourceContent}, candidate{site, SourceGlobal}) description, _ := pick(candidate{entry.Description, SourceRecord}, candidate{RenderTemplate(ts.DescriptionTemplate, vars), SourceTemplate}, candidate{l.Description, SourceContent}, candidate{fallback, SourceGlobal}) return title, description } // EnqueueGenerate queues the generation job through conga for typeKey // (empty: every type) and returns the summer_jobs id. func (s *Service) EnqueueGenerate(ctx context.Context, db *gorm.DB, typeKey string) (uint, error) { if s == nil || s.app == nil { return 0, ErrNotBooted } m, err := conga.From(s.app) if err != nil { return 0, err } return m.Dispatch(ctx, db, GenerateEntriesArgs{Type: typeKey}, conga.DispatchOpts{Label: s.Phrase("golem15.seo::lang.jobs.generate_entries", nil)}) } // RecordDeleted removes the entries of a deleted record in one // transaction and records each in history (via auto), so its values stay // recoverable (D-92, D-94). A model without a content type is ignored. func (s *Service) RecordDeleted(ctx context.Context, ev *pact.RecordDeleted) error { reg := s.Registry() if reg == nil || ev == nil { return nil } typ, ok := reg.ContentTypeForModel(ev.Model) if !ok || typ.Content == nil { return nil } db, err := s.DB(ctx) if err != nil { return err } ctx = WithVia(ctx, models.ViaAuto) // A lagoon transaction, so the history it records purges the site // cache after the commit. return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { entries, err := LoadEntries(tx, typ.Content.Model, ev.ID) if err != nil || len(entries) == 0 { return err } if err := tx.Where("model_type = ? AND record_id = ?", typ.Content.Model, ev.ID).Delete(&models.Entry{}).Error; err != nil { return err } for locale, e := range entries { if entryEmpty(e) { continue } title := e.EffectiveTitle if title == "" { title = e.URL } if err := s.Record(ctx, tx, Change{ Kind: models.SubjectEntry, SubjectID: EntrySubject(e.ModelType, e.RecordID, locale), What: s.recordWhat(typ, ev.ID, pact.ContentLocale{Title: title}, locale), Before: entryValues(e), }); err != nil { return err } } return nil }) } // RecordCreated stores the address snapshot of a newly created record: // each locale with an address gets an entry with its URL (existing entries, // written by the SEO tab in the same save, only take the URL). func (s *Service) RecordCreated(ctx context.Context, ev *pact.RecordSaved) error { reg := s.Registry() if reg == nil || ev == nil || !ev.Created { return nil } typ, ok := reg.ContentTypeForModel(ev.Model) if !ok || typ.Content == nil { return nil } rec, err := reg.Describe(ctx, typ.Key, ev.ID, s.UnprefixedLocale()) if err != nil || rec == nil { return err } db, err := s.DB(ctx) if err != nil { return err } return db.Transaction(func(tx *gorm.DB) error { for _, l := range rec.Locales { if strings.TrimSpace(l.URL) == "" { continue } entry, found, err := LoadEntry(tx, typ.Content.Model, ev.ID, l.Locale) if err != nil { return err } if found { if err := tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(map[string]any{"url": l.URL, "type_key": typ.Key}).Error; err != nil { return err } continue } row := models.Entry{ModelType: typ.Content.Model, RecordID: ev.ID, Locale: l.Locale, TypeKey: typ.Key, URL: l.URL} if err := tx.Create(&row).Error; err != nil { return err } } return nil }) }