251 lines
7.9 KiB
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
|
|
})
|
|
}
|