From 8ec9f04e02cb8f8f5682634f63f6aa8aae7a8f7d Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" <64498081+galjos@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:58:01 +0200 Subject: [PATCH] Split traffic.go into focused files traffic.go carried CLI definition, pipeline, normalization, filtering, rendering, catalog, geo math, and shared feed helpers. Now: traffic.go (types, command, ODH pipeline), traffic_normalize/filter/render/catalog/geo/feed.go, text.go already holds the shared text helpers. All 61 functions moved verbatim; verified by scripted diff against the pre-split file. Removes the firstNonEmptyString near-duplicate in diagnostics.go and fixes a 7-cell markdown separator under the 6-column traffic table. Closes #20. --- internal/commands/diagnostics.go | 14 +- internal/commands/traffic.go | 1002 ------------------------ internal/commands/traffic_catalog.go | 198 +++++ internal/commands/traffic_feed.go | 70 ++ internal/commands/traffic_filter.go | 263 +++++++ internal/commands/traffic_geo.go | 170 ++++ internal/commands/traffic_normalize.go | 223 ++++++ internal/commands/traffic_render.go | 148 ++++ 8 files changed, 1074 insertions(+), 1014 deletions(-) create mode 100644 internal/commands/traffic_catalog.go create mode 100644 internal/commands/traffic_feed.go create mode 100644 internal/commands/traffic_filter.go create mode 100644 internal/commands/traffic_geo.go create mode 100644 internal/commands/traffic_normalize.go create mode 100644 internal/commands/traffic_render.go diff --git a/internal/commands/diagnostics.go b/internal/commands/diagnostics.go index e7f501e..d08da87 100644 --- a/internal/commands/diagnostics.go +++ b/internal/commands/diagnostics.go @@ -317,7 +317,7 @@ func summarizeTourismEvents(records []map[string]any, day time.Time) []tourismEv events := make([]tourismEventSummary, 0, len(records)) for _, record := range records { events = append(events, tourismEventSummary{ - ID: firstNonEmptyString(record["Id"], record["ID"], record["id"]), + ID: firstNonEmpty(asString(record["Id"]), asString(record["ID"]), asString(record["id"])), Title: tourismEventTitle(record), StartDate: asString(record["StartDate"]), EndDate: asString(record["EndDate"]), @@ -383,7 +383,7 @@ func tourismEventTitle(record map[string]any) string { if !ok { return "" } - return firstNonEmptyString(titleMap["en"], titleMap["de"], titleMap["it"], titleMap["nl"]) + return firstNonEmpty(asString(titleMap["en"]), asString(titleMap["de"]), asString(titleMap["it"]), asString(titleMap["nl"])) } func tourismEventDateStatus(record map[string]any, day time.Time) string { @@ -457,16 +457,6 @@ func recommendedMobilityLatestCommand(result mobilityLatestResult) string { return strings.Join(parts, " ") } -func firstNonEmptyString(values ...any) string { - for _, value := range values { - text := strings.TrimSpace(asString(value)) - if text != "" { - return text - } - } - return "" -} - func shellQuote(value string) string { if value == "" { return "''" diff --git a/internal/commands/traffic.go b/internal/commands/traffic.go index bfd6408..2cdb674 100644 --- a/internal/commands/traffic.go +++ b/internal/commands/traffic.go @@ -8,16 +8,11 @@ import ( "context" "fmt" "io" - "math" "net/url" - "slices" - "sort" "strconv" "strings" - "text/tabwriter" "time" - "github.com/galjos/odh-cli/internal/output" "github.com/spf13/cobra" ) @@ -415,1000 +410,3 @@ type trafficResult struct { OutputFormat string `json:"-"` IncludeRaw bool `json:"-"` } - -func normalizeTrafficEvents(raw []map[string]any, query trafficQuery, area trafficArea, fromDay, toDay time.Time) ([]trafficEvent, []string) { - events := make([]trafficEvent, 0, len(raw)) - now := time.Now() - staleCount := 0 - hiddenStaleOpenEndedCount := 0 - expiredCount := 0 - futureCount := 0 - for _, record := range raw { - event := normalizeTrafficEvent(record, query.Raw, now) - if !trafficZoneMatches(event, query.ZoneID) { - continue - } - if !trafficAreaMatches(event, area) { - continue - } - if !trafficTypeMatches(event, query.Type) { - continue - } - if !trafficRoadMatches(event, query.Road) { - continue - } - if !trafficNearMatches(event, query.Near, query.Radius) { - continue - } - if !trafficSearchMatches(event, query.Search) { - continue - } - active := eventActiveInRange(event, fromDay, toDay) - event.Active = active - if !active && !query.IncludeExpired { - if eventEndBefore(event, fromDay) { - expiredCount++ - } else { - futureCount++ - } - continue - } - if event.Stale && !eventHasEnd(event) && !query.IncludeStale { - hiddenStaleOpenEndedCount++ - continue - } - if event.Stale { - staleCount++ - } - events = append(events, event) - } - deduped := dedupeTrafficEvents(events) - sort.SliceStable(deduped, func(i, j int) bool { - if deduped[i].Start != deduped[j].Start { - return deduped[i].Start < deduped[j].Start - } - if deduped[i].Road != deduped[j].Road { - return deduped[i].Road < deduped[j].Road - } - return deduped[i].Place < deduped[j].Place - }) - - feedWarning := timeseriesEventFeedWarning(newestTrafficEventTimestamp(deduped), "PROVINCE_BZ") - if normalizeTrafficTypeName(query.Type) == "bike" { - feedWarning += "; active reflects the stored date range, not verified current status. For current cycle-route notices, run: odh traffic search radroute --today --source content --json" - } - warnings := []string{feedWarning} - if len(events) != len(deduped) { - warnings = append(warnings, fmt.Sprintf("deduplicated %d raw matching rows to %d events", len(events), len(deduped))) - } - if staleCount > 0 { - warnings = append(warnings, fmt.Sprintf("%d matching events have transaction or publish timestamps older than 30 days", staleCount)) - } - if hiddenStaleOpenEndedCount > 0 { - warnings = append(warnings, fmt.Sprintf("%d stale open-ended matching events were hidden; pass --include-stale to inspect them", hiddenStaleOpenEndedCount)) - } - if expiredCount > 0 { - warnings = append(warnings, fmt.Sprintf("%d expired matching events were hidden", expiredCount)) - } - if futureCount > 0 { - warnings = append(warnings, fmt.Sprintf("%d future matching events were hidden", futureCount)) - } - if len(deduped) == 0 { - if warning := trafficNoMatchesWarning(query, area, hiddenStaleOpenEndedCount); warning != "" { - warnings = append(warnings, warning) - } - } - if warning := mobilityTruncationWarning("returned", "raw event rows", query.Limit, len(raw), "traffic completeness"); warning != "" { - warnings = append(warnings, warning) - } - warnings = append(warnings, "source is Open Data Hub PROVINCE_BZ; compare with the official traffic service before presenting this as a complete live road bulletin") - return deduped, warnings -} - -func normalizeTrafficEvent(record map[string]any, includeRaw bool, now time.Time) trafficEvent { - metadata, _ := record["evmetadata"].(map[string]any) - event := trafficEvent{ - ID: firstNonEmpty(asString(record["evuuid"]), asString(record["evname"])), - SeriesID: asString(record["evseriesuuid"]), - MessageID: asString(metadata["messageId"]), - Source: "odh", - Subtype: strings.TrimSpace(asString(metadata["subTycodeValue"])), - Severity: firstNonEmpty(asString(metadata["messageGradDescDe"]), asString(metadata["messageGradDescIt"])), - ZoneID: asString(metadata["messageZoneId"]), - Zone: strings.TrimSpace(asString(metadata["messageZoneDescDe"])), - ZoneIT: strings.TrimSpace(asString(metadata["messageZoneDescIt"])), - Road: normalizeRoad(asString(metadata["messageStreetNr"])), - RoadName: strings.TrimSpace(asString(metadata["messageStreetInternetDescDe"])), - Place: cleanTrafficText(asString(metadata["placeDe"])), - PlaceIT: cleanTrafficText(asString(metadata["placeIt"])), - Start: asString(record["evstart"]), - End: asString(record["evend"]), - PublishedAt: firstNonEmpty(asString(metadata["publishDateTime"]), asString(metadata["publisherDateTime"])), - TransactionTime: asString(record["evtransactiontime"]), - Coordinates: extractCoordinates(record["evlgeometry"]), - } - event.Type = classifyTrafficType(event) - if includeRaw { - event.Raw = record - } - event.Stale = trafficEventStale(event, now) - return event -} - -func writeTrafficOutput(stdout io.Writer, result trafficResult) error { - switch result.OutputFormat { - case "", "json": - return output.WriteJSON(stdout, result) - case "table": - return writeTrafficTable(stdout, result) - case "markdown", "md": - return writeTrafficMarkdown(stdout, result) - default: - return fmt.Errorf("unsupported format %q", result.OutputFormat) - } -} - -func writeTrafficZonesOutput(stdout io.Writer, result trafficZonesResult) error { - switch result.OutputFormat { - case "", "json": - return output.WriteJSON(stdout, result) - case "table": - tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "ZONE_ID\tNAME") - for _, zone := range result.Zones { - fmt.Fprintf(tw, "%s\t%s\n", zone.ZoneID, zone.Name) - } - return tw.Flush() - case "markdown", "md": - fmt.Fprintln(stdout, "| zone_id | name |") - fmt.Fprintln(stdout, "| --- | --- |") - for _, zone := range result.Zones { - fmt.Fprintf(stdout, "| %s | %s |\n", escapeMarkdown(zone.ZoneID), escapeMarkdown(zone.Name)) - } - return nil - default: - return fmt.Errorf("unsupported format %q", result.OutputFormat) - } -} - -func writeTrafficCategoriesOutput(stdout io.Writer, result trafficCategoriesResult) error { - switch result.OutputFormat { - case "", "json": - return output.WriteJSON(stdout, result) - case "table": - tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "NAME\tDESCRIPTION\tUPSTREAM_SUBTYPES") - for _, category := range result.Categories { - fmt.Fprintf(tw, "%s\t%s\t%s\n", category.Name, category.Description, strings.Join(category.UpstreamSubtypes, ",")) - } - return tw.Flush() - case "markdown", "md": - fmt.Fprintln(stdout, "| name | description | upstream_subtypes |") - fmt.Fprintln(stdout, "| --- | --- | --- |") - for _, category := range result.Categories { - fmt.Fprintf(stdout, "| %s | %s | %s |\n", - escapeMarkdown(category.Name), - escapeMarkdown(category.Description), - escapeMarkdown(strings.Join(category.UpstreamSubtypes, ", ")), - ) - } - return nil - default: - return fmt.Errorf("unsupported format %q", result.OutputFormat) - } -} - -func writeTrafficTable(stdout io.Writer, result trafficResult) error { - warnings := result.Warnings - if result.Source == trafficSourceODH && len(warnings) > 0 { - fmt.Fprintf(stdout, "warning: %s\n\n", warnings[0]) - warnings = warnings[1:] - } - tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "TYPE\tROAD\tPLACE\tTIME\tACTIVE\tSTALE") - for _, event := range result.Events { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%t\t%t\n", - event.Type, - firstNonEmpty(event.Road, event.RoadName), - compactText(firstNonEmpty(event.Place, event.PlaceIT), 90), - compactRange(event.Start, event.End), - event.Active, - event.Stale, - ) - } - if err := tw.Flush(); err != nil { - return err - } - for _, warning := range warnings { - fmt.Fprintf(stdout, "warning: %s\n", warning) - } - return nil -} - -func writeTrafficMarkdown(stdout io.Writer, result trafficResult) error { - warnings := result.Warnings - if result.Source == trafficSourceODH && len(warnings) > 0 { - fmt.Fprintf(stdout, "> warning: %s\n\n", warnings[0]) - warnings = warnings[1:] - } - fmt.Fprintln(stdout, "| type | road | place | time | active | stale |") - fmt.Fprintln(stdout, "| --- | --- | --- | --- | --- | --- |") - for _, event := range result.Events { - fmt.Fprintf(stdout, "| %s | %s | %s | %s | %t | %t |\n", - escapeMarkdown(event.Type), - escapeMarkdown(firstNonEmpty(event.Road, event.RoadName)), - escapeMarkdown(compactText(firstNonEmpty(event.Place, event.PlaceIT), 90)), - escapeMarkdown(compactRange(event.Start, event.End)), - event.Active, - event.Stale, - ) - } - for _, warning := range warnings { - fmt.Fprintf(stdout, "\n> warning: %s\n", warning) - } - return nil -} - -func trafficZoneMatches(event trafficEvent, zoneIDs string) bool { - values := trafficZoneIDValues(zoneIDs) - if len(values) == 0 { - return true - } - return containsString(values, event.ZoneID) -} - -func trafficAreaMatches(event trafficEvent, area trafficArea) bool { - if area.Name == "" { - return true - } - if len(area.ZoneIDs) > 0 && !containsString(area.ZoneIDs, event.ZoneID) { - return false - } - if len(area.Keywords) == 0 { - return true - } - haystack := normalizeTrafficSearchText(strings.Join([]string{event.Zone, event.ZoneIT, event.Road, event.RoadName, event.Place, event.PlaceIT}, " ")) - for _, keyword := range area.Keywords { - keyword = normalizeTrafficSearchText(keyword) - if keyword != "" && strings.Contains(haystack, keyword) { - return true - } - } - return false -} - -func trafficTypeMatches(event trafficEvent, filter string) bool { - filter = normalizeTrafficTypeName(filter) - if filter == "" || filter == "all" { - return true - } - if event.Type == filter { - return true - } - if filter == "closure" && strings.Contains(strings.ToLower(event.Place), "sperre") { - return true - } - if filter == "bike" && strings.Contains(strings.ToLower(event.RoadName), "rad") { - return true - } - if filter == "mountain-pass" && textContainsPass(event.RoadName+" "+event.Place) { - return true - } - return false -} - -func trafficRoadMatches(event trafficEvent, road string) bool { - road = normalizeRoad(road) - if road == "" { - return true - } - filter := compactRoadToken(road) - eventRoad := compactRoadToken(event.Road) - return strings.EqualFold(event.Road, road) || - (filter != "" && strings.Contains(eventRoad, filter)) || - strings.Contains(strings.ToLower(event.RoadName), strings.ToLower(road)) -} - -func trafficNearMatches(event trafficEvent, near, radius string) bool { - if strings.TrimSpace(near) == "" { - return true - } - lat, lon, radiusKM, err := parseNearRadius(near, radius) - if err != nil || len(event.Coordinates) < 2 { - return false - } - eventLon, eventLat := event.Coordinates[0], event.Coordinates[1] - return haversineKM(lat, lon, eventLat, eventLon) <= radiusKM -} - -func trafficSearchMatches(event trafficEvent, search string) bool { - if strings.TrimSpace(search) == "" { - return true - } - // Identifiers are matched whole, never by substring: they are opaque, and a - // bare "12" used to match every record whose message id contained 12. - identifiers := []string{ - normalizeTrafficSearchText(event.ID), - normalizeTrafficSearchText(event.SeriesID), - normalizeTrafficSearchText(event.MessageID), - } - haystack := normalizeTrafficSearchText(strings.Join([]string{ - event.Source, - event.Type, - event.Subtype, - event.Severity, - event.ZoneID, - event.Zone, - event.ZoneIT, - event.Road, - event.RoadName, - event.Place, - event.PlaceIT, - }, " ")) - // Road numbers are written both ways upstream ("SS12" and "SS 12"), so a - // space-stripped copy lets either spelling find either. - squashed := strings.ReplaceAll(haystack, " ", "") - groups := trafficSearchTermGroups(search) - if len(groups) == 0 { - return true - } - for _, group := range groups { - matched := false - for _, term := range group { - term = normalizeTrafficSearchText(term) - if term == "" { - continue - } - // Alphabetic terms match at a word boundary only (prefix of a word is - // fine: cycle aliases like "radweg" must still find "Radrouten"). An - // infix match would turn "auer" into every "Stützmauern". - if trafficTextHasTerm(haystack, term) || slices.Contains(identifiers, term) { - matched = true - break - } - if isRoadToken(term) && strings.Contains(squashed, term) { - matched = true - break - } - } - if !matched { - return false - } - } - return true -} - -// trafficTextHasTerm reports whether term sits at the start of any word in -// haystack. Prefixes are allowed; infix matches inside a longer word are not. -func trafficTextHasTerm(haystack, term string) bool { - for _, word := range strings.Fields(haystack) { - if strings.HasPrefix(word, term) { - return true - } - } - return false -} - -func trafficSearchTermGroups(search string) [][]string { - terms := joinRoadTokens(strings.Fields(normalizeTrafficSearchText(search))) - groups := make([][]string, 0, len(terms)) - for _, term := range terms { - if trafficSearchStopword(term) { - continue - } - groups = append(groups, trafficSearchAlternatives(term)) - } - return groups -} - -// isRoadToken reports a road number written as one word, for example ss12 or sp13. -// Only these are matched against the space-stripped haystack, so ordinary words -// cannot join across a space and match something nobody asked for. -func isRoadToken(term string) bool { - letters := 0 - for letters < len(term) && term[letters] >= 'a' && term[letters] <= 'z' { - letters++ - } - if letters == 0 || letters > 3 || letters == len(term) { - return false - } - for i := letters; i < len(term); i++ { - if term[i] < '0' || term[i] > '9' { - return false - } - } - return true -} - -// joinRoadTokens collapses "ss 12" into "ss12" so both spellings of a road -// number search the same way. Upstream text uses both. -func joinRoadTokens(terms []string) []string { - joined := make([]string, 0, len(terms)) - for i := 0; i < len(terms); i++ { - if i+1 < len(terms) && isRoadToken(terms[i]+terms[i+1]) { - joined = append(joined, terms[i]+terms[i+1]) - i++ - continue - } - joined = append(joined, terms[i]) - } - return joined -} - -func trafficSearchStopword(term string) bool { - switch term { - case "a", "an", "am", "and", "are", "auf", "bei", "by", "der", "die", "das", "del", "della", "den", "des", "di", "for", "heute", "in", "is", "la", "le", "near", "on", "road", "roads", "route", "street", "streets", "strasse", "strassen", "the", "today", "um", "und", "via", "why": - return true - default: - return false - } -} - -func trafficSearchAlternatives(term string) []string { - switch term { - case "blocked", "closed", "closure", "closures", "roadblock", "roadblocks": - return []string{"closure", "closed", "blocked", "sperre", "sperren", "gesperrt"} - case "baustelle", "baustellen", "construction", "roadwork", "roadworks", "works": - return []string{"roadworks", "baustelle", "baustellen", "arbeiten"} - case "event", "events", "veranstaltung", "veranstaltungen": - return []string{"event", "events", "veranstaltung", "veranstaltungen"} - case "sperre", "sperren", "gesperrt": - return []string{"sperre", "sperren", "gesperrt", "closure", "closed", "blocked"} - // The bike category's own aliases must find cycle notices: upstream writes - // Radroute and ciclabile, so a user typing the documented "radweg" would - // otherwise get a confident empty answer. - case "bike", "bici", "bicicletta", "ciclabil", "ciclabile", "cycle", "cycling", "fahrrad", "rad", "radroute", "radrouten", "radweg", "radwege": - return []string{"radroute", "radweg", "fahrrad", "ciclabil", "bicicl", "cycle"} - default: - return []string{term} - } -} - -func trafficNoMatchesWarning(query trafficQuery, area trafficArea, hiddenStaleOpenEndedCount int) string { - parts := trafficFilterParts(query, area) - if len(parts) == 0 { - return "" - } - warning := "no current ODH PROVINCE_BZ traffic events matched " + strings.Join(parts, ", ") + " in the selected date range" - if hiddenStaleOpenEndedCount > 0 { - warning += "; stale open-ended matches may exist, rerun with --include-stale to inspect them" - } - return warning -} - -// trafficFilterParts describes the narrowing filters a query applied, so an -// empty result can name what it was narrowed by. -func trafficFilterParts(query trafficQuery, area trafficArea) []string { - parts := make([]string, 0, 6) - if search := strings.TrimSpace(query.Search); search != "" { - parts = append(parts, fmt.Sprintf("search %q", search)) - } - if zoneID := strings.TrimSpace(query.ZoneID); zoneID != "" { - parts = append(parts, "zone-id "+zoneID) - } - if area.Name != "" { - parts = append(parts, "area "+area.Name) - } - if eventType := normalizeTrafficTypeName(query.Type); eventType != "" && eventType != "all" { - parts = append(parts, "type "+eventType) - } - if road := strings.TrimSpace(query.Road); road != "" { - parts = append(parts, "road "+road) - } - if near := strings.TrimSpace(query.Near); near != "" { - parts = append(parts, "near "+near) - } - return parts -} - -func eventActiveInRange(event trafficEvent, fromDay, toDay time.Time) bool { - start := parseODHTime(event.Start) - end := parseODHTime(event.End) - rangeStart := startOfDay(fromDay) - rangeEnd := endOfDay(toDay) - if start != nil && start.After(rangeEnd) { - return false - } - if end != nil && end.Before(rangeStart) { - return false - } - return true -} - -func eventEndBefore(event trafficEvent, fromDay time.Time) bool { - end := parseODHTime(event.End) - return end != nil && end.Before(startOfDay(fromDay)) -} - -func eventHasEnd(event trafficEvent) bool { - return parseODHTime(event.End) != nil -} - -// newestRecordTimestamp returns the most recent event timestamp across raw -// Mobility records, for callers that have not normalized them into events yet. -func newestRecordTimestamp(records []map[string]any) *time.Time { - var newest *time.Time - for _, record := range records { - for _, key := range []string{"evtransactiontime", "evpublishtime"} { - parsed := parseODHTime(asString(record[key])) - if parsed == nil { - continue - } - if newest == nil || parsed.After(*newest) { - newest = parsed - } - } - } - return newest -} - -// newestTrafficEventTimestamp returns the most recent transaction or publish -// timestamp across the given events, or nil when none of them carry one. -func newestTrafficEventTimestamp(events []trafficEvent) *time.Time { - var newest *time.Time - for _, event := range events { - for _, value := range []string{event.TransactionTime, event.PublishedAt} { - parsed := parseODHTime(value) - if parsed == nil { - continue - } - if newest == nil || parsed.After(*newest) { - newest = parsed - } - } - } - return newest -} - -// timeseriesEventFeedWarning reports the newest row this response carried rather -// than asserting an upstream feed status the CLI never checks, so the caveat -// stays true whatever upstream does next. -func timeseriesEventFeedWarning(newest *time.Time, announcementSource string) string { - replacement := fmt.Sprintf("odh call tourism /v1/Announcement --param source=%s --param rawsort=-LastChange", announcementSource) - if newest == nil { - return fmt.Sprintf("this Mobility Timeseries event feed returned no dated rows; an empty result is not evidence that roads are clear, so cross-check current notices with: %s", replacement) - } - return fmt.Sprintf("the newest row in this Mobility Timeseries event response is dated %s; this feed is not a live bulletin, and neither a stale nor an empty result is evidence that roads are clear, so cross-check current notices with: %s", newest.UTC().Format("2006-01-02"), replacement) -} - -// announcementSourceForOrigin maps a Mobility event origin to the Content API -// Announcement source that carries current notices for the same roads. -func announcementSourceForOrigin(origin string) string { - if strings.EqualFold(strings.TrimSpace(origin), "a22") { - return "a22" - } - return announcementTrafficSource -} - -func trafficEventStale(event trafficEvent, now time.Time) bool { - for _, value := range []string{event.TransactionTime, event.PublishedAt} { - parsed := parseODHTime(value) - if parsed != nil { - return parsed.Before(now.AddDate(0, 0, -30)) - } - } - return false -} - -func dedupeTrafficEvents(events []trafficEvent) []trafficEvent { - seen := map[string]struct{}{} - result := make([]trafficEvent, 0, len(events)) - for _, event := range events { - key := strings.Join([]string{ - event.ZoneID, - event.Road, - event.RoadName, - normalizeDedupText(event.Place), - event.Start, - event.End, - }, "|") - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - result = append(result, event) - } - return result -} - -func classifyTrafficType(event trafficEvent) string { - subtype := strings.ToUpper(strings.TrimSpace(event.Subtype)) - combined := strings.ToLower(event.RoadName + " " + event.Place) - switch subtype { - case "BAUSTELLE": - return "roadworks" - case "SPERRE": - if textContainsPass(combined) { - return "mountain-pass" - } - return "closure" - case "RADWEG_SPERRE": - return "bike" - case "VERANSTALTUNG": - return "event" - case "RADARKONTROLLE": - return "radar" - case "STAU", "UNFALL", "SCHNEEFALL", "AMPELREGELUNG", "VORSICHT", "FREI BEFAHRBAR": - if textContainsPass(combined) { - return "mountain-pass" - } - return "traffic" - default: - if strings.Contains(combined, "radroute") || strings.Contains(combined, "radweg") { - return "bike" - } - if textContainsPass(combined) { - return "mountain-pass" - } - return "traffic" - } -} - -func normalizeTrafficTypeFilter(value string) (string, error) { - normalized := normalizeTrafficTypeName(value) - switch normalized { - case "", "all", "roadworks", "closure", "event", "traffic", "mountain-pass", "bike", "radar": - return normalized, nil - default: - return "", fmt.Errorf("unsupported traffic type %q", value) - } -} - -func normalizeTrafficTypeName(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "", "all", "any": - return "all" - case "roadwork", "roadworks", "works", "baustelle", "baustellen": - return "roadworks" - case "closure", "closures", "closed", "sperre", "sperren": - return "closure" - case "events", "event", "veranstaltung", "veranstaltungen": - return "event" - case "traffic", "incident", "incidents", "stau", "unfall", "warning": - return "traffic" - case "mountain-pass", "mountain-pass-closure", "pass", "passes": - return "mountain-pass" - case "bike", "cycle", "cycling", "radweg": - return "bike" - case "radar", "speed", "speed-control": - return "radar" - default: - return strings.ToLower(strings.TrimSpace(value)) - } -} - -func knownTrafficZones() []trafficZone { - return []trafficZone{ - {ZoneID: "1", Name: "Vinschgau"}, - {ZoneID: "2", Name: "Burggrafenamt"}, - {ZoneID: "3", Name: "Bozen-Unterland"}, - {ZoneID: "4", Name: "Salten-Schlern"}, - {ZoneID: "5", Name: "Eisacktal-Wipptal"}, - {ZoneID: "6", Name: "Pustertal"}, - {ZoneID: "7", Name: "Ausserhalb Südtirol"}, - } -} - -func knownTrafficCategories() []trafficCategory { - return []trafficCategory{ - { - Name: "roadworks", - Description: "Roadworks and construction notices.", - Aliases: []string{"roadwork", "works", "baustelle", "baustellen"}, - UpstreamSubtypes: []string{"BAUSTELLE"}, - }, - { - Name: "closure", - Description: "Road closures and blocked-road notices.", - Aliases: []string{"closed", "closures", "sperre", "sperren", "gesperrt"}, - UpstreamSubtypes: []string{"SPERRE"}, - }, - { - Name: "event", - Description: "Road impacts caused by public events.", - Aliases: []string{"events", "veranstaltung", "veranstaltungen"}, - UpstreamSubtypes: []string{"VERANSTALTUNG"}, - }, - { - Name: "traffic", - Description: "Traffic incidents, congestion, warnings, and general restrictions.", - Aliases: []string{"incident", "incidents", "stau", "unfall", "warning"}, - UpstreamSubtypes: []string{"AMPELREGELUNG", "FREI BEFAHRBAR", "SCHNEEFALL", "STAU", "UNFALL", "VORSICHT"}, - }, - { - Name: "mountain-pass", - Description: "Pass and mountain-road closures or restrictions.", - Aliases: []string{"mountain-pass-closure", "pass", "passes"}, - UpstreamSubtypes: []string{"SPERRE", "WINTERSPERRE"}, - }, - { - Name: "bike", - Description: "Cycle-route and bike-path closures.", - Aliases: []string{"cycle", "cycling", "radweg"}, - UpstreamSubtypes: []string{"RADWEG_SPERRE"}, - }, - { - Name: "radar", - Description: "Speed-control notices.", - Aliases: []string{"speed", "speed-control"}, - UpstreamSubtypes: []string{"RADARKONTROLLE"}, - }, - } -} - -func validateTrafficZoneIDs(value string) error { - known := map[string]struct{}{} - for _, zone := range knownTrafficZones() { - known[zone.ZoneID] = struct{}{} - } - for _, zoneID := range trafficZoneIDValues(value) { - if _, ok := known[zoneID]; !ok { - return fmt.Errorf("unknown traffic zone-id %q; run odh traffic zones", zoneID) - } - } - return nil -} - -func trafficZoneIDValues(value string) []string { - parts := strings.FieldsFunc(value, func(r rune) bool { - return r == ',' || r == ';' || r == ' ' - }) - values := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - values = append(values, part) - } - } - return values -} - -func resolveTrafficArea(value string) (trafficArea, error) { - normalized := normalizeAreaAlias(value) - if normalized == "" || normalized == "all" { - return trafficArea{}, nil - } - areas := map[string]trafficArea{ - "bozen-unterland": {Name: "bozen-unterland", ZoneIDs: []string{"3"}}, - "ueberetsch-unterland": { - Name: "ueberetsch-unterland", - ZoneIDs: []string{"3"}, - Keywords: []string{ - "Salurn", "Neumarkt", "Auer", "Montan", "Tramin", "Kurtatsch", "Margreid", "Laag", "Buchholz", "Gfrill", - "Eppan", "Kaltern", "St. Pauls", "Unterrain", "Girlan", "Missian", "Montiggl", "Laimburg", "Mendel", "Sigmundskron", "Frangart", - }, - }, - "unterland": {Name: "unterland", ZoneIDs: []string{"3"}, Keywords: []string{"Salurn", "Neumarkt", "Auer", "Montan", "Tramin", "Kurtatsch", "Margreid", "Laag", "Buchholz", "Gfrill"}}, - "ueberetsch": {Name: "ueberetsch", ZoneIDs: []string{"3"}, Keywords: []string{"Eppan", "Kaltern", "St. Pauls", "Unterrain", "Girlan", "Missian", "Montiggl", "Laimburg", "Mendel", "Sigmundskron", "Frangart"}}, - "bozen": {Name: "bozen", ZoneIDs: []string{"3"}, Keywords: []string{"Bozen", "Bolzano", "Stadtgemeinde Bozen"}}, - "salurn": {Name: "salurn", ZoneIDs: []string{"3"}, Keywords: []string{"Salurn"}}, - "kaltern": {Name: "kaltern", ZoneIDs: []string{"3"}, Keywords: []string{"Kaltern"}}, - "tramin": {Name: "tramin", ZoneIDs: []string{"3"}, Keywords: []string{"Tramin"}}, - "eppan": {Name: "eppan", ZoneIDs: []string{"3"}, Keywords: []string{"Eppan", "St. Pauls", "Girlan", "Missian", "Unterrain", "Montiggl", "Frangart"}}, - "auer": {Name: "auer", ZoneIDs: []string{"3"}, Keywords: []string{"Auer"}}, - "neumarkt": {Name: "neumarkt", ZoneIDs: []string{"3"}, Keywords: []string{"Neumarkt"}}, - "kurtatsch": {Name: "kurtatsch", ZoneIDs: []string{"3"}, Keywords: []string{"Kurtatsch"}}, - "margreid": {Name: "margreid", ZoneIDs: []string{"3"}, Keywords: []string{"Margreid"}}, - "montan": {Name: "montan", ZoneIDs: []string{"3"}, Keywords: []string{"Montan"}}, - "burggrafenamt": {Name: "burggrafenamt", ZoneIDs: []string{"2"}}, - "eisacktal-wipptal": {Name: "eisacktal-wipptal", ZoneIDs: []string{"5"}}, - "pustertal": {Name: "pustertal", ZoneIDs: []string{"6"}}, - "vinschgau": {Name: "vinschgau", ZoneIDs: []string{"1"}}, - "salten-schlern": {Name: "salten-schlern", ZoneIDs: []string{"4"}}, - "ausserhalb-suedtirol": {Name: "ausserhalb-suedtirol", ZoneIDs: []string{"7"}}, - } - area, ok := areas[normalized] - if !ok { - return trafficArea{}, fmt.Errorf("unknown traffic area %q", value) - } - return area, nil -} - -func parseTrafficDateRange(from, to string) (time.Time, time.Time, error) { - fromDay, err := parseTrafficDate(from) - if err != nil { - return time.Time{}, time.Time{}, err - } - toDay, err := parseTrafficDate(to) - if err != nil { - return time.Time{}, time.Time{}, err - } - if toDay.Before(fromDay) { - return time.Time{}, time.Time{}, fmt.Errorf("--to must not be before --from") - } - return fromDay, toDay, nil -} - -func parseTrafficDate(value string) (time.Time, error) { - value = strings.TrimSpace(value) - if value == "" { - return time.Time{}, fmt.Errorf("date is required") - } - parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) - if err != nil { - return time.Time{}, fmt.Errorf("invalid date %q; use YYYY-MM-DD", value) - } - return parsed, nil -} - -func parseNearRadius(near, radius string) (float64, float64, float64, error) { - latText, lonText, ok := strings.Cut(strings.TrimSpace(near), ",") - if !ok { - return 0, 0, 0, fmt.Errorf("--near must use lat,lon") - } - lat, err := strconv.ParseFloat(strings.TrimSpace(latText), 64) - if err != nil { - return 0, 0, 0, fmt.Errorf("invalid latitude in --near: %w", err) - } - lon, err := strconv.ParseFloat(strings.TrimSpace(lonText), 64) - if err != nil { - return 0, 0, 0, fmt.Errorf("invalid longitude in --near: %w", err) - } - radiusKM, err := parseRadiusKM(radius) - if err != nil { - return 0, 0, 0, err - } - return lat, lon, radiusKM, nil -} - -func parseRadiusKM(value string) (float64, error) { - value = strings.TrimSpace(strings.ToLower(value)) - value = strings.TrimSuffix(value, "km") - parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) - if err != nil { - return 0, fmt.Errorf("invalid radius %q", value) - } - if parsed <= 0 { - return 0, fmt.Errorf("radius must be greater than zero") - } - return parsed, nil -} - -func extractCoordinates(value any) []float64 { - geometry, _ := value.(map[string]any) - coordinates, _ := geometry["coordinates"].([]any) - if len(coordinates) < 2 { - return nil - } - lon, lonOK := numberValue(coordinates[0]) - lat, latOK := numberValue(coordinates[1]) - if !lonOK || !latOK { - return nil - } - return []float64{lon, lat} -} - -func numberValue(value any) (float64, bool) { - switch typed := value.(type) { - case float64: - return typed, true - case int: - return float64(typed), true - case string: - parsed, err := strconv.ParseFloat(typed, 64) - return parsed, err == nil - default: - return 0, false - } -} - -func haversineKM(lat1, lon1, lat2, lon2 float64) float64 { - const earthRadiusKM = 6371.0 - toRad := func(deg float64) float64 { return deg * math.Pi / 180 } - dLat := toRad(lat2 - lat1) - dLon := toRad(lon2 - lon1) - a := math.Sin(dLat/2)*math.Sin(dLat/2) + - math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2) - return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) -} - -func startOfDay(value time.Time) time.Time { - year, month, day := value.Date() - return time.Date(year, month, day, 0, 0, 0, 0, value.Location()) -} - -func endOfDay(value time.Time) time.Time { - return startOfDay(value).Add(24*time.Hour - time.Nanosecond) -} - -func normalizeRoad(value string) string { - value = strings.ToUpper(strings.TrimSpace(value)) - value = strings.Join(strings.Fields(value), " ") - value = strings.ReplaceAll(value, "LS / SP", "LS/SP") - value = strings.ReplaceAll(value, "LS/ SP", "LS/SP") - value = strings.ReplaceAll(value, "LS /SP", "LS/SP") - return value -} - -func compactRoadToken(value string) string { - value = normalizeRoad(value) - replacer := strings.NewReplacer(" ", "", "/", "", "-", "") - return replacer.Replace(value) -} - -func normalizeAreaAlias(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - replacements := map[string]string{ - "ü": "ue", - "ö": "oe", - "ä": "ae", - "ß": "ss", - "_": "-", - " ": "-", - } - for old, newValue := range replacements { - value = strings.ReplaceAll(value, old, newValue) - } - value = strings.Trim(value, "-") - return value -} - -func normalizeTrafficSearchText(value string) string { - value = strings.ToLower(cleanTrafficText(value)) - replacer := strings.NewReplacer( - "ä", "ae", - "ö", "oe", - "ü", "ue", - "ß", "ss", - "à", "a", - "á", "a", - "è", "e", - "é", "e", - "ì", "i", - "í", "i", - "ò", "o", - "ó", "o", - "ù", "u", - "ú", "u", - "/", " ", - "-", " ", - "_", " ", - ".", " ", - ",", " ", - ";", " ", - ":", " ", - "(", " ", - ")", " ", - "?", " ", - "!", " ", - ) - value = replacer.Replace(value) - return strings.Join(strings.Fields(value), " ") -} - -func cleanTrafficText(value string) string { - value = strings.ReplaceAll(value, "\\n", "\n") - value = strings.ReplaceAll(value, "\r", "\n") - value = strings.Join(strings.Fields(value), " ") - return strings.TrimSpace(value) -} - -func compactRange(start, end string) string { - start = compactDate(start) - end = compactDate(end) - if start == "" { - return end - } - if end == "" || end == start { - return start - } - return start + " - " + end -} - -func compactDate(value string) string { - parsed := parseODHTime(value) - if parsed == nil { - return strings.TrimSpace(value) - } - return parsed.Format("2006-01-02") -} - -func normalizeDedupText(value string) string { - value = strings.ToLower(cleanTrafficText(value)) - return strings.Join(strings.Fields(value), " ") -} - -func textContainsPass(value string) bool { - value = strings.ToLower(value) - return strings.Contains(value, "pass") || strings.Contains(value, "joch") -} diff --git a/internal/commands/traffic_catalog.go b/internal/commands/traffic_catalog.go new file mode 100644 index 0000000..231ab93 --- /dev/null +++ b/internal/commands/traffic_catalog.go @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2026 Josef Gallmetzer +// +// SPDX-License-Identifier: MPL-2.0 + +package commands + +import ( + "fmt" + "strings" + "time" +) + +func normalizeTrafficTypeFilter(value string) (string, error) { + normalized := normalizeTrafficTypeName(value) + switch normalized { + case "", "all", "roadworks", "closure", "event", "traffic", "mountain-pass", "bike", "radar": + return normalized, nil + default: + return "", fmt.Errorf("unsupported traffic type %q", value) + } +} + +func normalizeTrafficTypeName(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "all", "any": + return "all" + case "roadwork", "roadworks", "works", "baustelle", "baustellen": + return "roadworks" + case "closure", "closures", "closed", "sperre", "sperren": + return "closure" + case "events", "event", "veranstaltung", "veranstaltungen": + return "event" + case "traffic", "incident", "incidents", "stau", "unfall", "warning": + return "traffic" + case "mountain-pass", "mountain-pass-closure", "pass", "passes": + return "mountain-pass" + case "bike", "cycle", "cycling", "radweg": + return "bike" + case "radar", "speed", "speed-control": + return "radar" + default: + return strings.ToLower(strings.TrimSpace(value)) + } +} + +func knownTrafficZones() []trafficZone { + return []trafficZone{ + {ZoneID: "1", Name: "Vinschgau"}, + {ZoneID: "2", Name: "Burggrafenamt"}, + {ZoneID: "3", Name: "Bozen-Unterland"}, + {ZoneID: "4", Name: "Salten-Schlern"}, + {ZoneID: "5", Name: "Eisacktal-Wipptal"}, + {ZoneID: "6", Name: "Pustertal"}, + {ZoneID: "7", Name: "Ausserhalb Südtirol"}, + } +} + +func knownTrafficCategories() []trafficCategory { + return []trafficCategory{ + { + Name: "roadworks", + Description: "Roadworks and construction notices.", + Aliases: []string{"roadwork", "works", "baustelle", "baustellen"}, + UpstreamSubtypes: []string{"BAUSTELLE"}, + }, + { + Name: "closure", + Description: "Road closures and blocked-road notices.", + Aliases: []string{"closed", "closures", "sperre", "sperren", "gesperrt"}, + UpstreamSubtypes: []string{"SPERRE"}, + }, + { + Name: "event", + Description: "Road impacts caused by public events.", + Aliases: []string{"events", "veranstaltung", "veranstaltungen"}, + UpstreamSubtypes: []string{"VERANSTALTUNG"}, + }, + { + Name: "traffic", + Description: "Traffic incidents, congestion, warnings, and general restrictions.", + Aliases: []string{"incident", "incidents", "stau", "unfall", "warning"}, + UpstreamSubtypes: []string{"AMPELREGELUNG", "FREI BEFAHRBAR", "SCHNEEFALL", "STAU", "UNFALL", "VORSICHT"}, + }, + { + Name: "mountain-pass", + Description: "Pass and mountain-road closures or restrictions.", + Aliases: []string{"mountain-pass-closure", "pass", "passes"}, + UpstreamSubtypes: []string{"SPERRE", "WINTERSPERRE"}, + }, + { + Name: "bike", + Description: "Cycle-route and bike-path closures.", + Aliases: []string{"cycle", "cycling", "radweg"}, + UpstreamSubtypes: []string{"RADWEG_SPERRE"}, + }, + { + Name: "radar", + Description: "Speed-control notices.", + Aliases: []string{"speed", "speed-control"}, + UpstreamSubtypes: []string{"RADARKONTROLLE"}, + }, + } +} + +func validateTrafficZoneIDs(value string) error { + known := map[string]struct{}{} + for _, zone := range knownTrafficZones() { + known[zone.ZoneID] = struct{}{} + } + for _, zoneID := range trafficZoneIDValues(value) { + if _, ok := known[zoneID]; !ok { + return fmt.Errorf("unknown traffic zone-id %q; run odh traffic zones", zoneID) + } + } + return nil +} + +func trafficZoneIDValues(value string) []string { + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == ' ' + }) + values := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + values = append(values, part) + } + } + return values +} + +func resolveTrafficArea(value string) (trafficArea, error) { + normalized := normalizeAreaAlias(value) + if normalized == "" || normalized == "all" { + return trafficArea{}, nil + } + areas := map[string]trafficArea{ + "bozen-unterland": {Name: "bozen-unterland", ZoneIDs: []string{"3"}}, + "ueberetsch-unterland": { + Name: "ueberetsch-unterland", + ZoneIDs: []string{"3"}, + Keywords: []string{ + "Salurn", "Neumarkt", "Auer", "Montan", "Tramin", "Kurtatsch", "Margreid", "Laag", "Buchholz", "Gfrill", + "Eppan", "Kaltern", "St. Pauls", "Unterrain", "Girlan", "Missian", "Montiggl", "Laimburg", "Mendel", "Sigmundskron", "Frangart", + }, + }, + "unterland": {Name: "unterland", ZoneIDs: []string{"3"}, Keywords: []string{"Salurn", "Neumarkt", "Auer", "Montan", "Tramin", "Kurtatsch", "Margreid", "Laag", "Buchholz", "Gfrill"}}, + "ueberetsch": {Name: "ueberetsch", ZoneIDs: []string{"3"}, Keywords: []string{"Eppan", "Kaltern", "St. Pauls", "Unterrain", "Girlan", "Missian", "Montiggl", "Laimburg", "Mendel", "Sigmundskron", "Frangart"}}, + "bozen": {Name: "bozen", ZoneIDs: []string{"3"}, Keywords: []string{"Bozen", "Bolzano", "Stadtgemeinde Bozen"}}, + "salurn": {Name: "salurn", ZoneIDs: []string{"3"}, Keywords: []string{"Salurn"}}, + "kaltern": {Name: "kaltern", ZoneIDs: []string{"3"}, Keywords: []string{"Kaltern"}}, + "tramin": {Name: "tramin", ZoneIDs: []string{"3"}, Keywords: []string{"Tramin"}}, + "eppan": {Name: "eppan", ZoneIDs: []string{"3"}, Keywords: []string{"Eppan", "St. Pauls", "Girlan", "Missian", "Unterrain", "Montiggl", "Frangart"}}, + "auer": {Name: "auer", ZoneIDs: []string{"3"}, Keywords: []string{"Auer"}}, + "neumarkt": {Name: "neumarkt", ZoneIDs: []string{"3"}, Keywords: []string{"Neumarkt"}}, + "kurtatsch": {Name: "kurtatsch", ZoneIDs: []string{"3"}, Keywords: []string{"Kurtatsch"}}, + "margreid": {Name: "margreid", ZoneIDs: []string{"3"}, Keywords: []string{"Margreid"}}, + "montan": {Name: "montan", ZoneIDs: []string{"3"}, Keywords: []string{"Montan"}}, + "burggrafenamt": {Name: "burggrafenamt", ZoneIDs: []string{"2"}}, + "eisacktal-wipptal": {Name: "eisacktal-wipptal", ZoneIDs: []string{"5"}}, + "pustertal": {Name: "pustertal", ZoneIDs: []string{"6"}}, + "vinschgau": {Name: "vinschgau", ZoneIDs: []string{"1"}}, + "salten-schlern": {Name: "salten-schlern", ZoneIDs: []string{"4"}}, + "ausserhalb-suedtirol": {Name: "ausserhalb-suedtirol", ZoneIDs: []string{"7"}}, + } + area, ok := areas[normalized] + if !ok { + return trafficArea{}, fmt.Errorf("unknown traffic area %q", value) + } + return area, nil +} + +func parseTrafficDateRange(from, to string) (time.Time, time.Time, error) { + fromDay, err := parseTrafficDate(from) + if err != nil { + return time.Time{}, time.Time{}, err + } + toDay, err := parseTrafficDate(to) + if err != nil { + return time.Time{}, time.Time{}, err + } + if toDay.Before(fromDay) { + return time.Time{}, time.Time{}, fmt.Errorf("--to must not be before --from") + } + return fromDay, toDay, nil +} + +func parseTrafficDate(value string) (time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, fmt.Errorf("date is required") + } + parsed, err := time.ParseInLocation("2006-01-02", value, time.Local) + if err != nil { + return time.Time{}, fmt.Errorf("invalid date %q; use YYYY-MM-DD", value) + } + return parsed, nil +} diff --git a/internal/commands/traffic_feed.go b/internal/commands/traffic_feed.go new file mode 100644 index 0000000..a575b63 --- /dev/null +++ b/internal/commands/traffic_feed.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2026 Josef Gallmetzer +// +// SPDX-License-Identifier: MPL-2.0 + +package commands + +import ( + "fmt" + "strings" + "time" +) + +// Shared Timeseries feed helpers. They live with the traffic code but are used +// beyond it: mobility events and A22 status report the same feed caveats. + +// newestRecordTimestamp returns the most recent event timestamp across raw +// Mobility records, for callers that have not normalized them into events yet. +func newestRecordTimestamp(records []map[string]any) *time.Time { + var newest *time.Time + for _, record := range records { + for _, key := range []string{"evtransactiontime", "evpublishtime"} { + parsed := parseODHTime(asString(record[key])) + if parsed == nil { + continue + } + if newest == nil || parsed.After(*newest) { + newest = parsed + } + } + } + return newest +} + +// newestTrafficEventTimestamp returns the most recent transaction or publish +// timestamp across the given events, or nil when none of them carry one. +func newestTrafficEventTimestamp(events []trafficEvent) *time.Time { + var newest *time.Time + for _, event := range events { + for _, value := range []string{event.TransactionTime, event.PublishedAt} { + parsed := parseODHTime(value) + if parsed == nil { + continue + } + if newest == nil || parsed.After(*newest) { + newest = parsed + } + } + } + return newest +} + +// timeseriesEventFeedWarning reports the newest row this response carried rather +// than asserting an upstream feed status the CLI never checks, so the caveat +// stays true whatever upstream does next. +func timeseriesEventFeedWarning(newest *time.Time, announcementSource string) string { + replacement := fmt.Sprintf("odh call tourism /v1/Announcement --param source=%s --param rawsort=-LastChange", announcementSource) + if newest == nil { + return fmt.Sprintf("this Mobility Timeseries event feed returned no dated rows; an empty result is not evidence that roads are clear, so cross-check current notices with: %s", replacement) + } + return fmt.Sprintf("the newest row in this Mobility Timeseries event response is dated %s; this feed is not a live bulletin, and neither a stale nor an empty result is evidence that roads are clear, so cross-check current notices with: %s", newest.UTC().Format("2006-01-02"), replacement) +} + +// announcementSourceForOrigin maps a Mobility event origin to the Content API +// Announcement source that carries current notices for the same roads. +func announcementSourceForOrigin(origin string) string { + if strings.EqualFold(strings.TrimSpace(origin), "a22") { + return "a22" + } + return announcementTrafficSource +} diff --git a/internal/commands/traffic_filter.go b/internal/commands/traffic_filter.go new file mode 100644 index 0000000..80c6b4f --- /dev/null +++ b/internal/commands/traffic_filter.go @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: 2026 Josef Gallmetzer +// +// SPDX-License-Identifier: MPL-2.0 + +package commands + +import ( + "fmt" + "slices" + "strings" +) + +func trafficZoneMatches(event trafficEvent, zoneIDs string) bool { + values := trafficZoneIDValues(zoneIDs) + if len(values) == 0 { + return true + } + return containsString(values, event.ZoneID) +} + +func trafficAreaMatches(event trafficEvent, area trafficArea) bool { + if area.Name == "" { + return true + } + if len(area.ZoneIDs) > 0 && !containsString(area.ZoneIDs, event.ZoneID) { + return false + } + if len(area.Keywords) == 0 { + return true + } + haystack := normalizeTrafficSearchText(strings.Join([]string{event.Zone, event.ZoneIT, event.Road, event.RoadName, event.Place, event.PlaceIT}, " ")) + for _, keyword := range area.Keywords { + keyword = normalizeTrafficSearchText(keyword) + if keyword != "" && strings.Contains(haystack, keyword) { + return true + } + } + return false +} + +func trafficTypeMatches(event trafficEvent, filter string) bool { + filter = normalizeTrafficTypeName(filter) + if filter == "" || filter == "all" { + return true + } + if event.Type == filter { + return true + } + if filter == "closure" && strings.Contains(strings.ToLower(event.Place), "sperre") { + return true + } + if filter == "bike" && strings.Contains(strings.ToLower(event.RoadName), "rad") { + return true + } + if filter == "mountain-pass" && textContainsPass(event.RoadName+" "+event.Place) { + return true + } + return false +} + +func trafficRoadMatches(event trafficEvent, road string) bool { + road = normalizeRoad(road) + if road == "" { + return true + } + filter := compactRoadToken(road) + eventRoad := compactRoadToken(event.Road) + return strings.EqualFold(event.Road, road) || + (filter != "" && strings.Contains(eventRoad, filter)) || + strings.Contains(strings.ToLower(event.RoadName), strings.ToLower(road)) +} + +func trafficNearMatches(event trafficEvent, near, radius string) bool { + if strings.TrimSpace(near) == "" { + return true + } + lat, lon, radiusKM, err := parseNearRadius(near, radius) + if err != nil || len(event.Coordinates) < 2 { + return false + } + eventLon, eventLat := event.Coordinates[0], event.Coordinates[1] + return haversineKM(lat, lon, eventLat, eventLon) <= radiusKM +} + +func trafficSearchMatches(event trafficEvent, search string) bool { + if strings.TrimSpace(search) == "" { + return true + } + // Identifiers are matched whole, never by substring: they are opaque, and a + // bare "12" used to match every record whose message id contained 12. + identifiers := []string{ + normalizeTrafficSearchText(event.ID), + normalizeTrafficSearchText(event.SeriesID), + normalizeTrafficSearchText(event.MessageID), + } + haystack := normalizeTrafficSearchText(strings.Join([]string{ + event.Source, + event.Type, + event.Subtype, + event.Severity, + event.ZoneID, + event.Zone, + event.ZoneIT, + event.Road, + event.RoadName, + event.Place, + event.PlaceIT, + }, " ")) + // Road numbers are written both ways upstream ("SS12" and "SS 12"), so a + // space-stripped copy lets either spelling find either. + squashed := strings.ReplaceAll(haystack, " ", "") + groups := trafficSearchTermGroups(search) + if len(groups) == 0 { + return true + } + for _, group := range groups { + matched := false + for _, term := range group { + term = normalizeTrafficSearchText(term) + if term == "" { + continue + } + // Alphabetic terms match at a word boundary only (prefix of a word is + // fine: cycle aliases like "radweg" must still find "Radrouten"). An + // infix match would turn "auer" into every "Stützmauern". + if trafficTextHasTerm(haystack, term) || slices.Contains(identifiers, term) { + matched = true + break + } + if isRoadToken(term) && strings.Contains(squashed, term) { + matched = true + break + } + } + if !matched { + return false + } + } + return true +} + +// trafficTextHasTerm reports whether term sits at the start of any word in +// haystack. Prefixes are allowed; infix matches inside a longer word are not. +func trafficTextHasTerm(haystack, term string) bool { + for _, word := range strings.Fields(haystack) { + if strings.HasPrefix(word, term) { + return true + } + } + return false +} + +func trafficSearchTermGroups(search string) [][]string { + terms := joinRoadTokens(strings.Fields(normalizeTrafficSearchText(search))) + groups := make([][]string, 0, len(terms)) + for _, term := range terms { + if trafficSearchStopword(term) { + continue + } + groups = append(groups, trafficSearchAlternatives(term)) + } + return groups +} + +// isRoadToken reports a road number written as one word, for example ss12 or sp13. +// Only these are matched against the space-stripped haystack, so ordinary words +// cannot join across a space and match something nobody asked for. +func isRoadToken(term string) bool { + letters := 0 + for letters < len(term) && term[letters] >= 'a' && term[letters] <= 'z' { + letters++ + } + if letters == 0 || letters > 3 || letters == len(term) { + return false + } + for i := letters; i < len(term); i++ { + if term[i] < '0' || term[i] > '9' { + return false + } + } + return true +} + +// joinRoadTokens collapses "ss 12" into "ss12" so both spellings of a road +// number search the same way. Upstream text uses both. +func joinRoadTokens(terms []string) []string { + joined := make([]string, 0, len(terms)) + for i := 0; i < len(terms); i++ { + if i+1 < len(terms) && isRoadToken(terms[i]+terms[i+1]) { + joined = append(joined, terms[i]+terms[i+1]) + i++ + continue + } + joined = append(joined, terms[i]) + } + return joined +} + +func trafficSearchStopword(term string) bool { + switch term { + case "a", "an", "am", "and", "are", "auf", "bei", "by", "der", "die", "das", "del", "della", "den", "des", "di", "for", "heute", "in", "is", "la", "le", "near", "on", "road", "roads", "route", "street", "streets", "strasse", "strassen", "the", "today", "um", "und", "via", "why": + return true + default: + return false + } +} + +func trafficSearchAlternatives(term string) []string { + switch term { + case "blocked", "closed", "closure", "closures", "roadblock", "roadblocks": + return []string{"closure", "closed", "blocked", "sperre", "sperren", "gesperrt"} + case "baustelle", "baustellen", "construction", "roadwork", "roadworks", "works": + return []string{"roadworks", "baustelle", "baustellen", "arbeiten"} + case "event", "events", "veranstaltung", "veranstaltungen": + return []string{"event", "events", "veranstaltung", "veranstaltungen"} + case "sperre", "sperren", "gesperrt": + return []string{"sperre", "sperren", "gesperrt", "closure", "closed", "blocked"} + // The bike category's own aliases must find cycle notices: upstream writes + // Radroute and ciclabile, so a user typing the documented "radweg" would + // otherwise get a confident empty answer. + case "bike", "bici", "bicicletta", "ciclabil", "ciclabile", "cycle", "cycling", "fahrrad", "rad", "radroute", "radrouten", "radweg", "radwege": + return []string{"radroute", "radweg", "fahrrad", "ciclabil", "bicicl", "cycle"} + default: + return []string{term} + } +} + +func trafficNoMatchesWarning(query trafficQuery, area trafficArea, hiddenStaleOpenEndedCount int) string { + parts := trafficFilterParts(query, area) + if len(parts) == 0 { + return "" + } + warning := "no current ODH PROVINCE_BZ traffic events matched " + strings.Join(parts, ", ") + " in the selected date range" + if hiddenStaleOpenEndedCount > 0 { + warning += "; stale open-ended matches may exist, rerun with --include-stale to inspect them" + } + return warning +} + +// trafficFilterParts describes the narrowing filters a query applied, so an +// empty result can name what it was narrowed by. +func trafficFilterParts(query trafficQuery, area trafficArea) []string { + parts := make([]string, 0, 6) + if search := strings.TrimSpace(query.Search); search != "" { + parts = append(parts, fmt.Sprintf("search %q", search)) + } + if zoneID := strings.TrimSpace(query.ZoneID); zoneID != "" { + parts = append(parts, "zone-id "+zoneID) + } + if area.Name != "" { + parts = append(parts, "area "+area.Name) + } + if eventType := normalizeTrafficTypeName(query.Type); eventType != "" && eventType != "all" { + parts = append(parts, "type "+eventType) + } + if road := strings.TrimSpace(query.Road); road != "" { + parts = append(parts, "road "+road) + } + if near := strings.TrimSpace(query.Near); near != "" { + parts = append(parts, "near "+near) + } + return parts +} diff --git a/internal/commands/traffic_geo.go b/internal/commands/traffic_geo.go new file mode 100644 index 0000000..d058690 --- /dev/null +++ b/internal/commands/traffic_geo.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2026 Josef Gallmetzer +// +// SPDX-License-Identifier: MPL-2.0 + +package commands + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" +) + +func parseNearRadius(near, radius string) (float64, float64, float64, error) { + latText, lonText, ok := strings.Cut(strings.TrimSpace(near), ",") + if !ok { + return 0, 0, 0, fmt.Errorf("--near must use lat,lon") + } + lat, err := strconv.ParseFloat(strings.TrimSpace(latText), 64) + if err != nil { + return 0, 0, 0, fmt.Errorf("invalid latitude in --near: %w", err) + } + lon, err := strconv.ParseFloat(strings.TrimSpace(lonText), 64) + if err != nil { + return 0, 0, 0, fmt.Errorf("invalid longitude in --near: %w", err) + } + radiusKM, err := parseRadiusKM(radius) + if err != nil { + return 0, 0, 0, err + } + return lat, lon, radiusKM, nil +} + +func parseRadiusKM(value string) (float64, error) { + value = strings.TrimSpace(strings.ToLower(value)) + value = strings.TrimSuffix(value, "km") + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return 0, fmt.Errorf("invalid radius %q", value) + } + if parsed <= 0 { + return 0, fmt.Errorf("radius must be greater than zero") + } + return parsed, nil +} + +func extractCoordinates(value any) []float64 { + geometry, _ := value.(map[string]any) + coordinates, _ := geometry["coordinates"].([]any) + if len(coordinates) < 2 { + return nil + } + lon, lonOK := numberValue(coordinates[0]) + lat, latOK := numberValue(coordinates[1]) + if !lonOK || !latOK { + return nil + } + return []float64{lon, lat} +} + +func numberValue(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case int: + return float64(typed), true + case string: + parsed, err := strconv.ParseFloat(typed, 64) + return parsed, err == nil + default: + return 0, false + } +} + +func haversineKM(lat1, lon1, lat2, lon2 float64) float64 { + const earthRadiusKM = 6371.0 + toRad := func(deg float64) float64 { return deg * math.Pi / 180 } + dLat := toRad(lat2 - lat1) + dLon := toRad(lon2 - lon1) + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2) + return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} + +func startOfDay(value time.Time) time.Time { + year, month, day := value.Date() + return time.Date(year, month, day, 0, 0, 0, 0, value.Location()) +} + +func endOfDay(value time.Time) time.Time { + return startOfDay(value).Add(24*time.Hour - time.Nanosecond) +} + +func normalizeRoad(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + value = strings.Join(strings.Fields(value), " ") + value = strings.ReplaceAll(value, "LS / SP", "LS/SP") + value = strings.ReplaceAll(value, "LS/ SP", "LS/SP") + value = strings.ReplaceAll(value, "LS /SP", "LS/SP") + return value +} + +func compactRoadToken(value string) string { + value = normalizeRoad(value) + replacer := strings.NewReplacer(" ", "", "/", "", "-", "") + return replacer.Replace(value) +} + +func normalizeAreaAlias(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + replacements := map[string]string{ + "ü": "ue", + "ö": "oe", + "ä": "ae", + "ß": "ss", + "_": "-", + " ": "-", + } + for old, newValue := range replacements { + value = strings.ReplaceAll(value, old, newValue) + } + value = strings.Trim(value, "-") + return value +} + +func normalizeTrafficSearchText(value string) string { + value = strings.ToLower(cleanTrafficText(value)) + replacer := strings.NewReplacer( + "ä", "ae", + "ö", "oe", + "ü", "ue", + "ß", "ss", + "à", "a", + "á", "a", + "è", "e", + "é", "e", + "ì", "i", + "í", "i", + "ò", "o", + "ó", "o", + "ù", "u", + "ú", "u", + "/", " ", + "-", " ", + "_", " ", + ".", " ", + ",", " ", + ";", " ", + ":", " ", + "(", " ", + ")", " ", + "?", " ", + "!", " ", + ) + value = replacer.Replace(value) + return strings.Join(strings.Fields(value), " ") +} + +func cleanTrafficText(value string) string { + value = strings.ReplaceAll(value, "\\n", "\n") + value = strings.ReplaceAll(value, "\r", "\n") + value = strings.Join(strings.Fields(value), " ") + return strings.TrimSpace(value) +} + +func textContainsPass(value string) bool { + value = strings.ToLower(value) + return strings.Contains(value, "pass") || strings.Contains(value, "joch") +} diff --git a/internal/commands/traffic_normalize.go b/internal/commands/traffic_normalize.go new file mode 100644 index 0000000..63015eb --- /dev/null +++ b/internal/commands/traffic_normalize.go @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: 2026 Josef Gallmetzer +// +// SPDX-License-Identifier: MPL-2.0 + +package commands + +import ( + "fmt" + "sort" + "strings" + "time" +) + +func normalizeTrafficEvents(raw []map[string]any, query trafficQuery, area trafficArea, fromDay, toDay time.Time) ([]trafficEvent, []string) { + events := make([]trafficEvent, 0, len(raw)) + now := time.Now() + staleCount := 0 + hiddenStaleOpenEndedCount := 0 + expiredCount := 0 + futureCount := 0 + for _, record := range raw { + event := normalizeTrafficEvent(record, query.Raw, now) + if !trafficZoneMatches(event, query.ZoneID) { + continue + } + if !trafficAreaMatches(event, area) { + continue + } + if !trafficTypeMatches(event, query.Type) { + continue + } + if !trafficRoadMatches(event, query.Road) { + continue + } + if !trafficNearMatches(event, query.Near, query.Radius) { + continue + } + if !trafficSearchMatches(event, query.Search) { + continue + } + active := eventActiveInRange(event, fromDay, toDay) + event.Active = active + if !active && !query.IncludeExpired { + if eventEndBefore(event, fromDay) { + expiredCount++ + } else { + futureCount++ + } + continue + } + if event.Stale && !eventHasEnd(event) && !query.IncludeStale { + hiddenStaleOpenEndedCount++ + continue + } + if event.Stale { + staleCount++ + } + events = append(events, event) + } + deduped := dedupeTrafficEvents(events) + sort.SliceStable(deduped, func(i, j int) bool { + if deduped[i].Start != deduped[j].Start { + return deduped[i].Start < deduped[j].Start + } + if deduped[i].Road != deduped[j].Road { + return deduped[i].Road < deduped[j].Road + } + return deduped[i].Place < deduped[j].Place + }) + + feedWarning := timeseriesEventFeedWarning(newestTrafficEventTimestamp(deduped), "PROVINCE_BZ") + if normalizeTrafficTypeName(query.Type) == "bike" { + feedWarning += "; active reflects the stored date range, not verified current status. For current cycle-route notices, run: odh traffic search radroute --today --source content --json" + } + warnings := []string{feedWarning} + if len(events) != len(deduped) { + warnings = append(warnings, fmt.Sprintf("deduplicated %d raw matching rows to %d events", len(events), len(deduped))) + } + if staleCount > 0 { + warnings = append(warnings, fmt.Sprintf("%d matching events have transaction or publish timestamps older than 30 days", staleCount)) + } + if hiddenStaleOpenEndedCount > 0 { + warnings = append(warnings, fmt.Sprintf("%d stale open-ended matching events were hidden; pass --include-stale to inspect them", hiddenStaleOpenEndedCount)) + } + if expiredCount > 0 { + warnings = append(warnings, fmt.Sprintf("%d expired matching events were hidden", expiredCount)) + } + if futureCount > 0 { + warnings = append(warnings, fmt.Sprintf("%d future matching events were hidden", futureCount)) + } + if len(deduped) == 0 { + if warning := trafficNoMatchesWarning(query, area, hiddenStaleOpenEndedCount); warning != "" { + warnings = append(warnings, warning) + } + } + if warning := mobilityTruncationWarning("returned", "raw event rows", query.Limit, len(raw), "traffic completeness"); warning != "" { + warnings = append(warnings, warning) + } + warnings = append(warnings, "source is Open Data Hub PROVINCE_BZ; compare with the official traffic service before presenting this as a complete live road bulletin") + return deduped, warnings +} + +func normalizeTrafficEvent(record map[string]any, includeRaw bool, now time.Time) trafficEvent { + metadata, _ := record["evmetadata"].(map[string]any) + event := trafficEvent{ + ID: firstNonEmpty(asString(record["evuuid"]), asString(record["evname"])), + SeriesID: asString(record["evseriesuuid"]), + MessageID: asString(metadata["messageId"]), + Source: "odh", + Subtype: strings.TrimSpace(asString(metadata["subTycodeValue"])), + Severity: firstNonEmpty(asString(metadata["messageGradDescDe"]), asString(metadata["messageGradDescIt"])), + ZoneID: asString(metadata["messageZoneId"]), + Zone: strings.TrimSpace(asString(metadata["messageZoneDescDe"])), + ZoneIT: strings.TrimSpace(asString(metadata["messageZoneDescIt"])), + Road: normalizeRoad(asString(metadata["messageStreetNr"])), + RoadName: strings.TrimSpace(asString(metadata["messageStreetInternetDescDe"])), + Place: cleanTrafficText(asString(metadata["placeDe"])), + PlaceIT: cleanTrafficText(asString(metadata["placeIt"])), + Start: asString(record["evstart"]), + End: asString(record["evend"]), + PublishedAt: firstNonEmpty(asString(metadata["publishDateTime"]), asString(metadata["publisherDateTime"])), + TransactionTime: asString(record["evtransactiontime"]), + Coordinates: extractCoordinates(record["evlgeometry"]), + } + event.Type = classifyTrafficType(event) + if includeRaw { + event.Raw = record + } + event.Stale = trafficEventStale(event, now) + return event +} + +func eventActiveInRange(event trafficEvent, fromDay, toDay time.Time) bool { + start := parseODHTime(event.Start) + end := parseODHTime(event.End) + rangeStart := startOfDay(fromDay) + rangeEnd := endOfDay(toDay) + if start != nil && start.After(rangeEnd) { + return false + } + if end != nil && end.Before(rangeStart) { + return false + } + return true +} + +func eventEndBefore(event trafficEvent, fromDay time.Time) bool { + end := parseODHTime(event.End) + return end != nil && end.Before(startOfDay(fromDay)) +} + +func eventHasEnd(event trafficEvent) bool { + return parseODHTime(event.End) != nil +} + +func trafficEventStale(event trafficEvent, now time.Time) bool { + for _, value := range []string{event.TransactionTime, event.PublishedAt} { + parsed := parseODHTime(value) + if parsed != nil { + return parsed.Before(now.AddDate(0, 0, -30)) + } + } + return false +} + +func dedupeTrafficEvents(events []trafficEvent) []trafficEvent { + seen := map[string]struct{}{} + result := make([]trafficEvent, 0, len(events)) + for _, event := range events { + key := strings.Join([]string{ + event.ZoneID, + event.Road, + event.RoadName, + normalizeDedupText(event.Place), + event.Start, + event.End, + }, "|") + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, event) + } + return result +} + +func classifyTrafficType(event trafficEvent) string { + subtype := strings.ToUpper(strings.TrimSpace(event.Subtype)) + combined := strings.ToLower(event.RoadName + " " + event.Place) + switch subtype { + case "BAUSTELLE": + return "roadworks" + case "SPERRE": + if textContainsPass(combined) { + return "mountain-pass" + } + return "closure" + case "RADWEG_SPERRE": + return "bike" + case "VERANSTALTUNG": + return "event" + case "RADARKONTROLLE": + return "radar" + case "STAU", "UNFALL", "SCHNEEFALL", "AMPELREGELUNG", "VORSICHT", "FREI BEFAHRBAR": + if textContainsPass(combined) { + return "mountain-pass" + } + return "traffic" + default: + if strings.Contains(combined, "radroute") || strings.Contains(combined, "radweg") { + return "bike" + } + if textContainsPass(combined) { + return "mountain-pass" + } + return "traffic" + } +} + +func normalizeDedupText(value string) string { + value = strings.ToLower(cleanTrafficText(value)) + return strings.Join(strings.Fields(value), " ") +} diff --git a/internal/commands/traffic_render.go b/internal/commands/traffic_render.go new file mode 100644 index 0000000..392fe8e --- /dev/null +++ b/internal/commands/traffic_render.go @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2026 Josef Gallmetzer +// +// SPDX-License-Identifier: MPL-2.0 + +package commands + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" + + "github.com/galjos/odh-cli/internal/output" +) + +func writeTrafficOutput(stdout io.Writer, result trafficResult) error { + switch result.OutputFormat { + case "", "json": + return output.WriteJSON(stdout, result) + case "table": + return writeTrafficTable(stdout, result) + case "markdown", "md": + return writeTrafficMarkdown(stdout, result) + default: + return fmt.Errorf("unsupported format %q", result.OutputFormat) + } +} + +func writeTrafficZonesOutput(stdout io.Writer, result trafficZonesResult) error { + switch result.OutputFormat { + case "", "json": + return output.WriteJSON(stdout, result) + case "table": + tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ZONE_ID\tNAME") + for _, zone := range result.Zones { + fmt.Fprintf(tw, "%s\t%s\n", zone.ZoneID, zone.Name) + } + return tw.Flush() + case "markdown", "md": + fmt.Fprintln(stdout, "| zone_id | name |") + fmt.Fprintln(stdout, "| --- | --- |") + for _, zone := range result.Zones { + fmt.Fprintf(stdout, "| %s | %s |\n", escapeMarkdown(zone.ZoneID), escapeMarkdown(zone.Name)) + } + return nil + default: + return fmt.Errorf("unsupported format %q", result.OutputFormat) + } +} + +func writeTrafficCategoriesOutput(stdout io.Writer, result trafficCategoriesResult) error { + switch result.OutputFormat { + case "", "json": + return output.WriteJSON(stdout, result) + case "table": + tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tDESCRIPTION\tUPSTREAM_SUBTYPES") + for _, category := range result.Categories { + fmt.Fprintf(tw, "%s\t%s\t%s\n", category.Name, category.Description, strings.Join(category.UpstreamSubtypes, ",")) + } + return tw.Flush() + case "markdown", "md": + fmt.Fprintln(stdout, "| name | description | upstream_subtypes |") + fmt.Fprintln(stdout, "| --- | --- | --- |") + for _, category := range result.Categories { + fmt.Fprintf(stdout, "| %s | %s | %s |\n", + escapeMarkdown(category.Name), + escapeMarkdown(category.Description), + escapeMarkdown(strings.Join(category.UpstreamSubtypes, ", ")), + ) + } + return nil + default: + return fmt.Errorf("unsupported format %q", result.OutputFormat) + } +} + +func writeTrafficTable(stdout io.Writer, result trafficResult) error { + warnings := result.Warnings + if result.Source == trafficSourceODH && len(warnings) > 0 { + fmt.Fprintf(stdout, "warning: %s\n\n", warnings[0]) + warnings = warnings[1:] + } + tw := tabwriter.NewWriter(stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "TYPE\tROAD\tPLACE\tTIME\tACTIVE\tSTALE") + for _, event := range result.Events { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%t\t%t\n", + event.Type, + firstNonEmpty(event.Road, event.RoadName), + compactText(firstNonEmpty(event.Place, event.PlaceIT), 90), + compactRange(event.Start, event.End), + event.Active, + event.Stale, + ) + } + if err := tw.Flush(); err != nil { + return err + } + for _, warning := range warnings { + fmt.Fprintf(stdout, "warning: %s\n", warning) + } + return nil +} + +func writeTrafficMarkdown(stdout io.Writer, result trafficResult) error { + warnings := result.Warnings + if result.Source == trafficSourceODH && len(warnings) > 0 { + fmt.Fprintf(stdout, "> warning: %s\n\n", warnings[0]) + warnings = warnings[1:] + } + fmt.Fprintln(stdout, "| type | road | place | time | active | stale |") + fmt.Fprintln(stdout, "| --- | --- | --- | --- | --- |") + for _, event := range result.Events { + fmt.Fprintf(stdout, "| %s | %s | %s | %s | %t | %t |\n", + escapeMarkdown(event.Type), + escapeMarkdown(firstNonEmpty(event.Road, event.RoadName)), + escapeMarkdown(compactText(firstNonEmpty(event.Place, event.PlaceIT), 90)), + escapeMarkdown(compactRange(event.Start, event.End)), + event.Active, + event.Stale, + ) + } + for _, warning := range warnings { + fmt.Fprintf(stdout, "\n> warning: %s\n", warning) + } + return nil +} + +func compactRange(start, end string) string { + start = compactDate(start) + end = compactDate(end) + if start == "" { + return end + } + if end == "" || end == start { + return start + } + return start + " - " + end +} + +func compactDate(value string) string { + parsed := parseODHTime(value) + if parsed == nil { + return strings.TrimSpace(value) + } + return parsed.Format("2006-01-02") +}