286 lines
8.5 KiB
Go
286 lines
8.5 KiB
Go
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)
|
||
}
|