Files
silo-server/internal/sections/recipes/seasonal.go
T

232 lines
7.8 KiB
Go

package recipes
import (
"encoding/json"
"errors"
"fmt"
"time"
)
// DatePredicate is a function that reports whether a given time falls within
// the seasonal window.
type DatePredicate func(time.Time) bool
// inMonth returns a predicate that matches any time within the given month.
func inMonth(m time.Month) DatePredicate {
return func(t time.Time) bool {
return t.Month() == m
}
}
// inMonths returns a predicate that matches any time within any of the given months.
func inMonths(months ...time.Month) DatePredicate {
set := make(map[time.Month]bool, len(months))
for _, m := range months {
set[m] = true
}
return func(t time.Time) bool {
return set[t.Month()]
}
}
// dateRange returns a predicate that matches times inclusively between
// (startMonth, startDay) and (endMonth, endDay) within the same calendar year.
// It does not wrap across year boundaries.
func dateRange(startMonth time.Month, startDay int, endMonth time.Month, endDay int) DatePredicate {
return func(t time.Time) bool {
y := t.Year()
start := time.Date(y, startMonth, startDay, 0, 0, 0, 0, t.Location())
end := time.Date(y, endMonth, endDay, 23, 59, 59, 999999999, t.Location())
return !t.Before(start) && !t.After(end)
}
}
// weekdayBeforeHour returns a predicate that matches times on the given weekday
// strictly before the given hour (24-hour, e.g. 13 for 1pm).
func weekdayBeforeHour(weekday time.Weekday, hour int) DatePredicate {
return func(t time.Time) bool {
return t.Weekday() == weekday && t.Hour() < hour
}
}
// SeasonalPredicates maps theme names to their date predicates.
// Themes without a predicate here are not supported.
var SeasonalPredicates = map[string]DatePredicate{
"halloween": inMonth(time.October),
"christmas": dateRange(time.December, 1, time.December, 31),
"valentines": dateRange(time.February, 7, time.February, 14),
"st_patricks": dateRange(time.March, 15, time.March, 17),
"thanksgiving": dateRange(time.November, 22, time.November, 30),
"summer": inMonths(time.June, time.July, time.August),
"summer_blockbuster": inMonths(time.June, time.July, time.August),
"saturday_morning": weekdayBeforeHour(time.Saturday, 13),
}
// SeasonalThemeOrder lists themes from most-specific to most-general. When
// multiple enabled themes match `now` (e.g. a Saturday morning in October
// triggers both halloween and saturday_morning), the earlier entry wins.
// Themes not listed here cannot participate in EnabledThemes auto-cycle.
var SeasonalThemeOrder = []string{
"valentines",
"st_patricks",
"thanksgiving",
"christmas",
"halloween",
"saturday_morning",
"summer_blockbuster",
"summer",
}
// SeasonalThemedParams configures the seasonal_themed resolver.
//
// There are two modes of use:
//
// 1. Multi-theme auto-cycle (preferred): set EnabledThemes to the list of
// themes the admin wants this section to rotate through. The fetcher picks
// the first SeasonalThemeOrder entry whose predicate matches now and uses
// it; if none match, the section is empty (suppressed off-season).
//
// 2. Legacy single-theme: set Theme to a single theme key. Mode controls
// whether the section is hidden off-season (auto, default) or pinned.
// Retained for backward compat with sections saved before the multi-theme
// shape; new gallery saves should use EnabledThemes.
//
// EnabledThemes takes precedence when both fields are populated.
//
// ThemeTitles is an optional per-theme display-name override. When the active
// theme has a non-empty entry here, the API replaces the section's saved Title
// with it for the duration of the in-season window — letting one section read
// "Halloween Picks" in October and "Christmas Movies" in December.
type SeasonalThemedParams struct {
EnabledThemes []string `json:"enabled_themes,omitempty"`
ThemeTitles map[string]string `json:"theme_titles,omitempty"`
// Legacy single-theme fields. Still honored when EnabledThemes is empty.
Theme string `json:"theme,omitempty"`
Mode string `json:"mode,omitempty"`
}
var validSeasonalModes = map[string]bool{
"": true, // valid; treated as "auto"
"auto": true,
"pinned": true,
}
type seasonalRecipe struct{}
func (seasonalRecipe) Type() string { return "seasonal_themed" }
func (seasonalRecipe) NewParams() any { return &SeasonalThemedParams{} }
func (seasonalRecipe) DefaultCacheTTL() time.Duration { return 24 * time.Hour }
func (seasonalRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
return delegateResolve("seasonal_themed", rc)
}
func (seasonalRecipe) Validate(raw json.RawMessage) error {
if len(raw) == 0 {
return errors.New("seasonal_themed: enabled_themes or theme is required")
}
var p SeasonalThemedParams
if err := json.Unmarshal(raw, &p); err != nil {
return err
}
// Multi-theme mode wins when populated.
if len(p.EnabledThemes) > 0 {
for _, t := range p.EnabledThemes {
if _, ok := SeasonalPredicates[t]; !ok {
return fmt.Errorf("seasonal_themed: unknown theme %q in enabled_themes", t)
}
}
// Mode is ignored in multi-theme mode (the off-season behaviour is
// implicit) but still validated if the caller sent something nonsense.
if !validSeasonalModes[p.Mode] {
return fmt.Errorf("seasonal_themed: invalid mode %q (want auto|pinned)", p.Mode)
}
return nil
}
// Legacy single-theme path.
if p.Theme == "" {
return errors.New("seasonal_themed: enabled_themes or theme is required")
}
if _, ok := SeasonalPredicates[p.Theme]; !ok {
return fmt.Errorf("seasonal_themed: unknown theme %q", p.Theme)
}
if !validSeasonalModes[p.Mode] {
return fmt.Errorf("seasonal_themed: invalid mode %q (want auto|pinned)", p.Mode)
}
return nil
}
func (seasonalRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "seasonal_themed",
Category: CategorySeasonal,
SupportsRotation: false,
AvoidDuplicates: false,
Presets: []GalleryPreset{
{
Key: "se_auto",
DisplayName: "Seasonal Picks",
Icon: "🗓️",
DescriptionShort: "Cycles automatically — Halloween in October, Christmas in December, etc.",
DescriptionLong: "One section that swaps in different theme picks based on the calendar. Toggle which holidays this profile celebrates from the Add section dialog.",
DefaultParams: json.RawMessage(
`{"enabled_themes":["halloween","christmas","valentines","st_patricks","thanksgiving","summer_blockbuster","saturday_morning"]}`,
),
},
},
}
}
// SeasonalTitleOverride returns the per-theme display name for the active
// theme, or "" when no override is configured (or no theme is active). The
// fetcher applies the override only when non-empty, so callers can fall back
// to the section's saved Title.
func SeasonalTitleOverride(p SeasonalThemedParams, now time.Time) string {
var theme string
switch {
case len(p.EnabledThemes) > 0:
theme = ActiveSeasonalTheme(p.EnabledThemes, now)
case p.Theme != "":
// Legacy mode — only honour an override if the predicate currently fires.
pred, ok := SeasonalPredicates[p.Theme]
if ok && pred(now) {
theme = p.Theme
}
}
if theme == "" {
return ""
}
return p.ThemeTitles[theme]
}
// ActiveSeasonalTheme returns the highest-priority enabled theme whose
// predicate matches `now`. Returns "" when no enabled theme is in season.
// Themes not in SeasonalThemeOrder are skipped silently.
func ActiveSeasonalTheme(enabled []string, now time.Time) string {
if len(enabled) == 0 {
return ""
}
enabledSet := make(map[string]bool, len(enabled))
for _, t := range enabled {
enabledSet[t] = true
}
for _, t := range SeasonalThemeOrder {
if !enabledSet[t] {
continue
}
pred, ok := SeasonalPredicates[t]
if !ok {
continue
}
if pred(now) {
return t
}
}
return ""
}
func init() {
Register(seasonalRecipe{})
}