Files
sm-seo-plugin/extensions_settings.go

286 lines
8.5 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 seo
import (
"context"
"fmt"
"regexp"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/controllers"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"github.com/goccy/go-yaml"
)
// TabGeneral is the settings form's General tab key; the per-type tabs
// follow it.
const TabGeneral = "golem15.seo::lang.settings.tab_general"
// typeFieldKey turns a content type key into the part of a field name:
// every character outside [a-z0-9_] becomes "_".
var typeFieldUnsafe = regexp.MustCompile(`[^a-z0-9_]`)
func typeFieldKey(key string) string {
return typeFieldUnsafe.ReplaceAllString(strings.ToLower(key), "_")
}
// typeField is one per-type settings field: its name, the content type and
// the type_settings column it edits.
type typeField struct {
name string
key string
column string
}
// typeSettingsFields lists the per-type fields of types in form order.
func typeSettingsFields(types []pact.MenuItemType) []typeField {
var out []typeField
for _, typ := range types {
base := "type_" + typeFieldKey(typ.Key) + "_"
for _, column := range []string{"inject", "title_template", "description_template", "robots"} {
out = append(out, typeField{name: base + column, key: typ.Key, column: column})
}
}
return out
}
// typeSettingsDocument builds the fields document of the per-type settings
// tabs (D-104): one tab per content type in registration order, the first
// after General and each next after the previous, labelled with the type's
// signpost label.
func typeSettingsDocument(types []pact.MenuItemType) ([]byte, error) {
type tabSpec struct {
Name string `yaml:"name"`
After string `yaml:"after"`
}
tabs := make([]tabSpec, 0, len(types))
fields := yaml.MapSlice{}
prev := TabGeneral
for _, typ := range types {
tab := typ.Label
if strings.TrimSpace(tab) == "" {
tab = typ.Key
}
tabs = append(tabs, tabSpec{Name: tab, After: prev})
prev = tab
base := "type_" + typeFieldKey(typ.Key) + "_"
fields = append(fields,
yaml.MapItem{Key: base + "inject", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.seo::lang.settings.type_inject"},
{Key: "comment", Value: "golem15.seo::lang.settings.type_inject_comment"},
{Key: "type", Value: "switch"},
{Key: "span", Value: "full"},
{Key: "tab", Value: tab},
}},
yaml.MapItem{Key: base + "title_template", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.seo::lang.settings.type_title_template"},
{Key: "comment", Value: "golem15.seo::lang.settings.type_title_template_comment"},
{Key: "type", Value: "text"},
{Key: "span", Value: "left"},
{Key: "tab", Value: tab},
}},
yaml.MapItem{Key: base + "description_template", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.seo::lang.settings.type_description_template"},
{Key: "type", Value: "text"},
{Key: "span", Value: "right"},
{Key: "tab", Value: tab},
}},
yaml.MapItem{Key: base + "robots", Value: yaml.MapSlice{
{Key: "label", Value: "golem15.seo::lang.settings.type_robots"},
{Key: "type", Value: "seorobots"},
{Key: "scope", Value: "type"},
{Key: "span", Value: "full"},
{Key: "tab", Value: tab},
}},
yaml.MapItem{Key: base + "placeholders", Value: yaml.MapSlice{
{Key: "type", Value: "partial"},
{Key: "path", Value: "type_placeholders"},
{Key: "span", Value: "full"},
{Key: "tab", Value: tab},
}},
)
}
return yaml.Marshal(yaml.MapSlice{
{Key: "tabs", Value: tabs},
{Key: "fields", Value: fields},
})
}
// typeSettingsTabs is the plugin's form extension on its own settings
// controller: it adds the per-type tabs (none when no content type is
// registered) and records every settings save in history.
func (p *Plugin) typeSettingsTabs(types []pact.MenuItemType) *pact.AdminFormExtension {
var doc []byte
if len(types) > 0 {
var err error
if doc, err = typeSettingsDocument(types); err != nil {
pluginLog.Error("golem15.seo: per-type settings tabs", "error", err)
return nil
}
}
fields := typeSettingsFields(types)
labels := map[string]string{}
for _, typ := range types {
labels[typ.Key] = typ.Label
}
return &pact.AdminFormExtension{
FieldsYAML: doc,
Permissions: []string{controllers.PermissionManage},
Dir: "extension",
PartialData: func(ctx context.Context, name string, _ any) (any, error) {
svc := p.Service()
if svc == nil || name != "type_placeholders" {
return nil, nil
}
return svc.Placeholders(ctx), nil
},
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
rows, err := classes.LoadTypeSettings(in.DB.WithContext(ctx))
if err != nil {
return nil, err
}
out := map[string]any{}
for _, f := range fields {
row, ok := rows[f.key]
if !ok {
row = models.DefaultTypeSetting(f.key)
}
out[f.name] = typeSettingValue(row, f.column)
}
return out, nil
},
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
svc := p.Service()
if svc == nil {
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
}
before, after := classes.ChangesValues(in.Changes, in.Created)
if err := svc.Record(ctx, in.DB, classes.Change{
Kind: models.SubjectSettings,
SubjectID: in.ID,
What: svc.Phrase("golem15.seo::lang.history.what_settings", nil),
Before: before,
After: after,
}); err != nil {
return pact.AdminFormSaveResult{}, err
}
return pact.AdminFormSaveResult{}, p.saveTypeSettings(ctx, in, fields, labels)
},
}
}
// typeSettingValue reads one column of a type settings row.
func typeSettingValue(row models.TypeSetting, column string) any {
switch column {
case "inject":
return row.Inject
case "title_template":
return row.TitleTemplate
case "description_template":
return row.DescriptionTemplate
case "robots":
return row.Robots
}
return nil
}
// saveTypeSettings stores the submitted per-type values through the
// service; a template with an unknown placeholder refuses the save.
func (p *Plugin) saveTypeSettings(ctx context.Context, in pact.AdminFormSave, fields []typeField, labels map[string]string) error {
svc := p.Service()
if svc == nil {
return classes.ErrNotBooted
}
rows, err := classes.LoadTypeSettings(in.DB)
if err != nil {
return err
}
changed := map[string]models.TypeSetting{}
refusals := pact.AdminFormErrors{}
var tr *phrasebook.Translator
if p.app != nil {
tr, _ = p.app.Lookup[*phrasebook.Translator]()
}
for _, f := range fields {
raw, ok := in.Values[f.name]
if !ok {
continue
}
row, seen := changed[f.key]
if !seen {
if row, ok = rows[f.key]; !ok {
row = models.DefaultTypeSetting(f.key)
}
}
switch f.column {
case "inject":
row.Inject = truthy(raw)
case "title_template", "description_template":
text := strings.TrimSpace(fmt.Sprint(stringOf(raw)))
for _, problem := range models.TemplateProblems(text) {
refusals[f.name] = append(refusals[f.name], controllers.Phrase(ctx, tr, problem.Key, problem.Params))
}
if f.column == "title_template" {
row.TitleTemplate = text
} else {
row.DescriptionTemplate = text
}
case "robots":
normal, err := classes.NormalizeRobots(stringOf(raw))
if err != nil {
refusals[f.name] = append(refusals[f.name], controllers.Phrase(ctx, tr, "golem15.seo::lang.robots.invalid", nil))
}
row.Robots = normal
}
changed[f.key] = row
}
if len(refusals) > 0 {
return refusals
}
for key, row := range changed {
old, ok := rows[key]
if !ok {
old = models.DefaultTypeSetting(key)
}
if err := svc.SaveTypeSetting(ctx, in.DB, row); err != nil {
return err
}
if err := svc.Record(ctx, in.DB, classes.Change{
Kind: models.SubjectSettings,
SubjectID: "type:" + key,
What: svc.Phrase("golem15.seo::lang.history.what_settings", nil) + " – " + svc.Phrase(labels[key], nil),
Before: classes.TypeSettingValues(old),
After: classes.TypeSettingValues(row),
}); err != nil {
return err
}
}
return nil
}
// truthy reads a submitted switch value.
func truthy(v any) bool {
switch b := v.(type) {
case bool:
return b
case string:
return b == "1" || strings.EqualFold(b, "true") || strings.EqualFold(b, "on")
case fmt.Stringer:
return b.String() == "1"
}
return false
}
// stringOf reads a submitted text value.
func stringOf(v any) string {
switch s := v.(type) {
case string:
return s
case nil:
return ""
}
return fmt.Sprint(v)
}