#!/usr/bin/env bun import chalk from "chalk"; import { program } from "commander"; import { fetchList } from "./lib/api.ts"; import { addList, getLists, removeList, resolveAlias } from "./lib/config.ts"; import { enrichPlaces } from "./lib/enrich.ts"; import { filterPlaces } from "./lib/filter.ts"; import { formatJson, formatListHeader, formatTable } from "./lib/output.ts"; import { searchPlaces, toEnrichedPlace } from "./lib/search.ts"; import { extractListId } from "./lib/url.ts"; import type { CliOptions, EnrichedPlace, Place } from "./types.ts"; program .name("gmaps") .description("Search Google Maps and fetch places from public lists"); // --- gmaps list --- program .command("list") .description("Fetch places from a public Google Maps list") .argument("", "list URL, short URL, or saved alias") .option("-q, --query ", "filter places by name or address") .option("-e, --enrich", "fetch ratings, categories, phone, website", false) .option("-f, --format ", "output format: json | table", "table") .option("-d, --delay ", "delay between enrichment requests in ms", "200") .action(async (urlOrAlias: string, rawOpts: Record) => { const opts = parseOpts(rawOpts); await handleError(async () => { const url = await resolveAlias(urlOrAlias); await runList(url, opts); }); }); // --- gmaps search --- program .command("search") .description("Search Google Maps for a specific place") .argument("", "place name (e.g. 'Mitts Restaurant Amsterdam')") .option("-f, --format ", "output format: json | table", "table") .action(async (query: string, rawOpts: Record) => { const format = rawOpts["format"] === "json" ? ("json" as const) : ("table" as const); await handleError(() => runSearch(query, format)); }); // --- gmaps lists --- const lists = program .command("lists") .description("Manage saved list aliases"); lists .command("add") .description("Save a list URL with an alias") .argument("", "short name for the list") .argument("", "Google Maps list URL") .action(async (alias: string, url: string) => { await handleError(async () => { await addList(alias, url); console.log(chalk.green(`Saved "${alias}"`)); }); }); lists .command("rm") .description("Remove a saved list alias") .argument("", "alias to remove") .action(async (alias: string) => { await handleError(async () => { const removed = await removeList(alias); if (removed) { console.log(chalk.green(`Removed "${alias}"`)); } else { throw new Error(`Alias "${alias}" not found`); } }); }); lists .command("ls") .description("List all saved list aliases") .action(async () => { await handleError(async () => { const saved = await getLists(); const entries = Object.entries(saved); if (entries.length === 0) { console.log(chalk.dim("No saved lists. Use `gmaps lists add ` to add one.")); return; } for (const [alias, url] of entries) { console.log(`${chalk.bold(alias)} ${chalk.dim("→")} ${chalk.dim(url)}`); } }); }); program.parse(); // --- helpers --- function parseOpts(rawOpts: Record): CliOptions { return { query: typeof rawOpts["query"] === "string" ? rawOpts["query"] : undefined, enrich: rawOpts["enrich"] === true, format: rawOpts["format"] === "json" ? "json" : "table", delay: Number(rawOpts["delay"]) || 200, }; } async function handleError(fn: () => Promise): Promise { try { await fn(); } catch (error) { const message = error instanceof Error ? error.message : "An unknown error occurred"; console.error(chalk.red(`Error: ${message}`)); process.exit(1); } } async function runList(url: string, opts: CliOptions): Promise { console.error(chalk.dim("Resolving list URL…")); const listId = await extractListId(url); console.error(chalk.dim("Fetching list…")); const list = await fetchList(listId); if (opts.format === "table") { console.log(formatListHeader(list.name, list.owner, list.totalCount)); } let places: Place[] = list.places; if (opts.query) { places = filterPlaces(places, opts.query); if (opts.format === "table") { console.error( chalk.dim( `Filtered to ${places.length} places matching "${opts.query}"`, ), ); } } if (places.length === 0) { if (opts.format === "table") { console.log(chalk.yellow("No places found.")); } else { console.log("[]"); } return; } if (opts.enrich) { console.error( chalk.dim(`Enriching ${places.length} places (${opts.delay}ms delay)…`), ); const enriched: EnrichedPlace[] = []; let count = 0; for await (const place of enrichPlaces(places, opts.delay)) { enriched.push(place); count++; if (opts.format === "table" && count % 10 === 0) { console.error(chalk.dim(` ${count}/${places.length} enriched…`)); } } if (opts.format === "json") { console.log(formatJson(enriched)); } else { console.log(formatTable(enriched)); } } else { if (opts.format === "json") { console.log(formatJson(places)); } else { console.log(formatTable(places)); } } } async function runSearch( query: string, format: "json" | "table", ): Promise { console.error(chalk.dim(`Searching for "${query}"…`)); const results = await searchPlaces(query); if (results.length === 0) { if (format === "table") { console.log(chalk.yellow("No results found.")); } else { console.log("[]"); } return; } const enriched = results.map(toEnrichedPlace); if (format === "json") { console.log(formatJson(enriched)); } else { console.log(formatTable(enriched)); } }