374 lines
13 KiB
Go
374 lines
13 KiB
Go
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 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 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 hourlyTemplateModules(snapshot module.Snapshot) (HourlyTemplateModules, error) {
|
|
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
|
if err != nil {
|
|
return HourlyTemplateModules{}, err
|
|
}
|
|
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
|
if err != nil {
|
|
return HourlyTemplateModules{}, err
|
|
}
|
|
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
|
if err != nil {
|
|
return HourlyTemplateModules{}, err
|
|
}
|
|
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
|
if err != nil {
|
|
return HourlyTemplateModules{}, err
|
|
}
|
|
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
|
if err != nil {
|
|
return HourlyTemplateModules{}, err
|
|
}
|
|
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
|
if err != nil {
|
|
return HourlyTemplateModules{}, err
|
|
}
|
|
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
|
if err != nil {
|
|
return HourlyTemplateModules{}, err
|
|
}
|
|
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
|
if err != nil {
|
|
return HourlyTemplateModules{}, err
|
|
}
|
|
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.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) {
|
|
metadata, err := optionalStanza[briefing.MetadataModule](snapshot, string(module.Metadata))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
current, err := optionalStanza[briefing.CurrentConditionsModule](snapshot, string(module.CurrentConditions))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
hourly, err := optionalStanza[briefing.HourlyForecastModule](snapshot, string(module.HourlyForecast))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
daily, err := optionalStanza[briefing.DerivedDailySummaryModule](snapshot, string(module.DerivedDailySummary))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
dayparts, err := optionalStanza[map[string]briefing.DerivedDaypartSummaryModule](snapshot, string(module.DerivedDaypartSummaries))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
precip, err := optionalStanza[briefing.PrecipTimingModule](snapshot, string(module.PrecipTiming))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
alerts, err := optionalStanza[briefing.AlertDigestModule](snapshot, string(module.AlertDigest))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
outlooks, err := optionalStanza[briefing.SPCConvectiveOutlooksModule](snapshot, string(module.SPCConvectiveOutlooks))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
discussion, err := optionalStanza[briefing.AreaForecastDiscussionModule](snapshot, string(module.AreaForecastDiscussion))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
spcDiscussion, err := optionalStanza[briefing.SPCConvectiveDiscussionModule](snapshot, string(module.SPCConvectiveDiscussion))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
story, err := optionalStanza[briefing.WeatherStoryModule](snapshot, string(module.WeatherStory))
|
|
if err != nil {
|
|
return TomorrowTemplateModules{}, err
|
|
}
|
|
planning, err := optionalStanza[briefing.TomorrowPlanningModule](snapshot, string(module.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
|
|
}
|
|
|
|
func optionalStanza[T any](snapshot module.Snapshot, name string) (*T, error) {
|
|
output, ok := snapshot.LookupStanza(name)
|
|
if !ok || output.Value == nil {
|
|
return nil, nil
|
|
}
|
|
value, _, err := module.StanzaValue[T](snapshot, name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build render context: %w", err)
|
|
}
|
|
return &value, nil
|
|
}
|
|
|
|
func orderedTomorrowDayparts(dayparts *map[string]briefing.DerivedDaypartSummaryModule, ordered []forecast.DaypartSummary) []TomorrowDaypartContext {
|
|
if dayparts == nil || len(*dayparts) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]TomorrowDaypartContext, 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, TomorrowDaypartContext{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, TomorrowDaypartContext{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")
|
|
}
|