package classes import ( "context" "fmt" "strings" "time" "git.golem15.com/golem15/sm-sitemap-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/conga" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/signpost" "gorm.io/gorm" ) // The config keys of the event-driven lastmod and the debounced rebuild. const ( // LastmodIgnoreConfigKey lists the attributes whose change does not // move lastmod. LastmodIgnoreConfigKey = "golem15.sitemap.lastmod_ignore" // RebuildDelayConfigKey is the delay of the rebuild a record event // queues, in seconds. RebuildDelayConfigKey = "golem15.sitemap.rebuild_delay_seconds" ) // DefaultLastmodIgnore is the ignore list when the config has none. var DefaultLastmodIgnore = []string{"updated_at", "created_at", "views", "sort_order"} // defaultRebuildDelay is the rebuild delay when the config has none. const defaultRebuildDelay = 60 * time.Second // LastmodIgnore reads golem15.sitemap.lastmod_ignore: a list of attribute // names (or one comma-separated string); DefaultLastmodIgnore when unset. func LastmodIgnore(cfg *compass.Config) map[string]bool { names := DefaultLastmodIgnore if cfg != nil { if v, ok := cfg.Lookup(LastmodIgnoreConfigKey); ok && v != nil { names = nil switch list := v.(type) { case []any: for _, item := range list { names = append(names, fmt.Sprint(item)) } case []string: names = list case string: names = strings.Split(list, ",") } } } out := make(map[string]bool, len(names)) for _, n := range names { if n = strings.TrimSpace(n); n != "" { out[n] = true } } return out } // RebuildDelay reads golem15.sitemap.rebuild_delay_seconds; 60 seconds when // unset, 0 or more when set. func RebuildDelay(cfg *compass.Config) time.Duration { if cfg == nil || !cfg.Has(RebuildDelayConfigKey) { return defaultRebuildDelay } if n := cfg.Int(RebuildDelayConfigKey); n > 0 { return time.Duration(n) * time.Second } return 0 } // Changed is what a save changed for lastmod: Any when a change outside the // ignore list exists, All when one of them is on the record's own row // (every locale's address moves), else Locales names the translated // locales that changed. type Changed struct { Any bool All bool Locales map[string]bool } // RealChanges filters a save's changes through the ignore list. A no-op // save (no changes) and one that changes only ignored attributes change // nothing (RESEARCH Pitfall 4). func RealChanges(changes []pact.RecordChange, ignore map[string]bool) Changed { out := Changed{Locales: map[string]bool{}} for _, c := range changes { if ignore[c.Attribute] { continue } out.Any = true if c.Locale == "" { out.All = true continue } out.Locales[c.Locale] = true } if out.All { out.Locales = map[string]bool{} } return out } // RecordSaved handles a saved record of a content type (D-30): when the // save changed an attribute outside golem15.sitemap.lastmod_ignore, the // lastmod of the record's rows moves to now (every locale's for a change // on the record's own row, else only the changed locales'), and a // debounced rebuild is queued. A model no content type declares is // ignored. func RecordSaved(ctx context.Context, app *backpack.App, ev *pact.RecordSaved) error { typ, db, ok := contentTypeOf(app, ev.Model) if !ok || ev == nil { return nil } changed := RealChanges(ev.Changes, LastmodIgnore(app.Config)) if !changed.Any { return nil } db = db.WithContext(ctx) now := time.Now().UTC().Truncate(time.Microsecond) q := db.Model(&models.URL{}).Where("type_key = ? AND record_id = ?", typ, ev.ID) if !changed.All { locales := make([]string, 0, len(changed.Locales)) for l := range changed.Locales { locales = append(locales, l) } q = q.Where("locale IN ?", locales) } if err := q.Updates(map[string]any{"lastmod": now, "updated_at": now}).Error; err != nil { return err } return RequestDebouncedRebuild(ctx, app, db) } // RecordDeleted handles a deleted record of a content type: its rows leave // the files at once (reason deleted, their file numbers kept as holes) and // a debounced rebuild is queued. func RecordDeleted(ctx context.Context, app *backpack.App, ev *pact.RecordDeleted) error { if ev == nil { return nil } typ, db, ok := contentTypeOf(app, ev.Model) if !ok { return nil } db = db.WithContext(ctx) now := time.Now().UTC() if err := db.Model(&models.URL{}). Where("type_key = ? AND record_id = ? AND excluded_reason <> ?", typ, ev.ID, models.ReasonDeleted). Updates(map[string]any{"excluded_reason": models.ReasonDeleted, "updated_at": now}).Error; err != nil { return err } return RequestDebouncedRebuild(ctx, app, db) } // contentTypeOf finds the content type key of a record event's model and // the application database. func contentTypeOf(app *backpack.App, model string) (string, *gorm.DB, bool) { if app == nil { return "", nil, false } reg, ok := app.Lookup[*signpost.Registry]() if !ok || reg == nil { return "", nil, false } typ, ok := reg.ContentTypeForModel(model) if !ok || typ.Content == nil { return "", nil, false } db, ok := app.Lookup[*gorm.DB]() if !ok || db == nil { return "", nil, false } return typ.Key, db, true } // RequestDebouncedRebuild queues one rebuild golem15.sitemap.rebuild_delay_seconds // from now unless one is already pending (P33-12): a conditional UPDATE of // rebuild_pending_until decides, and the job is enqueued in the same // transaction, so events within the delay share one rebuild. The job // clears the pending time when it starts. func RequestDebouncedRebuild(ctx context.Context, app *backpack.App, db *gorm.DB) error { delay := RebuildDelay(app.Config) m, err := conga.From(app) if err != nil { return err } return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { res := tx.Exec(`UPDATE golem15_sitemap_settings SET rebuild_pending_until = NOW() + make_interval(secs => ?) WHERE id = 1 AND (rebuild_pending_until IS NULL OR rebuild_pending_until <= NOW())`, delay.Seconds()) if res.Error != nil || res.RowsAffected == 0 { return res.Error } return m.Enqueue(ctx, tx, RebuildArgs{}, conga.EnqueueOpts{Delay: delay}) }) } // ClearPending ends the debounce window; the rebuild job calls it when it // starts, so events during the rebuild queue the next one. func ClearPending(ctx context.Context, app *backpack.App) error { db, ok := app.Lookup[*gorm.DB]() if !ok || db == nil { return ErrNoDatabase } return db.WithContext(ctx).Exec(`UPDATE golem15_sitemap_settings SET rebuild_pending_until = NULL WHERE id = 1`).Error }