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{} )