Files
sm-seo-plugin/models/page.go
Jakub Zych dfc85bc805 fix: store registry page timestamps in UTC
- GORM stamped created_at and updated_at in the process's local time,
  which the timestamp columns keep as the wall clock; east of UTC a page
  without a content date got a sitemap lastmod in the future
- Page.BeforeSave stamps both in UTC for the admin's and the API's saves,
  and the admin's address change writes updated_at in UTC
2026-11-04 19:24:15 +01:00

217 lines
7.0 KiB
Go

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