goplaces/internal/cli/render.go
James Brooks d157022860 feat: add route command for searching places along a route
Adds a new route command that searches for places along a driving route between two locations.

Features:
- Uses Google Routes API to get route polyline
- Samples waypoints evenly along the route by distance
- Searches for places at each waypoint with location bias
- Supports all travel modes (DRIVE, WALK, BICYCLE, TWO_WHEELER, TRANSIT)
- Configurable waypoint count, search radius, and result limit

Usage:
  goplaces route "coffee" --from "London, UK" --to "Bristol, UK"
  goplaces route "gas station" --from "NYC" --to "Boston" --max-waypoints 5
2026-01-02 22:30:00 +01:00

418 lines
11 KiB
Go

package cli
import (
"bytes"
"fmt"
"sort"
"strings"
"github.com/steipete/goplaces"
)
func renderSearch(color Color, response goplaces.SearchResponse) string {
var out bytes.Buffer
count := len(response.Results)
if count == 0 {
return emptyResultsMessage
}
out.WriteString(color.Bold(fmt.Sprintf("Results (%d)", count)))
out.WriteString("\n")
for i, place := range response.Results {
out.WriteString(fmt.Sprintf("%d. %s\n", i+1, formatTitle(color, place.Name, place.Address)))
writePlaceSummary(&out, color, place)
if i < count-1 {
out.WriteString("\n")
}
}
if strings.TrimSpace(response.NextPageToken) != "" {
out.WriteString("\n")
out.WriteString(color.Dim("Next page token:"))
out.WriteString(" ")
out.WriteString(response.NextPageToken)
}
return out.String()
}
func renderAutocomplete(color Color, response goplaces.AutocompleteResponse) string {
var out bytes.Buffer
count := len(response.Suggestions)
if count == 0 {
return emptyResultsMessage
}
out.WriteString(color.Bold(fmt.Sprintf("Suggestions (%d)", count)))
out.WriteString("\n")
for i, suggestion := range response.Suggestions {
title := formatTitle(color, autocompleteTitle(suggestion), autocompleteSubtitle(suggestion))
out.WriteString(fmt.Sprintf("%d. %s\n", i+1, title))
writeAutocompleteSuggestion(&out, color, suggestion)
if i < count-1 {
out.WriteString("\n")
}
}
return out.String()
}
func renderNearby(color Color, response goplaces.NearbySearchResponse) string {
var out bytes.Buffer
count := len(response.Results)
if count == 0 {
return emptyResultsMessage
}
out.WriteString(color.Bold(fmt.Sprintf("Nearby (%d)", count)))
out.WriteString("\n")
for i, place := range response.Results {
out.WriteString(fmt.Sprintf("%d. %s\n", i+1, formatTitle(color, place.Name, place.Address)))
writePlaceSummary(&out, color, place)
if i < count-1 {
out.WriteString("\n")
}
}
if strings.TrimSpace(response.NextPageToken) != "" {
out.WriteString("\n")
out.WriteString(color.Dim("Next page token:"))
out.WriteString(" ")
out.WriteString(response.NextPageToken)
}
return out.String()
}
func renderPhoto(color Color, response goplaces.PhotoMediaResponse) string {
var out bytes.Buffer
out.WriteString(color.Bold("Photo"))
out.WriteString("\n")
writeLine(&out, color, "Name", response.Name)
writeLine(&out, color, "URL", response.PhotoURI)
return out.String()
}
func renderDetails(color Color, place goplaces.PlaceDetails) string {
var out bytes.Buffer
out.WriteString(color.Bold(formatTitle(color, place.Name, place.Address)))
out.WriteString("\n")
writePlaceDetails(&out, color, place)
return out.String()
}
func renderResolve(color Color, response goplaces.LocationResolveResponse) string {
var out bytes.Buffer
count := len(response.Results)
if count == 0 {
return emptyResultsMessage
}
out.WriteString(color.Bold(fmt.Sprintf("Resolved (%d)", count)))
out.WriteString("\n")
for i, place := range response.Results {
out.WriteString(fmt.Sprintf("%d. %s\n", i+1, formatTitle(color, place.Name, place.Address)))
writeResolvedLocation(&out, color, place)
if i < count-1 {
out.WriteString("\n")
}
}
return out.String()
}
func renderRoute(color Color, response goplaces.RouteResponse) string {
var out bytes.Buffer
count := len(response.Waypoints)
if count == 0 {
return emptyResultsMessage
}
out.WriteString(color.Bold(fmt.Sprintf("Route waypoints (%d)", count)))
out.WriteString("\n")
for i, waypoint := range response.Waypoints {
out.WriteString(color.Bold(fmt.Sprintf("Waypoint %d", i+1)))
out.WriteString(" ")
out.WriteString(color.Dim(fmt.Sprintf("(%.6f, %.6f)", waypoint.Location.Lat, waypoint.Location.Lng)))
out.WriteString("\n")
if len(waypoint.Results) == 0 {
out.WriteString(emptyResultsMessage)
out.WriteString("\n")
} else {
for j, place := range waypoint.Results {
out.WriteString(fmt.Sprintf("%d. %s\n", j+1, formatTitle(color, place.Name, place.Address)))
writePlaceSummary(&out, color, place)
if j < len(waypoint.Results)-1 {
out.WriteString("\n")
}
}
}
if i < count-1 {
out.WriteString("\n")
}
}
return out.String()
}
func formatTitle(color Color, name string, address string) string {
display := strings.TrimSpace(name)
if display == "" {
display = "(no name)"
}
if address == "" {
return color.Cyan(display)
}
return color.Cyan(display) + " — " + address
}
const emptyResultsMessage = "No results."
func autocompleteTitle(suggestion goplaces.AutocompleteSuggestion) string {
if strings.TrimSpace(suggestion.MainText) != "" {
return suggestion.MainText
}
return suggestion.Text
}
func autocompleteSubtitle(suggestion goplaces.AutocompleteSuggestion) string {
if strings.TrimSpace(suggestion.SecondaryText) != "" {
return suggestion.SecondaryText
}
if strings.TrimSpace(suggestion.Text) == "" || strings.TrimSpace(suggestion.MainText) == "" {
return ""
}
return suggestion.Text
}
func writePlaceSummary(out *bytes.Buffer, color Color, place goplaces.PlaceSummary) {
writeLine(out, color, "ID", place.PlaceID)
writeLocation(out, color, place.Location)
writeRating(out, color, place.Rating, place.PriceLevel)
writeTypes(out, color, place.Types)
writeOpenNow(out, color, place.OpenNow)
}
func writeAutocompleteSuggestion(out *bytes.Buffer, color Color, suggestion goplaces.AutocompleteSuggestion) {
writeLine(out, color, "Kind", suggestion.Kind)
writeLine(out, color, "ID", suggestion.PlaceID)
writeLine(out, color, "Place", suggestion.Place)
writeTypes(out, color, suggestion.Types)
if suggestion.DistanceMeters != nil {
writeLine(out, color, "Distance", fmt.Sprintf("%dm", *suggestion.DistanceMeters))
}
}
func writePlaceDetails(out *bytes.Buffer, color Color, place goplaces.PlaceDetails) {
writeLine(out, color, "ID", place.PlaceID)
writeLocation(out, color, place.Location)
writeRating(out, color, place.Rating, place.PriceLevel)
writeTypes(out, color, place.Types)
writeOpenNow(out, color, place.OpenNow)
writeLine(out, color, "Phone", place.Phone)
writeLine(out, color, "Website", place.Website)
writePhotos(out, color, place.Photos)
writeReviews(out, color, place.Reviews)
if len(place.Hours) > 0 {
out.WriteString(color.Dim("Hours:"))
out.WriteString("\n")
for _, entry := range place.Hours {
out.WriteString(" - ")
out.WriteString(entry)
out.WriteString("\n")
}
}
}
func writeResolvedLocation(out *bytes.Buffer, color Color, place goplaces.ResolvedLocation) {
writeLine(out, color, "ID", place.PlaceID)
writeLocation(out, color, place.Location)
writeTypes(out, color, place.Types)
}
func writePhotos(out *bytes.Buffer, color Color, photos []goplaces.Photo) {
if len(photos) == 0 {
return
}
out.WriteString(color.Dim("Photos:"))
out.WriteString("\n")
const maxPhotos = 3
count := len(photos)
limit := count
if count > maxPhotos {
limit = maxPhotos
}
for i := 0; i < limit; i++ {
photo := photos[i]
line := photoLine(photo)
if line == "" {
continue
}
out.WriteString(" - ")
out.WriteString(line)
out.WriteString("\n")
}
if count > maxPhotos {
out.WriteString(color.Dim(fmt.Sprintf(" ... %d more", count-maxPhotos)))
out.WriteString("\n")
}
}
func writeReviews(out *bytes.Buffer, color Color, reviews []goplaces.Review) {
if len(reviews) == 0 {
return
}
out.WriteString(color.Dim("Reviews:"))
out.WriteString("\n")
// Keep CLI output compact by default.
const maxReviews = 3
count := len(reviews)
limit := count
if count > maxReviews {
limit = maxReviews
}
for i := 0; i < limit; i++ {
review := reviews[i]
line := reviewLine(review)
if line == "" {
continue
}
out.WriteString(" - ")
out.WriteString(line)
out.WriteString("\n")
}
if count > maxReviews {
out.WriteString(color.Dim(fmt.Sprintf(" ... %d more", count-maxReviews)))
out.WriteString("\n")
}
}
func writeLocation(out *bytes.Buffer, color Color, loc *goplaces.LatLng) {
if loc == nil {
return
}
writeLine(out, color, "Location", fmt.Sprintf("%.6f, %.6f", loc.Lat, loc.Lng))
}
func writeRating(out *bytes.Buffer, color Color, rating *float64, priceLevel *int) {
if rating == nil && priceLevel == nil {
return
}
parts := make([]string, 0, 2)
if rating != nil {
parts = append(parts, fmt.Sprintf("%.1f", *rating))
}
if priceLevel != nil {
parts = append(parts, fmt.Sprintf("$%d", *priceLevel))
}
writeLine(out, color, "Rating", strings.Join(parts, " · "))
}
func writeTypes(out *bytes.Buffer, color Color, types []string) {
if len(types) == 0 {
return
}
unique := uniqueStrings(types)
writeLine(out, color, "Types", strings.Join(unique, ", "))
}
func writeOpenNow(out *bytes.Buffer, color Color, openNow *bool) {
if openNow == nil {
return
}
value := "no"
if *openNow {
value = "yes"
}
writeLine(out, color, "Open now", value)
}
func writeLine(out *bytes.Buffer, color Color, label string, value string) {
if strings.TrimSpace(value) == "" {
return
}
out.WriteString(color.Dim(label + ":"))
out.WriteString(" ")
out.WriteString(value)
out.WriteString("\n")
}
func reviewLine(review goplaces.Review) string {
parts := make([]string, 0, 3)
if review.Rating != nil {
parts = append(parts, fmt.Sprintf("%.1f stars", *review.Rating))
}
if review.Author != nil && strings.TrimSpace(review.Author.DisplayName) != "" {
parts = append(parts, "by "+review.Author.DisplayName)
}
if strings.TrimSpace(review.RelativePublishTimeDescription) != "" {
parts = append(parts, "("+review.RelativePublishTimeDescription+")")
}
text := reviewText(review)
if text != "" {
parts = append(parts, text)
}
return strings.Join(parts, " ")
}
func photoLine(photo goplaces.Photo) string {
parts := make([]string, 0, 3)
if strings.TrimSpace(photo.Name) != "" {
parts = append(parts, photo.Name)
}
if photo.WidthPx > 0 && photo.HeightPx > 0 {
parts = append(parts, fmt.Sprintf("%dx%d", photo.WidthPx, photo.HeightPx))
}
if len(photo.AuthorAttributions) > 0 && strings.TrimSpace(photo.AuthorAttributions[0].DisplayName) != "" {
parts = append(parts, "by "+photo.AuthorAttributions[0].DisplayName)
}
return strings.Join(parts, " · ")
}
func reviewText(review goplaces.Review) string {
text := ""
if review.Text != nil {
text = review.Text.Text
}
// Fall back to original text when translation is empty.
if strings.TrimSpace(text) == "" && review.OriginalText != nil {
text = review.OriginalText.Text
}
return truncateText(strings.TrimSpace(text), 200)
}
func truncateText(value string, maxLen int) string {
if maxLen <= 0 || value == "" {
return value
}
if len(value) <= maxLen {
return value
}
// Byte-based truncation is OK here because we only display previews.
return strings.TrimSpace(value[:maxLen]) + "..."
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
sort.Strings(result)
return result
}