Files
sm-seo-plugin/classes/settings_write.go
Jakub Zych 6203aec2bc feat: auto-link phrases in the resolve answer (tracer)
- golem15_seo_autolinks (migration 202610280007) and the settings column
  autolink_max (Links tab, 0 to 20, default 3)
- classes/autolinks.go: checked save and delete with history, ForPage
- ok resolve answers carry autolinks for the page's locale without
  disabled phrases and links to the page itself, and autolink_max
2026-11-04 16:22:23 +01:00

434 lines
15 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"
"strconv"
"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", "page_form",
"page_segment", "page_title_suffix", "page_description_suffix",
"crawl_schedule", "crawl_max_pages", "crawl_delay", "crawl_keep",
"generic_anchors", "autolink_max",
}
// 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,
"page_form": &st.PageForm,
"page_segment": &st.PageSegment,
"page_title_suffix": &st.PageTitleSuffix,
"page_description_suffix": &st.PageDescriptionSuffix,
"crawl_schedule": &st.CrawlSchedule,
"generic_anchors": &st.GenericAnchors,
}
}
// settingsInts maps the number settings columns to their fields.
func settingsInts(st *models.Settings) map[string]*int {
return map[string]*int{
"crawl_max_pages": &st.CrawlMaxPages,
"crawl_delay": &st.CrawlDelay,
"crawl_keep": &st.CrawlKeep,
"autolink_max": &st.AutolinkMax,
}
}
// 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()),
"page_form": st.PageForm,
"page_segment": st.PageSegment,
"page_title_suffix": st.PageTitleSuffix,
"page_description_suffix": st.PageDescriptionSuffix,
"crawl_schedule": st.CrawlSchedule,
"crawl_max_pages": st.CrawlMaxPages,
"crawl_delay": st.CrawlDelay,
"crawl_keep": st.CrawlKeep,
"generic_anchors": st.GenericAnchors,
"autolink_max": st.AutolinkMax,
}
}
// 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
}
if field, ok := settingsInts(st)[column]; ok {
switch n := v.(type) {
case float64:
if n != float64(int(n)) {
return fmt.Errorf("golem15.seo: settings %s is not a whole number", column)
}
*field = int(n)
case int:
*field = n
case string:
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
return fmt.Errorf("golem15.seo: settings %s is not a number", column)
}
*field = i
default:
return fmt.Errorf("golem15.seo: settings %s is not a number", column)
}
return nil
}
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...)
}
for field, list := range row.AuditProblems() {
if _, sent := in.Values[field]; sent {
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)
}
})
}