Files
sm-seo-plugin/classes/generate.go

251 lines
7.9 KiB
Go

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/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.
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
}
return false, tx.Model(&models.Entry{}).Where("id = ?", entry.ID).Updates(map[string]any{
"url": l.URL,
"type_key": typ.Key,
"effective_title": title,
"effective_description": description,
}).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)
return db.Transaction(func(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
})
}