- 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)
288 lines
9.0 KiB
Go
288 lines
9.0 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// PagesControllerID is the Pages screen, the page registry (UI-SPEC C21).
|
|
const PagesControllerID = classes.PagesController
|
|
|
|
// pagesAdminController lists and edits the page registry (D-33). The
|
|
// address and the name per locale, their refusals and history come from
|
|
// the plugin's own form extension on this controller, which runs inside
|
|
// the save transaction once the row is written; the SEO tab is added
|
|
// because the registry is the content type seo-page.
|
|
type pagesAdminController struct {
|
|
app func() *backpack.App
|
|
}
|
|
|
|
func (pagesAdminController) ID() string { return PagesControllerID }
|
|
func (pagesAdminController) ModelName() string { return `Golem15\SEO\Models\Page` }
|
|
func (pagesAdminController) ConfigDir() string { return "controllers/pages" }
|
|
func (pagesAdminController) RequiredPermissions() []string {
|
|
return []string{PermissionManage}
|
|
}
|
|
|
|
// NewRecord is a page with the form's defaults: kind page, entered by
|
|
// hand, its content changed today.
|
|
func (pagesAdminController) NewRecord() any {
|
|
p := models.NewPage()
|
|
return &p
|
|
}
|
|
|
|
// FormBeforeCreate dates a page without a content date today and refuses
|
|
// an unknown kind or a parent that is unknown or makes a loop.
|
|
func (c pagesAdminController) FormBeforeCreate(ctx context.Context, model any) error {
|
|
p, ok := model.(*models.Page)
|
|
if !ok || p == nil {
|
|
return nil
|
|
}
|
|
if p.ContentUpdatedAt == nil || p.ContentUpdatedAt.IsZero() {
|
|
today := lagoon.DateOf(time.Now().UTC())
|
|
p.ContentUpdatedAt = &today
|
|
}
|
|
if p.Origin == "" {
|
|
p.Origin = models.PageOriginManual
|
|
}
|
|
return c.check(ctx, p)
|
|
}
|
|
|
|
// FormBeforeUpdate checks an update's kind and parent.
|
|
func (c pagesAdminController) FormBeforeUpdate(ctx context.Context, model any) error {
|
|
p, ok := model.(*models.Page)
|
|
if !ok || p == nil {
|
|
return nil
|
|
}
|
|
return c.check(ctx, p)
|
|
}
|
|
|
|
// check refuses the owner fields' problems (kind, parent_id); the address
|
|
// is checked by the form extension, which receives it.
|
|
func (c pagesAdminController) check(ctx context.Context, p *models.Page) error {
|
|
if p.ParentID != nil && *p.ParentID == 0 {
|
|
p.ParentID = nil
|
|
}
|
|
svc, err := classes.NewService(appOf(c.app))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
db, err := database(ctx, c.app)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
problems, err := svc.CheckPage(ctx, db, *p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
owner := map[string][]models.Problem{}
|
|
for _, field := range []string{"kind", "parent_id"} {
|
|
if list := problems[field]; len(list) > 0 {
|
|
owner[field] = list
|
|
}
|
|
}
|
|
return problemsError(ctx, translator(c.app), owner)
|
|
}
|
|
|
|
// FormAfterDelete records the deleted page in history inside the delete's
|
|
// transaction; the record event then deletes its SEO entries.
|
|
func (c pagesAdminController) FormAfterDelete(ctx context.Context, model any) error {
|
|
p, ok := model.(*models.Page)
|
|
if !ok || p == nil {
|
|
return nil
|
|
}
|
|
svc, err := classes.NewService(appOf(c.app))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
db, err := database(ctx, c.app)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return svc.RecordPage(ctx, db, classes.PageValues(*p), *p, true)
|
|
}
|
|
|
|
// reportedAt is the settings' newest route report time, nil when none.
|
|
func reportedAt(db *gorm.DB) (*time.Time, error) {
|
|
var rows []models.Settings
|
|
if err := db.Select("id", "routes_reported_at").Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
return rows[0].RoutesReportedAt, nil
|
|
}
|
|
|
|
// ListRowStates marks a reported page that the newest route report did not
|
|
// name (UI-SPEC C21): negative, labelled pages.state_missing. The state is
|
|
// read from the table, since ListExtendRecords shows the origin as its
|
|
// label.
|
|
func (pagesAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
|
|
var ids []uint
|
|
for _, record := range records {
|
|
if p, ok := record.(*models.Page); ok && p != nil {
|
|
ids = append(ids, p.ID)
|
|
}
|
|
}
|
|
missing := map[uint]bool{}
|
|
if len(ids) > 0 {
|
|
var rows []uint
|
|
if err := db.WithContext(ctx).Model(&models.Page{}).Where("id IN ?", ids).Where(models.MissingSQL).Pluck("id", &rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
for _, id := range rows {
|
|
missing[id] = true
|
|
}
|
|
}
|
|
out := make([][]pact.RowState, len(records))
|
|
for i, record := range records {
|
|
if p, ok := record.(*models.Page); ok && p != nil && missing[p.ID] {
|
|
out[i] = []pact.RowState{pact.RowStateNegative}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// pageKindLabels and pageOriginLabels are the phrase keys of the kinds and
|
|
// origins.
|
|
var (
|
|
pageKindLabels = classes.PageKindLabels
|
|
pageOriginLabels = map[string]string{
|
|
models.PageOriginManual: "golem15.seo::lang.pages.origin_manual",
|
|
models.PageOriginReported: "golem15.seo::lang.pages.origin_reported",
|
|
models.PageOriginMCP: "golem15.seo::lang.pages.origin_mcp",
|
|
}
|
|
)
|
|
|
|
// FilterOptions serves the choices of the kind, origin and missing
|
|
// filters.
|
|
func (pagesAdminController) FilterOptions(scope string) []pact.Option {
|
|
switch scope {
|
|
case models.FilterPageKind:
|
|
out := make([]pact.Option, 0, len(models.PageKinds))
|
|
for _, k := range models.PageKinds {
|
|
out = append(out, pact.Option{Value: k, Label: pageKindLabels[k]})
|
|
}
|
|
return out
|
|
case models.FilterPageOrigin:
|
|
out := make([]pact.Option, 0, len(models.PageOrigins))
|
|
for _, o := range models.PageOrigins {
|
|
out = append(out, pact.Option{Value: o, Label: pageOriginLabels[o]})
|
|
}
|
|
return out
|
|
case models.FilterPageMissing:
|
|
return []pact.Option{
|
|
{Value: models.MissingOnly, Label: "golem15.seo::lang.pages.filter_missing_only"},
|
|
{Value: models.MissingHide, Label: "golem15.seo::lang.pages.filter_missing_hide"},
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListExtendRecords shows the kind and the origin of the list's rows as
|
|
// their labels in the request locale. The rows are list values only and
|
|
// never saved; the columns still sort by the stored values.
|
|
func (c pagesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
|
|
tr := translator(c.app)
|
|
for _, record := range records {
|
|
p, ok := record.(*models.Page)
|
|
if !ok || p == nil {
|
|
continue
|
|
}
|
|
if key, ok := pageKindLabels[p.Kind]; ok {
|
|
p.Kind = Phrase(ctx, tr, key, nil)
|
|
}
|
|
if key, ok := pageOriginLabels[p.Origin]; ok {
|
|
p.Origin = Phrase(ctx, tr, key, nil)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DropdownOptions serves the parent dropdown: the registry pages by name
|
|
// (their address when they have none).
|
|
func (c pagesAdminController) DropdownOptions(field string) []pact.Option {
|
|
if field != "parent_id" {
|
|
return nil
|
|
}
|
|
db, err := database(context.Background(), c.app)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var rows []models.Page
|
|
if err := db.Where("url <> ''").Order("title ASC, url ASC").Limit(1000).Find(&rows).Error; err != nil {
|
|
log.Warn("golem15.seo: parent options", "error", err)
|
|
return nil
|
|
}
|
|
out := make([]pact.Option, 0, len(rows))
|
|
for _, p := range rows {
|
|
label := p.Title
|
|
if label == "" {
|
|
label = p.URL
|
|
} else {
|
|
label += " (" + p.URL + ")"
|
|
}
|
|
out = append(out, pact.Option{Value: p.IDText(), Label: label})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pageStatusView is the view model of P9 page_status: the reported or
|
|
// missing line, nothing for a page entered by hand.
|
|
type pageStatusView struct {
|
|
Text string
|
|
Warning bool
|
|
}
|
|
|
|
// PartialData serves P9 page_status (UI-SPEC C21).
|
|
func (c pagesAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
|
|
if name != "page_status" {
|
|
return nil, nil
|
|
}
|
|
p, ok := record.(*models.Page)
|
|
if !ok || p == nil || p.Origin != models.PageOriginReported {
|
|
return pageStatusView{}, nil
|
|
}
|
|
db, err := database(ctx, c.app)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reported, err := reportedAt(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tr := translator(c.app)
|
|
stamp := func(t *time.Time) string {
|
|
if t == nil {
|
|
return "—"
|
|
}
|
|
return t.UTC().Format("2006-01-02 15:04")
|
|
}
|
|
if classes.PageMissing(*p, reported) {
|
|
return pageStatusView{Text: Phrase(ctx, tr, "golem15.seo::lang.pages.status_missing", map[string]string{"time": stamp(reported)}), Warning: true}, nil
|
|
}
|
|
return pageStatusView{Text: Phrase(ctx, tr, "golem15.seo::lang.pages.status_reported", map[string]string{"time": stamp(p.ReportedAt)})}, nil
|
|
}
|
|
|
|
var (
|
|
_ pact.AdminController = pagesAdminController{}
|
|
_ pact.AdminPermissioned = pagesAdminController{}
|
|
_ pact.AdminRecordSource = pagesAdminController{}
|
|
_ pact.FormBeforeCreate = pagesAdminController{}
|
|
_ pact.FormBeforeUpdate = pagesAdminController{}
|
|
_ pact.FormAfterDelete = pagesAdminController{}
|
|
_ pact.ListRowStates = pagesAdminController{}
|
|
_ pact.FilterOptions = pagesAdminController{}
|
|
_ pact.ListExtendRecords = pagesAdminController{}
|
|
_ pact.DropdownOptionsProvider = pagesAdminController{}
|
|
_ pact.AdminPartialData = pagesAdminController{}
|
|
)
|