straeto 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -3
- package/package.json +1 -1
- package/src/api.ts +67 -24
- package/src/cli.test.ts +26 -0
- package/src/cli.ts +33 -3
- package/src/commands/next.ts +86 -0
- package/src/commands/plan.ts +15 -0
- package/src/commands/schemas.ts +6 -0
- package/src/commands/search.ts +22 -0
- package/src/prompt.ts +3 -2
package/README.md
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<a href="https://www.npmjs.com/package/straeto"><img src="https://img.shields.io/npm/v/straeto" alt="npm version" /></a>
|
|
13
13
|
<a href="https://github.com/magtastic/straeto-cli/actions/workflows/ci.yml"><img src="https://github.com/magtastic/straeto-cli/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
14
|
-
<a href="https://www.npmjs.com/package/straeto"><img src="https://img.shields.io/npm/dm/straeto" alt="npm downloads" /></a>
|
|
15
14
|
<a href="https://github.com/magtastic/straeto-cli/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/straeto" alt="license" /></a>
|
|
16
15
|
</p>
|
|
17
16
|
|
|
@@ -63,6 +62,15 @@ straeto stop Hlemmur
|
|
|
63
62
|
straeto stop 10000802
|
|
64
63
|
```
|
|
65
64
|
|
|
65
|
+
### `straeto search [query]`
|
|
66
|
+
|
|
67
|
+
Search for a place by name. Launches interactive mode with autocomplete if no query is provided.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
straeto search Höfðatorg # search by name
|
|
71
|
+
straeto search # interactive with autocomplete
|
|
72
|
+
```
|
|
73
|
+
|
|
66
74
|
### `straeto alerts`
|
|
67
75
|
|
|
68
76
|
Show active service alerts.
|
|
@@ -72,17 +80,40 @@ straeto alerts # alerts in Icelandic (default)
|
|
|
72
80
|
straeto alerts -l EN # alerts in English
|
|
73
81
|
```
|
|
74
82
|
|
|
83
|
+
### `straeto next <stop>`
|
|
84
|
+
|
|
85
|
+
Show real-time upcoming arrivals at a stop.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
straeto next Höfðatorg # all upcoming arrivals
|
|
89
|
+
straeto next Höfðatorg -r 14 # only route 14
|
|
90
|
+
straeto next Höfðatorg -d Grandi # filter by direction
|
|
91
|
+
straeto next Höfðatorg -r 14 -n 3 # limit to 3 results
|
|
92
|
+
```
|
|
93
|
+
|
|
75
94
|
### `straeto plan`
|
|
76
95
|
|
|
77
|
-
Plan a trip between two locations. Launches interactive mode by default with autocomplete search and time selection.
|
|
96
|
+
Plan a trip between two locations. Accepts place names or coordinates. Launches interactive mode by default with autocomplete search and time selection.
|
|
78
97
|
|
|
79
98
|
```bash
|
|
80
99
|
straeto plan # interactive mode
|
|
81
|
-
straeto plan -f
|
|
100
|
+
straeto plan -f Höfðatorg -t Mýrargata # place names
|
|
101
|
+
straeto plan -f 64.14,-21.90 -t 64.10,-21.94 # coordinates
|
|
82
102
|
straeto plan --at 08:30 # depart at specific time
|
|
83
103
|
straeto plan --by 17:00 # arrive by specific time
|
|
84
104
|
```
|
|
85
105
|
|
|
106
|
+
## Claude Code Plugin
|
|
107
|
+
|
|
108
|
+
Use Strætó directly inside [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — ask about bus routes, stops, alerts, or plan trips in natural language.
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
/plugin marketplace add magtastic/straeto-cli
|
|
112
|
+
/plugin install straeto@straeto
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Then type `/straeto` or just ask about buses in Reykjavík.
|
|
116
|
+
|
|
86
117
|
## Development
|
|
87
118
|
|
|
88
119
|
```bash
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -8,24 +8,65 @@ const HEADERS = {
|
|
|
8
8
|
Origin: "https://www.straeto.is",
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
const MAX_RETRIES = 3;
|
|
12
|
+
const TIMEOUT_MS = 5000;
|
|
13
|
+
|
|
11
14
|
async function query<T>(
|
|
12
15
|
gql: string,
|
|
13
16
|
variables: Record<string, unknown> | undefined,
|
|
14
17
|
schema: z.ZodType<T>,
|
|
15
18
|
): Promise<T> {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const start = Date.now();
|
|
20
|
+
|
|
21
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
22
|
+
if (Date.now() - start > TIMEOUT_MS) break;
|
|
23
|
+
|
|
24
|
+
if (attempt > 0) {
|
|
25
|
+
const delay = 200 * 2 ** (attempt - 1);
|
|
26
|
+
const remaining = TIMEOUT_MS - (Date.now() - start);
|
|
27
|
+
if (remaining <= 0) break;
|
|
28
|
+
await new Promise((r) => setTimeout(r, Math.min(delay, remaining)));
|
|
29
|
+
}
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(API_URL, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: HEADERS,
|
|
35
|
+
body: JSON.stringify({ query: gql, variables }),
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS - (Date.now() - start)),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
40
|
+
|
|
41
|
+
const json = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
|
42
|
+
if (json.errors && !json.data)
|
|
43
|
+
throw new Error(json.errors.map((error) => error.message).join(", "));
|
|
44
|
+
if (!json.data) throw new Error("No data returned from API");
|
|
45
|
+
|
|
46
|
+
const result = schema.safeParse(json.data);
|
|
47
|
+
if (!result.success) {
|
|
48
|
+
const issues = result.error.issues
|
|
49
|
+
.map((i) => ` ${i.path.join(".")}: ${i.message}`)
|
|
50
|
+
.join("\n");
|
|
51
|
+
const firstPath = result.error.issues[0]?.path ?? [];
|
|
52
|
+
let sample: unknown = json.data;
|
|
53
|
+
for (const key of firstPath) {
|
|
54
|
+
if (sample != null && typeof sample === "object")
|
|
55
|
+
sample = (sample as Record<string | number, unknown>)[key as string | number];
|
|
56
|
+
}
|
|
57
|
+
const raw = JSON.stringify(sample, null, 2);
|
|
58
|
+
const preview = raw && raw.length > 500 ? `${raw.slice(0, 500)}…` : raw;
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Schema validation failed:\n${issues}\n\nValue at "${firstPath.join(".")}":\n${preview}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return result.data;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (attempt === MAX_RETRIES - 1 || Date.now() - start > TIMEOUT_MS) throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
23
68
|
|
|
24
|
-
|
|
25
|
-
if (json.errors && !json.data)
|
|
26
|
-
throw new Error(json.errors.map((error) => error.message).join(", "));
|
|
27
|
-
if (!json.data) throw new Error("No data returned from API");
|
|
28
|
-
return schema.parse(json.data);
|
|
69
|
+
throw new Error("Strætó is not responding — please try again in a moment.");
|
|
29
70
|
}
|
|
30
71
|
|
|
31
72
|
// --- Schemas ---
|
|
@@ -168,14 +209,16 @@ export function findLastStop(bus: BusLocation, stops: Stop[]): string | undefine
|
|
|
168
209
|
// --- Response schemas ---
|
|
169
210
|
|
|
170
211
|
const BusLocationResponseSchema = z.object({
|
|
171
|
-
BusLocationByRoute: z
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
212
|
+
BusLocationByRoute: z
|
|
213
|
+
.object({
|
|
214
|
+
lastUpdate: z.string(),
|
|
215
|
+
results: z.array(BusLocationSchema),
|
|
216
|
+
})
|
|
217
|
+
.nullable(),
|
|
175
218
|
});
|
|
176
219
|
|
|
177
220
|
const StopsResponseSchema = z.object({
|
|
178
|
-
GtfsStops: z.object({ results: z.array(StopSchema) }),
|
|
221
|
+
GtfsStops: z.object({ results: z.array(StopSchema) }).nullable(),
|
|
179
222
|
});
|
|
180
223
|
|
|
181
224
|
const StopDetailSchema = StopSchema.extend({
|
|
@@ -187,15 +230,15 @@ const StopResponseSchema = z.object({
|
|
|
187
230
|
});
|
|
188
231
|
|
|
189
232
|
const AlertsResponseSchema = z.object({
|
|
190
|
-
Alerts: z.object({ results: z.array(AlertSchema) }),
|
|
233
|
+
Alerts: z.object({ results: z.array(AlertSchema) }).nullable(),
|
|
191
234
|
});
|
|
192
235
|
|
|
193
236
|
const GeocodeResponseSchema = z.object({
|
|
194
|
-
Geocode: z.object({ results: z.array(GeoResultSchema) }),
|
|
237
|
+
Geocode: z.object({ results: z.array(GeoResultSchema) }).nullable(),
|
|
195
238
|
});
|
|
196
239
|
|
|
197
240
|
const TripPlannerResponseSchema = z.object({
|
|
198
|
-
TripPlanner: z.object({ results: z.array(TripItinerarySchema) }),
|
|
241
|
+
TripPlanner: z.object({ results: z.array(TripItinerarySchema) }).nullable(),
|
|
199
242
|
});
|
|
200
243
|
|
|
201
244
|
// --- Queries ---
|
|
@@ -214,7 +257,7 @@ export async function getBusLocations(routes: string[]) {
|
|
|
214
257
|
{ routes },
|
|
215
258
|
BusLocationResponseSchema,
|
|
216
259
|
);
|
|
217
|
-
return data.BusLocationByRoute;
|
|
260
|
+
return data.BusLocationByRoute ?? { lastUpdate: "", results: [] };
|
|
218
261
|
}
|
|
219
262
|
|
|
220
263
|
export async function getStops() {
|
|
@@ -223,7 +266,7 @@ export async function getStops() {
|
|
|
223
266
|
undefined,
|
|
224
267
|
StopsResponseSchema,
|
|
225
268
|
);
|
|
226
|
-
return data.GtfsStops
|
|
269
|
+
return data.GtfsStops?.results ?? [];
|
|
227
270
|
}
|
|
228
271
|
|
|
229
272
|
export async function getStop(id: string) {
|
|
@@ -251,7 +294,7 @@ export async function getAlerts(language: string = "IS") {
|
|
|
251
294
|
{ language },
|
|
252
295
|
AlertsResponseSchema,
|
|
253
296
|
);
|
|
254
|
-
return data.Alerts
|
|
297
|
+
return data.Alerts?.results ?? [];
|
|
255
298
|
}
|
|
256
299
|
|
|
257
300
|
export async function geocode(placesQuery: string) {
|
|
@@ -264,7 +307,7 @@ export async function geocode(placesQuery: string) {
|
|
|
264
307
|
{ placesQuery },
|
|
265
308
|
GeocodeResponseSchema,
|
|
266
309
|
);
|
|
267
|
-
return data.Geocode
|
|
310
|
+
return data.Geocode?.results ?? [];
|
|
268
311
|
}
|
|
269
312
|
|
|
270
313
|
export async function planTrip(opts: {
|
|
@@ -298,5 +341,5 @@ export async function planTrip(opts: {
|
|
|
298
341
|
opts,
|
|
299
342
|
TripPlannerResponseSchema,
|
|
300
343
|
);
|
|
301
|
-
return data.TripPlanner
|
|
344
|
+
return data.TripPlanner?.results ?? [];
|
|
302
345
|
}
|
package/src/cli.test.ts
CHANGED
|
@@ -26,6 +26,8 @@ describe("cli --help", () => {
|
|
|
26
26
|
expect(stdout).toContain("route");
|
|
27
27
|
expect(stdout).toContain("stops");
|
|
28
28
|
expect(stdout).toContain("stop");
|
|
29
|
+
expect(stdout).toContain("search");
|
|
30
|
+
expect(stdout).toContain("next");
|
|
29
31
|
expect(stdout).toContain("alerts");
|
|
30
32
|
expect(stdout).toContain("plan");
|
|
31
33
|
});
|
|
@@ -70,6 +72,30 @@ describe("cli route (help/args)", () => {
|
|
|
70
72
|
});
|
|
71
73
|
});
|
|
72
74
|
|
|
75
|
+
describe("cli search (help/args)", () => {
|
|
76
|
+
test("shows help for search command", async () => {
|
|
77
|
+
const { stdout, exitCode } = await run("search", "--help");
|
|
78
|
+
expect(exitCode).toBe(0);
|
|
79
|
+
expect(stdout).toContain("--interactive");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("cli next (help/args)", () => {
|
|
84
|
+
test("shows help for next command", async () => {
|
|
85
|
+
const { stdout, exitCode } = await run("next", "--help");
|
|
86
|
+
expect(exitCode).toBe(0);
|
|
87
|
+
expect(stdout).toContain("--route");
|
|
88
|
+
expect(stdout).toContain("--direction");
|
|
89
|
+
expect(stdout).toContain("--limit");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("fails without stop argument", async () => {
|
|
93
|
+
const { stderr, exitCode } = await run("next");
|
|
94
|
+
expect(exitCode).toBe(1);
|
|
95
|
+
expect(stderr).toContain("missing required argument");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
73
99
|
describe("cli plan (help/args)", () => {
|
|
74
100
|
test("shows help for plan command", async () => {
|
|
75
101
|
const { stdout, exitCode } = await run("plan", "--help");
|
package/src/cli.ts
CHANGED
|
@@ -3,9 +3,11 @@ import chalk from "chalk";
|
|
|
3
3
|
import { program } from "commander";
|
|
4
4
|
import pkg from "../package.json";
|
|
5
5
|
import { alertsCommand } from "./commands/alerts";
|
|
6
|
+
import { nextCommand } from "./commands/next";
|
|
6
7
|
import { planCommand } from "./commands/plan";
|
|
7
8
|
import { routeCommand } from "./commands/route";
|
|
8
|
-
import { AlertsOpts, PlanOpts, RouteOpts, StopsOpts } from "./commands/schemas";
|
|
9
|
+
import { AlertsOpts, NextOpts, PlanOpts, RouteOpts, StopsOpts } from "./commands/schemas";
|
|
10
|
+
import { searchCommand } from "./commands/search";
|
|
9
11
|
import { stopCommand } from "./commands/stop";
|
|
10
12
|
import { stopsCommand } from "./commands/stops";
|
|
11
13
|
import { writeErr } from "./terminal";
|
|
@@ -75,11 +77,39 @@ program
|
|
|
75
77
|
}
|
|
76
78
|
});
|
|
77
79
|
|
|
80
|
+
program
|
|
81
|
+
.command("search")
|
|
82
|
+
.description("Search for a place by name")
|
|
83
|
+
.argument("[query...]", "Place name to search for")
|
|
84
|
+
.option("-i, --interactive", "Interactive mode with autocomplete")
|
|
85
|
+
.action(async (parts: string[], rawOpts: { interactive?: boolean }) => {
|
|
86
|
+
try {
|
|
87
|
+
await searchCommand(parts, !!rawOpts.interactive || parts.length === 0);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
handleError(error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
program
|
|
94
|
+
.command("next")
|
|
95
|
+
.description("Show upcoming arrivals at a stop")
|
|
96
|
+
.argument("<stop...>", "Stop name or ID")
|
|
97
|
+
.option("-r, --route <route>", "Filter by route number")
|
|
98
|
+
.option("-d, --direction <headsign>", "Filter by direction/headsign")
|
|
99
|
+
.option("-n, --limit <n>", "Limit results")
|
|
100
|
+
.action(async (parts: string[], rawOpts) => {
|
|
101
|
+
try {
|
|
102
|
+
await nextCommand(parts, NextOpts.parse(rawOpts));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
handleError(error);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
78
108
|
program
|
|
79
109
|
.command("plan")
|
|
80
110
|
.description("Plan a trip between two locations")
|
|
81
|
-
.option("-f, --from <
|
|
82
|
-
.option("-t, --to <
|
|
111
|
+
.option("-f, --from <place>", "Origin as place name or lat,lon")
|
|
112
|
+
.option("-t, --to <place>", "Destination as place name or lat,lon")
|
|
83
113
|
.option("-i, --interactive", "Interactive mode — search for places by name")
|
|
84
114
|
.option("-d, --date <date>", "Date (YYYY-MM-DD, defaults to today)")
|
|
85
115
|
.option("--at <time>", "Depart at time (HH:MM, defaults to now)")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import type { z } from "zod/v4";
|
|
3
|
+
import * as api from "../api";
|
|
4
|
+
import { writeLn } from "../terminal";
|
|
5
|
+
import type { NextOpts } from "./schemas";
|
|
6
|
+
|
|
7
|
+
interface Arrival {
|
|
8
|
+
routeNr: string;
|
|
9
|
+
headsign: string;
|
|
10
|
+
busId: string;
|
|
11
|
+
waitingTime: number;
|
|
12
|
+
stopsAway: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function nextCommand(parts: string[], opts: z.infer<typeof NextOpts>) {
|
|
16
|
+
const input = parts.join(" ");
|
|
17
|
+
const stops = await api.getStops();
|
|
18
|
+
const query = input.toLowerCase();
|
|
19
|
+
|
|
20
|
+
// Find matching stops
|
|
21
|
+
let matches = stops.filter((s) => s.name.toLowerCase() === query);
|
|
22
|
+
if (matches.length === 0) {
|
|
23
|
+
matches = stops.filter((s) => s.name.toLowerCase().includes(query));
|
|
24
|
+
}
|
|
25
|
+
if (matches.length === 0) throw new Error(`No stops matching "${input}".`);
|
|
26
|
+
|
|
27
|
+
// Collect all route numbers serving matched stops
|
|
28
|
+
const stopNames = new Set(matches.map((s) => s.name.toLowerCase()));
|
|
29
|
+
const allRoutes = [...new Set(matches.flatMap((s) => s.routes))];
|
|
30
|
+
|
|
31
|
+
// Filter by route if specified
|
|
32
|
+
const routes = opts.route ? allRoutes.filter((r) => r === opts.route) : allRoutes;
|
|
33
|
+
if (routes.length === 0) throw new Error(`Route ${opts.route} does not serve "${input}".`);
|
|
34
|
+
|
|
35
|
+
// Fetch bus locations for all relevant routes
|
|
36
|
+
const data = await api.getBusLocations(routes);
|
|
37
|
+
|
|
38
|
+
// Find buses heading to this stop
|
|
39
|
+
const arrivals: Arrival[] = [];
|
|
40
|
+
for (const bus of data.results) {
|
|
41
|
+
for (let i = 0; i < bus.nextStops.length; i++) {
|
|
42
|
+
const nextStop = bus.nextStops[i];
|
|
43
|
+
if (!nextStop?.stop) continue;
|
|
44
|
+
if (!stopNames.has(nextStop.stop.name.toLowerCase())) continue;
|
|
45
|
+
|
|
46
|
+
// Filter by direction/headsign if specified
|
|
47
|
+
if (opts.direction) {
|
|
48
|
+
const dir = opts.direction.toLowerCase();
|
|
49
|
+
if (!bus.headsign.toLowerCase().includes(dir)) continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
arrivals.push({
|
|
53
|
+
routeNr: bus.routeNr,
|
|
54
|
+
headsign: bus.headsign,
|
|
55
|
+
busId: bus.busId,
|
|
56
|
+
waitingTime: nextStop.waitingTime,
|
|
57
|
+
stopsAway: i + 1,
|
|
58
|
+
});
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Sort by arrival time
|
|
64
|
+
arrivals.sort((a, b) => a.waitingTime - b.waitingTime);
|
|
65
|
+
|
|
66
|
+
const stopLabel = matches[0]?.name ?? input;
|
|
67
|
+
|
|
68
|
+
if (arrivals.length === 0) {
|
|
69
|
+
writeLn(chalk.dim(`No upcoming arrivals at ${stopLabel}.`));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const limit = opts.limit ? parseInt(opts.limit, 10) : 10;
|
|
74
|
+
const shown = arrivals.slice(0, limit);
|
|
75
|
+
|
|
76
|
+
writeLn(`${chalk.bold.underline(stopLabel)} ${chalk.dim(`— ${arrivals.length} upcoming`)}\n`);
|
|
77
|
+
|
|
78
|
+
for (const a of shown) {
|
|
79
|
+
const route = chalk.bold.yellow(a.routeNr.padEnd(4));
|
|
80
|
+
const headsign = chalk.cyan(a.headsign.padEnd(20));
|
|
81
|
+
const time =
|
|
82
|
+
a.waitingTime <= 1 ? chalk.green.bold("< 1 mín") : chalk.green.bold(`${a.waitingTime} mín`);
|
|
83
|
+
const stops = chalk.dim(`${a.stopsAway} stop${a.stopsAway !== 1 ? "s" : ""} away`);
|
|
84
|
+
writeLn(` ${route}${headsign} ${time} ${stops}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/commands/plan.ts
CHANGED
|
@@ -12,6 +12,21 @@ export async function planCommand(opts: z.infer<typeof PlanOpts>) {
|
|
|
12
12
|
let from = opts.from;
|
|
13
13
|
let to = opts.to;
|
|
14
14
|
|
|
15
|
+
// Geocode place names to coordinates
|
|
16
|
+
const isCoords = (s: string) => /^-?\d+\.?\d*,-?\d+\.?\d*$/.test(s);
|
|
17
|
+
if (from && !isCoords(from)) {
|
|
18
|
+
const results = await api.geocode(from);
|
|
19
|
+
if (results.length === 0) throw new Error(`Could not find location "${from}"`);
|
|
20
|
+
const loc = results[0];
|
|
21
|
+
if (loc) from = `${loc.lat},${loc.lon}`;
|
|
22
|
+
}
|
|
23
|
+
if (to && !isCoords(to)) {
|
|
24
|
+
const results = await api.geocode(to);
|
|
25
|
+
if (results.length === 0) throw new Error(`Could not find location "${to}"`);
|
|
26
|
+
const loc = results[0];
|
|
27
|
+
if (loc) to = `${loc.lat},${loc.lon}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
if (opts.interactive || (!from && !to)) {
|
|
16
31
|
const { promptLocation, promptTimeMode } = await import("../prompt");
|
|
17
32
|
writeLn("");
|
package/src/commands/schemas.ts
CHANGED
|
@@ -16,6 +16,12 @@ export const AlertsOpts = z.object({
|
|
|
16
16
|
lang: z.string(),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
export const NextOpts = z.object({
|
|
20
|
+
route: z.string().optional(),
|
|
21
|
+
direction: z.string().optional(),
|
|
22
|
+
limit: z.string().optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
19
25
|
export const PlanOpts = z.object({
|
|
20
26
|
from: z.string().optional(),
|
|
21
27
|
to: z.string().optional(),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as api from "../api";
|
|
2
|
+
import * as fmt from "../format";
|
|
3
|
+
import { writeLn } from "../terminal";
|
|
4
|
+
|
|
5
|
+
export async function searchCommand(parts: string[], interactive: boolean) {
|
|
6
|
+
if (interactive || parts.length === 0) {
|
|
7
|
+
const { promptLocation } = await import("../prompt");
|
|
8
|
+
writeLn("");
|
|
9
|
+
const result = await promptLocation("Search", api.geocode);
|
|
10
|
+
writeLn(`\n ${result.name} ${result.lat}, ${result.lon}`);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const query = parts.join(" ");
|
|
15
|
+
const results = await api.geocode(query);
|
|
16
|
+
const limit = 10;
|
|
17
|
+
writeLn(fmt.formatGeocode(results.slice(0, limit)));
|
|
18
|
+
if (results.length > limit) {
|
|
19
|
+
const chalk = (await import("chalk")).default;
|
|
20
|
+
writeLn(chalk.dim(`\n … and ${results.length - limit} more`));
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/prompt.ts
CHANGED
|
@@ -221,7 +221,7 @@ export async function promptTimeMode(): Promise<TimeMode> {
|
|
|
221
221
|
|
|
222
222
|
// Need time input
|
|
223
223
|
await write(
|
|
224
|
-
`${ANSI.carriageReturn}${ANSI.clearLine}${chalk.bold(opt.label.replace("…", ":"))} `,
|
|
224
|
+
`${ANSI.carriageReturn}${ANSI.clearLine}${chalk.bold(opt.label.replace("…", ":"))} ${chalk.dim("(HH:MM) ")}`,
|
|
225
225
|
);
|
|
226
226
|
disableRaw();
|
|
227
227
|
|
|
@@ -229,7 +229,8 @@ export async function promptTimeMode(): Promise<TimeMode> {
|
|
|
229
229
|
if (/^\d{1,2}:\d{2}$/.test(timeInput)) {
|
|
230
230
|
return { time: timeInput, arrivalBy: opt.arrivalBy };
|
|
231
231
|
}
|
|
232
|
-
// Invalid, re-prompt
|
|
232
|
+
// Invalid, show hint and re-prompt
|
|
233
|
+
await write(chalk.yellow(" Please enter time as HH:MM (e.g. 08:30)\n"));
|
|
233
234
|
enableRaw();
|
|
234
235
|
await draw();
|
|
235
236
|
continue;
|