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:
@@ -32,6 +32,7 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
redirectsAdminController{app: app},
|
||||
notFoundAdminController{app: app},
|
||||
rulesAdminController{app: app},
|
||||
pagesAdminController{app: app},
|
||||
robotsAdminController{app: app},
|
||||
historyAdminController{app: app},
|
||||
transferAdminController{app: app},
|
||||
|
||||
298
controllers/api/registry.go
Normal file
298
controllers/api/registry.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListPagesInput is the query of GET /_seo/api/v1/pages.
|
||||
type ListPagesInput struct {
|
||||
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
|
||||
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"`
|
||||
Search string `json:"search,omitempty" query:"search" jsonschema:"text the address, another locale's address or the name contains"`
|
||||
ID uint `json:"id,omitempty" query:"id" jsonschema:"one page by id"`
|
||||
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"page, offer, service or landing"`
|
||||
Origin string `json:"origin,omitempty" query:"origin" jsonschema:"manual, reported or mcp"`
|
||||
Missing string `json:"missing,omitempty" query:"missing" jsonschema:"only: reported pages the site's newest route report did not include; hide: all others"`
|
||||
}
|
||||
|
||||
// PageIDInput names one registry page.
|
||||
type PageIDInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the registry page id"`
|
||||
}
|
||||
|
||||
// CreatePageInput is the body of POST /_seo/api/v1/pages.
|
||||
type CreatePageInput struct {
|
||||
URL map[string]string `json:"url" jsonschema:"the page's path per locale code, e.g. {\"pl\": \"/kontakt\", \"en\": \"/contact\"}; the default locale is required, a locale left out or empty has no version of the page; a plain string is the default locale's path"`
|
||||
Title map[string]string `json:"title,omitempty" jsonschema:"the page's name per locale code (default SEO title, HTML sitemap, breadcrumbs); a plain string is the default locale's"`
|
||||
Kind *string `json:"kind,omitempty" jsonschema:"page (the default), offer, service or landing: decides the sitemap file and the default structured data"`
|
||||
ParentID *uint `json:"parent_id,omitempty" jsonschema:"the parent registry page's id (breadcrumbs); null or 0 for the top level"`
|
||||
ContentUpdatedAt *string `json:"content_updated_at,omitempty" jsonschema:"the date the page's content last changed, YYYY-MM-DD (sitemap lastmod); today by default"`
|
||||
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
||||
}
|
||||
|
||||
// UpdatePageInput is PUT /_seo/api/v1/pages/{id}: only the fields sent are
|
||||
// changed; in url and title only the locales sent.
|
||||
type UpdatePageInput struct {
|
||||
ID uint `json:"id" path:"id" jsonschema:"the registry page id"`
|
||||
URL map[string]string `json:"url,omitempty" jsonschema:"paths per locale code to change; an empty path removes that locale's version (the default locale's cannot be removed)"`
|
||||
Title map[string]string `json:"title,omitempty" jsonschema:"names per locale code to change"`
|
||||
Kind *string `json:"kind,omitempty" jsonschema:"page, offer, service or landing"`
|
||||
ParentID *uint `json:"parent_id,omitempty" jsonschema:"the parent registry page's id; null or 0 for the top level"`
|
||||
ContentUpdatedAt *string `json:"content_updated_at,omitempty" jsonschema:"YYYY-MM-DD; empty clears it"`
|
||||
Note *string `json:"note,omitempty" jsonschema:"an internal note"`
|
||||
}
|
||||
|
||||
// pageBody is a decoded create or update body; a translatable value is a
|
||||
// locale map or a plain string (the default locale's).
|
||||
type pageBody struct {
|
||||
write classes.PageWrite
|
||||
}
|
||||
|
||||
func (b *pageBody) UnmarshalJSON(raw []byte) error {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
texts := func(name string) (map[string]string, error) {
|
||||
v, ok := fields[name]
|
||||
if !ok || bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
|
||||
return nil, nil
|
||||
}
|
||||
var s string
|
||||
if json.Unmarshal(v, &s) == nil {
|
||||
return map[string]string{"": s}, nil
|
||||
}
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(v, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m == nil {
|
||||
m = map[string]string{}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
var err error
|
||||
if b.write.URL, err = texts("url"); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.write.Title, err = texts("title"); err != nil {
|
||||
return err
|
||||
}
|
||||
str := func(name string) (*string, error) {
|
||||
v, ok := fields[name]
|
||||
if !ok || bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
|
||||
return nil, nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(v, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
if b.write.Kind, err = str("kind"); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.write.ContentUpdatedAt, err = str("content_updated_at"); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.write.Note, err = str("note"); err != nil {
|
||||
return err
|
||||
}
|
||||
if v, ok := fields["parent_id"]; ok {
|
||||
b.write.ParentSet = true
|
||||
if !bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
|
||||
var n uint
|
||||
if err := json.Unmarshal(v, &n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 0 {
|
||||
b.write.ParentID = &n
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PageJSON is a registry page in an answer: its default-locale address and
|
||||
// name, every locale's address and name, and whether the newest route
|
||||
// report missed it.
|
||||
func PageJSON(p models.Page, def string, reported *bool) map[string]any {
|
||||
urls := p.LocaleURLs(def)
|
||||
titles := map[string]string{def: p.Title}
|
||||
for l, t := range p.Titles.Get() {
|
||||
if l != def && t != "" {
|
||||
titles[l] = t
|
||||
}
|
||||
}
|
||||
var parent any
|
||||
if p.Parent() != 0 {
|
||||
parent = p.Parent()
|
||||
}
|
||||
var date any
|
||||
if p.ContentUpdatedAt != nil && !p.ContentUpdatedAt.IsZero() {
|
||||
date = p.ContentUpdatedAt.String()
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": p.ID, "url": p.URL, "urls": urls, "title": p.Title, "titles": titles,
|
||||
"kind": p.Kind, "origin": p.Origin, "parent_id": parent, "content_updated_at": date,
|
||||
"reported_at": p.ReportedAt, "note": p.Note, "created_at": p.CreatedAt, "updated_at": p.UpdatedAt,
|
||||
}
|
||||
if reported != nil {
|
||||
out["missing"] = *reported
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pageAnswer is a page in an answer with its missing state.
|
||||
func pageAnswer(ctx context.Context, svc *classes.Service, db *gorm.DB, p models.Page) (map[string]any, error) {
|
||||
settings, err := classes.LoadSettings(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
missing := classes.PageMissing(p, settings.RoutesReportedAt)
|
||||
return PageJSON(p, svc.DefaultLocale(ctx, db), &missing), nil
|
||||
}
|
||||
|
||||
// ListPages serves GET /_seo/api/v1/pages: the registry by address,
|
||||
// filtered by search text, id, kind, origin and the missing state.
|
||||
func ListPages(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
db, err := read(ctx, svc)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
in := listParams(r)
|
||||
query := r.URL.Query()
|
||||
q := db.Model(&models.Page{}).Where("url <> ''").Order("url ASC, id ASC")
|
||||
if in.Search != "" {
|
||||
p := like(in.Search)
|
||||
q = q.Where(`lower(url) LIKE ? OR lower(title) LIKE ? OR lower(urls::text) LIKE ? OR lower(titles::text) LIKE ?`, p, p, p, p)
|
||||
}
|
||||
if id, err := strconv.ParseUint(query.Get("id"), 10, 64); err == nil && id > 0 {
|
||||
q = q.Where("id = ?", id)
|
||||
}
|
||||
for param, scope := range map[string]string{"kind": models.FilterPageKind, "origin": models.FilterPageOrigin, "missing": models.FilterPageMissing} {
|
||||
if v := strings.TrimSpace(query.Get(param)); v != "" {
|
||||
q = models.Page{}.FilterScope(scope, q, v)
|
||||
}
|
||||
}
|
||||
var rows []models.Page
|
||||
total, err := paginate(q, in, &rows)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
settings, err := classes.LoadSettings(db)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
def := svc.DefaultLocale(ctx, db)
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
missing := classes.PageMissing(row, settings.RoutesReportedAt)
|
||||
out = append(out, PageJSON(row, def, &missing))
|
||||
}
|
||||
return http.StatusOK, listBody(out, in, total), nil
|
||||
})
|
||||
}
|
||||
|
||||
// savePage applies a body to p and saves it in tx.
|
||||
func savePage(ctx context.Context, svc *classes.Service, tx *gorm.DB, p *models.Page, w classes.PageWrite, before map[string]any) error {
|
||||
problems := classes.ApplyPageFields(p, w)
|
||||
if len(problems) > 0 {
|
||||
return &classes.ProblemsError{Problems: problems}
|
||||
}
|
||||
if err := svc.ApplyPageTexts(ctx, tx, p, w.URL, w.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.SavePage(ctx, tx, p, before)
|
||||
}
|
||||
|
||||
// CreatePage serves POST /_seo/api/v1/pages: 201 with the page, 422
|
||||
// validation_failed with the C21 refusals (url_required, url_invalid,
|
||||
// url_taken, url_owned, parent_loop). The origin is mcp through MCP.
|
||||
func CreatePage(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
var body pageBody
|
||||
if err := decode(r, maxBody, &body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
row := models.NewPage()
|
||||
row.Origin = classes.PageOrigin(ctx)
|
||||
var answer map[string]any
|
||||
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
if err := savePage(ctx, svc, tx, &row, body.write, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
answer, err = pageAnswer(ctx, svc, tx, row)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusCreated, map[string]any{"data": answer}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdatePage serves PUT /_seo/api/v1/pages/{id}: the fields sent change
|
||||
// and are checked like a create.
|
||||
func UpdatePage(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var body pageBody
|
||||
if err := decode(r, maxBody, &body); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
var answer map[string]any
|
||||
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
row, err := classes.LoadPage(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := savePage(ctx, svc, tx, &row, body.write, classes.PageValues(row)); err != nil {
|
||||
return err
|
||||
}
|
||||
answer, err = pageAnswer(ctx, svc, tx, row)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusOK, map[string]any{"data": answer}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeletePage serves DELETE /_seo/api/v1/pages/{id}: the page and its SEO
|
||||
// entries go (history keeps them); a page the site reports comes back with
|
||||
// its next route report.
|
||||
func DeletePage(service func() *classes.Service) http.HandlerFunc {
|
||||
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
|
||||
row, err := classes.LoadPage(tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.DeletePage(ctx, tx, row)
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return http.StatusOK, map[string]any{"deleted": id}, nil
|
||||
})
|
||||
}
|
||||
287
controllers/pages.go
Normal file
287
controllers/pages.go
Normal file
@@ -0,0 +1,287 @@
|
||||
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{}
|
||||
)
|
||||
7
controllers/pages/_page_status.htm
Normal file
7
controllers/pages/_page_status.htm
Normal file
@@ -0,0 +1,7 @@
|
||||
{{- with .Data -}}
|
||||
{{- if .Text -}}
|
||||
<div class="summer-callout{{ if .Warning }} summer-callout--warning{{ end }}" role="status">
|
||||
<p class="summer-callout__text">{{ .Text }}</p>
|
||||
</div>
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
18
controllers/pages/config_filter.yaml
Normal file
18
controllers/pages/config_filter.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# Pages filters (UI-SPEC C21): kind, origin and missing from the newest
|
||||
# route report.
|
||||
scopes:
|
||||
kind:
|
||||
label: golem15.seo::lang.pages.kind
|
||||
modelClass: Golem15\SEO\Models\Page
|
||||
nameFrom: kind
|
||||
scope: FilterPageKind
|
||||
origin:
|
||||
label: golem15.seo::lang.pages.origin
|
||||
modelClass: Golem15\SEO\Models\Page
|
||||
nameFrom: origin
|
||||
scope: FilterPageOrigin
|
||||
missing:
|
||||
label: golem15.seo::lang.pages.filter_missing
|
||||
modelClass: Golem15\SEO\Models\Page
|
||||
nameFrom: url
|
||||
scope: FilterPageMissing
|
||||
20
controllers/pages/config_form.yaml
Normal file
20
controllers/pages/config_form.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Page form (UI-SPEC C21). The address and the name per locale come from
|
||||
# the plugin's own form extension on this controller; the SEO tab is added
|
||||
# because the registry is a content type (seo-page).
|
||||
name: golem15.seo::lang.pages.name
|
||||
form: ~/plugins/golem15/seo/models/page/fields.yaml
|
||||
modelClass: Golem15\SEO\Models\Page
|
||||
defaultRedirect: golem15/seo/pages
|
||||
|
||||
create:
|
||||
redirect: golem15/seo/pages/update/:id
|
||||
redirectClose: golem15/seo/pages
|
||||
|
||||
update:
|
||||
redirect: golem15/seo/pages
|
||||
redirectClose: golem15/seo/pages
|
||||
|
||||
messages:
|
||||
create: golem15.seo::lang.pages.new
|
||||
update: golem15.seo::lang.pages.name
|
||||
deleteConfirm: golem15.seo::lang.pages.delete_confirm
|
||||
26
controllers/pages/config_list.yaml
Normal file
26
controllers/pages/config_list.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Pages list (UI-SPEC C21): the page registry by address; reported pages
|
||||
# missing from the newest route report are marked.
|
||||
list: ~/plugins/golem15/seo/models/page/columns.yaml
|
||||
modelClass: Golem15\SEO\Models\Page
|
||||
title: golem15.seo::lang.pages.list_title
|
||||
recordUrl: golem15/seo/pages/update/:id
|
||||
noRecordsMessage: golem15.seo::lang.pages.list_empty
|
||||
recordsPerPage: 20
|
||||
showCheckboxes: true
|
||||
showSetup: true
|
||||
filter: config_filter.yaml
|
||||
|
||||
defaultSort:
|
||||
column: url
|
||||
direction: asc
|
||||
|
||||
toolbar:
|
||||
buttons: [create, delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
messages:
|
||||
create: golem15.seo::lang.pages.new
|
||||
empty: golem15.seo::lang.pages.list_empty
|
||||
deleteConfirm: golem15.seo::lang.pages.delete_selected_confirm
|
||||
rowStateNegative: golem15.seo::lang.pages.state_missing
|
||||
Reference in New Issue
Block a user