- every write that records history (admin, MCP, batch, import, undo, automatic) queues the framework's site-cache purge (kind seo, slug the address or * for settings and robots.txt) after its commit, once per transaction; a rolled-back write never purges - the record-deleted cleanup and the address tracking of saves outside the admin run in lagoon transactions, so their history purges as well
271 lines
8.7 KiB
Go
271 lines
8.7 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/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
|
|
})
|
|
}
|