package controllers import ( "context" "errors" "time" "git.golem15.com/golem15/sm-sitemap-plugin/classes" "git.golem15.com/golem15/sm-sitemap-plugin/contract" "git.golem15.com/golem15/sm-sitemap-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/signpost" "gorm.io/gorm" ) const ( // SettingsControllerID is the cabana controller id of the sitemap // settings singleton (UI-SPEC C26), linked from the sitemap settings // tile. SettingsControllerID = "golem15.sitemap.settings" // PermissionAccessDefinitions guards the sitemap settings. The code is // the Phase 17 (and WinterCMS) one, so existing roles keep their access. PermissionAccessDefinitions = "golem15.sitemap.access_definitions" ) // The reasons the files partial counts as "left out", in display order // (P33-11). A deleted or unpublished address is a hole, not left out. var leftOutReasons = []string{contract.ReasonNoindex, contract.ReasonCanonical, contract.ReasonRedirect, contract.ReasonType} // settingsAdminController edits the sitemap settings singleton: Files, // Content types (added by the plugin's own form extension) and Additional // addresses. A save dispatches a rebuild. type settingsAdminController struct { app func() *backpack.App } func (settingsAdminController) ID() string { return SettingsControllerID } func (settingsAdminController) ModelName() string { return `Golem15\Sitemap\Models\Settings` } func (settingsAdminController) ConfigDir() string { return "controllers/settings" } func (settingsAdminController) NewRecord() any { return &models.Settings{} } func (settingsAdminController) RequiredPermissions() []string { return []string{PermissionAccessDefinitions} } // SingletonRecord returns settings row 1, creating it with the defaults // when the migration's row is gone (safe against a concurrent first // request through ON CONFLICT DO NOTHING). func (settingsAdminController) SingletonRecord(_ context.Context, db *gorm.DB) (any, error) { if db == nil { return nil, errors.New("golem15.sitemap: database is not configured") } var rows []models.Settings if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil { return nil, err } if len(rows) > 0 { return &rows[0], nil } if err := db.Exec(`INSERT INTO golem15_sitemap_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`).Error; err != nil { return nil, err } if err := db.Where("id = ?", 1).Limit(1).Find(&rows).Error; err != nil || len(rows) == 0 { return nil, errors.New("golem15.sitemap: settings row is missing") } return &rows[0], nil } // FormBeforeUpdate refuses the settings Problems finds (UI-SPEC C26: // errors.frontend_url on site_url, errors.file_size, errors.url_scheme and // errors.lastmod on an additional address). func (settingsAdminController) FormBeforeUpdate(_ context.Context, model any) error { s, ok := model.(*models.Settings) if !ok || s == nil { return nil } return s.Validate() } // FormBeforeCreate checks a created row like an update. func (c settingsAdminController) FormBeforeCreate(ctx context.Context, model any) error { return c.FormBeforeUpdate(ctx, model) } // FormAfterUpdate dispatches a rebuild once the save commits (C26: saving // the form rebuilds the files now). A queue error is logged and never fails // the save. func (c settingsAdminController) FormAfterUpdate(ctx context.Context, _ any) error { app := appOf(c.app) tx, err := database(ctx, c.app) if err != nil || app == nil { return nil } return classes.RequestRebuild(ctx, app, tx) } // FormAfterCreate dispatches a rebuild like an update. func (c settingsAdminController) FormAfterCreate(ctx context.Context, model any) error { return c.FormAfterUpdate(ctx, model) } // filesView is the view model of the S1 partial sitemap_files. type filesView struct { Building bool Empty bool Files []fileRow LeftOut []reasonRow Note string } // fileRow is one row of the S1 files table: the index first, then each // file. URL is the backend route that serves it. type fileRow struct { Name string URL string Count int LastMod string } // reasonRow is one row of the S1 left-out table. type reasonRow struct { Label string Count int } // PartialData serves the S1 files partial: the index and its files with // their address counts and newest lastmod, the left-out counts by reason // and the index note; files.empty before the first build and // files.building while a rebuild asked for from the admin runs. func (c settingsAdminController) PartialData(ctx context.Context, name string, _ any) (any, error) { switch name { case "sitemap_files": return c.filesData(ctx) case "news_sources": return c.newsSourcesData(ctx) } return nil, nil } // sourceRow is one row of the news_sources partial: the value to enter and // what it lists. type sourceRow struct { Value string Label string } // newsSourcesView is the view model of the news_sources partial. type newsSourcesView struct { Rows []sourceRow } // newsSourcesData lists the values the news_sources taglist takes // (classes.NewsSources): each article type, then the categories its // listed articles name, labelled ": ". func (c settingsAdminController) newsSourcesData(ctx context.Context) (any, error) { app := appOf(c.app) db, err := database(ctx, c.app) if err != nil { return nil, err } settings, err := classes.LoadSettings(db) if err != nil { return nil, err } var reg *signpost.Registry if app != nil { reg, _ = app.Lookup[*signpost.Registry]() } sources, err := classes.NewsSources(db, reg, models.Text(settings.DefaultLocale)) if err != nil { return nil, err } tr := translator(c.app) label := func(key string) string { if reg != nil { if typ, ok := reg.ContentType(key); ok && typ.Label != "" { return Phrase(ctx, tr, typ.Label, nil) } } return key } view := newsSourcesView{} for _, src := range sources { row := sourceRow{Value: src.Value} if src.ID == "" { row.Label = Phrase(ctx, tr, "golem15.sitemap::lang.news.source_type", map[string]string{"type": label(src.Type)}) } else { title := src.Title if title == "" { title = src.ID } row.Label = label(src.Type) + ": " + title } view.Rows = append(view.Rows, row) } return view, nil } // filesData is the view of the S1 files partial. func (c settingsAdminController) filesData(ctx context.Context) (any, error) { app := appOf(c.app) db, err := database(ctx, c.app) if err != nil { return nil, err } settings, err := classes.LoadSettings(db) if err != nil { return nil, err } site := classes.SiteURL(app, settings) view := filesView{Building: classes.Building(settings.RebuildRequestedAt, settings.BuiltAt, time.Now())} var files []struct { Grp string `gorm:"column:grp"` FileNo int `gorm:"column:file_no"` N int `gorm:"column:n"` LastMod *time.Time `gorm:"column:lastmod"` } if err := db.Raw(`SELECT grp, file_no, COUNT(*) AS n, MAX(lastmod) AS lastmod FROM golem15_sitemap_urls WHERE excluded_reason = '' AND file_no IS NOT NULL GROUP BY grp, file_no ORDER BY grp, file_no`).Scan(&files).Error; err != nil { return nil, err } // The partial kit links only root-relative addresses, so a file links // to the backend route that serves it; the note names the public index. link := func(name string) string { if name == "sitemap.xml" { return "/sitemap.xml" } return "/_sitemap/files/" + name } if len(files) == 0 { view.Empty = true } else { index := fileRow{Name: "sitemap.xml", URL: link("sitemap.xml")} var newest *time.Time for _, f := range files { index.Count += f.N if f.LastMod != nil && (newest == nil || f.LastMod.After(*newest)) { newest = f.LastMod } name := classes.FileName(f.Grp, f.FileNo) view.Files = append(view.Files, fileRow{Name: name, URL: link(name), Count: f.N, LastMod: formatTime(f.LastMod)}) } index.LastMod = formatTime(newest) view.Files = append([]fileRow{index}, view.Files...) } var counts []struct { Reason string `gorm:"column:excluded_reason"` N int `gorm:"column:n"` } if err := db.Raw(`SELECT excluded_reason, COUNT(*) AS n FROM golem15_sitemap_urls WHERE excluded_reason NOT IN ('', ?, ?) GROUP BY excluded_reason`, models.ReasonDeleted, models.ReasonUnpublished).Scan(&counts).Error; err != nil { return nil, err } byReason := map[string]int{} for _, row := range counts { byReason[row.Reason] = row.N } tr := translator(c.app) for _, reason := range leftOutReasons { if n := byReason[reason]; n > 0 { view.LeftOut = append(view.LeftOut, reasonRow{Label: Phrase(ctx, tr, "golem15.sitemap::lang.files.reason_"+reason, nil), Count: n}) delete(byReason, reason) } } for reason, n := range byReason { view.LeftOut = append(view.LeftOut, reasonRow{Label: reason, Count: n}) } indexURL := "/sitemap.xml" if site != "" { indexURL = site + "/sitemap.xml" } view.Note = Phrase(ctx, tr, "golem15.sitemap::lang.files.index_note", map[string]string{"url": indexURL}) return view, nil } // formatTime renders a lastmod for the files table, "" when unknown. func formatTime(t *time.Time) string { if t == nil { return "" } return t.UTC().Format("2006-01-02 15:04 UTC") } // appOf calls the lazy app accessor. func appOf(app func() *backpack.App) *backpack.App { if app == nil { return nil } return app() } // translator returns the application's translator, nil when none. func translator(app func() *backpack.App) *phrasebook.Translator { a := appOf(app) if a == nil { return nil } tr, _ := a.Lookup[*phrasebook.Translator]() return tr } // database returns the write transaction of ctx, else the app database. func database(ctx context.Context, app func() *backpack.App) (*gorm.DB, error) { if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil { return tx, nil } a := appOf(app) if a == nil { return nil, errors.New("golem15.sitemap: app is not booted") } db, ok := a.Lookup[*gorm.DB]() if !ok || db == nil { return nil, errors.New("golem15.sitemap: database is not configured") } return db.WithContext(ctx), nil } // Phrase translates key with params in the request locale; without a // translator it returns the key. func Phrase(ctx context.Context, tr *phrasebook.Translator, key string, params map[string]string) string { if tr == nil { return key } return tr.Get(ctx, key, params) } var ( _ pact.AdminController = settingsAdminController{} _ pact.AdminPermissioned = settingsAdminController{} _ pact.AdminRecordSource = settingsAdminController{} _ pact.AdminSingleton = settingsAdminController{} _ pact.AdminPartialData = settingsAdminController{} _ pact.FormBeforeCreate = settingsAdminController{} _ pact.FormBeforeUpdate = settingsAdminController{} _ pact.FormAfterCreate = settingsAdminController{} _ pact.FormAfterUpdate = settingsAdminController{} )