Files
sm-seo-plugin/controllers/issues.go
Jakub Zych c4f0b4f94c feat: Site audit and Crawls screens with their MCP tools
- golem15.seo.issues (Site audit): current issues errors first, labels,
  summary and fix columns, filters, P7 issue_details, Check this address
  again, Ignore, Stop ignoring, bulk Ignore, ignored rows badged
- golem15.seo.crawls (Crawls): newest first, P6 crawl_summary with one
  status callout, staging and rendered-comparison lines, stats strip and
  per-kind table; Cancel; a running crawl refuses delete
- Start crawl toolbar action on both, refusing already_running and
  no_site_url; navigation Site audit (gauge) and Crawls (globe)
- routes and MCP tools start/cancel/list/get/delete crawls, list_issues
  with filters (open by default), recheck_url, ignore and unignore
- AdminOperations maps both controllers; toolbar actions get their own
  operation kind
- TestCrawlAdmin, TestCrawlTools, TestIssueKindLabels
2026-11-04 15:34:22 +01:00

506 lines
18 KiB
Go

package controllers
import (
"context"
"errors"
"fmt"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// IssuesControllerID is the Site audit (UI-SPEC C23).
const IssuesControllerID = "golem15.seo.issues"
// The Site audit's actions: the toolbar's start, the record actions and
// the bulk ignore.
const (
ActionStartCrawl = "start_crawl"
IssueActionCheck = "recheck"
IssueActionIgnore = "ignore"
IssueActionShow = "unignore"
)
// errIssueReadOnly refuses creating or editing issues: the audit writes
// them.
var errIssueReadOnly = errors.New("golem15.seo: issues are read-only")
// issuesAdminController lists the audit's current issues, errors first,
// and previews one with P7 and its record actions (check again, ignore,
// stop ignoring); the toolbar starts a crawl and the bulk action ignores.
type issuesAdminController struct {
app func() *backpack.App
}
func (issuesAdminController) ID() string { return IssuesControllerID }
func (issuesAdminController) ModelName() string { return `Golem15\SEO\Models\Issue` }
func (issuesAdminController) ConfigDir() string { return "controllers/issues" }
func (issuesAdminController) NewRecord() any { return &models.Issue{} }
func (issuesAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// FormBeforeCreate refuses: the audit writes issues.
func (issuesAdminController) FormBeforeCreate(context.Context, any) error { return errIssueReadOnly }
// FormBeforeUpdate refuses: issues are never edited.
func (issuesAdminController) FormBeforeUpdate(context.Context, any) error { return errIssueReadOnly }
func (c issuesAdminController) service() *classes.Service {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil
}
return svc
}
// ListExtendQuery narrows the list to the current issues (the newest
// crawl's; before any crawl the CMS-data issues, checked now) and puts
// errors first, then warnings, then notices.
func (c issuesAdminController) ListExtendQuery(ctx context.Context, q *gorm.DB) *gorm.DB {
svc := c.service()
db, err := database(ctx, c.app)
if svc == nil || err != nil {
return q.Where("1 = 0")
}
latest, err := crawl.Current(ctx, svc, db)
if err != nil {
log.Warn("golem15.seo: site audit list", "error", err)
return q.Where("1 = 0")
}
return crawl.CurrentScope(q, latest).Order("golem15_seo_issues.rank ASC")
}
// startCrawl is the toolbar's Start crawl (also on Crawls): it refuses with
// crawls.already_running or crawls.no_site_url as an action error and
// toasts crawls.started.
func startCrawl(app func() *backpack.App) pact.AdminAction {
return pact.AdminAction{
Name: ActionStartCrawl,
Label: "golem15.seo::lang.crawls.start",
Permissions: []string{PermissionManage},
Run: func(ctx context.Context, _ pact.AdminActionInput) (pact.AdminActionResult, error) {
svc, err := classes.NewService(appOf(app))
if err != nil {
return pact.AdminActionResult{}, err
}
db, err := database(ctx, app)
if err != nil {
return pact.AdminActionResult{}, err
}
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
_, name, _ := svc.Actor(ctx, tx)
_, err := crawl.Start(ctx, svc, tx, models.TriggerAdmin, name)
return err
})
if err != nil {
return pact.AdminActionResult{}, startRefusal(ctx, app, err)
}
return pact.AdminActionResult{Message: "golem15.seo::lang.crawls.started"}, nil
},
}
}
// startRefusal turns a refused start into the admin's action error.
func startRefusal(ctx context.Context, app func() *backpack.App, err error) error {
tr := translator(app)
var running *crawl.AlreadyRunningError
switch {
case errors.As(err, &running):
return &cabana.ForbiddenError{Message: Phrase(ctx, tr, "golem15.seo::lang.crawls.already_running", map[string]string{"time": running.Since().UTC().Format("2006-01-02 15:04 UTC")})}
case errors.Is(err, crawl.ErrNoSiteURL):
return &cabana.ForbiddenError{Message: Phrase(ctx, tr, "golem15.seo::lang.crawls.no_site_url", nil)}
}
return err
}
// AdminActions is the toolbar's Start crawl.
func (c issuesAdminController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{startCrawl(c.app)}
}
// ignored reports whether a record is an ignored issue.
func (c issuesAdminController) ignored(ctx context.Context, record any) (bool, error) {
issue, ok := record.(*models.Issue)
if !ok || issue == nil {
return false, nil
}
db, err := database(ctx, c.app)
if err != nil {
return false, err
}
return crawl.IsIgnored(db, *issue)
}
// AdminRecordActions are the preview's Check this address again and Ignore
// (while open) and Stop ignoring (while ignored).
func (c issuesAdminController) AdminRecordActions() []pact.AdminRecordAction {
open := func(ctx context.Context, record any) (bool, error) {
ig, err := c.ignored(ctx, record)
return !ig, err
}
return []pact.AdminRecordAction{
{
Name: IssueActionCheck,
Label: "golem15.seo::lang.issues.recheck",
Confirm: "golem15.seo::lang.issues.recheck_confirm",
Permissions: []string{PermissionManage},
Applies: open,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
issue, ok := in.Record.(*models.Issue)
svc := c.service()
if !ok || issue == nil || svc == nil {
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
res, err := crawl.Recheck(ctx, svc, db, *issue)
if err != nil {
if errors.Is(err, crawl.ErrNoSiteURL) {
return pact.AdminRecordActionResult{}, &cabana.ForbiddenError{Message: Phrase(ctx, translator(c.app), "golem15.seo::lang.crawls.no_site_url", nil)}
}
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: RecheckMessage(ctx, svc, res)}, nil
},
},
{
Name: IssueActionIgnore,
Label: "golem15.seo::lang.issues.ignore",
Confirm: "golem15.seo::lang.issues.ignore_confirm",
Permissions: []string{PermissionManage},
Applies: open,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return c.ignore(ctx, []any{in.Record})
},
},
{
Name: IssueActionShow,
Label: "golem15.seo::lang.issues.unignore",
Confirm: "golem15.seo::lang.issues.unignore_confirm",
Permissions: []string{PermissionManage},
Applies: c.ignored,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
issue, ok := in.Record.(*models.Issue)
if !ok || issue == nil {
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
n, err := crawl.Unignore(db, []models.Issue{*issue})
if err != nil {
return pact.AdminRecordActionResult{}, err
}
svc := c.service()
return pact.AdminRecordActionResult{Message: svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.unignored", n, nil)}, nil
},
},
}
}
// RecheckMessage is the toast of a recheck: issues.rechecked with the
// remaining count, or issues.recheck_fixed.
func RecheckMessage(ctx context.Context, svc *classes.Service, res crawl.RecheckResult) string {
if res.Remaining == 0 {
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.recheck_fixed", nil)
}
return svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.rechecked", res.Remaining, nil)
}
// ignore stores the fingerprints of records as ignored.
func (c issuesAdminController) ignore(ctx context.Context, records []any) (pact.AdminRecordActionResult, error) {
svc := c.service()
db, err := database(ctx, c.app)
if err != nil || svc == nil {
return pact.AdminRecordActionResult{}, errors.Join(err, classes.ErrNotBooted)
}
var list []models.Issue
for _, r := range records {
if issue, ok := r.(*models.Issue); ok && issue != nil {
list = append(list, *issue)
}
}
adminID, _, _ := svc.Actor(ctx, db)
n, err := crawl.Ignore(db, list, adminID)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.ignored", n, nil)}, nil
}
// AdminBulkActions is the list's bulk Ignore issues.
func (c issuesAdminController) AdminBulkActions() []pact.AdminBulkAction {
return []pact.AdminBulkAction{{
Name: IssueActionIgnore,
Label: "golem15.seo::lang.issues.ignore_selected",
Confirm: "golem15.seo::lang.issues.ignore_selected_confirm",
Permissions: []string{PermissionManage},
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
res, err := c.ignore(ctx, in.Records)
if err != nil {
return pact.AdminBulkActionResult{}, err
}
return pact.AdminBulkActionResult{Message: res.Message, Affected: len(in.Records)}, nil
},
}}
}
// ListRowStates marks ignored issues disabled (issues.state_ignored).
func (issuesAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
var fps []string
for _, r := range records {
if issue, ok := r.(*models.Issue); ok && issue != nil {
fps = append(fps, issue.Fingerprint)
}
}
set, err := crawl.IgnoredSet(db.WithContext(ctx), fps)
if err != nil {
return nil, err
}
out := make([][]pact.RowState, len(records))
for i, r := range records {
if issue, ok := r.(*models.Issue); ok && issue != nil && set[issue.Fingerprint] {
out[i] = []pact.RowState{pact.RowStateDisabled}
}
}
return out, nil
}
// labelKeys are the phrase keys of the stored severity and source values.
var labelKeys = map[string]string{
models.SeverityError: "golem15.seo::lang.issues.sev_error",
models.SeverityWarning: "golem15.seo::lang.issues.sev_warning",
models.SeverityNotice: "golem15.seo::lang.issues.sev_notice",
models.SourceCrawl: "golem15.seo::lang.issues.source_crawl",
models.SourceCMS: "golem15.seo::lang.issues.source_cms",
}
// FixCell is the Fix column: issues.fix_tool with the tool, or
// issues.fix_code.
func FixCell(ctx context.Context, svc *classes.Service, issue models.Issue) string {
if issue.Fix == models.FixTool {
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_tool", map[string]string{"tool": issue.Tool})
}
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_code", nil)
}
// ListExtendRecords shows the severity, kind and source labels, the
// one-line summary and the fix route in the request locale.
func (c issuesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
svc := c.service()
if svc == nil {
return nil
}
for _, r := range records {
issue, ok := r.(*models.Issue)
if !ok || issue == nil {
continue
}
issue.Summary = svc.IssueSummary(ctx, issue.Kind, issue.DetailsMap())
issue.FixCell = FixCell(ctx, svc, *issue)
issue.KindCell = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.kind_"+issue.Kind, nil)
if key, ok := labelKeys[issue.Severity]; ok {
issue.Severity = svc.PhraseCtx(ctx, key, nil)
}
if key, ok := labelKeys[issue.Source]; ok {
issue.Source = svc.PhraseCtx(ctx, key, nil)
}
}
return nil
}
// DropdownOptions labels the preview's read-only kind, severity and
// source fields.
func (issuesAdminController) DropdownOptions(field string) []pact.Option {
switch field {
case "kind":
out := make([]pact.Option, 0, len(crawl.Kinds()))
for _, k := range crawl.Kinds() {
out = append(out, pact.Option{Value: k, Label: "golem15.seo::lang.issues.kind_" + k})
}
return out
case "severity":
return []pact.Option{
{Value: models.SeverityError, Label: labelKeys[models.SeverityError]},
{Value: models.SeverityWarning, Label: labelKeys[models.SeverityWarning]},
{Value: models.SeverityNotice, Label: labelKeys[models.SeverityNotice]},
}
case "source":
return []pact.Option{
{Value: models.SourceCrawl, Label: labelKeys[models.SourceCrawl]},
{Value: models.SourceCMS, Label: labelKeys[models.SourceCMS]},
}
}
return nil
}
// FilterOptions serves the severity, kind, source, fix and status filters.
func (c issuesAdminController) FilterOptions(scope string) []pact.Option {
switch scope {
case models.FilterIssueSeverity:
return c.DropdownOptions("severity")
case models.FilterIssueKind:
return c.DropdownOptions("kind")
case models.FilterIssueSource:
return c.DropdownOptions("source")
case models.FilterIssueFix:
return []pact.Option{
{Value: models.FixTool, Label: "golem15.seo::lang.issues.filter_fix_tool"},
{Value: models.FixCode, Label: "golem15.seo::lang.issues.filter_fix_code"},
}
case models.FilterIssueStatus:
return []pact.Option{
{Value: models.IssueOpen, Label: "golem15.seo::lang.issues.status_open"},
{Value: models.IssueIgnored, Label: "golem15.seo::lang.issues.status_ignored"},
}
}
return nil
}
// maxLinkedShown caps P7's linking pages before issues.and_more.
const maxLinkedShown = 10
// PartialData serves P7 issue_details (UI-SPEC C23).
func (c issuesAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
issue, ok := record.(*models.Issue)
svc := c.service()
if name != "issue_details" || !ok || issue == nil || svc == nil {
return classes.IssueDetailsView{}, nil
}
return IssueDetails(ctx, svc, c.app, *issue), nil
}
// IssueDetails builds P7: the kind's found rows, the linking pages, the
// orphan suggestions, how to fix it and the page address.
func IssueDetails(ctx context.Context, svc *classes.Service, app func() *backpack.App, issue models.Issue) classes.IssueDetailsView {
d := issue.DetailsMap()
view := classes.IssueDetailsView{}
label := func(key string) string { return svc.PhraseCtx(ctx, "golem15.seo::lang.issues."+key, nil) }
view.Rows = append(view.Rows, classes.IssueRow{Label: label("summary"), Value: svc.IssueSummary(ctx, issue.Kind, d)})
add := func(labelKey string, value any, mono bool) {
if value == nil {
return
}
text := fmt.Sprint(value)
if list, ok := value.([]any); ok {
parts := make([]string, 0, len(list))
for _, e := range list {
switch x := e.(type) {
case map[string]any:
var kv []string
for _, k := range []string{"type", "property", "text", "target", "locale", "url", "status", "from", "to"} {
if v, ok := x[k]; ok {
kv = append(kv, fmt.Sprint(v))
}
}
parts = append(parts, strings.Join(kv, " · "))
default:
parts = append(parts, fmt.Sprint(x))
}
}
text = strings.Join(parts, "; ")
}
if strings.TrimSpace(text) == "" {
return
}
view.Rows = append(view.Rows, classes.IssueRow{Label: label(labelKey), Value: text, Mono: mono})
}
add("detail_h1", d["h1"], false)
add("detail_skips", d["skips"], false)
add("detail_missing", d["missing"], false)
add("detail_error", d["error"], true)
add("detail_anchors", d["anchors"], false)
add("detail_alternates", d["alternates"], true)
add("detail_images", d["images"], true)
add("detail_target", d["target"], true)
add("detail_what", d["what"], false)
if from, ok := d["from"].([]any); ok && issue.Kind != classes.IssueImageAlt {
if _, isLevel := d["to"]; !isLevel {
for i, f := range from {
if i >= maxLinkedShown {
break
}
view.LinkedFrom = append(view.LinkedFrom, classes.IssueLink{URL: fmt.Sprint(f)})
}
total := len(from)
if n, ok := d["count"].(float64); ok && int(n) > total {
total = int(n)
}
if total > len(view.LinkedFrom) {
view.More = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.and_more", map[string]string{"count": fmt.Sprint(total - len(view.LinkedFrom))})
}
}
}
if issue.Kind == crawl.KindOrphanPage {
view.Orphan = true
if list, ok := d["suggestions"].([]any); ok {
for _, e := range list {
s, _ := e.(map[string]any)
u := fmt.Sprint(s["url"])
view.Suggestions = append(view.Suggestions, classes.IssueLink{URL: u, Phrase: fmt.Sprint(s["phrase"])})
}
}
}
if issue.Fix == models.FixTool {
view.FixText = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_tool_text", map[string]string{"tool": issue.Tool, "where": fixWhere(ctx, svc, issue)})
} else {
what, _ := d["what"].(string)
view.FixText = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_code_text", map[string]string{"what": what})
}
site := svc.SiteURL()
if strings.HasPrefix(issue.URL, "/") && site != "" {
view.PageURL = site + issue.URL
}
if strings.HasPrefix(issue.Kind, "jsonld_") && view.PageURL != "" {
view.RichResults = true
view.RichURL = "https://search.google.com/test/rich-results?url=" + view.PageURL
}
return view
}
// fixWhere names the admin screen where a CMS tool's value is changed.
func fixWhere(ctx context.Context, svc *classes.Service, issue models.Issue) string {
key := "golem15.seo::lang.issues.where_record"
switch issue.Tool {
case crawl.ToolUpdateRobots:
key = "golem15.seo::lang.issues.where_robots"
case crawl.ToolUpdateSettings:
key = "golem15.seo::lang.issues.where_settings"
case crawl.ToolCreateRedirect, "seo_update_redirect":
key = "golem15.seo::lang.issues.where_redirects"
case crawl.ToolCreatePage, crawl.ToolUpdatePage:
key = "golem15.seo::lang.issues.where_pages"
case "seo_update_rule":
key = "golem15.seo::lang.issues.where_rules"
}
return svc.PhraseCtx(ctx, key, nil)
}
var (
_ pact.AdminController = issuesAdminController{}
_ pact.AdminPermissioned = issuesAdminController{}
_ pact.AdminRecordSource = issuesAdminController{}
_ pact.FormBeforeCreate = issuesAdminController{}
_ pact.FormBeforeUpdate = issuesAdminController{}
_ pact.ListExtendQuery = issuesAdminController{}
_ pact.HasAdminActions = issuesAdminController{}
_ pact.HasAdminRecordActions = issuesAdminController{}
_ pact.HasAdminBulkActions = issuesAdminController{}
_ pact.ListRowStates = issuesAdminController{}
_ pact.ListExtendRecords = issuesAdminController{}
_ pact.DropdownOptionsProvider = issuesAdminController{}
_ pact.FilterOptions = issuesAdminController{}
_ pact.AdminPartialData = issuesAdminController{}
)