Files
sm-seo-plugin/models/issue.go
Jakub Zych c2b61bf8e9 feat: crawler finds a page with two H1 headings and stores the issue (tracer)
- golem15_seo_crawls, crawl pages, links, issues and ignores (P33-37)
- classes/crawl: fetchguard client on the site host and its www twin,
  sitemap index and robots.txt seeds, two workers with a delay, limits
- h1_missing and h1_multiple with the P33-38 fix route
- POST /_seo/api/v1/crawls queues the golem15.seo.crawl job; GET /issues
  answers the stored issues with id, severity, source, fix, found_at,
  ignored and crawl_id
2026-11-04 15:05:45 +01:00

157 lines
4.9 KiB
Go

package models
import (
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// Where a stored issue was found (UI-SPEC C23).
const (
SourceCrawl = "crawl"
SourceCMS = "cms"
)
// How an issue is fixed (P33-38): through an MCP tool that changes CMS
// data, or in the site's template code.
const (
FixTool = "tool"
FixCode = "code"
)
// The severities of an issue and their sort rank (errors first).
const (
SeverityError = "error"
SeverityWarning = "warning"
SeverityNotice = "notice"
)
// SeverityRank orders the severities: error 1, warning 2, notice 3.
func SeverityRank(severity string) int {
switch severity {
case SeverityError:
return 1
case SeverityWarning:
return 2
}
return 3
}
// Issue is one stored finding of the site audit on golem15_seo_issues
// (P33-37): the crawl it belongs to (nil for CMS-data issues stored before
// any crawl), where it was found, its kind and severity, the address and
// locale, the kind's details, the fixing MCP tool or fix code, and the
// fingerprint an ignore is keyed by.
type Issue struct {
ID uint `gorm:"column:id;primaryKey"`
CrawlID *uint `gorm:"column:crawl_id"`
Source string `gorm:"column:source"`
Kind string `gorm:"column:kind"`
Severity string `gorm:"column:severity"`
Rank int `gorm:"column:rank"`
URL string `gorm:"column:url"`
Locale string `gorm:"column:locale"`
Details lagoon.Jsonable[map[string]any] `gorm:"column:details"`
Tool string `gorm:"column:tool"`
Fix string `gorm:"column:fix"`
Fingerprint string `gorm:"column:fingerprint"`
FoundAt *time.Time `gorm:"column:found_at"`
// The list's values, filled by the Site audit controller's
// ListExtendRecords and never saved.
Summary string `gorm:"-:all;column:summary"`
FixCell string `gorm:"-:all;column:fix_cell"`
KindCell string `gorm:"-:all;column:kind_cell"`
}
func (Issue) TableName() string { return "golem15_seo_issues" }
// MorphName names an issue in the admin.
func (Issue) MorphName() string { return `Golem15\SEO\Models\Issue` }
// Fillable is empty: issues are written by the audit only.
func (Issue) Fillable() []string { return []string{} }
// Rules is empty: nothing is edited.
func (Issue) Rules() map[string]string { return map[string]string{} }
// DetailsMap returns the issue's details, never nil.
func (i Issue) DetailsMap() map[string]any {
if d := i.Details.Get(); d != nil {
return d
}
return map[string]any{}
}
// IssueIgnore is one ignored fingerprint on golem15_seo_issue_ignores: an
// issue with this fingerprint stays hidden in later crawls until it is no
// longer ignored (UI-SPEC C23).
type IssueIgnore struct {
ID uint `gorm:"column:id;primaryKey"`
Fingerprint string `gorm:"column:fingerprint"`
AdminID *uint `gorm:"column:admin_id"`
IgnoredAt *time.Time `gorm:"column:ignored_at"`
}
func (IssueIgnore) TableName() string { return "golem15_seo_issue_ignores" }
// The Site audit list's filter scopes and the status and fix values.
const (
FilterIssueSeverity = "FilterIssueSeverity"
FilterIssueKind = "FilterIssueKind"
FilterIssueSource = "FilterIssueSource"
FilterIssueFix = "FilterIssueFix"
FilterIssueStatus = "FilterIssueStatus"
IssueOpen = "open"
IssueIgnored = "ignored"
)
// IgnoredSQL is true for an issue whose fingerprint is ignored.
const IgnoredSQL = `EXISTS (SELECT 1 FROM golem15_seo_issue_ignores AS ig WHERE ig.fingerprint = golem15_seo_issues.fingerprint)`
// FilterScopes lists the Site audit list's filter scopes.
func (Issue) FilterScopes() []string {
return []string{FilterIssueSeverity, FilterIssueKind, FilterIssueSource, FilterIssueFix, FilterIssueStatus}
}
// FilterScope narrows the Site audit list; an unknown value matches
// nothing.
func (Issue) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil {
return db
}
s, _ := value.(string)
switch name {
case FilterIssueSeverity:
if s == SeverityError || s == SeverityWarning || s == SeverityNotice {
return db.Where("golem15_seo_issues.severity = ?", s)
}
case FilterIssueKind:
if s != "" {
return db.Where("golem15_seo_issues.kind = ?", s)
}
case FilterIssueSource:
if s == SourceCrawl || s == SourceCMS {
return db.Where("golem15_seo_issues.source = ?", s)
}
case FilterIssueFix:
if s == FixTool || s == FixCode {
return db.Where("golem15_seo_issues.fix = ?", s)
}
case FilterIssueStatus:
switch s {
case IssueOpen:
return db.Where("NOT " + IgnoredSQL)
case IssueIgnored:
return db.Where(IgnoredSQL)
}
default:
return db
}
return db.Where("1 = 0")
}
func init() {
Register(Issue{}, IssueIgnore{})
}