- 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
217 lines
7.0 KiB
Go
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{})
|
|
}
|