Files
sm-seo-plugin/controllers/settings.go
Jakub Zych fde5024b5a feat: publisher, local business and home crumb settings
- Publisher and Local business tabs with the C20 refusals (sameAs lines,
  person as local business, address, times, country, coordinates)
- the publisher node from the settings, as an Organization, a Person or
  the chosen local business type with address, geo and opening hours
- P8 publisher preview with Google's required and recommended findings
  (classes/schema Validate)
- the home crumb name on the General tab; every new key in the settings
  API and seo_update_settings
2026-11-04 14:14:59 +01:00

151 lines
4.9 KiB
Go

package controllers
import (
"context"
"errors"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/diary"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// log is the controllers' logger (component=seo).
var log = diary.For("seo")
// settingsAdminController edits the SEO settings singleton (D-104), linked
// from the SEO settings tile. Its per-content-type tabs are added by the
// plugin's own form extension on this controller.
type settingsAdminController struct {
app func() *backpack.App
}
func (settingsAdminController) ID() string { return SettingsControllerID }
func (settingsAdminController) ModelName() string { return `Golem15\SEO\Models\Settings` }
func (settingsAdminController) ConfigDir() string { return "controllers/settings" }
func (settingsAdminController) NewRecord() any { return &models.Settings{} }
func (settingsAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// SingletonRecord returns settings row 1, creating it with the defaults
// when the migration's row is gone (first or create, safe against a
// concurrent first request through ON CONFLICT DO NOTHING).
func (settingsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) {
if db == nil {
return nil, errors.New("golem15.seo: database is not configured")
}
var rows []models.Settings
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) > 0 {
return &rows[0], nil
}
row := models.DefaultSettings()
if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return nil, err
}
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 {
return nil, errors.New("golem15.seo: settings row is missing")
}
return &rows[0], nil
}
// FormBeforeUpdate refuses templates with unknown placeholders and
// malformed or duplicate verification file names.
func (c settingsAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
s, ok := model.(*models.Settings)
if !ok || s == nil {
return nil
}
if err := problemsError(ctx, translator(c.app), s.Problems()); err != nil {
return err
}
s.NofollowDomains = models.NormalizeDomains(s.NofollowDomains)
s.SponsoredDomains = models.NormalizeDomains(s.SponsoredDomains)
return nil
}
// FormBeforeCreate checks a created row like an update.
func (c settingsAdminController) FormBeforeCreate(ctx context.Context, model any) error {
return c.FormBeforeUpdate(ctx, model)
}
// FormAfterUpdate queues the entry generation job once the save commits,
// so the effective title and description cache follows new templates. A
// queue error is logged and never fails the save.
func (c settingsAdminController) FormAfterUpdate(ctx context.Context, _ any) error {
app := appOf(c.app)
svc, err := classes.NewService(app)
if err != nil {
return nil
}
tx, err := database(ctx, c.app)
if err != nil {
return nil
}
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
return
}
if _, err := svc.EnqueueGenerate(ctx, db.WithContext(ctx), ""); err != nil {
log.Warn("golem15.seo: queue entry generation", "error", err)
}
})
return nil
}
// stagingView is the view model of the staging partial.
type stagingView struct {
Staging bool
}
// PartialData serves the staging callout, shown outside production where
// every page is noindex whatever the settings say (D-22), the P3
// placeholder table and the P8 publisher preview.
func (c settingsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) {
switch name {
case "staging":
return stagingView{Staging: IsStaging(appOf(c.app))}, nil
case "placeholders":
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil, err
}
return svc.Placeholders(ctx), nil
case "publisher_preview":
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil, err
}
return svc.PublisherPreview(ctx)
}
return nil, nil
}
// IsStaging reports whether the application runs outside production. It
// depends on the environment only (D-22): no setting can change it.
func IsStaging(app *backpack.App) bool {
if app == nil || app.Config == nil {
return true
}
return app.Config.Environment() != "production"
}
var (
_ pact.AdminController = settingsAdminController{}
_ pact.AdminPermissioned = settingsAdminController{}
_ pact.AdminRecordSource = settingsAdminController{}
_ pact.AdminSingleton = settingsAdminController{}
_ pact.AdminPartialData = settingsAdminController{}
_ pact.FormBeforeCreate = settingsAdminController{}
_ pact.FormBeforeUpdate = settingsAdminController{}
_ pact.FormAfterUpdate = settingsAdminController{}
)