Files
TREK/server/src/nest/plugins/contributions/plugin-route-normalize.ts
T

143 lines
5.1 KiB
TypeScript

import { stripEmoji } from '../text-sanitize';
import type { PluginsRepository } from '../../../db/repositories/Plugins.repository';
type Tone = 'default' | 'success' | 'warn' | 'danger';
interface RouteWaypointIn {
lat: number;
lng: number;
name?: string;
placeId?: number;
}
interface RouteLegOut {
distance: number;
duration: number;
note?: string;
}
interface RouteViaOut {
lat: number;
lng: number;
label?: string;
tone: Tone;
dwellSeconds?: number;
}
export interface PluginRouteOut {
pluginId: string;
profile: string;
coordinates: Array<[number, number]>;
distance: number;
duration: number;
legs: RouteLegOut[];
viaPoints: RouteViaOut[];
}
const TONES: ReadonlySet<string> = new Set(['default', 'success', 'warn', 'danger']);
export const PROFILE_RE = /^[a-z][a-z0-9-]{0,23}$/;
const MAX_WAYPOINTS = 30;
const MAX_COORDINATES = 10_000; // vertex budget for the returned geometry
const MAX_VIAS = 40;
const MAX_DWELL_S = 86_400; // a "stop" longer than a day is nonsense data
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
function validCoord(lat: unknown, lng: unknown): boolean {
const a = Number(lat);
const b = Number(lng);
return Number.isFinite(a) && Number.isFinite(b) && a >= -90 && a <= 90 && b >= -180 && b <= 180;
}
export function readWaypoints(raw: unknown): RouteWaypointIn[] | null {
if (!Array.isArray(raw) || raw.length < 2 || raw.length > MAX_WAYPOINTS) return null;
const out: RouteWaypointIn[] = [];
for (const w of raw as Array<Record<string, unknown>>) {
if (!w || typeof w !== 'object' || !validCoord(w.lat, w.lng)) return null;
const name = w.name != null ? cap(w.name, 120) : undefined;
const placeId = Number.isInteger(w.placeId) ? (w.placeId as number) : undefined;
out.push({
lat: Number(w.lat),
lng: Number(w.lng),
...(name ? { name } : {}),
...(placeId !== undefined ? { placeId } : {}),
});
}
return out;
}
function nonNeg(v: unknown): number | null {
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? n : null;
}
export function normalize(
pluginId: string,
profile: string,
waypointCount: number,
raw: unknown,
): PluginRouteOut | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
if (!Array.isArray(r.coordinates) || r.coordinates.length < 2 || r.coordinates.length > MAX_COORDINATES) return null;
const coordinates: Array<[number, number]> = [];
for (const c of r.coordinates) {
if (!Array.isArray(c) || !validCoord(c[0], c[1])) return null;
coordinates.push([Number(c[0]), Number(c[1])]);
}
const distance = nonNeg(r.distance);
const duration = nonNeg(r.duration);
if (distance == null || duration == null) return null;
if (!Array.isArray(r.legs) || r.legs.length !== waypointCount - 1) return null;
const legs: RouteLegOut[] = [];
for (const l of r.legs as Array<Record<string, unknown>>) {
if (!l || typeof l !== 'object') return null;
const legDistance = nonNeg(l.distance);
const legDuration = nonNeg(l.duration);
if (legDistance == null || legDuration == null) return null;
const note = l.note != null ? cap(l.note, 120) : undefined;
legs.push({ distance: legDistance, duration: legDuration, ...(note ? { note } : {}) });
}
const viaPoints: RouteViaOut[] = [];
if (Array.isArray(r.viaPoints)) {
for (const v of (r.viaPoints as Array<Record<string, unknown>>).slice(0, MAX_VIAS * 4)) {
if (viaPoints.length >= MAX_VIAS) break;
if (!v || typeof v !== 'object' || !validCoord(v.lat, v.lng)) continue;
const dwell = Number(v.dwellSeconds);
const label = v.label != null ? cap(v.label, 80) : undefined;
viaPoints.push({
lat: Number(v.lat),
lng: Number(v.lng),
...(label ? { label } : {}),
tone: TONES.has(v.tone as string) ? (v.tone as Tone) : 'default',
...(Number.isFinite(dwell) && dwell >= 0 ? { dwellSeconds: Math.min(Math.round(dwell), MAX_DWELL_S) } : {}),
});
}
}
return { pluginId, profile, coordinates, distance, duration, legs, viaPoints };
}
/**
* SV4 (R-survivors) — was `SELECT capabilities FROM plugins WHERE id = ?` over a
* raw connection; now `PluginsRepository#findCapabilities` (PR52/PR53, Plan 3j
* Task 2's `plugin-runtime.service.ts#capabilityList`/`#mcpToolCapabilities`
* conversion of the SAME column read — reused here rather than adding a fourth
* near-identical method). Three external callers (`roadtrip-router.service.ts`'s
* RRT1/RRT2, `plugin-routes.controller.ts`) switch from the raw-connection
* signature to this repository-typed one — Task 5's.
*/
export async function declaredProfiles(plugins: PluginsRepository, pluginId: string): Promise<string[]> {
try {
const capabilities = await plugins.findCapabilities(pluginId);
const c = JSON.parse(capabilities || '{}') as { routeProfiles?: Array<{ id?: unknown }> };
if (!Array.isArray(c.routeProfiles)) return [];
return c.routeProfiles
.map((p) => (p && typeof p === 'object' && typeof p.id === 'string' ? p.id : ''))
.filter((id) => PROFILE_RE.test(id));
} catch {
return [];
}
}