package generatedtext import ( "fmt" "sort" "strings" "time" "unicode" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) type HourlyRenderContext struct { Report HourlyReportContext GeneratedText Hourly Modules HourlyTemplateModules Collected facts.CollectedFacts Derived facts.DerivedFacts } type HourlyReportContext struct { Title string LocationName string GeneratedAt time.Time GeneratedAtLabel string ValidPeriod timeutil.Period ValidPeriodLabel string Timezone string } type HourlyTemplateModules struct { Metadata *briefing.MetadataModule CurrentConditions *briefing.CurrentConditionsModule HourlyForecast *briefing.HourlyForecastModule PrecipTiming *briefing.PrecipTimingModule AlertDigest *briefing.AlertDigestModule SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule AreaForecastDiscussion *briefing.AreaForecastDiscussionModule SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule WeatherStory *briefing.WeatherStoryModule } type TomorrowRenderContext struct { Report TomorrowReportContext GeneratedText Tomorrow Modules TomorrowTemplateModules Collected facts.CollectedFacts Derived facts.DerivedFacts } type TodayRenderContext struct { Report TodayReportContext GeneratedText Today Modules TodayTemplateModules Collected facts.CollectedFacts Derived facts.DerivedFacts } type TodayReportContext struct { Title string ForecastDate time.Time ForecastDateLabel string ForecastDayName string GeneratedAt time.Time GeneratedAtLabel string ValidPeriod timeutil.Period Timezone string } type TodayTemplateModules struct { Metadata *briefing.MetadataModule CurrentConditions *briefing.CurrentConditionsModule HourlyForecast *briefing.HourlyForecastModule DerivedDailySummary *briefing.DerivedDailySummaryModule DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule Dayparts []TodayDaypartContext PrecipTiming *briefing.PrecipTimingModule AlertDigest *briefing.AlertDigestModule SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule AreaForecastDiscussion *briefing.AreaForecastDiscussionModule SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule WeatherStory *briefing.WeatherStoryModule TodayPlanning *briefing.TodayPlanningModule } type TodayDaypartContext struct { Key string Summary briefing.DerivedDaypartSummaryModule } type TomorrowReportContext struct { Title string ForecastDate time.Time ForecastDateLabel string ForecastDayName string GeneratedAt time.Time GeneratedAtLabel string ValidPeriod timeutil.Period Timezone string } type TomorrowTemplateModules struct { Metadata *briefing.MetadataModule CurrentConditions *briefing.CurrentConditionsModule HourlyForecast *briefing.HourlyForecastModule DerivedDailySummary *briefing.DerivedDailySummaryModule DerivedDaypartSummaries *map[string]briefing.DerivedDaypartSummaryModule Dayparts []TomorrowDaypartContext PrecipTiming *briefing.PrecipTimingModule AlertDigest *briefing.AlertDigestModule SPCConvectiveOutlooks *briefing.SPCConvectiveOutlooksModule AreaForecastDiscussion *briefing.AreaForecastDiscussionModule SPCConvectiveDiscussion *briefing.SPCConvectiveDiscussionModule WeatherStory *briefing.WeatherStoryModule TomorrowPlanning *briefing.TomorrowPlanningModule } type TomorrowDaypartContext struct { Key string Summary briefing.DerivedDaypartSummaryModule } func BuildHourlyRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Hourly, collected facts.CollectedFacts, derived facts.DerivedFacts) (HourlyRenderContext, error) { location, err := timeutil.LoadLocation(metadata.Timezone) if err != nil { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: %w", err) } if metadata.GeneratedAt.IsZero() { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: generatedAt is required") } if !metadata.ValidPeriod.IsValid() { return HourlyRenderContext{}, fmt.Errorf("build hourly render context: valid period is required") } modules, err := hourlyTemplateModules(snapshot) if err != nil { return HourlyRenderContext{}, err } return HourlyRenderContext{ Report: HourlyReportContext{ Title: "Hourly Report", LocationName: locationName(metadata), GeneratedAt: metadata.GeneratedAt, GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location), ValidPeriod: metadata.ValidPeriod, ValidPeriodLabel: periodLabel(metadata.ValidPeriod, location), Timezone: metadata.Timezone, }, GeneratedText: generated, Modules: modules, Collected: collected, Derived: derived, }, nil } func BuildTodayRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Today, collected facts.CollectedFacts, derived facts.DerivedFacts) (TodayRenderContext, error) { location, err := timeutil.LoadLocation(metadata.Timezone) if err != nil { return TodayRenderContext{}, fmt.Errorf("build today render context: %w", err) } if metadata.GeneratedAt.IsZero() { return TodayRenderContext{}, fmt.Errorf("build today render context: generatedAt is required") } if !metadata.ValidPeriod.IsValid() { return TodayRenderContext{}, fmt.Errorf("build today render context: valid period is required") } modules, err := todayTemplateModules(snapshot, derived) if err != nil { return TodayRenderContext{}, err } forecastDate := metadata.ValidPeriod.Start.In(location) return TodayRenderContext{ Report: TodayReportContext{ Title: "Today's Weather", ForecastDate: forecastDate, ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"), ForecastDayName: forecastDate.Format("Monday"), GeneratedAt: metadata.GeneratedAt, GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location), ValidPeriod: metadata.ValidPeriod, Timezone: metadata.Timezone, }, GeneratedText: generated, Modules: modules, Collected: collected, Derived: derived, }, nil } func BuildTomorrowRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, generated Tomorrow, collected facts.CollectedFacts, derived facts.DerivedFacts) (TomorrowRenderContext, error) { location, err := timeutil.LoadLocation(metadata.Timezone) if err != nil { return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: %w", err) } if metadata.GeneratedAt.IsZero() { return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: generatedAt is required") } if !metadata.ValidPeriod.IsValid() { return TomorrowRenderContext{}, fmt.Errorf("build tomorrow render context: valid period is required") } modules, err := tomorrowTemplateModules(snapshot, derived) if err != nil { return TomorrowRenderContext{}, err } forecastDate := metadata.ValidPeriod.Start.In(location) forecastDayName := forecastDate.Format("Monday") return TomorrowRenderContext{ Report: TomorrowReportContext{ Title: forecastDayName + "'s Weather", ForecastDate: forecastDate, ForecastDateLabel: forecastDate.Format("Monday, January 2, 2006"), ForecastDayName: forecastDayName, GeneratedAt: metadata.GeneratedAt, GeneratedAtLabel: generatedAtLabel(metadata.GeneratedAt, location), ValidPeriod: metadata.ValidPeriod, Timezone: metadata.Timezone, }, GeneratedText: generated, Modules: modules, Collected: collected, Derived: derived, }, nil } func todayTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TodayTemplateModules, error) { lookup := newModuleSnapshotLookup(snapshot) metadata, err := lookup.metadata() if err != nil { return TodayTemplateModules{}, err } current, err := lookup.currentConditions() if err != nil { return TodayTemplateModules{}, err } hourly, err := lookup.hourlyForecast() if err != nil { return TodayTemplateModules{}, err } daily, err := lookup.derivedDailySummary() if err != nil { return TodayTemplateModules{}, err } dayparts, err := lookup.derivedDaypartSummaries() if err != nil { return TodayTemplateModules{}, err } precip, err := lookup.precipTiming() if err != nil { return TodayTemplateModules{}, err } alerts, err := lookup.alertDigest() if err != nil { return TodayTemplateModules{}, err } outlooks, err := lookup.spcConvectiveOutlooks() if err != nil { return TodayTemplateModules{}, err } discussion, err := lookup.areaForecastDiscussion() if err != nil { return TodayTemplateModules{}, err } spcDiscussion, err := lookup.spcConvectiveDiscussion() if err != nil { return TodayTemplateModules{}, err } story, err := lookup.weatherStory() if err != nil { return TodayTemplateModules{}, err } planning, err := lookup.todayPlanning() if err != nil { return TodayTemplateModules{}, err } return TodayTemplateModules{ Metadata: metadata, CurrentConditions: current, HourlyForecast: hourly, DerivedDailySummary: daily, DerivedDaypartSummaries: dayparts, Dayparts: orderedTodayDayparts(dayparts, derived.DaypartSummaries), PrecipTiming: precip, AlertDigest: alerts, SPCConvectiveOutlooks: outlooks, AreaForecastDiscussion: discussion, SPCConvectiveDiscussion: spcDiscussion, WeatherStory: story, TodayPlanning: planning, }, nil } func hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) { lookup := newModuleSnapshotLookup(snapshot) metadata, err := lookup.metadata() if err != nil { return HourlyTemplateModules{}, err } current, err := lookup.currentConditions() if err != nil { return HourlyTemplateModules{}, err } hourly, err := lookup.hourlyForecast() if err != nil { return HourlyTemplateModules{}, err } precip, err := lookup.precipTiming() if err != nil { return HourlyTemplateModules{}, err } alerts, err := lookup.alertDigest() if err != nil { return HourlyTemplateModules{}, err } outlooks, err := lookup.spcConvectiveOutlooks() if err != nil { return HourlyTemplateModules{}, err } discussion, err := lookup.areaForecastDiscussion() if err != nil { return HourlyTemplateModules{}, err } spcDiscussion, err := lookup.spcConvectiveDiscussion() if err != nil { return HourlyTemplateModules{}, err } story, err := lookup.weatherStory() if err != nil { return HourlyTemplateModules{}, err } return HourlyTemplateModules{ Metadata: metadata, CurrentConditions: current, HourlyForecast: hourly, PrecipTiming: precip, AlertDigest: alerts, SPCConvectiveOutlooks: outlooks, AreaForecastDiscussion: discussion, SPCConvectiveDiscussion: spcDiscussion, WeatherStory: story, }, nil } func tomorrowTemplateModules(snapshot module.Snapshot, derived facts.DerivedFacts) (TomorrowTemplateModules, error) { lookup := newModuleSnapshotLookup(snapshot) metadata, err := lookup.metadata() if err != nil { return TomorrowTemplateModules{}, err } current, err := lookup.currentConditions() if err != nil { return TomorrowTemplateModules{}, err } hourly, err := lookup.hourlyForecast() if err != nil { return TomorrowTemplateModules{}, err } daily, err := lookup.derivedDailySummary() if err != nil { return TomorrowTemplateModules{}, err } dayparts, err := lookup.derivedDaypartSummaries() if err != nil { return TomorrowTemplateModules{}, err } precip, err := lookup.precipTiming() if err != nil { return TomorrowTemplateModules{}, err } alerts, err := lookup.alertDigest() if err != nil { return TomorrowTemplateModules{}, err } outlooks, err := lookup.spcConvectiveOutlooks() if err != nil { return TomorrowTemplateModules{}, err } discussion, err := lookup.areaForecastDiscussion() if err != nil { return TomorrowTemplateModules{}, err } spcDiscussion, err := lookup.spcConvectiveDiscussion() if err != nil { return TomorrowTemplateModules{}, err } story, err := lookup.weatherStory() if err != nil { return TomorrowTemplateModules{}, err } planning, err := lookup.tomorrowPlanning() if err != nil { return TomorrowTemplateModules{}, err } return TomorrowTemplateModules{ Metadata: metadata, CurrentConditions: current, HourlyForecast: hourly, DerivedDailySummary: daily, DerivedDaypartSummaries: dayparts, Dayparts: orderedTomorrowDayparts(dayparts, derived.DaypartSummaries), PrecipTiming: precip, AlertDigest: alerts, SPCConvectiveOutlooks: outlooks, AreaForecastDiscussion: discussion, SPCConvectiveDiscussion: spcDiscussion, WeatherStory: story, TomorrowPlanning: planning, }, nil } type moduleSnapshotLookup struct { snapshot module.Snapshot stanzas map[string]module.Output } func newModuleSnapshotLookup(snapshot module.Snapshot) moduleSnapshotLookup { stanzas := make(map[string]module.Output, len(snapshot.Outputs)) for _, output := range snapshot.Outputs { stanzas[output.StanzaName] = output } return moduleSnapshotLookup{ snapshot: snapshot, stanzas: stanzas, } } func (lookup moduleSnapshotLookup) metadata() (*briefing.MetadataModule, error) { return optionalStanza[briefing.MetadataModule](lookup, module.Metadata) } func (lookup moduleSnapshotLookup) currentConditions() (*briefing.CurrentConditionsModule, error) { return optionalStanza[briefing.CurrentConditionsModule](lookup, module.CurrentConditions) } func (lookup moduleSnapshotLookup) hourlyForecast() (*briefing.HourlyForecastModule, error) { return optionalStanza[briefing.HourlyForecastModule](lookup, module.HourlyForecast) } func (lookup moduleSnapshotLookup) derivedDailySummary() (*briefing.DerivedDailySummaryModule, error) { return optionalStanza[briefing.DerivedDailySummaryModule](lookup, module.DerivedDailySummary) } func (lookup moduleSnapshotLookup) derivedDaypartSummaries() (*map[string]briefing.DerivedDaypartSummaryModule, error) { return optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](lookup, module.DerivedDaypartSummaries) } func (lookup moduleSnapshotLookup) precipTiming() (*briefing.PrecipTimingModule, error) { return optionalStanza[briefing.PrecipTimingModule](lookup, module.PrecipTiming) } func (lookup moduleSnapshotLookup) alertDigest() (*briefing.AlertDigestModule, error) { return optionalStanza[briefing.AlertDigestModule](lookup, module.AlertDigest) } func (lookup moduleSnapshotLookup) spcConvectiveOutlooks() (*briefing.SPCConvectiveOutlooksModule, error) { return optionalStanza[briefing.SPCConvectiveOutlooksModule](lookup, module.SPCConvectiveOutlooks) } func (lookup moduleSnapshotLookup) areaForecastDiscussion() (*briefing.AreaForecastDiscussionModule, error) { return optionalStanza[briefing.AreaForecastDiscussionModule](lookup, module.AreaForecastDiscussion) } func (lookup moduleSnapshotLookup) spcConvectiveDiscussion() (*briefing.SPCConvectiveDiscussionModule, error) { return optionalStanza[briefing.SPCConvectiveDiscussionModule](lookup, module.SPCConvectiveDiscussion) } func (lookup moduleSnapshotLookup) weatherStory() (*briefing.WeatherStoryModule, error) { return optionalStanza[briefing.WeatherStoryModule](lookup, module.WeatherStory) } func (lookup moduleSnapshotLookup) todayPlanning() (*briefing.TodayPlanningModule, error) { return optionalStanza[briefing.TodayPlanningModule](lookup, module.TodayPlanning) } func (lookup moduleSnapshotLookup) tomorrowPlanning() (*briefing.TomorrowPlanningModule, error) { return optionalStanza[briefing.TomorrowPlanningModule](lookup, module.TomorrowPlanning) } func optionalStanza[T any](lookup moduleSnapshotLookup, id module.ID) (*T, error) { name := string(id) output, ok := lookup.stanzas[name] if !ok || output.Value == nil { return nil, nil } value, _, err := module.StanzaValue[T](lookup.snapshot, name) if err != nil { return nil, fmt.Errorf("build render context module %q: %w", id, err) } return &value, nil } func orderedTodayDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TodayDaypartContext { orderedRows := orderedDaypartRows(dayparts, ordered) out := make([]TodayDaypartContext, 0, len(orderedRows)) for _, row := range orderedRows { out = append(out, TodayDaypartContext{Key: row.Key, Summary: row.Summary}) } return out } func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext { orderedRows := orderedDaypartRows(dayparts, ordered) out := make([]TomorrowDaypartContext, 0, len(orderedRows)) for _, row := range orderedRows { out = append(out, TomorrowDaypartContext{Key: row.Key, Summary: row.Summary}) } return out } type orderedDaypartRow struct { Key string Summary briefing.DerivedDaypartSummaryModule } func orderedDaypartRows(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []orderedDaypartRow { if dayparts == nil || len(*dayparts) == 0 { return nil } out := make([]orderedDaypartRow, 0, len(*dayparts)) seen := map[string]struct{}{} for _, daypart := range ordered { for _, key := range daypartModuleKeyCandidates(daypart) { value, ok := (*dayparts)[key] if !ok { continue } if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} out = append(out, orderedDaypartRow{Key: key, Summary: value}) break } } var remaining []string for key := range *dayparts { if _, ok := seen[key]; !ok { remaining = append(remaining, key) } } sort.Strings(remaining) for _, key := range remaining { out = append(out, orderedDaypartRow{Key: key, Summary: (*dayparts)[key]}) } return out } func daypartModuleKeyCandidates(daypart forecast.DaypartSummary) []string { key := normalizedDaypartKey(daypart.Name) if key == "" { key = "unnamed" } if daypart.Period.Start.IsZero() { return []string{key} } return []string{key, daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key} } func normalizedDaypartKey(value string) string { lower := strings.ToLower(strings.TrimSpace(value)) var out strings.Builder lastUnderscore := false for _, r := range lower { if unicode.IsLetter(r) || unicode.IsDigit(r) { out.WriteRune(r) lastUnderscore = false continue } if !lastUnderscore { out.WriteByte('_') lastUnderscore = true } } return strings.Trim(out.String(), "_") } func locationName(metadata briefing.Metadata) string { if metadata.Location != nil { if metadata.Location.Name != "" && metadata.Location.Region != "" { return metadata.Location.Name + ", " + metadata.Location.Region } if metadata.Location.Name != "" { return metadata.Location.Name } } if metadata.SourceLocation != "" { return metadata.SourceLocation } if metadata.SourceLocationID != "" { return metadata.SourceLocationID } return "Unknown location" } func periodLabel(period timeutil.Period, location *time.Location) string { return fmt.Sprintf("%s to %s", timeLabel(period.Start, location), timeLabel(period.End, location)) } func timeLabel(value time.Time, location *time.Location) string { return value.In(location).Format("2006-01-02 at 3:04 PM") } func generatedAtLabel(value time.Time, location *time.Location) string { return value.In(location).Format("Monday, January 2, 2006 at 3:04 PM") }