goplaces/internal/cli/render.go
Peter Steinberger 522731953d
Some checks failed
ci / test (push) Has been cancelled
build / goreleaser (push) Has been cancelled
feat: add business status and route modifiers
Fixes #7
Fixes #8
2026-05-04 03:02:58 +01:00

487 lines
13 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 {
fmt.Fprintf(&out, "%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))
fmt.Fprintf(&out, "%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 {
fmt.Fprintf(&out, "%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 {
fmt.Fprintf(&out, "%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 {
fmt.Fprintf(&out, "%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 renderDirections(color Color, response goplaces.DirectionsResponse, includeSteps bool) string {
var out bytes.Buffer
mode := strings.TrimSpace(response.Mode)
header := "Directions"
if mode != "" {
header = fmt.Sprintf("Directions (%s)", mode)
}
out.WriteString(color.Bold(header))
out.WriteString("\n")
writeLine(&out, color, "From", response.StartAddress)
writeLine(&out, color, "To", response.EndAddress)
writeLine(&out, color, "Summary", response.Summary)
writeLine(&out, color, "Distance", response.DistanceText)
writeLine(&out, color, "Duration", response.DurationText)
if len(response.Warnings) > 0 {
out.WriteString(color.Dim("Warnings:"))
out.WriteString("\n")
for _, warning := range response.Warnings {
if strings.TrimSpace(warning) == "" {
continue
}
out.WriteString(" - ")
out.WriteString(warning)
out.WriteString("\n")
}
}
if includeSteps {
out.WriteString(color.Dim("Steps:"))
out.WriteString("\n")
if len(response.Steps) == 0 {
out.WriteString(" - ")
out.WriteString(emptyResultsMessage)
out.WriteString("\n")
} else {
for i, step := range response.Steps {
line := directionsStepLine(step)
if line == "" {
continue
}
fmt.Fprintf(&out, " %d. %s\n", i+1, line)
}
}
}
return out.String()
}
func formatTitle(color Color, name, 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.UserRatingCount, place.PriceLevel)
writeTypes(out, color, place.Types)
writeOpenNow(out, color, place.OpenNow)
writeLine(out, color, "Status", place.BusinessStatus)
}
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.UserRatingCount, place.PriceLevel)
writeTypes(out, color, place.Types)
writeOpenNow(out, color, place.OpenNow)
writeLine(out, color, "Status", place.BusinessStatus)
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, userRatingCount, priceLevel *int) {
if rating == nil && userRatingCount == nil && priceLevel == nil {
return
}
parts := make([]string, 0, 3)
if rating != nil {
ratingStr := fmt.Sprintf("%.1f", *rating)
if userRatingCount != nil {
ratingStr += fmt.Sprintf(" (%d)", *userRatingCount)
}
parts = append(parts, ratingStr)
} else if userRatingCount != nil {
parts = append(parts, fmt.Sprintf("%d ratings", *userRatingCount))
}
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, 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 directionsStepLine(step goplaces.DirectionsStep) string {
instruction := strings.TrimSpace(step.Instruction)
if instruction == "" {
instruction = "(no instruction)"
}
parts := []string{instruction}
if strings.TrimSpace(step.DistanceText) != "" {
parts = append(parts, step.DistanceText)
}
if strings.TrimSpace(step.DurationText) != "" {
parts = append(parts, step.DurationText)
}
return strings.Join(parts, " · ")
}
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
}