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{} )