// Package briefing builds report-specific structured briefing packages. package briefing import ( "encoding/json" "fmt" "os" "path/filepath" "time" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" "gitea.maximumdirect.net/eric/weatherreporter/internal/report" "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" ) const SchemaVersion = "weatherreporter.briefing.v1" type Package struct { Metadata Metadata `json:"metadata"` Daily *Daily `json:"daily,omitempty"` ThreeDay *ThreeDay `json:"threeDay,omitempty"` Weekend *Weekend `json:"weekend,omitempty"` Storm *Storm `json:"storm,omitempty"` } type Metadata struct { SchemaVersion string `json:"schemaVersion"` RunID string `json:"runId"` ReportID report.ID `json:"reportId"` Variant string `json:"variant,omitempty"` PromptID string `json:"promptId"` GeneratedAt time.Time `json:"generatedAt"` Units string `json:"units"` Timezone string `json:"timezone"` ValidPeriod timeutil.Period `json:"validPeriod"` SourceLocationID string `json:"sourceLocationId,omitempty"` SourceLocation string `json:"sourceLocation,omitempty"` Sources []SourceMetadata `json:"sources,omitempty"` SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"` } type SourceMetadata struct { Name string `json:"name"` Endpoint string `json:"endpoint,omitempty"` FetchedAt time.Time `json:"fetchedAt"` IssuedAt *time.Time `json:"issuedAt,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` DataSHA256 string `json:"dataSha256,omitempty"` Missing bool `json:"missing,omitempty"` Warnings []forecast.SourceWarning `json:"warnings,omitempty"` } type BuildContext struct { Resolved report.Resolved Bundle *forecast.Bundle Units string Timezone string } func BuildMetadata(ctx BuildContext) Metadata { metadata := ctx.Resolved.Metadata() sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle) return Metadata{ SchemaVersion: SchemaVersion, RunID: metadata.RunID, ReportID: metadata.ReportID, Variant: variantForReport(metadata.ReportID), PromptID: metadata.PromptID, GeneratedAt: metadata.GeneratedAt, Units: ctx.Units, Timezone: ctx.Timezone, ValidPeriod: metadata.ValidPeriod, SourceLocationID: sourceLocationID, SourceLocation: sourceLocation, Sources: sourceMetadata(ctx.Bundle), SourceWarnings: sourceWarnings(ctx.Bundle), } } func Save(path string, pkg Package) error { data, err := json.MarshalIndent(pkg, "", " ") if err != nil { return fmt.Errorf("marshal briefing package: %w", err) } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create briefing directory %q: %w", filepath.Dir(path), err) } tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp") if err != nil { return fmt.Errorf("create temporary briefing file: %w", err) } tmpName := tmp.Name() defer os.Remove(tmpName) if _, err := tmp.Write(data); err != nil { tmp.Close() return fmt.Errorf("write temporary briefing file: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close temporary briefing file: %w", err) } if err := os.Rename(tmpName, path); err != nil { return fmt.Errorf("save briefing %q: %w", path, err) } return nil } func sourceLocation(bundle *forecast.Bundle) (string, string) { if bundle == nil { return "", "" } for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} { if run == nil { continue } if run.LocationID != "" || run.LocationName != "" { return run.LocationID, run.LocationName } } return "", "" } func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata { if bundle == nil { return nil } out := make([]SourceMetadata, 0, len(bundle.Sources)) for _, source := range bundle.Sources { out = append(out, SourceMetadata{ Name: source.Name, Endpoint: source.Endpoint, FetchedAt: source.FetchedAt, IssuedAt: source.IssuedAt, UpdatedAt: source.UpdatedAt, DataSHA256: source.DataSHA256, Missing: source.Missing, Warnings: source.Warnings, }) } return out } func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning { if bundle == nil { return nil } return bundle.Warnings } func variantForReport(id report.ID) string { switch id { case report.DailyToday: return "today" case report.DailyTomorrow: return "tomorrow" default: return "" } }