All checks were successful
ci/woodpecker/manual/build-image Pipeline was successful
902 lines
22 KiB
Go
902 lines
22 KiB
Go
package nws
|
|
|
|
import (
|
|
"fmt"
|
|
"html"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type ForecastDiscussion struct {
|
|
OfficeID string
|
|
OfficeName string
|
|
Product string
|
|
IssuedAt time.Time
|
|
UpdatedAt *time.Time
|
|
|
|
KeyMessages []string
|
|
ShortTerm *ForecastDiscussionSection
|
|
LongTerm *ForecastDiscussionSection
|
|
}
|
|
|
|
type ForecastDiscussionSection struct {
|
|
Qualifier string
|
|
IssuedAt *time.Time
|
|
Text string
|
|
}
|
|
|
|
type forecastDiscussionSectionRole uint8
|
|
|
|
const (
|
|
forecastDiscussionSectionRoleKeyMessages forecastDiscussionSectionRole = iota
|
|
forecastDiscussionSectionRoleShortTerm
|
|
forecastDiscussionSectionRoleLongTerm
|
|
)
|
|
|
|
type forecastDiscussionSectionHeading struct {
|
|
section string
|
|
qualifier string
|
|
}
|
|
|
|
type forecastDiscussionSectionBlock struct {
|
|
heading forecastDiscussionSectionHeading
|
|
body []string
|
|
}
|
|
|
|
var (
|
|
forecastDiscussionSectionRoles = map[string]forecastDiscussionSectionRole{
|
|
"KEY MESSAGES": forecastDiscussionSectionRoleKeyMessages,
|
|
"KEY POINTS": forecastDiscussionSectionRoleKeyMessages,
|
|
"SHORT TERM": forecastDiscussionSectionRoleShortTerm,
|
|
"LONG TERM": forecastDiscussionSectionRoleLongTerm,
|
|
}
|
|
forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`)
|
|
forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`)
|
|
forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`)
|
|
)
|
|
|
|
func ParseForecastDiscussionHTML(raw string) (ForecastDiscussion, error) {
|
|
text, err := ExtractForecastDiscussionText(raw)
|
|
if err != nil {
|
|
return ForecastDiscussion{}, err
|
|
}
|
|
|
|
parsed, err := ParseForecastDiscussionText(text)
|
|
if err != nil {
|
|
return ForecastDiscussion{}, err
|
|
}
|
|
|
|
parsed.UpdatedAt = parseForecastDiscussionUpdatedAt(raw)
|
|
return parsed, nil
|
|
}
|
|
|
|
func ExtractForecastDiscussionText(raw string) (string, error) {
|
|
lower := strings.ToLower(raw)
|
|
searchFrom := 0
|
|
for {
|
|
openStart := strings.Index(lower[searchFrom:], "<pre")
|
|
if openStart < 0 {
|
|
return "", fmt.Errorf("missing <pre class=\"glossaryProduct\"> block")
|
|
}
|
|
openStart += searchFrom
|
|
|
|
openEnd := strings.Index(lower[openStart:], ">")
|
|
if openEnd < 0 {
|
|
return "", fmt.Errorf("unterminated <pre> tag")
|
|
}
|
|
openEnd += openStart
|
|
|
|
tag := lower[openStart : openEnd+1]
|
|
if isGlossaryProductTag(tag) {
|
|
closeStart := strings.Index(lower[openEnd+1:], "</pre>")
|
|
if closeStart < 0 {
|
|
return "", fmt.Errorf("missing closing </pre> for glossaryProduct block")
|
|
}
|
|
closeStart += openEnd + 1
|
|
|
|
text := html.UnescapeString(raw[openEnd+1 : closeStart])
|
|
text = strings.ReplaceAll(text, "\r\n", "\n")
|
|
text = strings.ReplaceAll(text, "\r", "\n")
|
|
return text, nil
|
|
}
|
|
|
|
searchFrom = openEnd + 1
|
|
}
|
|
}
|
|
|
|
func ParseForecastDiscussionText(text string) (ForecastDiscussion, error) {
|
|
lines := splitLines(text)
|
|
|
|
officeID := parseForecastDiscussionOfficeID(lines)
|
|
officeName, issuedAt, err := parseForecastDiscussionHeader(lines)
|
|
if err != nil {
|
|
return ForecastDiscussion{}, err
|
|
}
|
|
|
|
out := ForecastDiscussion{
|
|
OfficeID: officeID,
|
|
OfficeName: officeName,
|
|
Product: "afd",
|
|
IssuedAt: issuedAt.UTC(),
|
|
}
|
|
|
|
seenRoles := make(map[forecastDiscussionSectionRole]bool, len(forecastDiscussionSectionRoles))
|
|
for _, block := range parseForecastDiscussionSectionBlocks(lines) {
|
|
role, ok := forecastDiscussionSectionRoles[block.heading.section]
|
|
if !ok || seenRoles[role] {
|
|
continue
|
|
}
|
|
seenRoles[role] = true
|
|
|
|
switch role {
|
|
case forecastDiscussionSectionRoleKeyMessages:
|
|
out.KeyMessages = parseForecastDiscussionKeyMessages(block.body)
|
|
case forecastDiscussionSectionRoleShortTerm:
|
|
section, err := parseForecastDiscussionTextSection(block)
|
|
if err != nil {
|
|
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
|
|
}
|
|
out.ShortTerm = §ion
|
|
case forecastDiscussionSectionRoleLongTerm:
|
|
section, err := parseForecastDiscussionTextSection(block)
|
|
if err != nil {
|
|
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
|
|
}
|
|
out.LongTerm = §ion
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func isGlossaryProductTag(tag string) bool {
|
|
tag = strings.ToLower(tag)
|
|
return strings.Contains(tag, `class="glossaryproduct"`) ||
|
|
strings.Contains(tag, `class='glossaryproduct'`) ||
|
|
strings.Contains(tag, `class="glossaryproduct `) ||
|
|
strings.Contains(tag, `class='glossaryproduct `)
|
|
}
|
|
|
|
func parseForecastDiscussionUpdatedAt(raw string) *time.Time {
|
|
lower := strings.ToLower(raw)
|
|
searchFrom := 0
|
|
for {
|
|
metaStart := strings.Index(lower[searchFrom:], "<meta")
|
|
if metaStart < 0 {
|
|
return nil
|
|
}
|
|
metaStart += searchFrom
|
|
|
|
metaEnd := strings.Index(lower[metaStart:], ">")
|
|
if metaEnd < 0 {
|
|
return nil
|
|
}
|
|
metaEnd += metaStart
|
|
|
|
tag := raw[metaStart : metaEnd+1]
|
|
if !strings.EqualFold(strings.TrimSpace(extractHTMLAttr(tag, "name")), "DC.date.created") {
|
|
searchFrom = metaEnd + 1
|
|
continue
|
|
}
|
|
|
|
content := strings.TrimSpace(extractHTMLAttr(tag, "content"))
|
|
if content == "" {
|
|
return nil
|
|
}
|
|
t, err := ParseTime(content)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
tt := t.UTC()
|
|
return &tt
|
|
}
|
|
}
|
|
|
|
func extractHTMLAttr(tag, attr string) string {
|
|
lower := strings.ToLower(tag)
|
|
attrLower := strings.ToLower(attr)
|
|
for i := 0; i < len(lower); i++ {
|
|
idx := strings.Index(lower[i:], attrLower)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
idx += i
|
|
if idx > 0 {
|
|
prev := lower[idx-1]
|
|
if isAttrNameChar(prev) {
|
|
i = idx + len(attrLower)
|
|
continue
|
|
}
|
|
}
|
|
j := idx + len(attrLower)
|
|
for j < len(lower) && isHTMLSpace(lower[j]) {
|
|
j++
|
|
}
|
|
if j >= len(lower) || lower[j] != '=' {
|
|
i = idx + len(attrLower)
|
|
continue
|
|
}
|
|
j++
|
|
for j < len(lower) && isHTMLSpace(lower[j]) {
|
|
j++
|
|
}
|
|
if j >= len(tag) {
|
|
return ""
|
|
}
|
|
quote := tag[j]
|
|
if quote != '"' && quote != '\'' {
|
|
return ""
|
|
}
|
|
j++
|
|
k := j
|
|
for k < len(tag) && tag[k] != quote {
|
|
k++
|
|
}
|
|
if k >= len(tag) {
|
|
return ""
|
|
}
|
|
return html.UnescapeString(tag[j:k])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isHTMLSpace(b byte) bool {
|
|
switch b {
|
|
case ' ', '\n', '\r', '\t', '\f':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isAttrNameChar(b byte) bool {
|
|
switch {
|
|
case b >= 'a' && b <= 'z':
|
|
return true
|
|
case b >= 'A' && b <= 'Z':
|
|
return true
|
|
case b >= '0' && b <= '9':
|
|
return true
|
|
case b == '-' || b == '_' || b == ':':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func splitLines(text string) []string {
|
|
text = strings.ReplaceAll(text, "\r\n", "\n")
|
|
text = strings.ReplaceAll(text, "\r", "\n")
|
|
return strings.Split(text, "\n")
|
|
}
|
|
|
|
func parseForecastDiscussionOfficeID(lines []string) string {
|
|
for _, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if m := forecastDiscussionAFDRE.FindStringSubmatch(line); len(m) == 2 {
|
|
return m[1]
|
|
}
|
|
}
|
|
for _, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if m := forecastDiscussionWMORE.FindStringSubmatch(line); len(m) == 2 {
|
|
return m[1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseForecastDiscussionHeader(lines []string) (string, time.Time, error) {
|
|
for i, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if !strings.HasPrefix(line, "National Weather Service ") {
|
|
continue
|
|
}
|
|
|
|
officeName := line
|
|
for j := i + 1; j < len(lines); j++ {
|
|
tsLine := strings.TrimSpace(lines[j])
|
|
if tsLine == "" {
|
|
continue
|
|
}
|
|
issuedAt, err := parseForecastDiscussionIssueTime(tsLine)
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("parse bulletin issuedAt %q: %w", tsLine, err)
|
|
}
|
|
return officeName, issuedAt.UTC(), nil
|
|
}
|
|
|
|
return "", time.Time{}, fmt.Errorf("missing bulletin issue time after office line")
|
|
}
|
|
|
|
return "", time.Time{}, fmt.Errorf("missing office header")
|
|
}
|
|
|
|
func parseForecastDiscussionIssueTime(line string) (time.Time, error) {
|
|
line = strings.TrimSpace(line)
|
|
if isForecastDiscussionIssuedAtLine(line) {
|
|
line = strings.TrimSpace(line[len("Issued at"):])
|
|
}
|
|
|
|
parts := strings.Fields(line)
|
|
if len(parts) != 7 {
|
|
return time.Time{}, fmt.Errorf("unexpected issue time format")
|
|
}
|
|
|
|
loc, err := forecastDiscussionLocation(parts[2])
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
datePart, err := time.Parse("Mon Jan 2 2006", strings.Join(parts[3:], " "))
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
hour, minute, err := parseForecastDiscussionClock(parts[0], parts[1])
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
|
|
return time.Date(
|
|
datePart.Year(),
|
|
datePart.Month(),
|
|
datePart.Day(),
|
|
hour,
|
|
minute,
|
|
0,
|
|
0,
|
|
loc,
|
|
), nil
|
|
}
|
|
|
|
func parseForecastDiscussionClock(rawClock, rawAMPM string) (int, int, error) {
|
|
clock := strings.TrimSpace(rawClock)
|
|
ampm := strings.ToUpper(strings.TrimSpace(rawAMPM))
|
|
if ampm != "AM" && ampm != "PM" {
|
|
return 0, 0, fmt.Errorf("unexpected meridiem %q", rawAMPM)
|
|
}
|
|
|
|
n, err := strconv.Atoi(clock)
|
|
if err != nil {
|
|
return 0, 0, fmt.Errorf("invalid clock %q", rawClock)
|
|
}
|
|
|
|
hour := n
|
|
minute := 0
|
|
if len(clock) >= 3 {
|
|
hour = n / 100
|
|
minute = n % 100
|
|
}
|
|
|
|
if hour < 1 || hour > 12 {
|
|
return 0, 0, fmt.Errorf("invalid hour %q", rawClock)
|
|
}
|
|
if minute < 0 || minute > 59 {
|
|
return 0, 0, fmt.Errorf("invalid minute %q", rawClock)
|
|
}
|
|
|
|
if ampm == "AM" {
|
|
if hour == 12 {
|
|
hour = 0
|
|
}
|
|
return hour, minute, nil
|
|
}
|
|
|
|
if hour != 12 {
|
|
hour += 12
|
|
}
|
|
return hour, minute, nil
|
|
}
|
|
|
|
func forecastDiscussionLocation(abbrev string) (*time.Location, error) {
|
|
offsets := map[string]int{
|
|
"AST": -4 * 3600,
|
|
"ADT": -3 * 3600,
|
|
"EST": -5 * 3600,
|
|
"EDT": -4 * 3600,
|
|
"CST": -6 * 3600,
|
|
"CDT": -5 * 3600,
|
|
"MST": -7 * 3600,
|
|
"MDT": -6 * 3600,
|
|
"PST": -8 * 3600,
|
|
"PDT": -7 * 3600,
|
|
"AKST": -9 * 3600,
|
|
"AKDT": -8 * 3600,
|
|
"HST": -10 * 3600,
|
|
"UTC": 0,
|
|
"GMT": 0,
|
|
}
|
|
|
|
abbr := strings.ToUpper(strings.TrimSpace(abbrev))
|
|
offset, ok := offsets[abbr]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unsupported time zone %q", abbrev)
|
|
}
|
|
return time.FixedZone(abbr, offset), nil
|
|
}
|
|
|
|
func parseForecastDiscussionSectionHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
|
line = strings.TrimSpace(line)
|
|
if len(line) < 2 || line[0] != '.' {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
|
|
if strings.HasSuffix(line, "/...") {
|
|
return parseForecastDiscussionSlashQualifiedHeading(line)
|
|
}
|
|
if strings.HasSuffix(line, "...") {
|
|
if heading, ok := parseForecastDiscussionParenthesizedTerminalHeading(line); ok {
|
|
return heading, true
|
|
}
|
|
}
|
|
return parseForecastDiscussionEllipsisHeading(line)
|
|
}
|
|
|
|
func parseForecastDiscussionSlashQualifiedHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
|
content := strings.TrimSuffix(line[1:], "/...")
|
|
separator := -1
|
|
for i := 1; i < len(content); i++ {
|
|
if content[i] == '/' && isForecastDiscussionHorizontalWhitespace(content[i-1]) {
|
|
separator = i
|
|
break
|
|
}
|
|
}
|
|
if separator < 0 {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
|
|
section, ok := normalizeForecastDiscussionSectionIdentity(content[:separator])
|
|
if !ok {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
qualifier := strings.TrimSpace(content[separator+1:])
|
|
if qualifier == "" {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
return forecastDiscussionSectionHeading{section: section, qualifier: qualifier}, true
|
|
}
|
|
|
|
func parseForecastDiscussionParenthesizedTerminalHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
|
if len(line) < 4 || line[0] != '.' || !strings.HasSuffix(line, "...") {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
|
|
content := strings.TrimRight(line[1:len(line)-3], " \t")
|
|
if !strings.HasSuffix(content, ")") {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
|
|
separator := -1
|
|
for i := 1; i < len(content); i++ {
|
|
if content[i] == '(' && isForecastDiscussionHorizontalWhitespace(content[i-1]) {
|
|
separator = i
|
|
break
|
|
}
|
|
}
|
|
if separator < 0 {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
|
|
section, ok := normalizeForecastDiscussionSectionIdentity(content[:separator])
|
|
if !ok {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
qualifier := content[separator:]
|
|
if len(qualifier) <= 2 || strings.TrimSpace(qualifier[1:len(qualifier)-1]) == "" {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
return forecastDiscussionSectionHeading{section: section, qualifier: qualifier}, true
|
|
}
|
|
|
|
func parseForecastDiscussionEllipsisHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
|
content := line[1:]
|
|
delimiter := strings.Index(content, "...")
|
|
if delimiter < 0 {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
|
|
section, ok := normalizeForecastDiscussionSectionIdentity(content[:delimiter])
|
|
if !ok {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
return forecastDiscussionSectionHeading{
|
|
section: section,
|
|
qualifier: strings.TrimSpace(content[delimiter+3:]),
|
|
}, true
|
|
}
|
|
|
|
func normalizeForecastDiscussionSectionIdentity(raw string) (string, bool) {
|
|
var normalized strings.Builder
|
|
pendingSpace := false
|
|
hasLetterOrDigit := false
|
|
|
|
for i := 0; i < len(raw); i++ {
|
|
b := raw[i]
|
|
switch {
|
|
case isForecastDiscussionIdentityLetterOrDigit(b):
|
|
hasLetterOrDigit = true
|
|
case b == ' ' || b == '\t':
|
|
pendingSpace = normalized.Len() > 0
|
|
continue
|
|
case b == '/' && i > 0 && isForecastDiscussionHorizontalWhitespace(raw[i-1]):
|
|
return "", false
|
|
case b != '/' && b != '&' && b != '\'' && b != '-':
|
|
return "", false
|
|
}
|
|
|
|
if pendingSpace {
|
|
normalized.WriteByte(' ')
|
|
pendingSpace = false
|
|
}
|
|
normalized.WriteByte(b)
|
|
}
|
|
if !hasLetterOrDigit {
|
|
return "", false
|
|
}
|
|
return normalized.String(), true
|
|
}
|
|
|
|
func isForecastDiscussionIdentityLetterOrDigit(b byte) bool {
|
|
return b >= 'A' && b <= 'Z' || b >= '0' && b <= '9'
|
|
}
|
|
|
|
func isForecastDiscussionHorizontalWhitespace(b byte) bool {
|
|
return b == ' ' || b == '\t'
|
|
}
|
|
|
|
func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSectionBlock {
|
|
var blocks []forecastDiscussionSectionBlock
|
|
var active *forecastDiscussionSectionBlock
|
|
embeddedHeadings := false
|
|
|
|
finish := func() {
|
|
if active == nil {
|
|
return
|
|
}
|
|
blocks = append(blocks, *active)
|
|
active = nil
|
|
}
|
|
|
|
for _, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "$$" {
|
|
finish()
|
|
break
|
|
}
|
|
if line == "&&" || strings.Contains(line, "WATCHES/WARNINGS/ADVISORIES") {
|
|
finish()
|
|
embeddedHeadings = false
|
|
continue
|
|
}
|
|
|
|
heading, ok := parseForecastDiscussionSectionHeading(raw)
|
|
if ok {
|
|
finish()
|
|
active = &forecastDiscussionSectionBlock{heading: heading}
|
|
embeddedHeadings = isForecastDiscussionEmbeddedSectionWrapper(heading.section)
|
|
continue
|
|
}
|
|
if embeddedHeadings {
|
|
heading, ok = parseForecastDiscussionEmbeddedSectionHeading(raw)
|
|
if ok {
|
|
finish()
|
|
active = &forecastDiscussionSectionBlock{heading: heading}
|
|
continue
|
|
}
|
|
}
|
|
if active != nil {
|
|
active.body = append(active.body, raw)
|
|
}
|
|
}
|
|
finish()
|
|
|
|
return blocks
|
|
}
|
|
|
|
func isForecastDiscussionEmbeddedSectionWrapper(section string) bool {
|
|
return section == "PREV DISCUSSION"
|
|
}
|
|
|
|
func parseForecastDiscussionEmbeddedSectionHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || line[0] == '.' {
|
|
return forecastDiscussionSectionHeading{}, false
|
|
}
|
|
return parseForecastDiscussionSectionHeading("." + line)
|
|
}
|
|
|
|
func parseForecastDiscussionKeyMessages(body []string) []string {
|
|
body = removeForecastDiscussionPresentationMarkers(body)
|
|
body = trimBlankLines(body)
|
|
if len(body) > 0 && isForecastDiscussionKeyMessageMetadataLine(body[0]) {
|
|
body = trimBlankLines(body[1:])
|
|
}
|
|
if len(body) == 0 {
|
|
return nil
|
|
}
|
|
hasMarkers := false
|
|
for _, raw := range body {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if _, ok := stripForecastDiscussionKeyMessageMarker(line); ok {
|
|
hasMarkers = true
|
|
break
|
|
}
|
|
}
|
|
|
|
var messages []string
|
|
var current strings.Builder
|
|
|
|
flush := func() {
|
|
msg := strings.TrimSpace(current.String())
|
|
if msg != "" {
|
|
messages = append(messages, msg)
|
|
}
|
|
current.Reset()
|
|
}
|
|
|
|
seenMarker := false
|
|
for _, raw := range body {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" {
|
|
if !hasMarkers || !seenMarker {
|
|
flush()
|
|
}
|
|
continue
|
|
}
|
|
if stripped, ok := stripForecastDiscussionKeyMessageMarker(line); ok {
|
|
flush()
|
|
seenMarker = true
|
|
line = stripped
|
|
}
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if current.Len() > 0 {
|
|
current.WriteByte(' ')
|
|
}
|
|
current.WriteString(line)
|
|
}
|
|
flush()
|
|
|
|
return messages
|
|
}
|
|
|
|
func parseForecastDiscussionTextSection(block forecastDiscussionSectionBlock) (ForecastDiscussionSection, error) {
|
|
section := ForecastDiscussionSection{
|
|
Qualifier: block.heading.qualifier,
|
|
}
|
|
|
|
body := trimBlankLines(removeForecastDiscussionPresentationMarkers(block.body))
|
|
if section.Qualifier == "" && len(body) > 0 && isForecastDiscussionStandaloneParenthetical(body[0]) {
|
|
section.Qualifier = strings.TrimSpace(body[0])
|
|
body = trimBlankLines(body[1:])
|
|
}
|
|
if len(body) == 0 {
|
|
return section, nil
|
|
}
|
|
|
|
if isForecastDiscussionIssuedAtLine(body[0]) {
|
|
issuedAt, err := parseForecastDiscussionIssueTime(body[0])
|
|
if err != nil {
|
|
return ForecastDiscussionSection{}, fmt.Errorf("parse section issuedAt %q: %w", strings.TrimSpace(body[0]), err)
|
|
}
|
|
tt := issuedAt.UTC()
|
|
section.IssuedAt = &tt
|
|
body = trimBlankLines(body[1:])
|
|
}
|
|
|
|
body = trimForecastDiscussionSignatureLines(body)
|
|
section.Text = joinForecastDiscussionParagraphs(body)
|
|
return section, nil
|
|
}
|
|
|
|
func isForecastDiscussionPresentationMarker(line string) bool {
|
|
switch {
|
|
case strings.EqualFold(strings.TrimSpace(line), "-- Changed Discussion --"):
|
|
return true
|
|
case strings.EqualFold(strings.TrimSpace(line), "-- End Changed Discussion --"):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func removeForecastDiscussionPresentationMarkers(lines []string) []string {
|
|
body := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
if !isForecastDiscussionPresentationMarker(line) {
|
|
body = append(body, line)
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
func isForecastDiscussionStandaloneParenthetical(line string) bool {
|
|
line = strings.TrimSpace(line)
|
|
return len(line) > 2 && line[0] == '(' && line[len(line)-1] == ')' && strings.TrimSpace(line[1:len(line)-1]) != ""
|
|
}
|
|
|
|
func isForecastDiscussionIssuedAtLine(line string) bool {
|
|
line = strings.TrimSpace(line)
|
|
return len(line) > len("Issued at") &&
|
|
strings.EqualFold(line[:len("Issued at")], "Issued at") &&
|
|
isForecastDiscussionHorizontalWhitespace(line[len("Issued at")])
|
|
}
|
|
|
|
func isForecastDiscussionKeyMessageMetadataLine(line string) bool {
|
|
line = strings.TrimSpace(line)
|
|
for _, label := range []string{"Issued at", "Updated at"} {
|
|
if !hasForecastDiscussionASCIIPrefix(line, label) || len(line) == len(label) || !isForecastDiscussionHorizontalWhitespace(line[len(label)]) {
|
|
continue
|
|
}
|
|
if _, err := parseForecastDiscussionIssueTime(strings.TrimSpace(line[len(label):])); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return isForecastDiscussionKeyMessageAsOfLine(line)
|
|
}
|
|
|
|
func isForecastDiscussionKeyMessageAsOfLine(line string) bool {
|
|
const label = "As of"
|
|
|
|
if !hasForecastDiscussionASCIIPrefix(line, label) || len(line) == len(label) || !isForecastDiscussionHorizontalWhitespace(line[len(label)]) {
|
|
return false
|
|
}
|
|
remainder := strings.TrimSpace(line[len(label):])
|
|
if !strings.HasSuffix(remainder, "...") {
|
|
return false
|
|
}
|
|
fields := strings.Fields(strings.TrimSpace(strings.TrimSuffix(remainder, "...")))
|
|
if len(fields) != 3 {
|
|
return false
|
|
}
|
|
if _, _, err := parseForecastDiscussionClock(fields[0], fields[1]); err != nil {
|
|
return false
|
|
}
|
|
switch strings.ToLower(fields[2]) {
|
|
case "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func stripForecastDiscussionKeyMessageMarker(line string) (string, bool) {
|
|
if line == "" {
|
|
return "", false
|
|
}
|
|
|
|
if line[0] == '-' || line[0] == '*' {
|
|
content := line[1:]
|
|
hadWhitespace := len(content) > 0 && isForecastDiscussionHorizontalWhitespace(content[0])
|
|
content = strings.TrimLeft(content, " \t")
|
|
if hadWhitespace {
|
|
if stripped, ok := stripForecastDiscussionKeyMessageNumericMarker(content); ok {
|
|
content = stripped
|
|
}
|
|
}
|
|
return content, true
|
|
}
|
|
|
|
return stripForecastDiscussionKeyMessageNumericMarker(line)
|
|
}
|
|
|
|
func stripForecastDiscussionKeyMessageNumericMarker(line string) (string, bool) {
|
|
digitStart := 0
|
|
digitEnd := 0
|
|
parenthesized := len(line) > 0 && line[0] == '('
|
|
if parenthesized {
|
|
digitStart = 1
|
|
digitEnd = 1
|
|
}
|
|
for digitEnd < len(line) && line[digitEnd] >= '0' && line[digitEnd] <= '9' {
|
|
digitEnd++
|
|
}
|
|
if digitEnd == digitStart || digitEnd == len(line) {
|
|
return "", false
|
|
}
|
|
if parenthesized && line[digitEnd] != ')' {
|
|
return "", false
|
|
}
|
|
if !parenthesized && line[digitEnd] != ')' && line[digitEnd] != '.' {
|
|
return "", false
|
|
}
|
|
|
|
markerEnd := digitEnd + 1
|
|
if markerEnd < len(line) && !isForecastDiscussionHorizontalWhitespace(line[markerEnd]) {
|
|
return "", false
|
|
}
|
|
value, err := strconv.ParseUint(line[digitStart:digitEnd], 10, 0)
|
|
if err != nil || value == 0 {
|
|
return "", false
|
|
}
|
|
return strings.TrimLeft(line[markerEnd:], " \t"), true
|
|
}
|
|
|
|
func hasForecastDiscussionASCIIPrefix(line, prefix string) bool {
|
|
if len(line) < len(prefix) {
|
|
return false
|
|
}
|
|
for i := range prefix {
|
|
actual := line[i]
|
|
if actual >= 'A' && actual <= 'Z' {
|
|
actual += 'a' - 'A'
|
|
}
|
|
expected := prefix[i]
|
|
if expected >= 'A' && expected <= 'Z' {
|
|
expected += 'a' - 'A'
|
|
}
|
|
if actual != expected {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func trimBlankLines(lines []string) []string {
|
|
start := 0
|
|
for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
|
|
start++
|
|
}
|
|
|
|
end := len(lines)
|
|
for end > start && strings.TrimSpace(lines[end-1]) == "" {
|
|
end--
|
|
}
|
|
|
|
return lines[start:end]
|
|
}
|
|
|
|
func trimForecastDiscussionSignatureLines(lines []string) []string {
|
|
lines = trimBlankLines(lines)
|
|
for len(lines) > 0 {
|
|
last := strings.TrimSpace(lines[len(lines)-1])
|
|
if last == "" {
|
|
lines = lines[:len(lines)-1]
|
|
continue
|
|
}
|
|
if forecastDiscussionSigRE.MatchString(last) {
|
|
lines = trimBlankLines(lines[:len(lines)-1])
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func joinForecastDiscussionParagraphs(lines []string) string {
|
|
lines = trimBlankLines(lines)
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var paragraphs []string
|
|
current := make([]string, 0, len(lines))
|
|
|
|
flush := func() {
|
|
if len(current) == 0 {
|
|
return
|
|
}
|
|
paragraphs = append(paragraphs, strings.Join(current, " "))
|
|
current = current[:0]
|
|
}
|
|
|
|
for _, raw := range lines {
|
|
line := strings.TrimSpace(raw)
|
|
if line == "" {
|
|
flush()
|
|
continue
|
|
}
|
|
current = append(current, line)
|
|
}
|
|
flush()
|
|
|
|
return strings.Join(paragraphs, "\n\n")
|
|
}
|