294 lines
10 KiB
Go
294 lines
10 KiB
Go
package briefing
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
|
|
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
|
|
)
|
|
|
|
type ModuleContext struct {
|
|
Resolved report.Resolved
|
|
Collected facts.CollectedFacts
|
|
Derived facts.DerivedFacts
|
|
Units string
|
|
Timezone string
|
|
Location *LocationContext
|
|
}
|
|
|
|
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
|
|
|
|
type ModuleDefinition struct {
|
|
ID module.ID
|
|
StanzaName string
|
|
DefaultOptions any
|
|
RequiredCollected []module.FactRequirement
|
|
RequiredDerived []module.FactRequirement
|
|
SupportedReports []report.ID
|
|
MissingData module.MissingDataBehavior
|
|
AllowDuplicate bool
|
|
Builder ModuleBuilder
|
|
}
|
|
|
|
type ModuleRegistry struct {
|
|
definitions map[module.ID]ModuleDefinition
|
|
}
|
|
|
|
func DefaultModuleRegistry() (ModuleRegistry, error) {
|
|
return NewModuleRegistry(defaultModuleDefinitions())
|
|
}
|
|
|
|
func MustDefaultModuleRegistry() ModuleRegistry {
|
|
registry, err := DefaultModuleRegistry()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return registry
|
|
}
|
|
|
|
func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
|
|
registry := ModuleRegistry{definitions: map[module.ID]ModuleDefinition{}}
|
|
seenStanzas := map[string]module.ID{}
|
|
for i, definition := range definitions {
|
|
if definition.ID == "" {
|
|
return ModuleRegistry{}, fmt.Errorf("module definition[%d].id is required", i)
|
|
}
|
|
if definition.StanzaName == "" {
|
|
return ModuleRegistry{}, fmt.Errorf("module %q stanza name is required", definition.ID)
|
|
}
|
|
if _, ok := registry.definitions[definition.ID]; ok {
|
|
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
|
|
}
|
|
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
|
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
|
|
}
|
|
seenStanzas[definition.StanzaName] = definition.ID
|
|
registry.definitions[definition.ID] = definition
|
|
}
|
|
return registry, nil
|
|
}
|
|
|
|
func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) {
|
|
definition, ok := r.definitions[id]
|
|
if !ok {
|
|
return ModuleDefinition{}, fmt.Errorf("unknown module %q", id)
|
|
}
|
|
return definition, nil
|
|
}
|
|
|
|
func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (*module.Output, error) {
|
|
definition, err := r.Lookup(item.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
|
|
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
|
|
}
|
|
if err := definition.ValidateOptions(item.Options); err != nil {
|
|
return nil, err
|
|
}
|
|
if definition.Builder == nil {
|
|
return nil, fmt.Errorf("module %q has no builder", item.ID)
|
|
}
|
|
options := item.Options
|
|
if options == nil {
|
|
options = definition.DefaultOptions
|
|
}
|
|
output, err := definition.Builder(ctx, options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if output == nil {
|
|
return nil, nil
|
|
}
|
|
if output.ID != definition.ID {
|
|
return nil, fmt.Errorf("module %q produced output id %q", definition.ID, output.ID)
|
|
}
|
|
if output.StanzaName != definition.StanzaName {
|
|
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
|
|
seenModules := map[module.ID]struct{}{}
|
|
seenStanzas := map[string]module.ID{}
|
|
for i, item := range items {
|
|
definition, err := r.Lookup(item.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("modules[%d]: %w", i, err)
|
|
}
|
|
if _, ok := seenModules[item.ID]; ok && !definition.AllowDuplicate {
|
|
return fmt.Errorf("modules[%d]: duplicate module %q", i, item.ID)
|
|
}
|
|
seenModules[item.ID] = struct{}{}
|
|
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
|
|
return fmt.Errorf("modules[%d]: duplicate stanza name %q for modules %q and %q", i, definition.StanzaName, existingID, item.ID)
|
|
}
|
|
seenStanzas[definition.StanzaName] = item.ID
|
|
if !definition.SupportsReport(reportID) {
|
|
return fmt.Errorf("modules[%d]: module %q is not compatible with report %q", i, item.ID, reportID)
|
|
}
|
|
if err := definition.ValidateOptions(item.Options); err != nil {
|
|
return fmt.Errorf("modules[%d]: %w", i, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d ModuleDefinition) SupportsReport(id report.ID) bool {
|
|
if len(d.SupportedReports) == 0 {
|
|
return true
|
|
}
|
|
for _, supported := range d.SupportedReports {
|
|
if supported == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (d ModuleDefinition) ValidateOptions(options any) error {
|
|
if options == nil {
|
|
return nil
|
|
}
|
|
if d.DefaultOptions == nil {
|
|
return fmt.Errorf("module %q does not accept options", d.ID)
|
|
}
|
|
want := reflect.TypeOf(d.DefaultOptions)
|
|
got := reflect.TypeOf(options)
|
|
if got == want {
|
|
return nil
|
|
}
|
|
if got.Kind() == reflect.Pointer && got.Elem() == want {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
|
|
}
|
|
|
|
func defaultModuleDefinitions() []ModuleDefinition {
|
|
allReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend, report.Storm}
|
|
daypartReports := []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay, report.Weekend}
|
|
return []ModuleDefinition{
|
|
{
|
|
ID: module.Metadata,
|
|
StanzaName: "metadata",
|
|
DefaultOptions: module.MetadataOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataEmpty,
|
|
Builder: buildMetadataModule,
|
|
},
|
|
{
|
|
ID: module.CurrentConditions,
|
|
StanzaName: "current_conditions",
|
|
DefaultOptions: module.CurrentConditionsOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataOmit,
|
|
Builder: buildCurrentConditionsModule,
|
|
},
|
|
{
|
|
ID: module.DerivedDailySummary,
|
|
StanzaName: "derived_daily_summary",
|
|
DefaultOptions: module.DerivedDailySummaryOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
|
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow},
|
|
MissingData: module.MissingDataError,
|
|
},
|
|
{
|
|
ID: module.DerivedDaypartSummaries,
|
|
StanzaName: "derived_daypart_summaries",
|
|
DefaultOptions: module.DerivedDaypartSummariesOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
|
SupportedReports: daypartReports,
|
|
MissingData: module.MissingDataError,
|
|
},
|
|
{
|
|
ID: module.HourlyTable,
|
|
StanzaName: "hourly_table",
|
|
DefaultOptions: module.HourlyTableOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
|
|
SupportedReports: []report.ID{report.Storm},
|
|
MissingData: module.MissingDataError,
|
|
},
|
|
{
|
|
ID: module.PrecipTiming,
|
|
StanzaName: "precip_timing",
|
|
DefaultOptions: module.PrecipTimingOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataEmpty,
|
|
},
|
|
{
|
|
ID: module.AlertDigest,
|
|
StanzaName: "alert_digest",
|
|
DefaultOptions: module.AlertDigestOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataEmpty,
|
|
Builder: buildAlertDigestModule,
|
|
},
|
|
{
|
|
ID: module.AreaForecastDiscussion,
|
|
StanzaName: "area_forecast_discussion",
|
|
DefaultOptions: module.AreaForecastDiscussionOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataOmit,
|
|
Builder: buildAreaForecastDiscussionModule,
|
|
},
|
|
{
|
|
ID: module.WeatherStory,
|
|
StanzaName: "weather_story",
|
|
DefaultOptions: module.WeatherStoryOptions{},
|
|
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
|
|
SupportedReports: allReports,
|
|
MissingData: module.MissingDataOmit,
|
|
Builder: buildWeatherStoryModule,
|
|
},
|
|
{
|
|
ID: module.ForecastDelta,
|
|
StanzaName: "forecast_delta",
|
|
DefaultOptions: module.ForecastDeltaOptions{},
|
|
SupportedReports: []report.ID{report.DailyToday, report.DailyTomorrow, report.ThreeDay},
|
|
MissingData: module.MissingDataEmpty,
|
|
},
|
|
{
|
|
ID: module.OutdoorWindows,
|
|
StanzaName: "outdoor_windows",
|
|
DefaultOptions: module.OutdoorWindowsOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
|
|
SupportedReports: daypartReports,
|
|
MissingData: module.MissingDataEmpty,
|
|
},
|
|
{
|
|
ID: module.TomorrowPlanning,
|
|
StanzaName: "tomorrow_planning",
|
|
DefaultOptions: module.TomorrowPlanningOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
|
SupportedReports: []report.ID{report.DailyTomorrow},
|
|
MissingData: module.MissingDataEmpty,
|
|
},
|
|
{
|
|
ID: module.WeekendPlanning,
|
|
StanzaName: "weekend_planning",
|
|
DefaultOptions: module.WeekendPlanningOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
|
|
SupportedReports: []report.ID{report.Weekend},
|
|
MissingData: module.MissingDataEmpty,
|
|
},
|
|
{
|
|
ID: module.StormWindowSummary,
|
|
StanzaName: "storm_window_summary",
|
|
DefaultOptions: module.StormWindowSummaryOptions{},
|
|
RequiredDerived: []module.FactRequirement{module.RequiresDerivedStormWindowSummary},
|
|
SupportedReports: []report.ID{report.Storm},
|
|
MissingData: module.MissingDataError,
|
|
},
|
|
}
|
|
}
|