feat: page registry with its admin screen and signpost type
- golem15_seo_pages (migration 202610280002): the site's own frontend pages
with an address and a name per locale, kind, origin, parent, content date
and note; settings routes_reported_at
- SEO > Pages (golem15.seo.pages) after URL rules: list with kind, origin
and missing filters and the missing row state, form with the P9 partial;
the plugin's form extension saves the address and name per locale and
refuses url_required, url_invalid, url_taken and url_owned
- menu item type seo-page with a content descriptor, so each page gets
the SEO tab, resolves ok and is listed in the sitemaps grouped by kind
(pages, offers, services, landing-pages, titled per locale)
- management API GET/POST /_seo/api/v1/pages, PUT/DELETE /pages/{id} and
the MCP tools seo_list_pages, seo_create_page, seo_update_page,
seo_delete_page (in this commit because the operations parity test maps
every Pages operation to its tool)
This commit is contained in:
733
classes/registry.go
Normal file
733
classes/registry.go
Normal file
@@ -0,0 +1,733 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The page registry (D-33): the site's own frontend pages (home, contact,
|
||||
// offer pages) as a signpost content type of the SEO plugin, so each page
|
||||
// gets the SEO tab, resolves ok and is listed in the sitemaps.
|
||||
const (
|
||||
// PageTypeKey is the registry's menu item type.
|
||||
PageTypeKey = "seo-page"
|
||||
// PagesController is the Pages screen's admin controller.
|
||||
PagesController = "golem15.seo.pages"
|
||||
// PagePattern is the registry type's pattern. A registry page stores
|
||||
// its own address per locale, so the pattern only satisfies signpost.
|
||||
PagePattern = "/:page"
|
||||
// PageLabel is the registry type's label (a content type's label names
|
||||
// its tab in the SEO settings, so it is not the plain "Page" another
|
||||
// content type may use).
|
||||
PageLabel = "golem15.seo::lang.pages.type"
|
||||
)
|
||||
|
||||
// PageModel is the registry page's morph type, the model its SEO entries
|
||||
// and record events name.
|
||||
var PageModel = models.Page{}.MorphName()
|
||||
|
||||
// The route report's limits (P33-20).
|
||||
const (
|
||||
MaxReportedRoutes = 2000
|
||||
MaxRouteBytes = 2048
|
||||
)
|
||||
|
||||
// PageOrigin is the origin of a page created through the management API:
|
||||
// mcp through MCP, else manual.
|
||||
func PageOrigin(ctx context.Context) string {
|
||||
if ViaOf(ctx) == models.ViaMCP {
|
||||
return models.PageOriginMCP
|
||||
}
|
||||
return models.PageOriginManual
|
||||
}
|
||||
|
||||
// The phrase keys of the registry refusals (UI-SPEC C21).
|
||||
const (
|
||||
errPageURLRequired = "golem15.seo::lang.pages.url_required"
|
||||
errPageURLInvalid = "golem15.seo::lang.pages.url_invalid"
|
||||
errPageURLTaken = "golem15.seo::lang.pages.url_taken"
|
||||
errPageURLOwned = "golem15.seo::lang.pages.url_owned"
|
||||
errPageParentLoop = "golem15.seo::lang.pages.parent_loop"
|
||||
errValueInvalid = "golem15.seo::lang.api.value_invalid"
|
||||
)
|
||||
|
||||
// pageGroups are the sitemap file groups of the page kinds (P33-18).
|
||||
var pageGroups = map[string]string{
|
||||
models.PageKindPage: "pages",
|
||||
models.PageKindOffer: "offers",
|
||||
models.PageKindService: "services",
|
||||
models.PageKindLanding: "landing-pages",
|
||||
}
|
||||
|
||||
// PageKindLabels are the phrase keys of the page kinds.
|
||||
var PageKindLabels = map[string]string{
|
||||
models.PageKindPage: "golem15.seo::lang.pages.kind_page",
|
||||
models.PageKindOffer: "golem15.seo::lang.pages.kind_offer",
|
||||
models.PageKindService: "golem15.seo::lang.pages.kind_service",
|
||||
models.PageKindLanding: "golem15.seo::lang.pages.kind_landing",
|
||||
}
|
||||
|
||||
// PageGroup is the sitemap file group of a page kind; an unknown kind is
|
||||
// listed with the pages.
|
||||
func PageGroup(kind string) string {
|
||||
if g, ok := pageGroups[kind]; ok {
|
||||
return g
|
||||
}
|
||||
return pageGroups[models.PageKindPage]
|
||||
}
|
||||
|
||||
// LoadPage returns registry page id; ErrNoRecord when there is none.
|
||||
func LoadPage(db *gorm.DB, id uint) (models.Page, error) {
|
||||
var rows []models.Page
|
||||
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return models.Page{}, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return models.Page{}, ErrNoRecord
|
||||
}
|
||||
return rows[0], nil
|
||||
}
|
||||
|
||||
// pageByID reads a registry page by its id as text, nil when none.
|
||||
func pageByID(db *gorm.DB, id string) (*models.Page, error) {
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64)
|
||||
if err != nil || n == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
p, err := LoadPage(db, uint(n))
|
||||
if errors.Is(err, ErrNoRecord) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// PageAt returns the registry page whose address in any locale is path,
|
||||
// with that locale; nil when none.
|
||||
func (s *Service) PageAt(ctx context.Context, db *gorm.DB, path string) (*models.Page, string, error) {
|
||||
var rows []models.Page
|
||||
if err := db.Where("url = ?", path).Limit(1).Find(&rows).Error; err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
return &rows[0], s.DefaultLocale(ctx, db), nil
|
||||
}
|
||||
var hit []struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
Locale string `gorm:"column:locale"`
|
||||
}
|
||||
if err := db.Raw(`SELECT p.id, e.key AS locale FROM golem15_seo_pages p, jsonb_each_text(p.urls) e WHERE e.value = ? ORDER BY p.id LIMIT 1`, path).Scan(&hit).Error; err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(hit) == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
p, err := LoadPage(db, hit[0].ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if def := s.DefaultLocale(ctx, db); hit[0].Locale == def {
|
||||
return nil, "", nil
|
||||
}
|
||||
return &p, hit[0].Locale, nil
|
||||
}
|
||||
|
||||
// PageRecord describes a registry page as a signpost content record: its
|
||||
// address and name per locale (the default locale first, then the other
|
||||
// enabled locales with an address), its content date as lastmod and its
|
||||
// parent page.
|
||||
func (s *Service) PageRecord(ctx context.Context, db *gorm.DB, p models.Page) pact.ContentRecord {
|
||||
def := s.DefaultLocale(ctx, db)
|
||||
urls := p.LocaleURLs(def)
|
||||
rec := pact.ContentRecord{ID: p.IDText(), Published: true, LastMod: p.UpdatedAt}
|
||||
if p.ContentUpdatedAt != nil && !p.ContentUpdatedAt.IsZero() {
|
||||
day := p.ContentUpdatedAt.Time(time.UTC)
|
||||
rec.LastMod = &day
|
||||
}
|
||||
for _, locale := range s.pageLocales(ctx, db, urls) {
|
||||
rec.Locales = append(rec.Locales, pact.ContentLocale{Locale: locale, URL: urls[locale], Title: p.TitleIn(locale)})
|
||||
}
|
||||
if p.ParentID != nil && *p.ParentID != 0 {
|
||||
rec.Parents = []pact.ContentRef{{Type: PageTypeKey, ID: strconv.FormatUint(uint64(*p.ParentID), 10)}}
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// pageLocales orders the locales of urls: the site's locales first (the
|
||||
// default one leading), then any other stored locale by code.
|
||||
func (s *Service) pageLocales(ctx context.Context, db *gorm.DB, urls map[string]string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, l := range s.Locales(ctx, db) {
|
||||
if urls[l] != "" && !seen[l] {
|
||||
out = append(out, l)
|
||||
seen[l] = true
|
||||
}
|
||||
}
|
||||
var rest []string
|
||||
for l := range urls {
|
||||
if !seen[l] {
|
||||
rest = append(rest, l)
|
||||
}
|
||||
}
|
||||
sort.Strings(rest)
|
||||
return append(out, rest...)
|
||||
}
|
||||
|
||||
// PageMenuItemType is the registry's menu item type seo-page: a menu item
|
||||
// may point at a registry page, and its content descriptor lists, describes
|
||||
// and matches the pages for signpost's consumers (the SEO tab, the
|
||||
// resolver, the sitemaps). service is asked on every call.
|
||||
func PageMenuItemType(service func() *Service) pact.MenuItemType {
|
||||
svc := func() (*Service, error) {
|
||||
if service == nil {
|
||||
return nil, ErrNotBooted
|
||||
}
|
||||
s := service()
|
||||
if s == nil {
|
||||
return nil, ErrNotBooted
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
return pact.MenuItemType{
|
||||
Key: PageTypeKey,
|
||||
Label: PageLabel,
|
||||
DefaultPattern: PagePattern,
|
||||
References: func(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
s, err := svc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.pageReferences(ctx)
|
||||
},
|
||||
Resolve: func(ctx context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
s, err := svc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.resolvePageItem(ctx, in.Reference)
|
||||
},
|
||||
Content: &pact.ContentDescriptor{
|
||||
Controller: PagesController,
|
||||
Model: PageModel,
|
||||
Kind: pact.ContentKindPage,
|
||||
Records: func(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
s, err := svc()
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
return s.pageRecords(ctx, in)
|
||||
},
|
||||
Describe: func(ctx context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
|
||||
s, err := svc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := pageByID(db, in.ID)
|
||||
if err != nil || p == nil {
|
||||
return nil, err
|
||||
}
|
||||
rec := s.PageRecord(ctx, db, *p)
|
||||
return &rec, nil
|
||||
},
|
||||
Match: func(ctx context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
|
||||
s, err := svc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, locale, err := s.PageAt(ctx, db, in.Path)
|
||||
if err != nil || p == nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pact.ContentMatchResult{ID: p.IDText(), Locale: locale, Exact: true}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pageRecords is one page of registry records by id.
|
||||
func (s *Service) pageRecords(ctx context.Context, in pact.ContentList) (pact.ContentPage, error) {
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
after, _ := strconv.ParseUint(strings.TrimSpace(in.After), 10, 64)
|
||||
var rows []models.Page
|
||||
if err := db.Where("id > ? AND url <> ''", after).Order("id ASC").Limit(limit).Find(&rows).Error; err != nil {
|
||||
return pact.ContentPage{}, err
|
||||
}
|
||||
out := pact.ContentPage{}
|
||||
for _, p := range rows {
|
||||
out.Records = append(out.Records, s.PageRecord(ctx, db, p))
|
||||
}
|
||||
if len(rows) == limit {
|
||||
out.Next = rows[len(rows)-1].IDText()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pageReferences lists the registry pages a menu item can point at, as a
|
||||
// tree by parent, each level by address.
|
||||
func (s *Service) pageReferences(ctx context.Context) ([]pact.MenuItemReference, error) {
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []models.Page
|
||||
if err := db.Where("url <> ''").Order("url ASC").Limit(5000).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children := map[uint][]models.Page{}
|
||||
known := map[uint]bool{}
|
||||
for _, p := range rows {
|
||||
known[p.ID] = true
|
||||
}
|
||||
for _, p := range rows {
|
||||
parent := p.Parent()
|
||||
if !known[parent] {
|
||||
parent = 0
|
||||
}
|
||||
children[parent] = append(children[parent], p)
|
||||
}
|
||||
visited := map[uint]bool{}
|
||||
var build func(parent uint, depth int) []pact.MenuItemReference
|
||||
build = func(parent uint, depth int) []pact.MenuItemReference {
|
||||
var out []pact.MenuItemReference
|
||||
for _, p := range children[parent] {
|
||||
if visited[p.ID] || depth > 50 {
|
||||
continue
|
||||
}
|
||||
visited[p.ID] = true
|
||||
label := p.Title
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = p.URL
|
||||
}
|
||||
out = append(out, pact.MenuItemReference{Value: p.IDText(), Label: label, Items: build(p.ID, depth+1)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
return build(0, 0), nil
|
||||
}
|
||||
|
||||
// resolvePageItem resolves a menu item pointing at registry page id: its
|
||||
// default-locale address and name, and one alternate per locale.
|
||||
func (s *Service) resolvePageItem(ctx context.Context, id string) (*pact.ResolvedMenuItem, error) {
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := pageByID(db, id)
|
||||
if err != nil || p == nil {
|
||||
return nil, err
|
||||
}
|
||||
rec := s.PageRecord(ctx, db, *p)
|
||||
item := &pact.ResolvedMenuItem{Title: p.Title, URL: p.URL, MTime: rec.LastMod}
|
||||
for _, l := range rec.Locales {
|
||||
item.Alternates = append(item.Alternates, pact.MenuItemAlternate{Locale: l.Locale, URL: l.URL, Title: l.Title})
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// PageWrite is one change of a registry page through the API or MCP: a
|
||||
// nil field is left as it is. URL and Title map locales to text; a locale
|
||||
// sent empty removes that locale's address or name, and the "" key stands
|
||||
// for the default locale. ParentSet with a nil ParentID moves the page to
|
||||
// the top level.
|
||||
type PageWrite struct {
|
||||
URL map[string]string
|
||||
Title map[string]string
|
||||
Kind *string
|
||||
ParentSet bool
|
||||
ParentID *uint
|
||||
ContentUpdatedAt *string
|
||||
Note *string
|
||||
}
|
||||
|
||||
// ApplyPageTexts merges submitted addresses and names into p: def is the
|
||||
// default locale, whose values go to the url and title columns. Every
|
||||
// address is trimmed and normalised with the site's address rules, so it
|
||||
// is stored in the form the resolver matches.
|
||||
func (s *Service) ApplyPageTexts(ctx context.Context, db *gorm.DB, p *models.Page, urls, titles map[string]string) error {
|
||||
def := s.DefaultLocale(ctx, db)
|
||||
settings, err := LoadSettings(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts := NormalizeOptionsOf(settings)
|
||||
if urls != nil {
|
||||
others := map[string]string{}
|
||||
for l, v := range p.URLs.Get() {
|
||||
others[l] = v
|
||||
}
|
||||
for locale, path := range urls {
|
||||
if locale == "" {
|
||||
locale = def
|
||||
}
|
||||
path = normalPagePath(path, opts)
|
||||
if locale == def {
|
||||
p.URL = path
|
||||
continue
|
||||
}
|
||||
if path == "" {
|
||||
delete(others, locale)
|
||||
} else {
|
||||
others[locale] = path
|
||||
}
|
||||
}
|
||||
p.URLs = lagoon.Jsonable[map[string]string]{Data: others, Valid: true}
|
||||
}
|
||||
if titles != nil {
|
||||
others := map[string]string{}
|
||||
for l, v := range p.Titles.Get() {
|
||||
others[l] = v
|
||||
}
|
||||
for locale, title := range titles {
|
||||
title = strings.TrimSpace(title)
|
||||
if locale == "" {
|
||||
locale = def
|
||||
}
|
||||
if locale == def {
|
||||
p.Title = title
|
||||
continue
|
||||
}
|
||||
if title == "" {
|
||||
delete(others, locale)
|
||||
} else {
|
||||
others[locale] = title
|
||||
}
|
||||
}
|
||||
p.Titles = lagoon.Jsonable[map[string]string]{Data: others, Valid: true}
|
||||
}
|
||||
if !p.URLs.Valid {
|
||||
p.URLs = lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}
|
||||
}
|
||||
if !p.Titles.Valid {
|
||||
p.Titles = lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalPagePath trims an address and, when it is a valid path, applies
|
||||
// the site's address rules (lowercase, trailing slash) to it.
|
||||
func normalPagePath(path string, opts NormalizeOptions) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if !ValidPagePath(path) {
|
||||
return path
|
||||
}
|
||||
norm, _ := Normalize(&url.URL{Path: path}, opts)
|
||||
if norm.Path == "" {
|
||||
return path
|
||||
}
|
||||
return norm.Path
|
||||
}
|
||||
|
||||
// ValidPagePath reports whether path is a registry address: a path
|
||||
// starting with a single "/", without a domain, query or fragment, of at
|
||||
// most MaxRouteBytes bytes of valid UTF-8 without control characters.
|
||||
func ValidPagePath(path string) bool {
|
||||
if path == "" || len(path) > MaxRouteBytes || !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
|
||||
return false
|
||||
}
|
||||
if !utf8.ValidString(path) || strings.ContainsAny(path, "?#\\ ") {
|
||||
return false
|
||||
}
|
||||
for _, r := range path {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(path)
|
||||
return err == nil && !u.IsAbs() && u.Host == ""
|
||||
}
|
||||
|
||||
// ApplyPageFields sets the scalar fields of w on p; an invalid kind or
|
||||
// date is a problem.
|
||||
func ApplyPageFields(p *models.Page, w PageWrite) map[string][]models.Problem {
|
||||
problems := map[string][]models.Problem{}
|
||||
if w.Kind != nil {
|
||||
p.Kind = strings.TrimSpace(*w.Kind)
|
||||
}
|
||||
if w.ParentSet {
|
||||
p.ParentID = nil
|
||||
if w.ParentID != nil && *w.ParentID != 0 {
|
||||
ref := models.PageRef(*w.ParentID)
|
||||
p.ParentID = &ref
|
||||
}
|
||||
}
|
||||
if w.ContentUpdatedAt != nil {
|
||||
v := strings.TrimSpace(*w.ContentUpdatedAt)
|
||||
if v == "" {
|
||||
p.ContentUpdatedAt = nil
|
||||
} else if d, err := lagoon.ParseDate(v); err == nil {
|
||||
p.ContentUpdatedAt = &d
|
||||
} else {
|
||||
problems["content_updated_at"] = append(problems["content_updated_at"], models.Problem{Key: errValueInvalid})
|
||||
}
|
||||
}
|
||||
if w.Note != nil {
|
||||
p.Note = strings.TrimSpace(*w.Note)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// CheckPage lists the refusals of a registry page before a save (UI-SPEC
|
||||
// C21), keyed by field (url, kind, parent_id): a missing default-locale
|
||||
// address, an invalid address, an address another registry page uses, an
|
||||
// address another content type owns (naming the type and the record), an
|
||||
// unknown kind, and a parent that is unknown or would make a loop.
|
||||
func (s *Service) CheckPage(ctx context.Context, db *gorm.DB, p models.Page) (map[string][]models.Problem, error) {
|
||||
problems := map[string][]models.Problem{}
|
||||
add := func(field string, pr models.Problem) {
|
||||
for _, have := range problems[field] {
|
||||
if have.Key == pr.Key {
|
||||
return
|
||||
}
|
||||
}
|
||||
problems[field] = append(problems[field], pr)
|
||||
}
|
||||
if !models.KnownPageKind(p.Kind) {
|
||||
add("kind", models.Problem{Key: errValueInvalid})
|
||||
}
|
||||
def := s.DefaultLocale(ctx, db)
|
||||
if strings.TrimSpace(p.URL) == "" {
|
||||
add("url", models.Problem{Key: errPageURLRequired})
|
||||
}
|
||||
urls := p.LocaleURLs(def)
|
||||
var paths []string
|
||||
for _, locale := range s.pageLocales(ctx, db, urls) {
|
||||
path := urls[locale]
|
||||
if !ValidPagePath(path) {
|
||||
add("url", models.Problem{Key: errPageURLInvalid})
|
||||
continue
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
if len(paths) > 0 {
|
||||
var taken int64
|
||||
q := db.Model(&models.Page{}).Where(`(url IN ? OR EXISTS (SELECT 1 FROM jsonb_each_text(golem15_seo_pages.urls) e WHERE e.value IN ?))`, paths, paths)
|
||||
if p.ID != 0 {
|
||||
q = q.Where("id <> ?", p.ID)
|
||||
}
|
||||
if err := q.Count(&taken).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if taken > 0 {
|
||||
add("url", models.Problem{Key: errPageURLTaken})
|
||||
}
|
||||
for _, path := range paths {
|
||||
owner, err := s.ownerOutsideRegistry(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner != nil {
|
||||
add("url", *owner)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if parent := p.Parent(); parent != 0 {
|
||||
loop, known, err := parentLoop(db, p.ID, parent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case !known:
|
||||
add("parent_id", models.Problem{Key: errValueInvalid})
|
||||
case loop:
|
||||
add("parent_id", models.Problem{Key: errPageParentLoop})
|
||||
}
|
||||
}
|
||||
return problems, nil
|
||||
}
|
||||
|
||||
// parentLoop walks up from parent: loop is true when it reaches id (or
|
||||
// goes round), known false when parent does not exist.
|
||||
func parentLoop(db *gorm.DB, id, parent uint) (loop, known bool, err error) {
|
||||
seen := map[uint]bool{}
|
||||
cur := parent
|
||||
for depth := 0; cur != 0; depth++ {
|
||||
if (id != 0 && cur == id) || seen[cur] || depth > 100 {
|
||||
return true, true, nil
|
||||
}
|
||||
seen[cur] = true
|
||||
p, err := LoadPage(db, cur)
|
||||
if errors.Is(err, ErrNoRecord) {
|
||||
return false, depth > 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if p.Parent() == 0 {
|
||||
return false, true, nil
|
||||
}
|
||||
cur = p.Parent()
|
||||
}
|
||||
return false, true, nil
|
||||
}
|
||||
|
||||
// ownerOutsideRegistry returns the url_owned refusal when a content type
|
||||
// other than the registry owns path exactly: the type's label and the
|
||||
// record's title as params. nil when none does.
|
||||
func (s *Service) ownerOutsideRegistry(ctx context.Context, path string) (*models.Problem, error) {
|
||||
reg := s.Registry()
|
||||
if reg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
unprefixed := s.UnprefixedLocale()
|
||||
for _, typ := range reg.ContentTypes() {
|
||||
if typ.Key == PageTypeKey || typ.Content == nil || typ.Content.Match == nil {
|
||||
continue
|
||||
}
|
||||
got, err := typ.Content.Match(ctx, pact.ContentMatch{Path: trimSlash(path), Pattern: reg.Pattern(typ.Key, ""), UnprefixedLocale: unprefixed})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if got == nil || !got.Exact {
|
||||
continue
|
||||
}
|
||||
title := path
|
||||
if rec, err := reg.Describe(ctx, typ.Key, got.ID, unprefixed); err == nil && rec != nil {
|
||||
if l := contentLocale(rec, got.Locale); strings.TrimSpace(l.Title) != "" {
|
||||
title = l.Title
|
||||
}
|
||||
}
|
||||
label := typ.Label
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = typ.Key
|
||||
}
|
||||
return &models.Problem{Key: errPageURLOwned, Params: map[string]string{"type": s.Phrase(label, nil), "title": title}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// PageValues are a registry page's values for history.
|
||||
func PageValues(p models.Page) map[string]any {
|
||||
parent := any(nil)
|
||||
if p.Parent() != 0 {
|
||||
parent = p.Parent()
|
||||
}
|
||||
date := ""
|
||||
if p.ContentUpdatedAt != nil {
|
||||
date = p.ContentUpdatedAt.String()
|
||||
}
|
||||
return map[string]any{
|
||||
"url": p.URL, "urls": plainValue(p.URLs.Get()), "title": p.Title, "titles": plainValue(p.Titles.Get()),
|
||||
"kind": p.Kind, "origin": p.Origin, "parent_id": parent, "content_updated_at": date, "note": p.Note,
|
||||
}
|
||||
}
|
||||
|
||||
// pageWhat is a registry page's history description.
|
||||
func (s *Service) pageWhat(p models.Page) string {
|
||||
return s.Phrase("golem15.seo::lang.history.what_page", map[string]string{"url": p.URL})
|
||||
}
|
||||
|
||||
// RecordPage records a registry page's change in history.
|
||||
func (s *Service) RecordPage(ctx context.Context, tx *gorm.DB, before map[string]any, p models.Page, deleted bool) error {
|
||||
c := Change{Kind: models.SubjectPage, SubjectID: p.IDText(), What: s.pageWhat(p), Before: before}
|
||||
if !deleted {
|
||||
c.After = PageValues(p)
|
||||
}
|
||||
return s.Record(ctx, tx, c)
|
||||
}
|
||||
|
||||
// SavePage checks and writes a registry page inside tx, records it in
|
||||
// history, and fires the record event after commit, so the SEO tab's
|
||||
// address tracking and the sitemap follow as they do for an admin save.
|
||||
func (s *Service) SavePage(ctx context.Context, tx *gorm.DB, p *models.Page, before map[string]any) error {
|
||||
problems, err := s.CheckPage(ctx, tx, *p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &ProblemsError{Problems: problems}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
created := p.ID == 0
|
||||
if created {
|
||||
p.CreatedAt = &now
|
||||
}
|
||||
p.UpdatedAt = &now
|
||||
if err := tx.Save(p).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.RecordPage(ctx, tx, before, *p, false); err != nil {
|
||||
return err
|
||||
}
|
||||
cabana.EmitRecordSaved(ctx, s.app, tx, pact.RecordSaved{Controller: PagesController, Model: PageModel, ID: p.IDText(), Created: created})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePage deletes a registry page inside tx, records it in history and
|
||||
// fires the record event after commit: the SEO plugin then deletes the
|
||||
// page's SEO entries and the sitemap drops its addresses.
|
||||
func (s *Service) DeletePage(ctx context.Context, tx *gorm.DB, p models.Page) error {
|
||||
if err := tx.Delete(&models.Page{}, p.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.RecordPage(ctx, tx, PageValues(p), p, true); err != nil {
|
||||
return err
|
||||
}
|
||||
cabana.EmitRecordDeleted(ctx, s.app, tx, pact.RecordDeleted{Controller: PagesController, Model: PageModel, ID: p.IDText()})
|
||||
return nil
|
||||
}
|
||||
|
||||
// PageMissing reports whether p is a reported page that the newest route
|
||||
// report (at reported) did not name.
|
||||
func PageMissing(p models.Page, reported *time.Time) bool {
|
||||
if p.Origin != models.PageOriginReported || reported == nil {
|
||||
return false
|
||||
}
|
||||
return p.ReportedAt == nil || p.ReportedAt.Before(*reported)
|
||||
}
|
||||
|
||||
// pageKinds reads the kind of each registry page id (as text).
|
||||
func pageKinds(db *gorm.DB, ids []string) (map[string]string, error) {
|
||||
var nums []uint64
|
||||
for _, id := range ids {
|
||||
if n, err := strconv.ParseUint(id, 10, 64); err == nil {
|
||||
nums = append(nums, n)
|
||||
}
|
||||
}
|
||||
out := map[string]string{}
|
||||
if len(nums) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []struct {
|
||||
ID uint64 `gorm:"column:id"`
|
||||
Kind string `gorm:"column:kind"`
|
||||
}
|
||||
if err := db.Raw(`SELECT id, kind FROM golem15_seo_pages WHERE id IN ?`, nums).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
out[strconv.FormatUint(r.ID, 10)] = r.Kind
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user