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" "gorm.io/gorm/clause" ) // 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 ) // RouteReportTokenConfigKey is the bearer token the frontend server sends // with its route report to POST /_seo/api/v1/registry/routes (P33-20). // Empty uses golem15.seo.notfound_token; both empty turn the report off. const RouteReportTokenConfigKey = "golem15.seo.route_report_token" // RouteReportToken is the route report's token: route_report_token, else // notfound_token, "" when neither is set. func (s *Service) RouteReportToken() string { if cfg := s.Config(); cfg != nil { if t := strings.TrimSpace(cfg.String(RouteReportTokenConfigKey)); t != "" { return t } } return s.NotFoundToken() } // 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 } // ErrTooManyRoutes refuses a route report of more than MaxReportedRoutes // paths. var ErrTooManyRoutes = errors.New("golem15.seo: a route report holds at most 2000 paths") // 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 } // RouteReport is the outcome of a route report: the paths inserted as new // registry pages, the known pages whose reported_at moved, and the paths // skipped (invalid, or owned by another content type). type RouteReport struct { Inserted int `json:"inserted"` Updated int `json:"updated"` Skipped int `json:"skipped"` ReportedAt time.Time `json:"reported_at"` } // ReportRoutes takes the frontend server's static routes (D-33, P33-20) // inside tx: a path no registry page has is inserted with origin reported, // kind page and a name from its last segment; a known page gets // reported_at; a path another content type owns, or that is no valid // address, is skipped. Nothing is ever deleted, and no value an editor set // is overwritten. The settings' routes_reported_at becomes the report's // time, so a reported page missing from it shows as missing. More than // MaxReportedRoutes paths is ErrTooManyRoutes. func (s *Service) ReportRoutes(ctx context.Context, tx *gorm.DB, paths []string) (RouteReport, error) { now := time.Now().UTC() out := RouteReport{ReportedAt: now} if len(paths) > MaxReportedRoutes { return out, ErrTooManyRoutes } settings, err := SettingsRow(tx) if err != nil { return out, err } opts := NormalizeOptionsOf(settings) ctx = WithVia(ctx, models.ViaAuto) seen := map[string]bool{} for _, raw := range paths { path := strings.TrimSpace(raw) if len(path) > MaxRouteBytes || !ValidPagePath(path) { out.Skipped++ continue } path = normalPagePath(path, opts) if seen[path] { continue } seen[path] = true page, _, err := s.PageAt(ctx, tx, path) if err != nil { return out, err } if page != nil { if err := tx.Model(&models.Page{}).Where("id = ?", page.ID).UpdateColumn("reported_at", now).Error; err != nil { return out, err } out.Updated++ continue } owned, err := s.ownerOutsideRegistry(ctx, path) if err != nil { return out, err } if owned != nil { out.Skipped++ continue } p := models.Page{ URL: path, URLs: lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}, Title: s.routeTitle(path), Titles: lagoon.Jsonable[map[string]string]{Data: map[string]string{}, Valid: true}, Kind: models.PageKindPage, Origin: models.PageOriginReported, ReportedAt: &now, CreatedAt: &now, UpdatedAt: &now, } res := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&p) if res.Error != nil { return out, res.Error } if res.RowsAffected == 0 || p.ID == 0 { // A concurrent report inserted it first. continue } out.Inserted++ if err := s.RecordPage(ctx, tx, nil, p, false); err != nil { return out, err } cabana.EmitRecordSaved(ctx, s.app, tx, pact.RecordSaved{Controller: PagesController, Model: PageModel, ID: p.IDText(), Created: true}) } if err := tx.Model(&models.Settings{}).Where("id = ?", 1).UpdateColumn("routes_reported_at", now).Error; err != nil { return out, err } return out, nil } // routeTitle is a reported page's name: its last path segment with // hyphens and underscores as spaces and the first letter capitalised; the // home page is named pages.home_title. func (s *Service) routeTitle(path string) string { seg := strings.Trim(path, "/") if i := strings.LastIndex(seg, "/"); i >= 0 { seg = seg[i+1:] } if decoded, err := url.PathUnescape(seg); err == nil { seg = decoded } seg = strings.TrimSpace(strings.NewReplacer("-", " ", "_", " ").Replace(seg)) if seg == "" { return s.Phrase("golem15.seo::lang.pages.home_title", nil) } r, size := utf8.DecodeRuneInString(seg) return string(unicode.ToUpper(r)) + seg[size:] } // 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 }