Files
sm-seo-plugin/extensions.go
2026-10-27 10:45:30 +01:00

205 lines
6.2 KiB
Go

package seo
import (
"context"
"fmt"
"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/signpost"
)
// TabSEO is the key of the SEO tab added to every content type's form.
const TabSEO = "golem15.seo::lang.tab.seo"
// AdminActionExtensions adds the SEO tab (D-01, D-91) to the admin form of
// every content type registered in signpost with a content descriptor that
// names a controller. The content plugins import nothing from this plugin.
func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension {
reg := p.registry()
if reg == nil {
return nil
}
var out []pact.AdminActionExtension
types := reg.ContentTypes()
for _, typ := range types {
if typ.Content == nil || strings.TrimSpace(typ.Content.Controller) == "" {
continue
}
out = append(out, pact.AdminActionExtension{
Controller: typ.Content.Controller,
Form: p.seoTab(typ.Key),
})
}
if form := p.typeSettingsTabs(types); form != nil {
out = append(out, pact.AdminActionExtension{Controller: controllers.SettingsControllerID, Form: form})
}
out = append(out, pact.AdminActionExtension{Controller: controllers.RulesControllerID, Form: p.ruleHistory()})
return out
}
// ruleHistory is a field-less extension on the plugin's own rules
// controller: its Save sees the save's changes inside the save transaction
// and records them in history (deletes are recorded by the controller).
func (p *Plugin) ruleHistory() *pact.AdminFormExtension {
return &pact.AdminFormExtension{
Permissions: []string{controllers.PermissionManage},
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)
url := ""
if r, ok := in.Record.(*models.Rule); ok && r != nil {
url = r.URL
}
return pact.AdminFormSaveResult{}, svc.Record(ctx, in.DB, classes.Change{
Kind: models.SubjectRule,
SubjectID: in.ID,
What: svc.Phrase("golem15.seo::lang.history.what_rule", map[string]string{"url": url}),
Before: before,
After: after,
})
},
}
}
func (p *Plugin) registry() *signpost.Registry {
if p == nil || p.app == nil {
return nil
}
reg, _ := p.app.Lookup[*signpost.Registry]()
return reg
}
// seoTabYAML is the SEO tab's fields document: the tab placed after the
// owner's tabs (no anchor) and its fields.
const seoTabYAML = `tabs:
- name: golem15.seo::lang.tab.seo
fields:
seo_title:
label: golem15.seo::lang.field.title
type: mltext
tab: golem15.seo::lang.tab.seo
`
// seoTab is the form extension of content type key.
func (p *Plugin) seoTab(key string) *pact.AdminFormExtension {
return &pact.AdminFormExtension{
FieldsYAML: []byte(seoTabYAML),
Permissions: []string{controllers.PermissionManage},
Visible: func(ctx context.Context) bool {
return p.injected(ctx, key)
},
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
return p.loadSEOTab(ctx, key, in)
},
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
return p.saveSEOTab(ctx, key, in)
},
}
}
// contentType returns the registered content type key with a descriptor.
func (p *Plugin) contentType(key string) (pact.MenuItemType, error) {
reg := p.registry()
if reg == nil {
return pact.MenuItemType{}, fmt.Errorf("golem15.seo: menu item type registry is not published")
}
typ, ok := reg.ContentType(key)
if !ok {
return pact.MenuItemType{}, fmt.Errorf("golem15.seo: content type %q is not registered", key)
}
return typ, nil
}
// loadSEOTab returns the stored SEO tab values of a record, every field a
// map of locale to text.
func (p *Plugin) loadSEOTab(ctx context.Context, key string, in pact.AdminFormLoad) (map[string]any, error) {
svc := p.Service()
if svc == nil {
return nil, classes.ErrNotBooted
}
typ, err := p.contentType(key)
if err != nil {
return nil, err
}
entries, err := classes.LoadEntries(in.DB.WithContext(ctx), typ.Content.Model, in.ID)
if err != nil {
return nil, err
}
out := map[string]any{}
for column, perLocale := range classes.RecordValuesOf(entries) {
out[classes.FieldPrefix+column] = perLocale
}
return out, nil
}
// saveSEOTab stores the submitted SEO tab values of a record inside the
// record's save transaction.
func (p *Plugin) saveSEOTab(ctx context.Context, key string, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
svc := p.Service()
if svc == nil {
return pact.AdminFormSaveResult{}, classes.ErrNotBooted
}
typ, err := p.contentType(key)
if err != nil {
return pact.AdminFormSaveResult{}, err
}
values := classes.RecordValues{}
for _, column := range classes.EntryColumns {
raw, ok := in.Values[classes.FieldPrefix+column]
if !ok {
continue
}
values[column] = localeMap(raw)
}
if _, _, err := svc.SaveRecord(ctx, in.DB, classes.RecordWrite{Type: typ, ID: in.ID, Values: values}); err != nil {
return pact.AdminFormSaveResult{}, err
}
return pact.AdminFormSaveResult{}, nil
}
// localeMap reads a submitted value as locale -> text: a translatable
// field's map, or a plain string under the empty locale key.
func localeMap(raw any) map[string]string {
switch v := raw.(type) {
case map[string]string:
return v
case map[string]any:
out := make(map[string]string, len(v))
for locale, value := range v {
if s, ok := value.(string); ok {
out[locale] = s
}
}
return out
case string:
return map[string]string{"": v}
}
return map[string]string{}
}
// injected reports whether the SEO tab of content type key is shown: false
// only when its settings row turns it off. It answers true when it cannot
// decide, so a database error never hides stored values.
func (p *Plugin) injected(ctx context.Context, key string) bool {
svc := p.Service()
if svc == nil {
return true
}
db, err := svc.DB(ctx)
if err != nil {
return true
}
row, err := classes.LoadTypeSetting(db, key)
if err != nil {
return true
}
return row.Inject
}