Files
sm-seo-plugin/controllers/autolinks.go
Jakub Zych ab4e83416d feat: auto-links screen, keyphrase feed and MCP tools
- SEO › Auto-links (golem15.seo.autolinks, after Pages): list by phrase
  with locale, origin and active filters, the disabled row state, the
  form with its refusals, history through a field-less extension
- toolbar Add focus keyphrases (from_keyphrases) and
  POST /autolinks/from-keyphrases
- GET/POST /autolinks, PUT /autolinks/{id}, DELETE /autolinks and the
  tools seo_list_autolinks, seo_create_autolink, seo_update_autolink,
  seo_delete_autolinks, seo_autolinks_from_keyphrases
- the settings tile description and the permission comment name
  auto-links and the site audit
2026-11-04 16:30:56 +01:00

237 lines
7.8 KiB
Go

package controllers
import (
"context"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// AutolinksControllerID is the Auto-links screen (UI-SPEC C22).
const AutolinksControllerID = "golem15.seo.autolinks"
// ActionFromKeyphrases is the Auto-links toolbar action that adds the
// records' focus keyphrases (UI-SPEC C22).
const ActionFromKeyphrases = "from_keyphrases"
// autolinksAdminController lists and edits the auto-link phrases (D-82).
// History of a save comes from the plugin's own field-less form extension
// on this controller, which runs inside the save transaction once the row
// is written.
type autolinksAdminController struct {
app func() *backpack.App
}
func (autolinksAdminController) ID() string { return AutolinksControllerID }
func (autolinksAdminController) ModelName() string { return `Golem15\SEO\Models\Autolink` }
func (autolinksAdminController) ConfigDir() string { return "controllers/autolinks" }
func (autolinksAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// NewRecord is a phrase with the form's defaults: every locale, active,
// entered by hand.
func (autolinksAdminController) NewRecord() any {
a := models.NewAutolink()
return &a
}
// FormBeforeCreate refuses a short phrase, an invalid address and a
// phrase the locale already has (UI-SPEC C22); a create is manual.
func (c autolinksAdminController) FormBeforeCreate(ctx context.Context, model any) error {
if a, ok := model.(*models.Autolink); ok && a != nil {
a.Origin = models.AutolinkOriginManual
}
return c.check(ctx, model)
}
// FormBeforeUpdate checks an update like a create.
func (c autolinksAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
return c.check(ctx, model)
}
func (c autolinksAdminController) check(ctx context.Context, model any) error {
a, ok := model.(*models.Autolink)
if !ok || a == nil {
return nil
}
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return err
}
db, err := database(ctx, c.app)
if err != nil {
return err
}
problems, err := svc.AutolinkProblems(ctx, db, a)
if err != nil {
return err
}
return problemsError(ctx, translator(c.app), problems)
}
// FormAfterDelete records the deleted phrase in history inside the
// delete's transaction.
func (c autolinksAdminController) FormAfterDelete(ctx context.Context, model any) error {
a, ok := model.(*models.Autolink)
if !ok || a == nil {
return nil
}
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return err
}
db, err := database(ctx, c.app)
if err != nil {
return err
}
return svc.RecordAutolink(ctx, db, classes.AutolinkValues(*a), *a, true)
}
// ListRowStates marks an inactive phrase disabled (autolinks.state_off).
func (autolinksAdminController) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) {
out := make([][]pact.RowState, len(records))
for i, record := range records {
if a, ok := record.(*models.Autolink); ok && a != nil && !a.Enabled {
out[i] = []pact.RowState{pact.RowStateDisabled}
}
}
return out, nil
}
// autolinkOriginLabels are the phrase keys of the origins.
var autolinkOriginLabels = map[string]string{
models.AutolinkOriginManual: "golem15.seo::lang.autolinks.origin_manual",
models.AutolinkOriginKeyphrase: "golem15.seo::lang.autolinks.origin_keyphrase",
models.AutolinkOriginMCP: "golem15.seo::lang.autolinks.origin_mcp",
}
// locales returns the site's content locales, the default first.
func (c autolinksAdminController) locales(ctx context.Context) []string {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil
}
db, err := database(ctx, c.app)
if err != nil {
return nil
}
return svc.Locales(ctx, db)
}
// FilterOptions serves the choices of the locale and origin filters.
func (c autolinksAdminController) FilterOptions(scope string) []pact.Option {
switch scope {
case models.FilterAutolinkLocale:
out := []pact.Option{{Value: models.AutolinkLocaleAll, Label: "golem15.seo::lang.autolinks.locale_all"}}
for _, code := range c.locales(context.Background()) {
out = append(out, pact.Option{Value: code, Label: strings.ToUpper(code)})
}
return out
case models.FilterAutolinkOrigin:
out := make([]pact.Option, 0, len(models.AutolinkOrigins))
for _, o := range models.AutolinkOrigins {
out = append(out, pact.Option{Value: o, Label: autolinkOriginLabels[o]})
}
return out
}
return nil
}
// DropdownOptions serves the locale dropdown: each content locale (the
// empty option is every locale).
func (c autolinksAdminController) DropdownOptions(field string) []pact.Option {
if field != "locale" {
return nil
}
codes := c.locales(context.Background())
out := make([]pact.Option, 0, len(codes))
for _, code := range codes {
out = append(out, pact.Option{Value: code, Label: strings.ToUpper(code)})
}
return out
}
// ListExtendRecords shows the locale (all languages when empty) and the
// origin of the list's rows as their labels in the request locale. The
// rows are list values only and never saved.
func (c autolinksAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
tr := translator(c.app)
for _, record := range records {
a, ok := record.(*models.Autolink)
if !ok || a == nil {
continue
}
if a.Locale == "" {
a.Locale = Phrase(ctx, tr, "golem15.seo::lang.autolinks.locale_all", nil)
} else {
a.Locale = strings.ToUpper(a.Locale)
}
if key, ok := autolinkOriginLabels[a.Origin]; ok {
a.Origin = Phrase(ctx, tr, key, nil)
}
}
return nil
}
// AdminActions is the toolbar's Add focus keyphrases.
func (c autolinksAdminController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{{
Name: ActionFromKeyphrases,
Label: "golem15.seo::lang.autolinks.from_keyphrases",
Permissions: []string{PermissionManage},
Run: func(ctx context.Context, _ pact.AdminActionInput) (pact.AdminActionResult, error) {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return pact.AdminActionResult{}, err
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminActionResult{}, err
}
added := 0
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
var err error
added, err = svc.FromKeyphrases(classes.WithTx(ctx, tx), tx)
return err
})
if err != nil {
return pact.AdminActionResult{}, err
}
return pact.AdminActionResult{Message: KeyphrasesMessage(ctx, c.app, added)}, nil
},
}}
}
// KeyphrasesMessage is the Add focus keyphrases toast: the count added,
// or that there was nothing to add.
func KeyphrasesMessage(ctx context.Context, app func() *backpack.App, added int) string {
tr := translator(app)
if added == 0 {
return Phrase(ctx, tr, "golem15.seo::lang.autolinks.keyphrases_none", nil)
}
if tr == nil {
return "golem15.seo::lang.autolinks.keyphrases_added"
}
return tr.Choice(ctx, "golem15.seo::lang.autolinks.keyphrases_added", added, nil)
}
var (
_ pact.AdminController = autolinksAdminController{}
_ pact.AdminPermissioned = autolinksAdminController{}
_ pact.AdminRecordSource = autolinksAdminController{}
_ pact.FormBeforeCreate = autolinksAdminController{}
_ pact.FormBeforeUpdate = autolinksAdminController{}
_ pact.FormAfterDelete = autolinksAdminController{}
_ pact.ListRowStates = autolinksAdminController{}
_ pact.FilterOptions = autolinksAdminController{}
_ pact.ListExtendRecords = autolinksAdminController{}
_ pact.DropdownOptionsProvider = autolinksAdminController{}
_ pact.HasAdminActions = autolinksAdminController{}
)