package main import ( "context" "fmt" "strings" "time" ) func runDiscussion(ctx context.Context, cache *Cache, loc Location, searchQuery, userAgent string) (string, error) { client := NewNWSClient(userAgent) if loc.OfficeID == "" { office, err := client.FetchOfficeID(ctx, loc) if err != nil { return "", err } loc.OfficeID = office if err := cache.UpdateLocation(loc); err != nil { if searchQuery == "" { return "", err } if err := cache.SaveLocation(searchQuery, loc); err != nil { return "", err } } } cached, ok, err := cache.LoadAFD() if err != nil { return "", err } productID, productURL, err := client.FetchLatestAFDMeta(ctx, loc.OfficeID) if err != nil { return "", err } if ok && cached.OfficeID == loc.OfficeID && cached.ProductID == productID { cached.CheckedAt = time.Now().UTC() if err := cache.SaveAFD(cached); err != nil { return "", err } return cached.Text, nil } text, issuanceTime, err := client.FetchAFDText(ctx, productURL) if err != nil { return "", err } payload := AFDCache{ OfficeID: loc.OfficeID, ProductID: productID, Text: text, IssuanceTime: issuanceTime, CheckedAt: time.Now().UTC(), } if err := cache.SaveAFD(payload); err != nil { return "", err } return text, nil } func (c *NWSClient) FetchLatestAFDMeta(ctx context.Context, officeID string) (string, string, error) { officeID = strings.TrimSpace(strings.ToUpper(officeID)) if officeID == "" { return "", "", fmt.Errorf("office id is empty") } url := fmt.Sprintf("https://api.weather.gov/products/types/AFD/locations/%s", officeID) var resp struct { Graph []struct { ID string `json:"id"` Ref string `json:"@id"` } `json:"@graph"` } if err := c.getJSON(ctx, url, &resp); err != nil { return "", "", err } if len(resp.Graph) == 0 { return "", "", fmt.Errorf("no AFD products for %s", officeID) } productID := strings.TrimSpace(resp.Graph[0].ID) productURL := strings.TrimSpace(resp.Graph[0].Ref) if productID == "" || productURL == "" { return "", "", fmt.Errorf("invalid AFD metadata") } return productID, productURL, nil } func (c *NWSClient) FetchAFDText(ctx context.Context, url string) (string, string, error) { var resp struct { ProductText string `json:"productText"` IssuanceTime string `json:"issuanceTime"` } if err := c.getJSON(ctx, url, &resp); err != nil { return "", "", err } text := sanitizeAFDText(resp.ProductText) if text == "" { return "", "", fmt.Errorf("AFD product text missing") } return text, strings.TrimSpace(resp.IssuanceTime), nil } func sanitizeAFDText(text string) string { lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { lines = lines[1:] } if len(lines) == 0 { return "" } for i, line := range lines { if strings.Contains(strings.ToLower(line), "area forecast discussion") { lines = lines[i:] break } } for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { lines = lines[1:] } return strings.TrimSpace(strings.Join(lines, "\n")) } func formatAFDMarkdown(text string) string { lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") out := make([]string, 0, len(lines)) titleDone := false for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "&&" { continue } if !titleDone && strings.EqualFold(trimmed, "Area Forecast Discussion") { out = append(out, "# Area Forecast Discussion") titleDone = true continue } if header, rest := splitKeyMessage(trimmed); header != "" { out = append(out, "## "+header) if rest != "" { out = append(out, rest) } continue } if isAFDHeaderLine(trimmed) { title := afdHeaderToTitle(trimmed) out = append(out, "# "+title) continue } out = append(out, line) } return strings.TrimSpace(strings.Join(out, "\n")) } func isAFDHeaderLine(line string) bool { if !strings.HasPrefix(line, ".") { return false } return strings.HasSuffix(line, "...") || strings.HasSuffix(line, ".") } func afdHeaderToTitle(line string) string { trimmed := strings.Trim(line, ". ") parts := strings.Fields(trimmed) for i, part := range parts { subparts := strings.Split(part, "/") for j, sub := range subparts { subparts[j] = titleWord(sub) } parts[i] = strings.Join(subparts, "/") } return strings.Join(parts, " ") } func titleWord(word string) string { if word == "" { return "" } lower := strings.ToLower(word) r := []rune(lower) r[0] = []rune(strings.ToUpper(string(r[0])))[0] return string(r) } func splitKeyMessage(line string) (string, string) { lower := strings.ToLower(line) if !strings.HasPrefix(lower, "key message") { return "", "" } parts := strings.SplitN(line, "...", 2) header := strings.TrimSpace(parts[0]) rest := "" if len(parts) > 1 { rest = strings.TrimSpace(parts[1]) } fields := strings.Fields(header) if len(fields) < 2 { return "", "" } fields[0] = "Key" fields[1] = "Message" return strings.Join(fields, " "), rest }