package models import ( "fmt" "strconv" "strings" "time" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" ) // The kinds of a registry page (D-33): they decide the sitemap file group // and the default structured data. const ( PageKindPage = "page" PageKindOffer = "offer" PageKindService = "service" PageKindLanding = "landing" ) // PageKinds lists the kinds in form order. var PageKinds = []string{PageKindPage, PageKindOffer, PageKindService, PageKindLanding} // The origins of a registry page: entered in the admin, reported by the // frontend server's route report, or created through MCP. const ( PageOriginManual = "manual" PageOriginReported = "reported" PageOriginMCP = "mcp" ) // PageOrigins lists the origins in filter order. var PageOrigins = []string{PageOriginManual, PageOriginReported, PageOriginMCP} // Page is one frontend page of the page registry on golem15_seo_pages // (D-33): a page the site serves itself (home, contact, an offer), with // its address per locale, a kind, a name, a parent, the date its content // last changed and a note. URL is the default-locale path; URLs holds the // other locales' paths and Titles the other locales' names (a locale // without a path has no version of the page). ReportedAt is the newest // route report that named the page. type Page struct { ID uint `gorm:"column:id;primaryKey"` URL string `gorm:"column:url"` URLs lagoon.Jsonable[map[string]string] `gorm:"column:urls"` Title string `gorm:"column:title"` Titles lagoon.Jsonable[map[string]string] `gorm:"column:titles"` Kind string `gorm:"column:kind"` Origin string `gorm:"column:origin"` ParentID *PageRef `gorm:"column:parent_id"` ContentUpdatedAt *lagoon.Date `gorm:"column:content_updated_at"` ReportedAt *time.Time `gorm:"column:reported_at"` Note string `gorm:"column:note"` CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime:false"` UpdatedAt *time.Time `gorm:"column:updated_at;autoUpdateTime:false"` } // BeforeSave stamps UpdatedAt (and CreatedAt of a new row) in UTC on every // save, the admin's and the API's. GORM's own timestamps use the process's // local time, which a timestamp column stores as the wall clock: on a // machine east of UTC that put the page's sitemap lastmod in the future. func (p *Page) BeforeSave(*gorm.DB) error { now := time.Now().UTC() if p.CreatedAt == nil || p.CreatedAt.IsZero() { p.CreatedAt = &now } p.UpdatedAt = &now return nil } func (Page) TableName() string { return "golem15_seo_pages" } // MorphName names a registry page in history. func (Page) MorphName() string { return `Golem15\SEO\Models\Page` } // Fillable lists the owner form's columns; the addresses and names per // locale are saved by the plugin's form extension on the Pages screen. func (*Page) Fillable() []string { return []string{"kind", "parent_id", "content_updated_at", "note"} } // Rules is empty: the Pages controller and the registry service check a // page before a save (classes.CheckPage). func (*Page) Rules() map[string]string { return map[string]string{} } // NewPage is a page with the form's defaults: kind page, entered by hand, // its content changed today. func NewPage() Page { today := lagoon.DateOf(time.Now().UTC()) return Page{ URLs: lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}, Titles: lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}, Kind: PageKindPage, Origin: PageOriginManual, ContentUpdatedAt: &today, } } // LocaleURLs returns the page's path per locale: def (the default locale) // with URL, then every other locale with a non-empty path. func (p Page) LocaleURLs(def string) map[string]string { out := map[string]string{} for locale, path := range p.URLs.Get() { if path = strings.TrimSpace(path); path != "" && locale != def { out[locale] = path } } if u := strings.TrimSpace(p.URL); u != "" { out[def] = u } return out } // TitleIn returns the page's name in locale, the default-locale name when // that locale has none. func (p Page) TitleIn(locale string) string { if t := strings.TrimSpace(p.Titles.Get()[locale]); t != "" { return t } return p.Title } // PageRef is a registry page id held by another page (its parent). The // admin's parent dropdown posts it as text, which UnmarshalText reads; "" // is 0, the top level. type PageRef uint // UnmarshalText reads a page id written as text. func (r *PageRef) UnmarshalText(b []byte) error { text := strings.TrimSpace(string(b)) if text == "" { *r = 0 return nil } n, err := strconv.ParseUint(text, 10, 64) if err != nil { return fmt.Errorf("models: page id %q is not a number", b) } *r = PageRef(n) return nil } // Parent returns the parent page's id, 0 at the top level. func (p Page) Parent() uint { if p.ParentID == nil { return 0 } return uint(*p.ParentID) } // IDText is the page's id as text. func (p Page) IDText() string { return strconv.FormatUint(uint64(p.ID), 10) } // KnownPageKind reports whether kind is one of PageKinds. func KnownPageKind(kind string) bool { for _, k := range PageKinds { if k == kind { return true } } return false } // The filter scopes of the Pages list (UI-SPEC C21). const ( FilterPageKind = "FilterPageKind" FilterPageOrigin = "FilterPageOrigin" FilterPageMissing = "FilterPageMissing" MissingOnly = "only" MissingHide = "hide" ) // FilterScopes lists the Pages list's filter scopes. func (Page) FilterScopes() []string { return []string{FilterPageKind, FilterPageOrigin, FilterPageMissing} } // MissingSQL is true for a reported page the newest route report did not // name: its reported_at is older than the settings' routes_reported_at. const MissingSQL = `(golem15_seo_pages.origin = 'reported' AND EXISTS (SELECT 1 FROM golem15_seo_settings s WHERE s.id = 1 AND s.routes_reported_at IS NOT NULL AND (golem15_seo_pages.reported_at IS NULL OR golem15_seo_pages.reported_at < s.routes_reported_at)))` // FilterScope narrows the Pages list; an unknown value matches nothing. func (Page) FilterScope(name string, db *gorm.DB, value any) *gorm.DB { if db == nil { return db } s, _ := value.(string) switch name { case FilterPageKind: if !KnownPageKind(s) { return db.Where("1 = 0") } return db.Where("golem15_seo_pages.kind = ?", s) case FilterPageOrigin: for _, o := range PageOrigins { if o == s { return db.Where("golem15_seo_pages.origin = ?", s) } } return db.Where("1 = 0") case FilterPageMissing: switch s { case MissingOnly: return db.Where(MissingSQL) case MissingHide: return db.Where("NOT " + MissingSQL) } return db.Where("1 = 0") } return db } func init() { Register(Page{}) }