Files
sm-seo-plugin/classes/settings_write.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

379 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package classes
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// SettingsRow returns settings row 1 inside db, creating it with the
// defaults when the migration's row is gone.
func SettingsRow(db *gorm.DB) (models.Settings, error) {
var rows []models.Settings
if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
return models.Settings{}, 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 models.Settings{}, err
}
return LoadSettings(db)
}
// SettingsColumns are the settings columns the API reads and writes, in
// form order: the admin form's columns and the CSV delimiter of the
// import and export screen.
var SettingsColumns = []string{
"site_name", "twitter_site", "title_template", "home_template",
"default_description", "default_og_image", "query_params",
"noindex_paths", "search_paths", "url_host", "url_slash",
"url_lowercase", "links_new_tab", "nofollow_domains",
"sponsored_domains", "verify_google", "verify_bing", "verify_yandex",
"verify_other", "verify_files", "csv_delimiter", "breadcrumb_home",
"publisher_type", "publisher_name", "publisher_logo", "publisher_url",
"publisher_email", "publisher_phone", "publisher_same_as",
"local_enabled", "local_type", "local_price_range", "local_street",
"local_postal_code", "local_city", "local_region", "local_country",
"local_latitude", "local_longitude", "local_hours",
}
// settingsText maps the plain text settings columns to their fields.
func settingsText(st *models.Settings) map[string]*string {
return map[string]*string{
"site_name": &st.SiteName,
"twitter_site": &st.TwitterSite,
"title_template": &st.TitleTemplate,
"home_template": &st.HomeTemplate,
"default_description": &st.DefaultDescription,
"default_og_image": &st.DefaultOGImage,
"noindex_paths": &st.NoindexPaths,
"search_paths": &st.SearchPaths,
"url_host": &st.URLHost,
"url_slash": &st.URLSlash,
"verify_google": &st.VerifyGoogle,
"verify_bing": &st.VerifyBing,
"verify_yandex": &st.VerifyYandex,
"csv_delimiter": &st.CSVDelimiter,
"nofollow_domains": &st.NofollowDomains,
"sponsored_domains": &st.SponsoredDomains,
"breadcrumb_home": &st.BreadcrumbHome,
"publisher_type": &st.PublisherType,
"publisher_name": &st.PublisherName,
"publisher_logo": &st.PublisherLogo,
"publisher_url": &st.PublisherURL,
"publisher_email": &st.PublisherEmail,
"publisher_phone": &st.PublisherPhone,
"publisher_same_as": &st.PublisherSameAs,
"local_type": &st.LocalType,
"local_price_range": &st.LocalPriceRange,
"local_street": &st.LocalStreet,
"local_postal_code": &st.LocalPostalCode,
"local_city": &st.LocalCity,
"local_region": &st.LocalRegion,
"local_country": &st.LocalCountry,
"local_latitude": &st.LocalLatitude,
"local_longitude": &st.LocalLongitude,
}
}
// SettingsValues are the settings row's values keyed by column, for
// history and the API.
func SettingsValues(st models.Settings) map[string]any {
return map[string]any{
"site_name": st.SiteName,
"twitter_site": st.TwitterSite,
"title_template": st.TitleTemplate,
"home_template": st.HomeTemplate,
"default_description": st.DefaultDescription,
"default_og_image": st.DefaultOGImage,
"query_params": plainValue(st.Params()),
"noindex_paths": st.NoindexPaths,
"search_paths": st.SearchPaths,
"url_host": st.URLHost,
"url_slash": st.URLSlash,
"url_lowercase": st.URLLowercase,
"links_new_tab": st.LinksNewTab,
"nofollow_domains": st.NofollowDomains,
"sponsored_domains": st.SponsoredDomains,
"verify_google": st.VerifyGoogle,
"verify_bing": st.VerifyBing,
"verify_yandex": st.VerifyYandex,
"verify_other": plainValue(st.OtherMeta()),
"verify_files": plainValue(st.Files()),
"csv_delimiter": st.CSVDelimiter,
"breadcrumb_home": st.BreadcrumbHome,
"publisher_type": st.PublisherType,
"publisher_name": st.PublisherName,
"publisher_logo": st.PublisherLogo,
"publisher_url": st.PublisherURL,
"publisher_email": st.PublisherEmail,
"publisher_phone": st.PublisherPhone,
"publisher_same_as": st.PublisherSameAs,
"local_enabled": st.LocalEnabled,
"local_type": st.LocalType,
"local_price_range": st.LocalPriceRange,
"local_street": st.LocalStreet,
"local_postal_code": st.LocalPostalCode,
"local_city": st.LocalCity,
"local_region": st.LocalRegion,
"local_country": st.LocalCountry,
"local_latitude": st.LocalLatitude,
"local_longitude": st.LocalLongitude,
"local_hours": plainValue(st.Hours()),
}
}
// SetSettingsValue writes one column of the settings from a JSON value
// (a string, a bool, or a list of objects); an unknown column or a value
// of the wrong shape is an error.
func SetSettingsValue(st *models.Settings, column string, v any) error {
text := func() (string, error) {
switch s := v.(type) {
case string:
return s, nil
case nil:
return "", nil
}
return "", fmt.Errorf("golem15.seo: settings %s is not text", column)
}
list := func(dst any) error {
raw, err := json.Marshal(v)
if err != nil {
return err
}
if string(raw) == "null" {
raw = []byte("[]")
}
return json.Unmarshal(raw, dst)
}
if field, ok := settingsText(st)[column]; ok {
var err error
*field, err = text()
return err
}
var err error
switch column {
case "url_lowercase":
b, ok := v.(bool)
if !ok {
return fmt.Errorf("golem15.seo: settings %s is not a bool", column)
}
st.URLLowercase = b
case "links_new_tab", "local_enabled":
b, ok := v.(bool)
if !ok {
return fmt.Errorf("golem15.seo: settings %s is not a bool", column)
}
if column == "links_new_tab" {
st.LinksNewTab = b
} else {
st.LocalEnabled = b
}
case "local_hours":
var out []models.OpeningHours
if err = list(&out); err == nil {
st.LocalHours = lagoon.Jsonable[[]models.OpeningHours]{Data: out, Valid: true}
}
case "query_params":
var out []models.QueryParam
if err = list(&out); err == nil {
st.QueryParams = lagoon.Jsonable[[]models.QueryParam]{Data: out, Valid: true}
}
case "verify_other":
var out []models.VerifyMeta
if err = list(&out); err == nil {
st.VerifyOther = lagoon.Jsonable[[]models.VerifyMeta]{Data: out, Valid: true}
}
case "verify_files":
var out []models.VerifyFile
if err = list(&out); err == nil {
st.VerifyFiles = lagoon.Jsonable[[]models.VerifyFile]{Data: out, Valid: true}
}
default:
return fmt.Errorf("golem15.seo: unknown settings column %q", column)
}
return err
}
// TypeSettingWrite changes the settings of one content type: a nil field
// is left as it is.
type TypeSettingWrite struct {
Inject *bool `json:"inject,omitempty"`
TitleTemplate *string `json:"title_template,omitempty"`
DescriptionTemplate *string `json:"description_template,omitempty"`
Robots *string `json:"robots,omitempty"`
}
// SettingsWrite is one change of the SEO settings: Values are columns of
// SettingsColumns with JSON values, Types per content type key.
type SettingsWrite struct {
Values map[string]any
Types map[string]TypeSettingWrite
}
// SaveSettings writes the settings inside tx and records them in history,
// like the settings screen (D-104): templates with an unknown placeholder,
// malformed or duplicate verification files, an unknown address option or
// delimiter, robots values outside the grammar and unknown content types
// are refused with a *ProblemsError. Once the write commits, the entry
// generation job is queued so the effective-value cache follows.
func (s *Service) SaveSettings(ctx context.Context, tx *gorm.DB, in SettingsWrite) (models.Settings, error) {
row, err := SettingsRow(tx)
if err != nil {
return models.Settings{}, err
}
before := SettingsValues(row)
problems := map[string][]models.Problem{}
known := map[string]bool{}
for _, c := range SettingsColumns {
known[c] = true
}
for column, v := range in.Values {
if !known[column] {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.field_unknown"})
continue
}
if s, ok := v.(string); ok {
v = strings.TrimSpace(s)
}
if err := SetSettingsValue(&row, column, v); err != nil {
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
}
}
oneOf := func(column, value string, allowed ...string) {
for _, a := range allowed {
if value == a {
return
}
}
problems[column] = append(problems[column], models.Problem{Key: "golem15.seo::lang.api.value_invalid"})
}
oneOf("url_host", row.URLHost, models.HostKeep, models.HostWWW, models.HostBare)
oneOf("url_slash", row.URLSlash, models.SlashKeep, models.SlashAdd, models.SlashDrop)
oneOf("csv_delimiter", row.CSVDelimiter, "", ",", ";")
for _, column := range []string{"default_og_image", "publisher_logo"} {
value := row.DefaultOGImage
if column == "publisher_logo" {
value = row.PublisherLogo
}
if _, sent := in.Values[column]; sent && !ValidImageURL(value) {
problems[column] = append(problems[column], models.Problem{Key: errImageURL})
}
}
for field, list := range row.Problems() {
problems[field] = append(problems[field], list...)
}
types, err := LoadTypeSettings(tx)
if err != nil {
return models.Settings{}, err
}
changed := map[string]models.TypeSetting{}
reg := s.Registry()
for key, w := range in.Types {
field := "types." + key
if reg == nil {
problems[field] = append(problems[field], models.Problem{Key: "golem15.seo::lang.api.type_unknown"})
continue
}
if _, ok := reg.ContentType(key); !ok {
problems[field] = append(problems[field], models.Problem{Key: "golem15.seo::lang.api.type_unknown"})
continue
}
ts, ok := types[key]
if !ok {
ts = models.DefaultTypeSetting(key)
}
if w.Inject != nil {
ts.Inject = *w.Inject
}
if w.TitleTemplate != nil {
ts.TitleTemplate = strings.TrimSpace(*w.TitleTemplate)
problems[field+".title_template"] = append(problems[field+".title_template"], models.TemplateProblems(ts.TitleTemplate)...)
}
if w.DescriptionTemplate != nil {
ts.DescriptionTemplate = strings.TrimSpace(*w.DescriptionTemplate)
problems[field+".description_template"] = append(problems[field+".description_template"], models.TemplateProblems(ts.DescriptionTemplate)...)
}
if w.Robots != nil {
normal, err := NormalizeRobots(*w.Robots)
if err != nil {
problems[field+".robots"] = append(problems[field+".robots"], models.Problem{Key: "golem15.seo::lang.robots.invalid"})
}
ts.Robots = normal
}
changed[key] = ts
}
for field, list := range problems {
if len(list) == 0 {
delete(problems, field)
}
}
if len(problems) > 0 {
return models.Settings{}, &ProblemsError{Problems: problems}
}
row.NofollowDomains = models.NormalizeDomains(row.NofollowDomains)
row.SponsoredDomains = models.NormalizeDomains(row.SponsoredDomains)
now := time.Now().UTC()
row.UpdatedAt = &now
if err := tx.Save(&row).Error; err != nil {
return models.Settings{}, err
}
if err := s.Record(ctx, tx, Change{
Kind: models.SubjectSettings,
SubjectID: "1",
What: s.Phrase("golem15.seo::lang.history.what_settings", nil),
Before: before,
After: SettingsValues(row),
}); err != nil {
return models.Settings{}, err
}
for key, ts := range changed {
old, ok := types[key]
if !ok {
old = models.DefaultTypeSetting(key)
}
if err := s.SaveTypeSetting(ctx, tx, ts); err != nil {
return models.Settings{}, err
}
label := key
if typ, ok := reg.ContentType(key); ok && typ.Label != "" {
label = s.Phrase(typ.Label, nil)
}
if err := s.Record(ctx, tx, Change{
Kind: models.SubjectSettings,
SubjectID: "type:" + key,
What: s.Phrase("golem15.seo::lang.history.what_settings", nil) + " – " + label,
Before: TypeSettingValues(old),
After: TypeSettingValues(ts),
}); err != nil {
return models.Settings{}, err
}
}
s.queueGenerateAfterCommit(ctx, tx)
return row, nil
}
// queueGenerateAfterCommit queues the entry generation job once tx
// commits; a queue error is logged.
func (s *Service) queueGenerateAfterCommit(ctx context.Context, tx *gorm.DB) {
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
db, err := s.GormDB()
if err != nil {
return
}
if _, err := s.EnqueueGenerate(ctx, db.WithContext(ctx), ""); err != nil {
log.Warn("golem15.seo: queue entry generation", "error", err)
}
})
}